diff options
| author | Craig Jennings <c@cjennings.net> | 2026-02-23 11:54:25 -0600 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-02-23 11:54:25 -0600 |
| commit | fd2ea796b20bcbebea19c43978fb08e3cd6754ed (patch) | |
| tree | 344efeff361b1c8953bd4f56d304a8ec70637899 /installer/lib | |
| parent | 8560e2a2798f9318fb28283d5ef7242fed20d447 (diff) | |
| download | archangel-fd2ea796b20bcbebea19c43978fb08e3cd6754ed.tar.gz archangel-fd2ea796b20bcbebea19c43978fb08e3cd6754ed.zip | |
refactor: rename custom/ to installer/ for clarity
The custom/ directory name was an archiso implementation detail. Renamed
to installer/ which clearly communicates that this directory contains the
installer scripts and utilities that ship on the ISO.
Updated all references in build.sh, Makefile, test-install.sh, and README.
Diffstat (limited to 'installer/lib')
| -rw-r--r-- | installer/lib/btrfs.sh | 900 | ||||
| -rw-r--r-- | installer/lib/common.sh | 173 | ||||
| -rw-r--r-- | installer/lib/config.sh | 131 | ||||
| -rw-r--r-- | installer/lib/disk.sh | 204 | ||||
| -rw-r--r-- | installer/lib/zfs.sh | 359 |
5 files changed, 1767 insertions, 0 deletions
diff --git a/installer/lib/btrfs.sh b/installer/lib/btrfs.sh new file mode 100644 index 0000000..321c05c --- /dev/null +++ b/installer/lib/btrfs.sh @@ -0,0 +1,900 @@ +#!/usr/bin/env bash +# btrfs.sh - Btrfs-specific functions for archangel installer +# Source this file after common.sh, config.sh, disk.sh + +############################# +# Btrfs/LUKS Constants +############################# + +# LUKS settings +LUKS_MAPPER_NAME="cryptroot" + +# Mount options for btrfs subvolumes +BTRFS_OPTS="noatime,compress=zstd,space_cache=v2,discard=async" + +# Subvolume layout (matches ZFS dataset structure) +# Format: "name:mountpoint:extra_opts" +BTRFS_SUBVOLS=( + "@:/::" + "@home:/home::" + "@snapshots:/.snapshots::" + "@var_log:/var/log::" + "@var_cache:/var/cache::" + "@tmp:/tmp::nosuid,nodev" + "@var_tmp:/var/tmp::nosuid,nodev" + "@media:/media::compress=no" + "@vms:/vms::nodatacow,compress=no" + "@var_lib_docker:/var/lib/docker::" +) + +############################# +# LUKS Functions +############################# + +create_luks_container() { + local partition="$1" + local passphrase="$2" + + step "Creating LUKS Encrypted Container" + + info "Setting up LUKS encryption on $partition..." + + # Create LUKS container (-q for batch mode, -d - to read key from stdin) + echo -n "$passphrase" | cryptsetup -q luksFormat --type luks2 \ + --cipher aes-xts-plain64 --key-size 512 --hash sha512 \ + --iter-time 2000 --pbkdf argon2id \ + -d - "$partition" \ + || error "Failed to create LUKS container" + + info "LUKS container created." +} + +open_luks_container() { + local partition="$1" + local passphrase="$2" + local name="${3:-$LUKS_MAPPER_NAME}" + + info "Opening LUKS container..." + + echo -n "$passphrase" | cryptsetup open "$partition" "$name" -d - \ + || error "Failed to open LUKS container" + + info "LUKS container opened as /dev/mapper/$name" +} + +close_luks_container() { + local name="${1:-$LUKS_MAPPER_NAME}" + + cryptsetup close "$name" 2>/dev/null || true +} + +# Testing keyfile for automated LUKS testing +# When TESTING=yes, we embed a keyfile in initramfs to allow unattended boot +LUKS_KEYFILE="/etc/cryptroot.key" + +setup_luks_testing_keyfile() { + local passphrase="$1" + shift + local partitions=("$@") + + [[ "${TESTING:-}" != "yes" ]] && return 0 + + step "Setting Up Testing Keyfile (TESTING MODE)" + warn "Adding keyfile to initramfs for automated testing." + warn "This reduces security - for testing only!" + + # Generate random keyfile + dd if=/dev/urandom of="/mnt${LUKS_KEYFILE}" bs=512 count=4 status=none \ + || error "Failed to generate keyfile" + chmod 000 "/mnt${LUKS_KEYFILE}" + + # Add keyfile to each LUKS partition (slot 1, passphrase stays in slot 0) + for partition in "${partitions[@]}"; do + info "Adding keyfile to $partition..." + echo -n "$passphrase" | cryptsetup luksAddKey "$partition" "/mnt${LUKS_KEYFILE}" -d - \ + || error "Failed to add keyfile to $partition" + done + + info "Testing keyfile configured for ${#partitions[@]} partition(s)." +} + +# Multi-disk LUKS functions +create_luks_containers() { + local passphrase="$1" + shift + local partitions=("$@") + + step "Creating LUKS Encrypted Containers" + + local i=0 + for partition in "${partitions[@]}"; do + info "Setting up LUKS encryption on $partition..." + echo -n "$passphrase" | cryptsetup -q luksFormat --type luks2 \ + --cipher aes-xts-plain64 --key-size 512 --hash sha512 \ + --iter-time 2000 --pbkdf argon2id \ + -d - "$partition" \ + || error "Failed to create LUKS container on $partition" + ((++i)) + done + + info "Created $i LUKS containers." +} + +open_luks_containers() { + local passphrase="$1" + shift + local partitions=("$@") + + step "Opening LUKS Containers" + + local i=0 + for partition in "${partitions[@]}"; do + local name="${LUKS_MAPPER_NAME}${i}" + [[ $i -eq 0 ]] && name="$LUKS_MAPPER_NAME" # First one has no suffix + info "Opening LUKS container: $partition -> /dev/mapper/$name" + echo -n "$passphrase" | cryptsetup open "$partition" "$name" -d - \ + || error "Failed to open LUKS container: $partition" + ((++i)) + done + + info "Opened ${#partitions[@]} LUKS containers." +} + +close_luks_containers() { + local count="${1:-1}" + + for ((i=0; i<count; i++)); do + local name="${LUKS_MAPPER_NAME}${i}" + [[ $i -eq 0 ]] && name="$LUKS_MAPPER_NAME" + cryptsetup close "$name" 2>/dev/null || true + done +} + +# Get list of opened LUKS mapper devices +get_luks_devices() { + local count="$1" + local devices=() + + for ((i=0; i<count; i++)); do + local name="${LUKS_MAPPER_NAME}${i}" + [[ $i -eq 0 ]] && name="$LUKS_MAPPER_NAME" + devices+=("/dev/mapper/$name") + done + + echo "${devices[@]}" +} + +configure_crypttab() { + local partitions=("$@") + + step "Configuring crypttab" + + echo "# LUKS encrypted root partitions" > /mnt/etc/crypttab + + # Use keyfile if in testing mode, otherwise prompt for passphrase + local key_source="none" + if [[ "${TESTING:-}" == "yes" ]]; then + key_source="$LUKS_KEYFILE" + info "Testing mode: using keyfile for automatic unlock" + fi + + local i=0 + for partition in "${partitions[@]}"; do + local uuid + uuid=$(blkid -s UUID -o value "$partition") + local name="${LUKS_MAPPER_NAME}${i}" + [[ $i -eq 0 ]] && name="$LUKS_MAPPER_NAME" + + echo "$name UUID=$uuid $key_source luks,discard" >> /mnt/etc/crypttab + info "crypttab: $name -> UUID=$uuid" + ((++i)) + done + + info "crypttab configured for $i partition(s)" +} + +configure_luks_initramfs() { + step "Configuring Initramfs for LUKS" + + # Backup original + cp /mnt/etc/mkinitcpio.conf /mnt/etc/mkinitcpio.conf.bak + + # Add encrypt hook before filesystems (configure_btrfs_initramfs overwrites + # this with the final hook list, using sd-encrypt for multi-disk setups) + sed -i 's/^HOOKS=.*/HOOKS=(base udev microcode modconf kms keyboard keymap consolefont block encrypt filesystems fsck)/' \ + /mnt/etc/mkinitcpio.conf + + # Include keyfile in initramfs for testing mode (unattended boot) + if [[ "${TESTING:-}" == "yes" ]]; then + info "Testing mode: embedding keyfile in initramfs" + sed -i "s|^FILES=.*|FILES=($LUKS_KEYFILE)|" /mnt/etc/mkinitcpio.conf + # If FILES line doesn't exist, add it + if ! grep -q "^FILES=" /mnt/etc/mkinitcpio.conf; then + echo "FILES=($LUKS_KEYFILE)" >> /mnt/etc/mkinitcpio.conf + fi + fi + + # Create crypttab.initramfs for sd-encrypt (used by multi-disk LUKS) + # sd-encrypt reads this file to open all LUKS devices during initramfs + if [[ -f /mnt/etc/crypttab ]]; then + cp /mnt/etc/crypttab /mnt/etc/crypttab.initramfs + info "Created crypttab.initramfs for sd-encrypt." + fi + + info "Added encrypt hook to initramfs." +} + +configure_luks_grub() { + local partition="$1" + + step "Configuring GRUB for LUKS" + + local uuid + uuid=$(blkid -s UUID -o value "$partition") + + # Enable GRUB cryptodisk support (required for encrypted /boot) + echo "GRUB_ENABLE_CRYPTODISK=y" >> /mnt/etc/default/grub + + # Add cryptdevice to GRUB cmdline + # For testing mode, also add cryptkey parameter for automated unlock + local cryptkey_param="" + if [[ "${TESTING:-}" == "yes" ]]; then + # rootfs: prefix tells encrypt hook the keyfile is in the initramfs + cryptkey_param="cryptkey=rootfs:$LUKS_KEYFILE " + info "Testing mode: adding cryptkey parameter for automated unlock" + fi + + sed -i "s|^GRUB_CMDLINE_LINUX=\"|GRUB_CMDLINE_LINUX=\"cryptdevice=UUID=$uuid:$LUKS_MAPPER_NAME:allow-discards ${cryptkey_param}|" \ + /mnt/etc/default/grub + + info "GRUB configured with cryptdevice parameter and cryptodisk enabled." +} + +############################# +# Btrfs Pre-flight +############################# + +btrfs_preflight() { + step "Checking Btrfs Requirements" + + # Check for btrfs-progs + if ! command_exists mkfs.btrfs; then + error "btrfs-progs not installed. Cannot create btrfs filesystem." + fi + info "btrfs-progs available." + + # Check for required tools + require_command btrfs + require_command grub-install + + info "Btrfs preflight checks passed." +} + +############################# +# Btrfs Volume Creation +############################# + +# Create btrfs filesystem (single or multi-device) +# Usage: create_btrfs_volume device1 [device2 ...] [--raid-level level] +create_btrfs_volume() { + local devices=() + local raid_level="" + + # Parse arguments + while [[ $# -gt 0 ]]; do + case "$1" in + --raid-level) + raid_level="$2" + shift 2 + ;; + *) + devices+=("$1") + shift + ;; + esac + done + + step "Creating Btrfs Filesystem" + + local num_devices=${#devices[@]} + + if [[ $num_devices -eq 1 ]]; then + # Single device + info "Formatting ${devices[0]} as btrfs..." + mkfs.btrfs -f -L "archroot" "${devices[0]}" || error "Failed to create btrfs filesystem" + info "Btrfs filesystem created on ${devices[0]}" + else + # Multi-device RAID + local data_profile="raid1" + local meta_profile="raid1" + + case "$raid_level" in + stripe) + data_profile="raid0" + meta_profile="raid1" # Always mirror metadata for safety + info "Creating striped btrfs (RAID0 data, RAID1 metadata) with $num_devices devices..." + ;; + mirror) + data_profile="raid1" + meta_profile="raid1" + info "Creating mirrored btrfs (RAID1) with $num_devices devices..." + ;; + *) + # Default to mirror for safety + data_profile="raid1" + meta_profile="raid1" + info "Creating mirrored btrfs (RAID1) with $num_devices devices..." + ;; + esac + + mkfs.btrfs -f -L "archroot" \ + -d "$data_profile" \ + -m "$meta_profile" \ + "${devices[@]}" || error "Failed to create btrfs filesystem" + + info "Btrfs $raid_level filesystem created on ${devices[*]}" + fi +} + +############################# +# Subvolume Creation +############################# + +create_btrfs_subvolumes() { + local partition="$1" + + step "Creating Btrfs Subvolumes" + + # Mount the raw btrfs volume temporarily + mount "$partition" /mnt || error "Failed to mount btrfs volume" + + # Create each subvolume + for subvol_spec in "${BTRFS_SUBVOLS[@]}"; do + IFS=':' read -r name mountpoint extra <<< "$subvol_spec" + info "Creating subvolume: $name -> $mountpoint" + btrfs subvolume create "/mnt/$name" || error "Failed to create subvolume $name" + done + + # Unmount raw volume + umount /mnt + + info "Created ${#BTRFS_SUBVOLS[@]} subvolumes." +} + +############################# +# Btrfs Mount Functions +############################# + +mount_btrfs_subvolumes() { + local partition="$1" + + step "Mounting Btrfs Subvolumes" + + # Mount root subvolume first + info "Mounting @ -> /mnt" + mount -o "subvol=@,$BTRFS_OPTS" "$partition" /mnt || error "Failed to mount root subvolume" + + # Create mount points and mount remaining subvolumes + for subvol_spec in "${BTRFS_SUBVOLS[@]}"; do + IFS=':' read -r name mountpoint extra <<< "$subvol_spec" + + # Skip root, already mounted + [[ "$name" == "@" ]] && continue + + # Build mount options + local opts="subvol=$name,$BTRFS_OPTS" + + # Apply extra options (override defaults where specified) + if [[ -n "$extra" ]]; then + # Handle compress=no by removing compress from opts and not adding it + if [[ "$extra" == *"compress=no"* ]]; then + opts=$(echo "$opts" | sed 's/,compress=zstd//') + fi + # Handle nodatacow + if [[ "$extra" == *"nodatacow"* ]]; then + opts="$opts,nodatacow" + opts=$(echo "$opts" | sed 's/,compress=zstd//') + fi + # Handle nosuid,nodev for tmp + if [[ "$extra" == *"nosuid"* ]]; then + opts="$opts,nosuid,nodev" + fi + fi + + info "Mounting $name -> /mnt$mountpoint" + mkdir -p "/mnt$mountpoint" + mount -o "$opts" "$partition" "/mnt$mountpoint" || error "Failed to mount $name" + done + + # Set permissions on tmp directories + chmod 1777 /mnt/tmp /mnt/var/tmp + + info "All subvolumes mounted." +} + +############################# +# Fstab Generation +############################# + +generate_btrfs_fstab() { + local partition="$1" + local efi_partition="$2" + + step "Generating fstab" + + local uuid + uuid=$(blkid -s UUID -o value "$partition") + + # Start with header + cat > /mnt/etc/fstab << EOF +# /etc/fstab - Btrfs subvolume mounts +# IMPORTANT: Using subvol= NOT subvolid= for snapshot compatibility +# Generated by archangel installer + +EOF + + # Add each subvolume + for subvol_spec in "${BTRFS_SUBVOLS[@]}"; do + IFS=':' read -r name mountpoint extra <<< "$subvol_spec" + + # Build mount options + local opts="subvol=$name,$BTRFS_OPTS" + + # Apply extra options + if [[ -n "$extra" ]]; then + if [[ "$extra" == *"compress=no"* ]]; then + opts=$(echo "$opts" | sed 's/,compress=zstd//') + fi + if [[ "$extra" == *"nodatacow"* ]]; then + opts="$opts,nodatacow" + opts=$(echo "$opts" | sed 's/,compress=zstd//') + fi + if [[ "$extra" == *"nosuid"* ]]; then + opts="$opts,nosuid,nodev" + fi + fi + + echo "UUID=$uuid $mountpoint btrfs $opts 0 0" >> /mnt/etc/fstab + done + + # Add EFI partition + local efi_uuid + efi_uuid=$(blkid -s UUID -o value "$efi_partition") + echo "" >> /mnt/etc/fstab + echo "# EFI System Partition" >> /mnt/etc/fstab + echo "UUID=$efi_uuid /efi vfat defaults,noatime 0 2" >> /mnt/etc/fstab + + info "fstab generated with ${#BTRFS_SUBVOLS[@]} btrfs mounts + EFI" +} + +############################# +# Snapper Configuration +############################# + +configure_snapper() { + step "Configuring Snapper" + + # Snapper needs D-Bus which isn't available in chroot + # Create a firstboot service to properly initialize snapper + + info "Creating snapper firstboot configuration..." + + # Create the firstboot script using echo (more reliable than HEREDOC) + { + echo '#!/bin/bash' + echo '# Snapper firstboot configuration' + echo 'set -e' + echo '' + echo '# Check if snapper is already configured' + echo 'if snapper list-configs 2>/dev/null | grep -q "^root"; then' + echo ' exit 0' + echo 'fi' + echo '' + echo 'echo "Configuring snapper for btrfs root..."' + echo '' + echo '# Unmount the pre-created @snapshots' + echo 'umount /.snapshots 2>/dev/null || true' + echo 'rmdir /.snapshots 2>/dev/null || true' + echo '' + echo '# Let snapper create its config' + echo 'snapper -c root create-config /' + echo '' + echo '# Replace snapper .snapshots with our @snapshots' + echo 'btrfs subvolume delete /.snapshots' + echo 'mkdir /.snapshots' + echo 'ROOT_DEV=$(findmnt -n -o SOURCE / | sed "s/\[.*\]//")' + echo 'mount -o subvol=@snapshots "$ROOT_DEV" /.snapshots' + echo 'chmod 750 /.snapshots' + echo '' + echo '# Configure timeline' + echo 'snapper -c root set-config "TIMELINE_CREATE=yes"' + echo 'snapper -c root set-config "TIMELINE_CLEANUP=yes"' + echo 'snapper -c root set-config "TIMELINE_LIMIT_HOURLY=6"' + echo 'snapper -c root set-config "TIMELINE_LIMIT_DAILY=7"' + echo 'snapper -c root set-config "TIMELINE_LIMIT_WEEKLY=2"' + echo 'snapper -c root set-config "TIMELINE_LIMIT_MONTHLY=1"' + echo 'snapper -c root set-config "NUMBER_LIMIT=50"' + echo '' + echo '# Create genesis snapshot' + echo 'snapper -c root create -d "genesis"' + echo '' + echo '# Update GRUB (config on EFI partition)' + echo 'grub-mkconfig -o /efi/grub/grub.cfg' + echo '' + echo 'echo "Snapper configuration complete!"' + } > /mnt/usr/local/bin/snapper-firstboot + chmod +x /mnt/usr/local/bin/snapper-firstboot + + # Create systemd service for firstboot + { + echo '[Unit]' + echo 'Description=Snapper First Boot Configuration' + echo 'After=local-fs.target dbus.service' + echo 'Wants=dbus.service' + echo 'ConditionPathExists=!/etc/snapper/.firstboot-done' + echo '' + echo '[Service]' + echo 'Type=oneshot' + echo 'ExecStart=/usr/local/bin/snapper-firstboot' + echo 'ExecStartPost=/usr/bin/touch /etc/snapper/.firstboot-done' + echo 'RemainAfterExit=yes' + echo '' + echo '[Install]' + echo 'WantedBy=multi-user.target' + } > /mnt/etc/systemd/system/snapper-firstboot.service + + # Enable the firstboot service + arch-chroot /mnt systemctl enable snapper-firstboot.service + + # Enable snapper timers + arch-chroot /mnt systemctl enable snapper-timeline.timer + arch-chroot /mnt systemctl enable snapper-cleanup.timer + + info "Snapper firstboot service configured." + info "Snapper will be fully configured on first boot." +} + +############################# +# GRUB Configuration +############################# + +configure_grub() { + local efi_partition="$1" + + step "Configuring GRUB Bootloader" + + # Mount EFI partition + mkdir -p /mnt/efi + mount "$efi_partition" /mnt/efi + + # Configure GRUB defaults for btrfs + info "Setting GRUB configuration..." + cat > /mnt/etc/default/grub << 'EOF' +# GRUB configuration for btrfs root with snapshots +GRUB_DEFAULT=0 +GRUB_TIMEOUT=5 +GRUB_DISTRIBUTOR="Arch" +GRUB_CMDLINE_LINUX_DEFAULT="loglevel=3" +GRUB_CMDLINE_LINUX="console=tty0 console=ttyS0,115200" + +# Serial console support (for headless/VM testing) +GRUB_TERMINAL="console serial" +GRUB_SERIAL_COMMAND="serial --speed=115200 --unit=0 --word=8 --parity=no --stop=1" + +# Disable os-prober (single-boot system) +GRUB_DISABLE_OS_PROBER=true + +# Btrfs: tell GRUB where to find /boot within subvolume +GRUB_BTRFS_OVERRIDE_BOOT_PARTITION_DETECTION=true +EOF + + # Add LUKS encryption settings if enabled + if [[ "$NO_ENCRYPT" != "yes" && -n "$LUKS_PASSPHRASE" ]]; then + echo "" >> /mnt/etc/default/grub + echo "# LUKS encryption support" >> /mnt/etc/default/grub + echo "GRUB_ENABLE_CRYPTODISK=y" >> /mnt/etc/default/grub + + # For multi-disk LUKS, sd-encrypt reads crypttab.initramfs — no cmdline params needed + # For single-disk LUKS, the encrypt hook needs cryptdevice= on the cmdline + local num_luks_disks + num_luks_disks=$(echo "$DISKS" | tr ',' '\n' | wc -l) + + if [[ $num_luks_disks -eq 1 ]]; then + local luks_part + luks_part=$(echo "$DISKS" | cut -d',' -f1)2 + if [[ -b "$luks_part" ]]; then + local uuid + uuid=$(blkid -s UUID -o value "$luks_part") + local cryptkey_param="" + if [[ "${TESTING:-}" == "yes" ]]; then + cryptkey_param="cryptkey=rootfs:$LUKS_KEYFILE " + info "Testing mode: adding cryptkey parameter for automated unlock" + fi + sed -i "s|^GRUB_CMDLINE_LINUX=\"|GRUB_CMDLINE_LINUX=\"cryptdevice=UUID=$uuid:$LUKS_MAPPER_NAME:allow-discards ${cryptkey_param}|" \ + /mnt/etc/default/grub + info "Added cryptdevice parameter for LUKS partition." + fi + else + info "Multi-disk LUKS: sd-encrypt reads crypttab.initramfs (no cryptdevice cmdline needed)" + fi + fi + + # Create grub directory on EFI partition + # GRUB modules on FAT32 EFI partition avoid btrfs subvolume path issues + mkdir -p /mnt/efi/grub + + # Install GRUB with boot-directory on EFI partition + info "Installing GRUB to EFI partition..." + arch-chroot /mnt grub-install --target=x86_64-efi --efi-directory=/efi \ + --bootloader-id=GRUB --boot-directory=/efi \ + || error "GRUB installation failed" + + # Create symlink BEFORE grub-mkconfig (grub-btrfs expects /boot/grub) + rm -rf /mnt/boot/grub 2>/dev/null || true + arch-chroot /mnt ln -sfn /efi/grub /boot/grub + + # Generate GRUB config (uses /boot/grub symlink -> /efi/grub) + info "Generating GRUB configuration..." + arch-chroot /mnt grub-mkconfig -o /boot/grub/grub.cfg \ + || error "Failed to generate GRUB config" + + # Sync to ensure grub.cfg is written to FAT32 EFI partition + sync + + # Enable grub-btrfsd for automatic snapshot menu updates + info "Enabling grub-btrfs daemon..." + arch-chroot /mnt systemctl enable grub-btrfsd + + info "GRUB configured with btrfs snapshot support." +} + +############################# +# EFI Redundancy (Multi-disk) +############################# + +# Install GRUB to all EFI partitions for redundancy +install_grub_all_efi() { + local efi_partitions=("$@") + + step "Installing GRUB to All EFI Partitions" + + local i=1 + for efi_part in "${efi_partitions[@]}"; do + # First EFI at /efi (already mounted), subsequent at /efi2, /efi3, etc. + local chroot_efi_dir="/efi" + local mount_point="/mnt/efi" + local bootloader_id="GRUB" + + if [[ $i -gt 1 ]]; then + chroot_efi_dir="/efi${i}" + mount_point="/mnt/efi${i}" + bootloader_id="GRUB-disk${i}" + + # Mount secondary EFI partitions + if ! mountpoint -q "$mount_point" 2>/dev/null; then + mkdir -p "$mount_point" + mount "$efi_part" "$mount_point" || { warn "Failed to mount $efi_part"; ((++i)); continue; } + # Also create the directory in chroot for grub-install + mkdir -p "/mnt${chroot_efi_dir}" + mount --bind "$mount_point" "/mnt${chroot_efi_dir}" + fi + fi + + info "Installing GRUB to $efi_part ($bootloader_id)..." + arch-chroot /mnt grub-install --target=x86_64-efi \ + --efi-directory="$chroot_efi_dir" \ + --bootloader-id="$bootloader_id" \ + --boot-directory=/efi \ + || warn "GRUB install to $efi_part may have failed (continuing)" + + ((++i)) + done + + info "GRUB installed to ${#efi_partitions[@]} EFI partition(s)." +} + +# Create pacman hook to sync GRUB across all EFI partitions +create_grub_sync_hook() { + local efi_partitions=("$@") + + step "Creating GRUB Sync Hook" + + # Only needed for multi-disk + if [[ ${#efi_partitions[@]} -lt 2 ]]; then + info "Single disk - no sync hook needed." + return + fi + + # Create sync script + local script_content='#!/bin/bash +# Sync GRUB to all EFI partitions after grub package update +# Generated by archangel installer + +set -e + +EFI_PARTITIONS=(' + for part in "${efi_partitions[@]}"; do + script_content+="\"$part\" " + done + script_content+=') + +PRIMARY_EFI="/efi" + +sync_grub() { + local i=0 + for part in "${EFI_PARTITIONS[@]}"; do + if [[ $i -eq 0 ]]; then + # Primary - just reinstall GRUB + grub-install --target=x86_64-efi --efi-directory="$PRIMARY_EFI" \ + --bootloader-id=GRUB --boot-directory=/efi 2>/dev/null || true + else + # Secondary - mount, install, unmount + local mount_point="/tmp/efi-sync-$i" + mkdir -p "$mount_point" + mount "$part" "$mount_point" 2>/dev/null || continue + grub-install --target=x86_64-efi --efi-directory="$mount_point" \ + --bootloader-id="GRUB-disk$((i+1))" --boot-directory=/efi 2>/dev/null || true + umount "$mount_point" 2>/dev/null || true + rmdir "$mount_point" 2>/dev/null || true + fi + ((++i)) + done +} + +sync_grub +' + echo "$script_content" > /mnt/usr/local/bin/grub-sync-efi + chmod +x /mnt/usr/local/bin/grub-sync-efi + + # Create pacman hook + mkdir -p /mnt/etc/pacman.d/hooks + cat > /mnt/etc/pacman.d/hooks/99-grub-sync-efi.hook << 'HOOKEOF' +[Trigger] +Type = Package +Operation = Upgrade +Target = grub + +[Action] +Description = Syncing GRUB to all EFI partitions... +When = PostTransaction +Exec = /usr/local/bin/grub-sync-efi +HOOKEOF + + info "GRUB sync hook created for ${#efi_partitions[@]} EFI partitions." +} + +############################# +# Pacman Snapshot Hook +############################# + +configure_btrfs_pacman_hook() { + step "Configuring Pacman Snapshot Hook" + + # snap-pac handles this automatically when installed + # Just verify it's set up + info "snap-pac will create pre/post snapshots for pacman transactions." + info "Snapshots visible in GRUB menu via grub-btrfs." +} + +############################# +# Genesis Snapshot +############################# + +create_btrfs_genesis_snapshot() { + step "Creating Genesis Snapshot" + + # Genesis snapshot will be created by snapper-firstboot service on first boot + # This ensures snapper is properly configured before creating snapshots + + info "Genesis snapshot will be created on first boot." + info "The snapper-firstboot service handles this automatically." +} + +############################# +# Btrfs Services +############################# + +configure_btrfs_services() { + step "Configuring System Services" + + # Enable standard services + arch-chroot /mnt systemctl enable NetworkManager + arch-chroot /mnt systemctl enable avahi-daemon + + # Snapper timers (already enabled in configure_snapper) + + # grub-btrfsd (already enabled in configure_grub) + + info "System services configured." +} + +############################# +# Btrfs Initramfs +############################# + +configure_btrfs_initramfs() { + step "Configuring Initramfs for Btrfs" + + # Backup original + cp /mnt/etc/mkinitcpio.conf /mnt/etc/mkinitcpio.conf.bak + + # Remove archiso drop-in if present + if [[ -f /mnt/etc/mkinitcpio.conf.d/archiso.conf ]]; then + info "Removing archiso drop-in config..." + rm -f /mnt/etc/mkinitcpio.conf.d/archiso.conf + fi + + # Create proper linux-lts preset + info "Creating linux-lts preset..." + cat > /mnt/etc/mkinitcpio.d/linux-lts.preset << 'EOF' +# mkinitcpio preset file for linux-lts + +PRESETS=(default fallback) + +ALL_kver="/boot/vmlinuz-linux-lts" + +default_image="/boot/initramfs-linux-lts.img" + +fallback_image="/boot/initramfs-linux-lts-fallback.img" +fallback_options="-S autodetect" +EOF + + # Configure hooks for btrfs + # Include encrypt hook if LUKS is enabled, btrfs hook if multi-device + local num_disks=${#SELECTED_DISKS[@]} + local luks_enabled="no" + [[ "$NO_ENCRYPT" != "yes" && -n "$LUKS_PASSPHRASE" ]] && luks_enabled="yes" + + if [[ $num_disks -gt 1 && "$luks_enabled" == "yes" ]]; then + # Multi-disk LUKS: use sd-encrypt (reads crypttab.initramfs to open ALL devices) + # The traditional encrypt hook only supports a single cryptdevice + info "Multi-device LUKS: using sd-encrypt for multi-device LUKS unlock" + sed -i "s/^HOOKS=.*/HOOKS=(base systemd microcode modconf kms keyboard sd-vconsole block sd-encrypt btrfs filesystems fsck)/" \ + /mnt/etc/mkinitcpio.conf + elif [[ $num_disks -gt 1 ]]; then + info "Multi-device btrfs: adding btrfs hook for device assembly" + sed -i "s/^HOOKS=.*/HOOKS=(base udev microcode modconf kms keyboard keymap consolefont block btrfs filesystems fsck)/" \ + /mnt/etc/mkinitcpio.conf + elif [[ "$luks_enabled" == "yes" ]]; then + sed -i "s/^HOOKS=.*/HOOKS=(base udev microcode modconf kms keyboard keymap consolefont block encrypt filesystems fsck)/" \ + /mnt/etc/mkinitcpio.conf + else + sed -i "s/^HOOKS=.*/HOOKS=(base udev microcode modconf kms keyboard keymap consolefont block filesystems fsck)/" \ + /mnt/etc/mkinitcpio.conf + fi + + # Regenerate initramfs + info "Regenerating initramfs..." + arch-chroot /mnt mkinitcpio -P + + info "Initramfs configured for btrfs." +} + +############################# +# Btrfs Cleanup +############################# + +btrfs_cleanup() { + step "Cleaning Up Btrfs" + + # Unmount in reverse order + info "Unmounting subvolumes..." + + # Sync all filesystems before unmounting (important for FAT32 EFI partition) + sync + + # Unmount EFI first + umount /mnt/efi 2>/dev/null || true + + # Unmount all btrfs subvolumes (reverse order) + for ((i=${#BTRFS_SUBVOLS[@]}-1; i>=0; i--)); do + IFS=':' read -r name mountpoint extra <<< "${BTRFS_SUBVOLS[$i]}" + [[ "$name" == "@" ]] && continue + umount "/mnt$mountpoint" 2>/dev/null || true + done + + # Unmount root last + umount /mnt 2>/dev/null || true + + info "Btrfs cleanup complete." +} diff --git a/installer/lib/common.sh b/installer/lib/common.sh new file mode 100644 index 0000000..0f02e37 --- /dev/null +++ b/installer/lib/common.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# common.sh - Shared functions for archangel installer +# Source this file: source "$(dirname "$0")/lib/common.sh" + +############################# +# Output Functions +############################# + +# Colors (optional, gracefully degrade if not supported) +if [[ -t 1 ]]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + BLUE='\033[0;34m' + BOLD='\033[1m' + NC='\033[0m' # No Color +else + RED='' + GREEN='' + YELLOW='' + BLUE='' + BOLD='' + NC='' +fi + +info() { echo -e "${GREEN}[INFO]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } +step() { echo ""; echo -e "${BOLD}==> $1${NC}"; } +prompt() { echo -e "${BLUE}$1${NC}"; } + +# Log to file if LOG_FILE is set +log() { + local msg + msg="[$(date +'%Y-%m-%d %H:%M:%S')] $1" + if [[ -n "$LOG_FILE" ]]; then + echo "$msg" >> "$LOG_FILE" + fi +} + +############################# +# Validation Functions +############################# + +require_root() { + if [[ $EUID -ne 0 ]]; then + error "This script must be run as root" + fi +} + +command_exists() { + command -v "$1" &>/dev/null +} + +require_command() { + command_exists "$1" || error "Required command not found: $1" +} + +############################# +# FZF Prompts +############################# + +# Check if fzf is available +has_fzf() { + command_exists fzf +} + +# Generic fzf selection +# Usage: result=$(fzf_select "prompt" "option1" "option2" ...) +fzf_select() { + local prompt="$1" + shift + local options=("$@") + + if has_fzf; then + printf '%s\n' "${options[@]}" | fzf --prompt="$prompt " --height=15 --reverse + else + # Fallback to simple select + PS3="$prompt " + select opt in "${options[@]}"; do + if [[ -n "$opt" ]]; then + echo "$opt" + break + fi + done + fi +} + +# Multi-select with fzf +# Usage: readarray -t results < <(fzf_multi "prompt" "opt1" "opt2" ...) +fzf_multi() { + local prompt="$1" + shift + local options=("$@") + + if has_fzf; then + printf '%s\n' "${options[@]}" | fzf --prompt="$prompt " --height=20 --reverse --multi + else + # Fallback: just return all options (user must edit) + printf '%s\n' "${options[@]}" + fi +} + +############################# +# Filesystem Selection +############################# + +# Select filesystem type (ZFS or Btrfs) +# Sets global FILESYSTEM variable +select_filesystem() { + step "Select Filesystem" + + local options=( + "ZFS - Built-in encryption, best data integrity (recommended)" + "Btrfs - Copy-on-write, LUKS encryption, GRUB snapshot boot" + ) + + local selected + selected=$(fzf_select "Filesystem:" "${options[@]}") + + case "$selected" in + ZFS*) + FILESYSTEM="zfs" + info "Selected: ZFS" + ;; + Btrfs*) + FILESYSTEM="btrfs" + info "Selected: Btrfs" + ;; + *) + error "No filesystem selected" + ;; + esac +} + +############################# +# Disk Utilities +############################# + +# Get disk size in human-readable format +get_disk_size() { + local disk="$1" + lsblk -dno SIZE "$disk" 2>/dev/null | tr -d ' ' +} + +# Get disk model +get_disk_model() { + local disk="$1" + lsblk -dno MODEL "$disk" 2>/dev/null | tr -d ' ' | head -c 20 +} + +# Check if disk is in use (mounted or has holders) +disk_in_use() { + local disk="$1" + [[ -n "$(lsblk -no MOUNTPOINT "$disk" 2>/dev/null | grep -v '^$')" ]] && return 0 + [[ -n "$(ls /sys/block/"$(basename "$disk")"/holders/ 2>/dev/null)" ]] && return 0 + return 1 +} + +# List available disks (not in use) +list_available_disks() { + local disks=() + for disk in /dev/nvme[0-9]n[0-9] /dev/sd[a-z] /dev/vd[a-z]; do + [[ -b "$disk" ]] || continue + disk_in_use "$disk" && continue + local size + size=$(get_disk_size "$disk") + local model + model=$(get_disk_model "$disk") + disks+=("$disk ($size, $model)") + done + printf '%s\n' "${disks[@]}" +} diff --git a/installer/lib/config.sh b/installer/lib/config.sh new file mode 100644 index 0000000..358a5f4 --- /dev/null +++ b/installer/lib/config.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# config.sh - Configuration and argument handling for archangel installer +# Source this file after common.sh + +############################# +# Global Config Variables +############################# + +CONFIG_FILE="" +UNATTENDED=false + +# These get populated by config file or interactive prompts +FILESYSTEM="" # "zfs" or "btrfs" +HOSTNAME="" +TIMEZONE="" +LOCALE="" +KEYMAP="" +SELECTED_DISKS=() +RAID_LEVEL="" +WIFI_SSID="" +WIFI_PASSWORD="" +ENCRYPTION_ENABLED=false +ZFS_PASSPHRASE="" +LUKS_PASSPHRASE="" +ROOT_PASSWORD="" +SSH_ENABLED=false +SSH_KEY="" + +############################# +# Argument Parsing +############################# + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --config-file) + if [[ -n "$2" && ! "$2" =~ ^- ]]; then + CONFIG_FILE="$2" + shift 2 + else + error "--config-file requires a path argument" + fi + ;; + --help|-h) + show_usage + exit 0 + ;; + *) + error "Unknown option: $1 (use --help for usage)" + ;; + esac + done +} + +show_usage() { + cat <<EOF +Usage: archangel [OPTIONS] + +Arch Linux installer with ZFS/Btrfs support and snapshot management. + +Options: + --config-file PATH Use config file for unattended installation + --help, -h Show this help message + +Without --config-file, runs in interactive mode. +See /root/archangel.conf.example for a config template. +EOF +} + +############################# +# Config File Loading +############################# + +load_config() { + local config_path="$1" + + if [[ ! -f "$config_path" ]]; then + error "Config file not found: $config_path" + fi + + info "Loading config from: $config_path" + + # Source the config file (it's just key=value pairs) + # shellcheck disable=SC1090 + source "$config_path" + + # Convert DISKS from comma-separated string to array + if [[ -n "$DISKS" ]]; then + IFS=',' read -ra SELECTED_DISKS <<< "$DISKS" + fi + + UNATTENDED=true + info "Running in unattended mode" +} + +check_config() { + # Only use config when explicitly specified with --config-file + # This prevents accidental disk destruction from an unnoticed config file + if [[ -n "$CONFIG_FILE" ]]; then + load_config "$CONFIG_FILE" + fi +} + +############################# +# Config Validation +############################# + +validate_config() { + local errors=0 + + [[ -z "$HOSTNAME" ]] && { warn "HOSTNAME not set"; ((errors++)); } + [[ -z "$TIMEZONE" ]] && { warn "TIMEZONE not set"; ((errors++)); } + [[ ${#SELECTED_DISKS[@]} -eq 0 ]] && { warn "No disks selected"; ((errors++)); } + [[ -z "$ROOT_PASSWORD" ]] && { warn "ROOT_PASSWORD not set"; ((errors++)); } + + # Validate disks exist + for disk in "${SELECTED_DISKS[@]}"; do + [[ -b "$disk" ]] || { warn "Disk not found: $disk"; ((errors++)); } + done + + # Validate timezone + if [[ -n "$TIMEZONE" && ! -f "/usr/share/zoneinfo/$TIMEZONE" ]]; then + warn "Invalid timezone: $TIMEZONE" + ((errors++)) + fi + + if [[ $errors -gt 0 ]]; then + error "Config validation failed with $errors error(s)" + fi + info "Config validation passed" +} diff --git a/installer/lib/disk.sh b/installer/lib/disk.sh new file mode 100644 index 0000000..2e7deb3 --- /dev/null +++ b/installer/lib/disk.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# disk.sh - Disk partitioning functions for archangel installer +# Source this file after common.sh + +############################# +# Partition Disks +############################# + +# Partition a single disk for ZFS/Btrfs installation +# Creates: EFI partition (512M) + root partition (rest) +# Uses global FILESYSTEM variable to determine partition type +partition_disk() { + local disk="$1" + local efi_size="${2:-512M}" + + # Determine root partition type based on filesystem + local root_type="BF00" # ZFS (Solaris root) + if [[ "$FILESYSTEM" == "btrfs" ]]; then + root_type="8300" # Linux filesystem + fi + + info "Partitioning $disk..." + + # Wipe existing partition table + sgdisk --zap-all "$disk" || error "Failed to wipe $disk" + + # Create EFI partition (512M, type EF00) + sgdisk -n 1:0:+${efi_size} -t 1:EF00 -c 1:"EFI" "$disk" || error "Failed to create EFI partition on $disk" + + # Create root partition (rest of disk) + sgdisk -n 2:0:0 -t 2:$root_type -c 2:"ROOT" "$disk" || error "Failed to create root partition on $disk" + + # Notify kernel of partition changes + partprobe "$disk" 2>/dev/null || true + sleep 1 + + info "Partitioned $disk: EFI=${efi_size}, ROOT=remainder" +} + +# Partition multiple disks (for RAID configurations) +partition_disks() { + local efi_size="${1:-512M}" + shift + local disks=("$@") + + for disk in "${disks[@]}"; do + partition_disk "$disk" "$efi_size" + done +} + +############################# +# Partition Helpers +############################# + +# Get EFI partition path for a disk +get_efi_partition() { + local disk="$1" + if [[ "$disk" =~ nvme ]]; then + echo "${disk}p1" + else + echo "${disk}1" + fi +} + +# Get root partition path for a disk +get_root_partition() { + local disk="$1" + if [[ "$disk" =~ nvme ]]; then + echo "${disk}p2" + else + echo "${disk}2" + fi +} + +# Get all root partitions from disk array +get_root_partitions() { + local disks=("$@") + local parts=() + for disk in "${disks[@]}"; do + parts+=("$(get_root_partition "$disk")") + done + printf '%s\n' "${parts[@]}" +} + +# Get all EFI partitions from disk array +get_efi_partitions() { + local disks=("$@") + local parts=() + for disk in "${disks[@]}"; do + parts+=("$(get_efi_partition "$disk")") + done + printf '%s\n' "${parts[@]}" +} + +############################# +# EFI Partition Management +############################# + +# Format EFI partition +format_efi() { + local partition="$1" + local label="${2:-EFI}" + + info "Formatting EFI partition: $partition" + mkfs.fat -F32 -n "$label" "$partition" || error "Failed to format EFI: $partition" +} + +# Format all EFI partitions +format_efi_partitions() { + local disks=("$@") + local first=true + + for disk in "${disks[@]}"; do + local efi + efi=$(get_efi_partition "$disk") + if $first; then + format_efi "$efi" "EFI" + first=false + else + format_efi "$efi" "EFI2" + fi + done +} + +# Mount EFI partition +mount_efi() { + local partition="$1" + local mount_point="${2:-/mnt/efi}" + + mkdir -p "$mount_point" + mount "$partition" "$mount_point" || error "Failed to mount EFI at $mount_point" + info "Mounted EFI: $partition -> $mount_point" +} + +############################# +# Disk Selection (Interactive) +############################# + +# Interactive disk selection using fzf +select_disks() { + local available + available=$(list_available_disks) + + if [[ -z "$available" ]]; then + error "No available disks found" + fi + + step "Select installation disk(s)" + prompt "Use Tab to select multiple disks for RAID, Enter to confirm" + + local selected + if has_fzf; then + selected=$(echo "$available" | fzf --multi --prompt="Select disk(s): " --height=15 --reverse) + else + echo "$available" + read -rp "Enter disk path(s) separated by space: " selected + fi + + if [[ -z "$selected" ]]; then + error "No disk selected" + fi + + # Extract just the device paths (remove size/model info) + SELECTED_DISKS=() + while IFS= read -r line; do + local disk + disk=$(echo "$line" | cut -d' ' -f1) + SELECTED_DISKS+=("$disk") + done <<< "$selected" + + info "Selected disks: ${SELECTED_DISKS[*]}" +} + +############################# +# RAID Level Selection +############################# + +select_raid_level() { + local num_disks=${#SELECTED_DISKS[@]} + + if [[ $num_disks -eq 1 ]]; then + RAID_LEVEL="" + info "Single disk - no RAID" + return + fi + + step "Select RAID level" + + local options=() + options+=("mirror - Mirror data across disks (recommended)") + + if [[ $num_disks -ge 3 ]]; then + options+=("raidz1 - Single parity, lose 1 disk capacity") + fi + if [[ $num_disks -ge 4 ]]; then + options+=("raidz2 - Double parity, lose 2 disks capacity") + fi + + local selected + selected=$(fzf_select "RAID level:" "${options[@]}") + RAID_LEVEL=$(echo "$selected" | cut -d' ' -f1) + + info "Selected RAID level: $RAID_LEVEL" +} diff --git a/installer/lib/zfs.sh b/installer/lib/zfs.sh new file mode 100644 index 0000000..feda91d --- /dev/null +++ b/installer/lib/zfs.sh @@ -0,0 +1,359 @@ +#!/usr/bin/env bash +# zfs.sh - ZFS-specific functions for archangel installer +# Source this file after common.sh, config.sh, disk.sh + +############################# +# ZFS Constants +############################# + +POOL_NAME="${POOL_NAME:-zroot}" +ASHIFT="${ASHIFT:-12}" +COMPRESSION="${COMPRESSION:-zstd}" + +############################# +# ZFS Pre-flight +############################# + +zfs_preflight() { + # Check ZFS module + if ! lsmod | grep -q zfs; then + info "Loading ZFS module..." + modprobe zfs || error "Failed to load ZFS module. Is zfs-linux-lts installed?" + fi + info "ZFS module loaded successfully." +} + +############################# +# ZFS Pool Creation +############################# + +create_zfs_pool() { + local encryption="${1:-true}" + local passphrase="$2" + + step "Creating ZFS Pool" + + # Destroy existing pool if present + if zpool list "$POOL_NAME" &>/dev/null; then + warn "Pool $POOL_NAME already exists. Destroying..." + zpool destroy -f "$POOL_NAME" + fi + + # Get root partitions + local zfs_parts=() + for disk in "${SELECTED_DISKS[@]}"; do + zfs_parts+=("$(get_root_partition "$disk")") + done + + # Build pool configuration based on RAID level + local pool_config + if [[ "$RAID_LEVEL" == "stripe" ]]; then + pool_config="${zfs_parts[*]}" + info "Creating striped pool with ${#zfs_parts[@]} disks (NO redundancy)..." + warn "Data loss will occur if ANY disk fails!" + elif [[ -n "$RAID_LEVEL" ]]; then + pool_config="$RAID_LEVEL ${zfs_parts[*]}" + info "Creating $RAID_LEVEL pool with ${#zfs_parts[@]} disks..." + else + pool_config="${zfs_parts[0]}" + info "Creating single-disk pool..." + fi + + # Base pool options + local pool_opts=( + -f + -o ashift="$ASHIFT" + -o autotrim=on + -O acltype=posixacl + -O atime=off + -O canmount=off + -O compression="$COMPRESSION" + -O dnodesize=auto + -O normalization=formD + -O relatime=on + -O xattr=sa + -O mountpoint=none + -R /mnt + ) + + # Create pool (with or without encryption) + if [[ "$encryption" == "false" ]]; then + warn "Creating pool WITHOUT encryption" + zpool create "${pool_opts[@]}" "$POOL_NAME" $pool_config + else + info "Creating encrypted pool..." + echo "$passphrase" | zpool create "${pool_opts[@]}" \ + -O encryption=aes-256-gcm \ + -O keyformat=passphrase \ + -O keylocation=prompt \ + "$POOL_NAME" $pool_config + fi + + info "ZFS pool created successfully." + zpool status "$POOL_NAME" +} + +############################# +# ZFS Dataset Creation +############################# + +create_zfs_datasets() { + step "Creating ZFS Datasets" + + # Root dataset container + zfs create -o mountpoint=none -o canmount=off "$POOL_NAME/ROOT" + + # Calculate reservation (20% of pool, capped 5-20G) + local pool_size_bytes + pool_size_bytes=$(zpool get -Hp size "$POOL_NAME" | awk '{print $3}') + local pool_size_gb=$((pool_size_bytes / 1024 / 1024 / 1024)) + local reserve_gb=$((pool_size_gb / 5)) + [[ $reserve_gb -gt 20 ]] && reserve_gb=20 + [[ $reserve_gb -lt 5 ]] && reserve_gb=5 + + # Main root filesystem + zfs create -o mountpoint=/ -o canmount=noauto -o reservation=${reserve_gb}G "$POOL_NAME/ROOT/default" + zfs mount "$POOL_NAME/ROOT/default" + + # Home + zfs create -o mountpoint=/home "$POOL_NAME/home" + zfs create -o mountpoint=/root "$POOL_NAME/home/root" + + # Media - compression off for already-compressed files + zfs create -o mountpoint=/media -o compression=off "$POOL_NAME/media" + + # VMs - 64K recordsize for VM disk images + zfs create -o mountpoint=/vms -o recordsize=64K "$POOL_NAME/vms" + + # Var datasets + zfs create -o mountpoint=/var -o canmount=off "$POOL_NAME/var" + zfs create -o mountpoint=/var/log "$POOL_NAME/var/log" + zfs create -o mountpoint=/var/cache "$POOL_NAME/var/cache" + zfs create -o mountpoint=/var/lib -o canmount=off "$POOL_NAME/var/lib" + zfs create -o mountpoint=/var/lib/pacman "$POOL_NAME/var/lib/pacman" + zfs create -o mountpoint=/var/lib/docker "$POOL_NAME/var/lib/docker" + + # Temp directories - excluded from snapshots + zfs create -o mountpoint=/var/tmp -o com.sun:auto-snapshot=false "$POOL_NAME/var/tmp" + zfs create -o mountpoint=/tmp -o com.sun:auto-snapshot=false "$POOL_NAME/tmp" + chmod 1777 /mnt/tmp /mnt/var/tmp + + info "Datasets created:" + zfs list -r "$POOL_NAME" -o name,mountpoint,compression +} + +############################# +# ZFSBootMenu Configuration +############################# + +configure_zfsbootmenu() { + step "Configuring ZFSBootMenu" + + # Ensure hostid exists + if [[ ! -f /etc/hostid ]]; then + zgenhostid + fi + local host_id + host_id=$(hostid) + + # Copy hostid to installed system + cp /etc/hostid /mnt/etc/hostid + + # Create ZFSBootMenu directory on EFI + mkdir -p /mnt/efi/EFI/ZBM + + # Download ZFSBootMenu release EFI binary + info "Downloading ZFSBootMenu..." + local zbm_url="https://get.zfsbootmenu.org/efi" + if ! curl -fsSL -o /mnt/efi/EFI/ZBM/zfsbootmenu.efi "$zbm_url"; then + error "Failed to download ZFSBootMenu" + fi + info "ZFSBootMenu binary installed." + + # Set kernel command line on the ROOT PARENT dataset + local cmdline="rw loglevel=3" + + # Add AMD GPU workarounds if needed + if lspci | grep -qi "amd.*display\|amd.*vga"; then + info "AMD GPU detected - adding workaround parameters" + cmdline="$cmdline amdgpu.pg_mask=0 amdgpu.cwsr_enable=0" + fi + + zfs set org.zfsbootmenu:commandline="$cmdline" "$POOL_NAME/ROOT" + info "Kernel command line set on $POOL_NAME/ROOT" + + # Set bootfs property + zpool set bootfs="$POOL_NAME/ROOT/default" "$POOL_NAME" + info "Default boot filesystem set to $POOL_NAME/ROOT/default" + + # Create EFI boot entries for each disk + local zbm_cmdline="spl_hostid=0x${host_id} zbm.timeout=3 zbm.prefer=${POOL_NAME} zbm.import_policy=hostid" + + for i in "${!SELECTED_DISKS[@]}"; do + local disk="${SELECTED_DISKS[$i]}" + local label="ZFSBootMenu" + if [[ ${#SELECTED_DISKS[@]} -gt 1 ]]; then + label="ZFSBootMenu-disk$((i+1))" + fi + + info "Creating EFI boot entry: $label on $disk" + efibootmgr --create \ + --disk "$disk" \ + --part 1 \ + --label "$label" \ + --loader '\EFI\ZBM\zfsbootmenu.efi' \ + --unicode "$zbm_cmdline" \ + --quiet + done + + # Set as primary boot option + local bootnum + bootnum=$(efibootmgr | grep "ZFSBootMenu" | head -1 | grep -oP 'Boot\K[0-9A-F]+') + if [[ -n "$bootnum" ]]; then + local current_order + current_order=$(efibootmgr | grep "BootOrder" | cut -d: -f2 | tr -d ' ') + efibootmgr --bootorder "$bootnum,$current_order" --quiet + info "ZFSBootMenu set as primary boot option" + fi + + info "ZFSBootMenu configuration complete." +} + +############################# +# ZFS Services +############################# + +configure_zfs_services() { + step "Configuring ZFS Services" + + arch-chroot /mnt systemctl enable zfs.target + arch-chroot /mnt systemctl disable zfs-import-cache.service + arch-chroot /mnt systemctl enable zfs-import-scan.service + arch-chroot /mnt systemctl enable zfs-mount.service + arch-chroot /mnt systemctl enable zfs-import.target + + # Disable cachefile - we use zfs-import-scan + zpool set cachefile=none "$POOL_NAME" + rm -f /mnt/etc/zfs/zpool.cache + + info "ZFS services configured." +} + +############################# +# Pacman Snapshot Hook +############################# + +configure_zfs_pacman_hook() { + step "Configuring Pacman Snapshot Hook" + + mkdir -p /mnt/etc/pacman.d/hooks + + cat > /mnt/etc/pacman.d/hooks/zfs-snapshot.hook << EOF +[Trigger] +Operation = Upgrade +Operation = Install +Operation = Remove +Type = Package +Target = * + +[Action] +Description = Creating ZFS snapshot before pacman transaction... +When = PreTransaction +Exec = /usr/local/bin/zfs-pre-snapshot +EOF + + cat > /mnt/usr/local/bin/zfs-pre-snapshot << 'EOF' +#!/bin/bash +POOL="zroot" +DATASET="$POOL/ROOT/default" +TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S) +SNAPSHOT_NAME="pre-pacman_$TIMESTAMP" + +if zfs snapshot "$DATASET@$SNAPSHOT_NAME"; then + echo "Created snapshot: $DATASET@$SNAPSHOT_NAME" +else + echo "Warning: Failed to create snapshot" >&2 +fi +EOF + + chmod +x /mnt/usr/local/bin/zfs-pre-snapshot + info "Pacman hook configured." +} + +############################# +# ZFS Tools +############################# + +install_zfs_tools() { + step "Installing ZFS Management Tools" + + # Copy ZFS management scripts + cp /usr/local/bin/zfssnapshot /mnt/usr/local/bin/zfssnapshot + cp /usr/local/bin/zfsrollback /mnt/usr/local/bin/zfsrollback + chmod +x /mnt/usr/local/bin/zfssnapshot + chmod +x /mnt/usr/local/bin/zfsrollback + + info "ZFS management scripts installed: zfssnapshot, zfsrollback" +} + +############################# +# EFI Sync (Multi-disk) +############################# + +sync_zfs_efi_partitions() { + local efi_parts=() + for disk in "${SELECTED_DISKS[@]}"; do + efi_parts+=("$(get_efi_partition "$disk")") + done + + # Skip if only one disk + [[ ${#efi_parts[@]} -le 1 ]] && return + + step "Syncing EFI partitions for redundancy" + + for ((i=1; i<${#efi_parts[@]}; i++)); do + local secondary="${efi_parts[$i]}" + local tmp_mount="/tmp/efi_sync_$$" + + mkdir -p "$tmp_mount" + mount "$secondary" "$tmp_mount" + rsync -a /mnt/efi/ "$tmp_mount/" + umount "$tmp_mount" + rmdir "$tmp_mount" + + info "Synced EFI to $secondary" + done +} + +############################# +# Genesis Snapshot +############################# + +create_zfs_genesis_snapshot() { + step "Creating Genesis Snapshot" + + local snapshot_name="genesis" + zfs snapshot -r "$POOL_NAME@$snapshot_name" + + info "Genesis snapshot created: $POOL_NAME@$snapshot_name" + info "You can restore to this point anytime with: zfsrollback $snapshot_name" +} + +############################# +# ZFS Cleanup +############################# + +zfs_cleanup() { + step "Cleaning up ZFS" + + # Unmount all ZFS datasets + zfs unmount -a 2>/dev/null || true + + # Unmount EFI + umount /mnt/efi 2>/dev/null || true + + # Export pool (important for clean import on boot) + zpool export "$POOL_NAME" + + info "ZFS pool exported cleanly." +} |
