aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-12 23:58:01 -0400
committerCraig Jennings <c@cjennings.net>2026-04-12 23:58:01 -0400
commit19f4624749228fcbe385d1edf1d2542c036440ff (patch)
tree961e815d2226178345f5ec7ec7cb82f23da34379
parent863ceeac8fdb10258a58d35bcee6874097fffc88 (diff)
downloadarchangel-19f4624749228fcbe385d1edf1d2542c036440ff.tar.gz
archangel-19f4624749228fcbe385d1edf1d2542c036440ff.zip
refactor: extract pure RAID logic to lib/raid.sh with bats coverage
Peel the testable pieces of get_raid_level() out of the 1600-line installer monolith into installer/lib/raid.sh: - raid_valid_levels_for_count(count) — replaces the inline option-list builder in get_raid_level() - raid_is_valid(level, count) — useful for unattended-config validation - raid_usable_bytes(level, count, smallest, total) — usable-space math - raid_fault_tolerance(level, count) — max tolerable disk failures archangel now sources lib/raid.sh and uses raid_valid_levels_for_count for the fzf option list. Fzf preview subshell still inlines its own usable-bytes arithmetic (calling exported lib functions across preview subshells is fragile; left for a later pass). 30 bats tests in tests/unit/test_raid.bats cover the full enumeration table, every valid/invalid level-vs-count combo from 2 to 5 disks, mixed-size mirror, and unknown-level error paths. make test: 53/53.
-rwxr-xr-xinstaller/archangel11
-rw-r--r--installer/lib/raid.sh70
-rw-r--r--tests/unit/test_raid.bats199
3 files changed, 274 insertions, 6 deletions
diff --git a/installer/archangel b/installer/archangel
index 615829d..d1831cf 100755
--- a/installer/archangel
+++ b/installer/archangel
@@ -29,6 +29,7 @@ source "$SCRIPT_DIR/lib/common.sh"
source "$SCRIPT_DIR/lib/config.sh"
source "$SCRIPT_DIR/lib/disk.sh"
source "$SCRIPT_DIR/lib/btrfs.sh"
+source "$SCRIPT_DIR/lib/raid.sh"
#############################
# Configuration
@@ -368,18 +369,16 @@ get_raid_level() {
local total_gb=$((total_bytes / 1073741824))
local smallest_gb=$((smallest_bytes / 1073741824))
- # Build options based on disk count
- local options="mirror\nstripe"
- [[ $disk_count -ge 3 ]] && options+="\nraidz1"
- [[ $disk_count -ge 4 ]] && options+="\nraidz2"
- [[ $disk_count -ge 5 ]] && options+="\nraidz3"
+ # Build options based on disk count (pure logic → lib/raid.sh)
+ local options
+ options=$(raid_valid_levels_for_count "$disk_count")
# Export variables for preview subshell
export RAID_DISK_COUNT=$disk_count
export RAID_TOTAL_GB=$total_gb
export RAID_SMALLEST_GB=$smallest_gb
- RAID_LEVEL=$(echo -e "$options" \
+ RAID_LEVEL=$(echo "$options" \
| fzf --height=20 --layout=reverse --border \
--header="Select RAID Level ($disk_count disks, ${total_gb}GB total)" \
--preview='
diff --git a/installer/lib/raid.sh b/installer/lib/raid.sh
new file mode 100644
index 0000000..3e28177
--- /dev/null
+++ b/installer/lib/raid.sh
@@ -0,0 +1,70 @@
+#!/usr/bin/env bash
+# raid.sh - Pure RAID-level logic (testable, no I/O).
+# Source after common.sh.
+
+#############################
+# Valid-level enumeration
+#############################
+
+# Print valid RAID levels for a given disk count, one per line.
+# Count <2: nothing printed (single disk = no RAID).
+# Count 2: mirror, stripe
+# Count 3+: + raidz1
+# Count 4+: + raidz2
+# Count 5+: + raidz3
+raid_valid_levels_for_count() {
+ local count=$1
+ [[ $count -lt 2 ]] && return 0
+ echo mirror
+ echo stripe
+ [[ $count -ge 3 ]] && echo raidz1
+ [[ $count -ge 4 ]] && echo raidz2
+ [[ $count -ge 5 ]] && echo raidz3
+ return 0
+}
+
+# Return 0 if level is valid for the given disk count, 1 otherwise.
+# Empty level with count 1 is valid (no RAID).
+raid_is_valid() {
+ local level=$1 count=$2
+ if [[ $count -le 1 ]]; then
+ [[ -z "$level" ]]
+ return
+ fi
+ raid_valid_levels_for_count "$count" | grep -qxF "$level"
+}
+
+#############################
+# Usable-space computation
+#############################
+
+# Print usable bytes for a level given disk count, smallest-disk bytes,
+# and total bytes across all disks. Writes nothing and returns 1 for
+# unknown levels.
+#
+# Usage: raid_usable_bytes LEVEL COUNT SMALLEST_BYTES TOTAL_BYTES
+raid_usable_bytes() {
+ local level=$1 count=$2 smallest=$3 total=$4
+ case "$level" in
+ mirror) echo "$smallest" ;;
+ stripe) echo "$total" ;;
+ raidz1) echo $(( (count - 1) * smallest )) ;;
+ raidz2) echo $(( (count - 2) * smallest )) ;;
+ raidz3) echo $(( (count - 3) * smallest )) ;;
+ *) return 1 ;;
+ esac
+}
+
+# Print fault-tolerance (max number of disks that can fail) for a level
+# at the given disk count. Unknown level → return 1.
+raid_fault_tolerance() {
+ local level=$1 count=$2
+ case "$level" in
+ mirror) echo $(( count - 1 )) ;;
+ stripe) echo 0 ;;
+ raidz1) echo 1 ;;
+ raidz2) echo 2 ;;
+ raidz3) echo 3 ;;
+ *) return 1 ;;
+ esac
+}
diff --git a/tests/unit/test_raid.bats b/tests/unit/test_raid.bats
new file mode 100644
index 0000000..4e2f842
--- /dev/null
+++ b/tests/unit/test_raid.bats
@@ -0,0 +1,199 @@
+#!/usr/bin/env bats
+# Unit tests for installer/lib/raid.sh
+
+setup() {
+ # shellcheck disable=SC1091
+ source "${BATS_TEST_DIRNAME}/../../installer/lib/raid.sh"
+}
+
+#############################
+# raid_valid_levels_for_count
+#############################
+
+@test "raid_valid_levels_for_count: 0 disks → empty output" {
+ run raid_valid_levels_for_count 0
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "raid_valid_levels_for_count: 1 disk → empty output" {
+ run raid_valid_levels_for_count 1
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "raid_valid_levels_for_count: 2 disks → mirror + stripe" {
+ run raid_valid_levels_for_count 2
+ [ "$status" -eq 0 ]
+ [ "$output" = "mirror
+stripe" ]
+}
+
+@test "raid_valid_levels_for_count: 3 disks → + raidz1" {
+ run raid_valid_levels_for_count 3
+ [ "$status" -eq 0 ]
+ [ "$output" = "mirror
+stripe
+raidz1" ]
+}
+
+@test "raid_valid_levels_for_count: 4 disks → + raidz2" {
+ run raid_valid_levels_for_count 4
+ [ "$status" -eq 0 ]
+ [ "$output" = "mirror
+stripe
+raidz1
+raidz2" ]
+}
+
+@test "raid_valid_levels_for_count: 5 disks → + raidz3" {
+ run raid_valid_levels_for_count 5
+ [ "$status" -eq 0 ]
+ [ "$output" = "mirror
+stripe
+raidz1
+raidz2
+raidz3" ]
+}
+
+@test "raid_valid_levels_for_count: 8 disks → same as 5 (no new levels)" {
+ levels_5=$(raid_valid_levels_for_count 5)
+ levels_8=$(raid_valid_levels_for_count 8)
+ [ "$levels_5" = "$levels_8" ]
+}
+
+#############################
+# raid_is_valid
+#############################
+
+@test "raid_is_valid: empty level + 1 disk = valid (no RAID)" {
+ run raid_is_valid "" 1
+ [ "$status" -eq 0 ]
+}
+
+@test "raid_is_valid: any level + 1 disk = invalid" {
+ run raid_is_valid mirror 1
+ [ "$status" -eq 1 ]
+}
+
+@test "raid_is_valid: mirror + 2 disks = valid" {
+ run raid_is_valid mirror 2
+ [ "$status" -eq 0 ]
+}
+
+@test "raid_is_valid: stripe + 2 disks = valid" {
+ run raid_is_valid stripe 2
+ [ "$status" -eq 0 ]
+}
+
+@test "raid_is_valid: raidz1 + 2 disks = invalid (need 3)" {
+ run raid_is_valid raidz1 2
+ [ "$status" -eq 1 ]
+}
+
+@test "raid_is_valid: raidz1 + 3 disks = valid" {
+ run raid_is_valid raidz1 3
+ [ "$status" -eq 0 ]
+}
+
+@test "raid_is_valid: raidz2 + 3 disks = invalid (need 4)" {
+ run raid_is_valid raidz2 3
+ [ "$status" -eq 1 ]
+}
+
+@test "raid_is_valid: raidz2 + 4 disks = valid" {
+ run raid_is_valid raidz2 4
+ [ "$status" -eq 0 ]
+}
+
+@test "raid_is_valid: raidz3 + 4 disks = invalid (need 5)" {
+ run raid_is_valid raidz3 4
+ [ "$status" -eq 1 ]
+}
+
+@test "raid_is_valid: raidz3 + 5 disks = valid" {
+ run raid_is_valid raidz3 5
+ [ "$status" -eq 0 ]
+}
+
+@test "raid_is_valid: unknown level = invalid" {
+ run raid_is_valid raidz99 5
+ [ "$status" -eq 1 ]
+}
+
+#############################
+# raid_usable_bytes
+#############################
+
+@test "raid_usable_bytes: mirror returns smallest disk's bytes" {
+ run raid_usable_bytes mirror 3 100 300
+ [ "$status" -eq 0 ]
+ [ "$output" = "100" ]
+}
+
+@test "raid_usable_bytes: stripe returns total bytes" {
+ run raid_usable_bytes stripe 3 100 300
+ [ "$status" -eq 0 ]
+ [ "$output" = "300" ]
+}
+
+@test "raid_usable_bytes: raidz1 = (n-1) * smallest" {
+ run raid_usable_bytes raidz1 3 100 300
+ [ "$status" -eq 0 ]
+ [ "$output" = "200" ]
+}
+
+@test "raid_usable_bytes: raidz2 = (n-2) * smallest" {
+ run raid_usable_bytes raidz2 4 100 400
+ [ "$status" -eq 0 ]
+ [ "$output" = "200" ]
+}
+
+@test "raid_usable_bytes: raidz3 = (n-3) * smallest" {
+ run raid_usable_bytes raidz3 5 100 500
+ [ "$status" -eq 0 ]
+ [ "$output" = "200" ]
+}
+
+@test "raid_usable_bytes: mixed-size mirror honors smallest (not average)" {
+ run raid_usable_bytes mirror 3 80 300
+ [ "$status" -eq 0 ]
+ [ "$output" = "80" ]
+}
+
+@test "raid_usable_bytes: unknown level returns status 1" {
+ run raid_usable_bytes bogus 3 100 300
+ [ "$status" -eq 1 ]
+ [ -z "$output" ]
+}
+
+#############################
+# raid_fault_tolerance
+#############################
+
+@test "raid_fault_tolerance: mirror of 3 = can lose 2" {
+ run raid_fault_tolerance mirror 3
+ [ "$output" = "2" ]
+}
+
+@test "raid_fault_tolerance: mirror of 5 = can lose 4" {
+ run raid_fault_tolerance mirror 5
+ [ "$output" = "4" ]
+}
+
+@test "raid_fault_tolerance: stripe = 0" {
+ run raid_fault_tolerance stripe 4
+ [ "$output" = "0" ]
+}
+
+@test "raid_fault_tolerance: raidz1/2/3 = 1/2/3 regardless of disk count" {
+ [ "$(raid_fault_tolerance raidz1 3)" = "1" ]
+ [ "$(raid_fault_tolerance raidz1 8)" = "1" ]
+ [ "$(raid_fault_tolerance raidz2 4)" = "2" ]
+ [ "$(raid_fault_tolerance raidz3 5)" = "3" ]
+}
+
+@test "raid_fault_tolerance: unknown level returns status 1" {
+ run raid_fault_tolerance bogus 3
+ [ "$status" -eq 1 ]
+}