aboutsummaryrefslogtreecommitdiff
path: root/installer/lib
diff options
context:
space:
mode:
Diffstat (limited to 'installer/lib')
-rw-r--r--installer/lib/btrfs.sh900
-rw-r--r--installer/lib/common.sh173
-rw-r--r--installer/lib/config.sh131
-rw-r--r--installer/lib/disk.sh204
-rw-r--r--installer/lib/zfs.sh359
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."
+}