diff options
| author | Craig Jennings <c@cjennings.net> | 2026-04-12 23:58:01 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-04-12 23:58:01 -0400 |
| commit | 19f4624749228fcbe385d1edf1d2542c036440ff (patch) | |
| tree | 961e815d2226178345f5ec7ec7cb82f23da34379 | |
| parent | 863ceeac8fdb10258a58d35bcee6874097fffc88 (diff) | |
| download | archangel-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-x | installer/archangel | 11 | ||||
| -rw-r--r-- | installer/lib/raid.sh | 70 | ||||
| -rw-r--r-- | tests/unit/test_raid.bats | 199 |
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 ] +} |
