aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-08-14 09:59:55 -0500
committerCraig Jennings <c@cjennings.net>2026-08-14 09:59:55 -0500
commit1a900e50433b0fcd3a192eed1c7989233a6928cf (patch)
treefe558b95439053b7bfd3be7297dc8ef00db2f3a9
parent4186fcf3f75831b28333dbfb0814318ec88dd0d0 (diff)
downloadarchangel-1a900e50433b0fcd3a192eed1c7989233a6928cf.tar.gz
archangel-1a900e50433b0fcd3a192eed1c7989233a6928cf.zip
feat(install): optional SWAP_SIZE carves a swap partition for hibernate
Hibernate needs a resume target outside the pool (swap-on-zvol deadlocks, a long-standing OpenZFS issue), and that's only decidable at partition time. SWAP_SIZE=100G in a config file carves partition 3 (type 8200) physically between EFI and root. Root still takes the remainder and the EFI=1/ROOT=2 numbering contract holds. validate_config rejects malformed sizes and multi-disk layouts. get_swap_partition handles nvme vs sata naming. This only partitions: formatting, encryption, and the resume chain stay manual for now. The change ran the real velox reinstall on 2026-08-13 (EFI=512M, SWAP=100G, ROOT=remainder).
-rw-r--r--installer/lib/config.sh17
-rw-r--r--installer/lib/disk.sh18
-rw-r--r--tests/unit/test_config.bats43
-rw-r--r--tests/unit/test_disk.bats46
4 files changed, 123 insertions, 1 deletions
diff --git a/installer/lib/config.sh b/installer/lib/config.sh
index ed54e36..dea65a0 100644
--- a/installer/lib/config.sh
+++ b/installer/lib/config.sh
@@ -17,6 +17,10 @@ LOCALE="en_US.UTF-8"
KEYMAP="us"
ENABLE_SSH="yes" # SSH with root login (default yes for headless)
NO_ENCRYPT="no" # Skip filesystem encryption (testing only)
+SWAP_SIZE="" # Optional swap partition (e.g. "100G") — carved
+ # between EFI and root so hibernate has a resume
+ # target outside the pool. Single-disk only; empty
+ # keeps the original two-partition layout.
# Required fields — installer errors out if any are still empty at install time.
HOSTNAME=""
@@ -142,6 +146,19 @@ validate_config() {
((++errors))
fi
+ # SWAP_SIZE: sgdisk size syntax (digits + K/M/G/T), single disk only —
+ # a per-disk swap on a RAID layout has no defined resume device.
+ if [[ -n "$SWAP_SIZE" ]]; then
+ if [[ ! "$SWAP_SIZE" =~ ^[0-9]+[KMGT]$ ]]; then
+ warn "Invalid SWAP_SIZE '$SWAP_SIZE' (expected e.g. 100G)"
+ ((++errors))
+ fi
+ if [[ ${#SELECTED_DISKS[@]} -gt 1 ]]; then
+ warn "SWAP_SIZE is only supported on single-disk installs"
+ ((++errors))
+ fi
+ fi
+
if [[ $errors -gt 0 ]]; then
error "Config validation failed with $errors error(s)"
fi
diff --git a/installer/lib/disk.sh b/installer/lib/disk.sh
index ae7801b..ba2d3a2 100644
--- a/installer/lib/disk.sh
+++ b/installer/lib/disk.sh
@@ -25,12 +25,18 @@ partition_disk() {
wipefs -af "$disk" || error "Failed to wipe signatures on $disk"
sgdisk --zap-all "$disk" || error "Failed to zap GPT on $disk"
sgdisk -n 1:0:+${efi_size} -t 1:EF00 -c 1:"EFI" "$disk" || error "Failed to create EFI partition on $disk"
+ # Optional swap partition for hibernate: physically between EFI and
+ # root (carved first so root's 0:0 takes the remainder), numbered 3 so
+ # the EFI=1/ROOT=2 contract every downstream caller relies on holds.
+ if [[ -n "${SWAP_SIZE:-}" ]]; then
+ sgdisk -n 3:0:+${SWAP_SIZE} -t 3:8200 -c 3:"SWAP" "$disk" || error "Failed to create swap partition on $disk"
+ fi
sgdisk -n 2:0:0 -t 2:$root_type -c 2:"ROOT" "$disk" || error "Failed to create root partition on $disk"
partprobe "$disk" 2>/dev/null || true
sleep 1
- info "Partitioned $disk: EFI=${efi_size}, ROOT=remainder"
+ info "Partitioned $disk: EFI=${efi_size}${SWAP_SIZE:+, SWAP=${SWAP_SIZE}}, ROOT=remainder"
}
# Partition every disk in SELECTED_DISKS, format each EFI partition,
@@ -92,6 +98,16 @@ get_root_partition() {
fi
}
+# Partition 3 exists only when SWAP_SIZE was set at partition time.
+get_swap_partition() {
+ local disk="$1"
+ if [[ "$disk" =~ nvme ]]; then
+ echo "${disk}p3"
+ else
+ echo "${disk}3"
+ fi
+}
+
#############################
# Disk Selection (Interactive)
#############################
diff --git a/tests/unit/test_config.bats b/tests/unit/test_config.bats
index 4169c5e..554c0c7 100644
--- a/tests/unit/test_config.bats
+++ b/tests/unit/test_config.bats
@@ -302,3 +302,46 @@ EOF
[[ "$output" == *"LUKS_PASSPHRASE"* ]]
! [[ "$output" == *"ZFS_PASSPHRASE"* ]]
}
+
+#############################
+# SWAP_SIZE validation
+#############################
+
+@test "validate_config accepts empty SWAP_SIZE (default layout)" {
+ HOSTNAME=h; TIMEZONE=UTC; ROOT_PASSWORD=x
+ SELECTED_DISKS=(/dev/sda); RAID_LEVEL=""; SWAP_SIZE=""
+ run validate_config
+ [[ "$output" != *"SWAP_SIZE"* ]]
+}
+
+@test "validate_config accepts a well-formed SWAP_SIZE on a single disk" {
+ HOSTNAME=h; TIMEZONE=UTC; ROOT_PASSWORD=x
+ SELECTED_DISKS=(/dev/sda); RAID_LEVEL=""; SWAP_SIZE=100G
+ run validate_config
+ [[ "$output" != *"Invalid SWAP_SIZE"* ]]
+ [[ "$output" != *"single-disk"* ]]
+}
+
+@test "validate_config rejects SWAP_SIZE without a unit suffix" {
+ HOSTNAME=h; TIMEZONE=UTC; ROOT_PASSWORD=x
+ SELECTED_DISKS=(/dev/sda); RAID_LEVEL=""; SWAP_SIZE=100
+ run validate_config
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"Invalid SWAP_SIZE"* ]]
+}
+
+@test "validate_config rejects SWAP_SIZE with a bogus unit" {
+ HOSTNAME=h; TIMEZONE=UTC; ROOT_PASSWORD=x
+ SELECTED_DISKS=(/dev/sda); RAID_LEVEL=""; SWAP_SIZE=100Q
+ run validate_config
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"Invalid SWAP_SIZE"* ]]
+}
+
+@test "validate_config rejects SWAP_SIZE on a multi-disk layout" {
+ HOSTNAME=h; TIMEZONE=UTC; ROOT_PASSWORD=x
+ SELECTED_DISKS=(/dev/sda /dev/sdb); RAID_LEVEL=mirror; SWAP_SIZE=100G
+ run validate_config
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"single-disk"* ]]
+}
diff --git a/tests/unit/test_disk.bats b/tests/unit/test_disk.bats
index b962445..462f5d7 100644
--- a/tests/unit/test_disk.bats
+++ b/tests/unit/test_disk.bats
@@ -320,3 +320,49 @@ partition_disks_setup() {
run disk_in_use /dev/zzz999
[ "$status" -eq 1 ]
}
+
+#############################
+# get_swap_partition
+#############################
+
+@test "get_swap_partition: SATA disk gets numeric suffix 3" {
+ run get_swap_partition /dev/sda
+ [ "$status" -eq 0 ]
+ [ "$output" = "/dev/sda3" ]
+}
+
+@test "get_swap_partition: NVMe disk gets p-prefixed suffix 3" {
+ run get_swap_partition /dev/nvme0n1
+ [ "$status" -eq 0 ]
+ [ "$output" = "/dev/nvme0n1p3" ]
+}
+
+#############################
+# partition_disk SWAP_SIZE layout
+#############################
+
+@test "partition_disk without SWAP_SIZE keeps the two-partition layout" {
+ partition_disks_setup
+ FILESYSTEM=zfs
+ SWAP_SIZE=""
+ partition_disk /dev/sda
+ for c in "${CALLS[@]}"; do
+ [[ "$c" != *"8200"* ]]
+ done
+}
+
+@test "partition_disk with SWAP_SIZE carves swap before root so root takes remainder" {
+ partition_disks_setup
+ FILESYSTEM=zfs
+ SWAP_SIZE=100G
+ partition_disk /dev/nvme0n1
+ local swap_idx=-1 root_idx=-1 i=0
+ for c in "${CALLS[@]}"; do
+ [[ "$c" == *"-n 3:0:+100G -t 3:8200"* ]] && swap_idx=$i
+ [[ "$c" == *"-n 2:0:0"* ]] && root_idx=$i
+ i=$((i+1))
+ done
+ [ "$swap_idx" -ge 0 ]
+ [ "$root_idx" -ge 0 ]
+ [ "$swap_idx" -lt "$root_idx" ]
+}