aboutsummaryrefslogtreecommitdiff
path: root/scripts/testing
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/testing')
-rw-r--r--scripts/testing/archsetup-test-zfs.conf21
-rwxr-xr-xscripts/testing/create-base-vm.sh11
-rw-r--r--scripts/testing/lib/network-diagnostics.sh96
-rw-r--r--scripts/testing/lib/testinfra.sh120
-rw-r--r--scripts/testing/lib/validation.sh844
-rwxr-xr-xscripts/testing/lib/vm-utils.sh68
-rw-r--r--scripts/testing/maint-scenarios/10-journal-vacuum.sh30
-rw-r--r--scripts/testing/maint-scenarios/11-coredump-age-out.sh21
-rw-r--r--scripts/testing/maint-scenarios/20-cache-keep3.sh24
-rw-r--r--scripts/testing/maint-scenarios/21-cache-uninstalled.sh20
-rw-r--r--scripts/testing/maint-scenarios/22-orphan-remove.sh22
-rw-r--r--scripts/testing/maint-scenarios/23-pacnew-delete.sh22
-rw-r--r--scripts/testing/maint-scenarios/30-unit-reset.sh28
-rw-r--r--scripts/testing/maint-scenarios/31-fstrim-enable.sh22
-rw-r--r--scripts/testing/maint-scenarios/32-cron-enable.sh20
-rw-r--r--scripts/testing/run-maint-nspawn.sh268
-rw-r--r--scripts/testing/run-maint-scenarios.sh374
-rwxr-xr-xscripts/testing/run-test-baremetal.sh59
-rwxr-xr-xscripts/testing/run-test.sh44
-rw-r--r--scripts/testing/tests/conftest.py111
-rw-r--r--scripts/testing/tests/test_archsetup.py26
-rw-r--r--scripts/testing/tests/test_backups.py44
-rw-r--r--scripts/testing/tests/test_boot.py83
-rw-r--r--scripts/testing/tests/test_config_applied.py55
-rw-r--r--scripts/testing/tests/test_desktop.py140
-rw-r--r--scripts/testing/tests/test_dotfiles.py19
-rw-r--r--scripts/testing/tests/test_hardening.py50
-rw-r--r--scripts/testing/tests/test_keyring.py35
-rw-r--r--scripts/testing/tests/test_packages.py109
-rw-r--r--scripts/testing/tests/test_services.py103
-rw-r--r--scripts/testing/tests/test_users.py34
31 files changed, 2019 insertions, 904 deletions
diff --git a/scripts/testing/archsetup-test-zfs.conf b/scripts/testing/archsetup-test-zfs.conf
new file mode 100644
index 0000000..a5459cf
--- /dev/null
+++ b/scripts/testing/archsetup-test-zfs.conf
@@ -0,0 +1,21 @@
+# archsetup-test-zfs.conf - Archangel config for archsetup ZFS test VMs
+# Used by create-base-vm.sh (FS_PROFILE=zfs) for fully automated base VM creation
+#
+# Usage: archangel --config-file /root/archsetup-test.conf
+#
+# Note: User creation is handled by archsetup, not archangel.
+# See archsetup-vm.conf for archsetup configuration (shared across profiles -
+# archsetup detects ZFS from the live root, so it needs no filesystem setting).
+#
+# Unencrypted ZFS root: encryption isn't what the harness validates, and
+# NO_ENCRYPT=yes skips the passphrase prompt for a fully unattended install.
+
+FILESYSTEM=zfs
+HOSTNAME=archsetup-test
+TIMEZONE=America/Chicago
+LOCALE=en_US.UTF-8
+KEYMAP=us
+DISKS=/dev/vda
+NO_ENCRYPT=yes
+ROOT_PASSWORD=archsetup
+ENABLE_SSH=yes
diff --git a/scripts/testing/create-base-vm.sh b/scripts/testing/create-base-vm.sh
index b8a4e2b..e626813 100755
--- a/scripts/testing/create-base-vm.sh
+++ b/scripts/testing/create-base-vm.sh
@@ -20,10 +20,19 @@ source "$SCRIPT_DIR/lib/vm-utils.sh"
# Configuration
VM_IMAGES_DIR="$PROJECT_ROOT/vm-images"
-CONFIG_FILE="$SCRIPT_DIR/archsetup-test.conf"
LIVE_ISO_PASSWORD="archangel"
SNAPSHOT_NAME="clean-install"
+# FS_PROFILE (btrfs default / zfs) picks the archangel base-install config.
+# btrfs -> archsetup-test.conf, zfs -> archsetup-test-zfs.conf. The matching
+# base image name is derived from FS_PROFILE by init_vm_paths.
+FS_PROFILE="${FS_PROFILE:-btrfs}"
+if [ "$FS_PROFILE" = "btrfs" ]; then
+ CONFIG_FILE="$SCRIPT_DIR/archsetup-test.conf"
+else
+ CONFIG_FILE="$SCRIPT_DIR/archsetup-test-${FS_PROFILE}.conf"
+fi
+
# Initialize logging
mkdir -p "$PROJECT_ROOT/test-results"
LOGFILE="$PROJECT_ROOT/test-results/create-base-vm-$(date +'%Y%m%d-%H%M%S').log"
diff --git a/scripts/testing/lib/network-diagnostics.sh b/scripts/testing/lib/network-diagnostics.sh
index 38788e5..dc54334 100644
--- a/scripts/testing/lib/network-diagnostics.sh
+++ b/scripts/testing/lib/network-diagnostics.sh
@@ -6,58 +6,110 @@
# Note: logging.sh and vm-utils.sh should already be sourced by the calling script
# Uses globals: ROOT_PASSWORD, SSH_PORT, SSH_OPTS, VM_IP (from vm-utils.sh or calling script)
+# Optional global: TEST_RESULTS_DIR (raw command outputs are saved there when set)
-# Run quick network diagnostics
+# Gather one read-only fact from the VM, print it, and save the raw output.
+# Facts are collected regardless of pass/fail so a failing install still leaves
+# the IP/route/resolver evidence in the log and the results dir.
+# $1 label human-readable label for the fact
+# $2 slug filename slug for the saved raw output
+# $3 cmd remote command to run over the shared ssh_base
+# Uses the caller's locals ssh_base and results_dir (dynamic scope).
+_netdiag_fact() {
+ local label="$1" slug="$2" cmd="$3" out
+ out="$($ssh_base "$cmd" 2>&1)"
+ info "${label}:"
+ printf '%s\n' "$out" | while IFS= read -r line; do
+ info " $line"
+ done
+ if [ -n "$results_dir" ]; then
+ printf '%s\n' "$out" > "$results_dir/netdiag-${slug}.txt" 2>/dev/null || true
+ fi
+}
+
+# Run quick network diagnostics.
+#
+# Evidence first: collect read-only facts (interfaces, route, resolver)
+# unconditionally, then run every reachability check and report all failures at
+# the end. A DNS failure is named as a DNS failure, not masked as a generic "no
+# internet" or misattributed to the Arch mirror. Returns 0 when all checks pass,
+# non-zero when any check fails, so callers keep their success/failure contract.
run_network_diagnostics() {
local password="${ROOT_PASSWORD:-archsetup}"
local port="${SSH_PORT:-22}"
local host="${VM_IP:-localhost}"
local ssh_base="sshpass -p $password ssh $SSH_OPTS -p $port root@$host"
+ local results_dir="${TEST_RESULTS_DIR:-}"
+ local failures=()
section "Pre-flight Network Diagnostics"
- # Test 1: Basic connectivity (use curl instead of ping - SLIRP may not handle ICMP)
- step "Testing internet connectivity"
- if $ssh_base "curl -s --connect-timeout 5 -o /dev/null http://archlinux.org" 2>/dev/null; then
- success "Internet connectivity OK"
- else
- error "No internet connectivity"
- return 1
- fi
+ # --- Phase 1: collect read-only facts, unconditionally ---
+ # These never gate the outcome; they exist so a failed install still has
+ # the interface/route/resolver evidence to diagnose from.
+ step "Collecting interface addresses"
+ _netdiag_fact "Interface addresses (ip -brief addr)" "ip-addr" "ip -brief addr"
+
+ step "Collecting default route"
+ _netdiag_fact "Default route (ip route show default)" "ip-route" "ip route show default"
- # Test 2: DNS resolution (use getent which is always available, unlike nslookup/dig)
+ step "Reading resolver configuration"
+ _netdiag_fact "Resolver (/etc/resolv.conf)" "resolv-conf" "cat /etc/resolv.conf"
+
+ # --- Phase 2: generic connectivity checks (run all, don't short-circuit) ---
+ # DNS, egress, and TLS are independent failure modes. Keeping them separate
+ # means a resolver problem reads as DNS, not as a downstream mirror failure.
step "Testing DNS resolution"
if $ssh_base "getent hosts archlinux.org >/dev/null 2>&1" 2>/dev/null; then
success "DNS resolution OK"
else
error "DNS resolution failed"
- return 1
+ failures+=("DNS resolution (getent hosts archlinux.org)")
fi
- # Test 3: Arch mirror accessibility
+ step "Testing HTTP egress"
+ if $ssh_base "curl -s --connect-timeout 5 -o /dev/null http://archlinux.org" 2>/dev/null; then
+ success "HTTP egress OK"
+ else
+ error "HTTP egress failed"
+ failures+=("HTTP egress (http://archlinux.org)")
+ fi
+
+ step "Testing TLS/HTTPS egress"
+ if $ssh_base "curl -s --connect-timeout 5 -o /dev/null https://archlinux.org" 2>/dev/null; then
+ success "TLS/HTTPS egress OK"
+ else
+ error "TLS/HTTPS egress failed"
+ failures+=("TLS/HTTPS egress (https://archlinux.org)")
+ fi
+
+ # --- Phase 3: Arch-specific checks (run all, don't short-circuit) ---
step "Testing Arch mirror access"
if $ssh_base "curl -s -I https://geo.mirror.pkgbuild.com/ | head -1 | grep -qE '(200|301|302)'" 2>/dev/null; then
success "Arch mirrors accessible"
else
error "Cannot reach Arch mirrors"
- return 1
+ failures+=("Arch mirror (https://geo.mirror.pkgbuild.com/)")
fi
- # Test 4: AUR accessibility
step "Testing AUR access"
if $ssh_base "curl -s -I https://aur.archlinux.org/ | head -1 | grep -qE '(200|405)'" 2>/dev/null; then
success "AUR accessible"
else
error "Cannot reach AUR"
- return 1
+ failures+=("AUR (https://aur.archlinux.org/)")
fi
- # Show network info
- info "Network configuration:"
- $ssh_base "ip addr show | grep 'inet ' | grep -v '127.0.0.1'" 2>/dev/null | while IFS= read -r line; do
- info " $line"
- done
+ # --- Summary: report every failure, not just the first ---
+ if [ ${#failures[@]} -eq 0 ]; then
+ success "Network diagnostics complete - all checks passed"
+ return 0
+ fi
- success "Network diagnostics complete"
- return 0
+ error "Network diagnostics found ${#failures[@]} failure(s):"
+ local f
+ for f in "${failures[@]}"; do
+ error " - $f"
+ done
+ return 1
}
diff --git a/scripts/testing/lib/testinfra.sh b/scripts/testing/lib/testinfra.sh
new file mode 100644
index 0000000..0822a9f
--- /dev/null
+++ b/scripts/testing/lib/testinfra.sh
@@ -0,0 +1,120 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Testinfra post-install validation sweep (runs on the host, over SSH).
+#
+# This is the primary post-install validator (it replaced the shell
+# run_all_validations sweep). It connects to the freshly-installed VM over SSH
+# and runs the pytest suite under scripts/testing/tests/. Its result drives the
+# run's pass/fail, and per-test failures are bucketed (archsetup / base_install
+# / unknown) into the same issue-attribution report the shell sweep produced.
+#
+# Auth: reuse the root key the harness already authorized (inject_root_key),
+# which survives the sshd prohibit-password hardening; mint our own only if the
+# harness didn't (standalone use). pytest connects key-only via a generated
+# ssh-config. Key + config live in the results dir and are discarded with it.
+#
+# Uses globals from run-test.sh / vm-utils.sh: SCRIPT_DIR, VM_IP, SSH_PORT,
+# ROOT_PASSWORD, ROOT_SSH_KEY, ARCHSETUP_VM_CONF, plus the validation.sh
+# helpers attribute_issue / VALIDATION_*. Toggle with RUN_TESTINFRA=false.
+
+# Record each pytest failure from the attribution file into the issue arrays
+# (validation.sh's attribute_issue), so generate_issue_report covers them.
+_testinfra_record_attribution() {
+ local file="$1" bucket=""
+ [ -f "$file" ] || return 0
+ while IFS= read -r line; do
+ case "$line" in
+ "[archsetup]") bucket=archsetup ;;
+ "[base_install]") bucket=base ;;
+ "[unknown]") bucket=unknown ;;
+ " "*) attribute_issue "testinfra: ${line# }" "$bucket" ;;
+ esac
+ done < "$file"
+}
+
+# run_testinfra_validation <results_dir>
+# Returns 0 only when the pytest sweep ran and passed. Returns non-zero when it
+# failed OR could not run (missing tooling / SSH setup) — a sweep that can't run
+# is not a pass. RUN_TESTINFRA=false is the one explicit opt-out (returns 0).
+run_testinfra_validation() {
+ local results_dir="$1"
+ local tests_dir="$SCRIPT_DIR/tests"
+ local key="$results_dir/testinfra_key"
+ local sshcfg="$results_dir/testinfra_ssh_config"
+
+ if [ "${RUN_TESTINFRA:-true}" != "true" ]; then
+ warn "RUN_TESTINFRA=false - skipping the Testinfra validation sweep"
+ return 0
+ fi
+ if ! command -v pytest >/dev/null 2>&1 || ! python3 -c 'import testinfra' >/dev/null 2>&1; then
+ error "Testinfra/pytest not installed on host - cannot validate (run: make deps)"
+ return 1
+ fi
+
+ section "Running Validation Checks (Testinfra)"
+
+ # Prefer the harness's already-authorized root key; mint one if absent.
+ if [ -n "${ROOT_SSH_KEY:-}" ] && [ -f "${ROOT_SSH_KEY}" ]; then
+ key="$ROOT_SSH_KEY"
+ else
+ rm -f "$key" "$key.pub"
+ if ! ssh-keygen -t ed25519 -N "" -q -f "$key"; then
+ error "testinfra: ssh-keygen failed"
+ return 1
+ fi
+ if ! copy_to_vm "$key.pub" "/tmp/testinfra_key.pub" "$ROOT_PASSWORD"; then
+ error "testinfra: pubkey copy failed"
+ return 1
+ fi
+ if ! vm_exec "$ROOT_PASSWORD" \
+ "mkdir -p /root/.ssh && chmod 700 /root/.ssh && cat /tmp/testinfra_key.pub >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys"; then
+ error "testinfra: authorizing key in VM failed"
+ return 1
+ fi
+ fi
+
+ # ssh-config so testinfra connects key-only, no host-key prompt.
+ cat > "$sshcfg" <<EOF
+Host testinfra-target
+ HostName ${VM_IP:-localhost}
+ Port ${SSH_PORT:-2222}
+ User root
+ IdentityFile $key
+ IdentitiesOnly yes
+ StrictHostKeyChecking no
+ UserKnownHostsFile /dev/null
+EOF
+
+ # The account archsetup created, for the tests that need it.
+ local test_user
+ test_user=$(sed -n 's/^USERNAME=//p' "$ARCHSETUP_VM_CONF" 2>/dev/null | head -n1)
+ : "${test_user:=cjennings}"
+
+ local logf="$results_dir/testinfra.log"
+ ARCHSETUP_TEST_USER="$test_user" pytest "$tests_dir" \
+ --hosts="ssh://testinfra-target" \
+ --ssh-config="$sshcfg" \
+ --attribution-file="$results_dir/testinfra-attribution.txt" \
+ -v >> "$logf" 2>&1
+ local rc=$?
+
+ # Surface pytest's counts through the shared validation counters so the
+ # issue report summary is meaningful (the shell sweep no longer runs).
+ local summary
+ summary=$(grep -oE '[0-9]+ (passed|failed|error|errors|skipped)' "$logf" | tail -10)
+ VALIDATION_PASSED=$(echo "$summary" | awk '/passed/{print $1}' | tail -1); VALIDATION_PASSED=${VALIDATION_PASSED:-0}
+ VALIDATION_WARNINGS=$(echo "$summary" | awk '/skipped/{print $1}' | tail -1); VALIDATION_WARNINGS=${VALIDATION_WARNINGS:-0}
+ local nfail nerr
+ nfail=$(echo "$summary" | awk '/failed/{print $1}' | tail -1); nfail=${nfail:-0}
+ nerr=$(echo "$summary" | awk '/error/{print $1}' | tail -1); nerr=${nerr:-0}
+ VALIDATION_FAILED=$((nfail + nerr))
+
+ if [ "$rc" -eq 0 ]; then
+ success "Testinfra validation passed ($VALIDATION_PASSED passed, $VALIDATION_WARNINGS skipped)"
+ else
+ error "Testinfra validation failed ($VALIDATION_FAILED failed/error; see testinfra.log)"
+ _testinfra_record_attribution "$results_dir/testinfra-attribution.txt"
+ fi
+ return "$rc"
+}
diff --git a/scripts/testing/lib/validation.sh b/scripts/testing/lib/validation.sh
index 6855da7..fa7ddcc 100644
--- a/scripts/testing/lib/validation.sh
+++ b/scripts/testing/lib/validation.sh
@@ -21,38 +21,7 @@ declare -a UNKNOWN_ISSUES
# SSH helper (uses globals: VM_IP, ROOT_PASSWORD)
ssh_cmd() {
sshpass -p "$ROOT_PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
- -o ConnectTimeout=10 -p "${SSH_PORT:-22}" "root@$VM_IP" "$@" 2>/dev/null
-}
-
-# Validation result helpers
-validation_pass() {
- local test_name="$1"
- success "$test_name"
- ((VALIDATION_PASSED++)) || true
-}
-
-validation_fail() {
- local test_name="$1"
- local details="${2:-}"
- error "$test_name"
- [ -n "$details" ] && info " Details: $details"
- ((VALIDATION_FAILED++)) || true
-}
-
-validation_warn() {
- local test_name="$1"
- local details="${2:-}"
- warn "$test_name"
- [ -n "$details" ] && info " Details: $details"
- ((VALIDATION_WARNINGS++)) || true
-}
-
-# A check whose precondition can't hold in this environment (headless VM,
-# slirp networking, pre-reboot state). Logged for the record, counted nowhere
-# — a warning that fires on every run trains readers to ignore warnings.
-validation_skip() {
- local test_name="$1"
- info "SKIP: $test_name"
+ -o ConnectTimeout=10 ${SSH_KEY_OPT:-} -p "${SSH_PORT:-22}" "root@$VM_IP" "$@" 2>/dev/null
}
# Attribute an issue to archsetup or base install
@@ -265,802 +234,6 @@ categorize_errors() {
}
#=============================================================================
-# VALIDATION CHECKS
-#=============================================================================
-
-run_all_validations() {
- section "Running Validation Checks"
-
- # User & Authentication
- validate_user_created
- validate_user_shell
- validate_user_groups
-
- # Dotfiles
- validate_dotfiles
-
- # Package Managers
- validate_yay_installed
- validate_pacman_working
-
- # Window Manager (detects DWM or Hyprland automatically)
- validate_window_manager
-
- # Essential Services
- validate_firewall
- validate_dns_config
- validate_avahi
- validate_fail2ban
- validate_networkmanager
-
- # Developer Tools
- validate_emacs
- validate_git_config
- validate_dev_tools
-
- # System Configuration
- validate_zfs_config
- validate_boot_config
- validate_autologin_config
- validate_gnome_keyring_setup
-
- # Boot & Initramfs (critical for ZFS systems)
- validate_terminus_font
- validate_mkinitcpio_hooks
- validate_initramfs_consolefont
- validate_nvme_module
-
- # Archsetup Specific
- validate_archsetup_log
- validate_state_markers
-}
-
-#-----------------------------------------------------------------------------
-# User & Authentication Validations
-#-----------------------------------------------------------------------------
-
-validate_user_created() {
- step "Checking if user 'cjennings' exists"
- if ssh_cmd "id cjennings" &>> "$LOGFILE"; then
- validation_pass "User cjennings exists"
- else
- validation_fail "User cjennings not found"
- attribute_issue "User cjennings not created" "archsetup"
- fi
-}
-
-validate_user_shell() {
- step "Checking if ZSH is default shell"
- local shell=$(ssh_cmd "getent passwd cjennings | cut -d: -f7")
- if [ "$shell" = "/bin/zsh" ] || [ "$shell" = "/usr/bin/zsh" ]; then
- validation_pass "ZSH is default shell"
- else
- validation_fail "ZSH not default shell (got: $shell)"
- attribute_issue "ZSH not set as default shell" "archsetup"
- fi
-}
-
-validate_user_groups() {
- step "Checking user group memberships"
- # Groups added by archsetup:
- # - wheel (useradd -G wheel)
- # - sys,adm,network,scanner,power,uucp,audio,lp,rfkill,video,storage,optical,users (usermod -aG)
- # - docker (gpasswd -a, added later in developer_workstation)
- local expected_groups="wheel sys adm network scanner power uucp audio lp rfkill video storage optical users docker"
- local missing_groups=""
-
- for group in $expected_groups; do
- if ! ssh_cmd "groups cjennings" | grep -q "\b$group\b"; then
- missing_groups="$missing_groups $group"
- fi
- done
-
- if [ -z "$missing_groups" ]; then
- validation_pass "User in all expected groups (15 groups)"
- else
- validation_fail "User missing groups:$missing_groups"
- attribute_issue "User missing groups:$missing_groups" "archsetup"
- fi
-}
-
-#-----------------------------------------------------------------------------
-# Dotfiles Validations
-#-----------------------------------------------------------------------------
-
-validate_dotfiles() {
- step "Checking dotfiles setup"
-
- # 1. Check if .zshrc is a symlink
- if ! ssh_cmd "test -L /home/cjennings/.zshrc"; then
- validation_fail "Dotfiles not stowed (.zshrc is not a symlink)"
- attribute_issue "Dotfiles stow failed" "archsetup"
- return 1
- fi
-
- # 2. Check symlink points to correct location. archsetup now clones the
- # dotfiles repo to ~/.dotfiles and stows from there (DOTFILES_DIR default).
- # Which tree owns .zshrc depends on DESKTOP_ENV: none stows the standalone
- # minimal/ tree; dwm and hyprland stow common/.
- local target=$(ssh_cmd "readlink /home/cjennings/.zshrc")
- local desktop_env=$(sed -n 's/^DESKTOP_ENV=//p' "$ARCHSETUP_VM_CONF" 2>/dev/null | head -n1)
- local expected_pattern=".dotfiles/common/.zshrc"
- [ "$desktop_env" = "none" ] && expected_pattern=".dotfiles/minimal/.zshrc"
-
- if ! echo "$target" | grep -q "$expected_pattern"; then
- validation_fail "Dotfiles symlink points to wrong location: $target"
- attribute_issue "Dotfiles symlink incorrect: $target" "archsetup"
- return 1
- fi
-
- # 3. Check the target file actually exists (not a broken symlink)
- if ! ssh_cmd "test -f /home/cjennings/.zshrc"; then
- validation_fail "Dotfiles symlink is broken (target doesn't exist)"
- ssh_cmd "ls -la /home/cjennings/.zshrc" >> "$LOGFILE" 2>&1
- attribute_issue "Dotfiles symlink broken" "archsetup"
- return 1
- fi
-
- # 4. Check user can actually read the file (not just root)
- local result=$(ssh_cmd "sudo -u cjennings cat /home/cjennings/.zshrc > /dev/null 2>&1 && echo OK || echo FAIL")
- if [ "$result" != "OK" ]; then
- validation_fail "Dotfiles not readable by user (permission issue)"
- ssh_cmd "ls -la /home/cjennings/.zshrc" >> "$LOGFILE" 2>&1
- attribute_issue "Dotfiles not readable by user" "archsetup"
- return 1
- fi
-
- validation_pass "Dotfiles configured correctly (symlink to $target, readable by user)"
-}
-
-#-----------------------------------------------------------------------------
-# Package Manager Validations
-#-----------------------------------------------------------------------------
-
-validate_yay_installed() {
- step "Checking if yay (AUR helper) is installed and functional"
-
- # Check binary exists
- if ! ssh_cmd "which yay" &>> "$LOGFILE"; then
- validation_fail "yay not found"
- attribute_issue "yay not installed" "archsetup"
- return 1
- fi
-
- # Check yay can query packages (functional test)
- if ssh_cmd "sudo -u cjennings yay -Qi yay" &>> "$LOGFILE"; then
- validation_pass "yay is installed and functional"
- else
- validation_fail "yay binary exists but query failed"
- attribute_issue "yay not functional" "archsetup"
- fi
-}
-
-validate_pacman_working() {
- step "Checking if pacman is functional"
- if ssh_cmd "pacman -Qi base" &>> "$LOGFILE"; then
- validation_pass "pacman is functional"
- else
- validation_fail "pacman query failed"
- attribute_issue "pacman not functional" "unknown"
- fi
-}
-
-#-----------------------------------------------------------------------------
-# Window Manager Validations
-#-----------------------------------------------------------------------------
-
-validate_suckless_tools() {
- step "Checking suckless tools (dwm, st, dmenu, slock)"
- local missing=""
-
- for tool in dwm st dmenu slock; do
- if ! ssh_cmd "test -f /usr/local/bin/$tool"; then
- missing="$missing $tool"
- fi
- done
-
- if [ -z "$missing" ]; then
- validation_pass "All suckless tools installed (dwm, st, dmenu, slock)"
- else
- validation_fail "Missing suckless tools:$missing"
- attribute_issue "Missing suckless tools:$missing" "archsetup"
- fi
-}
-
-validate_hyprland_tools() {
- step "Checking Hyprland tools"
- local missing=""
-
- # Check core Hyprland packages
- for pkg in hyprland hypridle hyprlock waybar fuzzel swww grim slurp gammastep foot; do
- if ! ssh_cmd "pacman -Q $pkg &>/dev/null"; then
- missing="$missing $pkg"
- fi
- done
-
- if [ -z "$missing" ]; then
- validation_pass "All Hyprland tools installed"
- else
- validation_fail "Missing Hyprland tools:$missing"
- attribute_issue "Missing Hyprland tools:$missing" "archsetup"
- fi
-}
-
-validate_hyprland_config() {
- step "Checking Hyprland configuration files"
- local missing=""
-
- for config in ".config/hypr/hyprland.conf" ".config/hypr/hypridle.conf" \
- ".config/hypr/hyprlock.conf" ".config/waybar/config" \
- ".config/fuzzel/fuzzel.ini" ".config/gammastep/config.ini"; do
- if ! ssh_cmd "test -f /home/cjennings/$config"; then
- missing="$missing $config"
- fi
- done
-
- if [ -z "$missing" ]; then
- validation_pass "All Hyprland config files present"
- else
- validation_fail "Missing Hyprland configs:$missing"
- attribute_issue "Missing Hyprland configs:$missing" "archsetup"
- fi
-}
-
-validate_hyprland_socket() {
- step "Checking Hyprland IPC socket"
- # The socket only exists while the compositor runs. In the headless test
- # VM nobody logs in graphically, so a missing socket with no Hyprland
- # process is the expected state, not a finding.
- if ssh_cmd "test -S /tmp/hypr/*/.socket.sock 2>/dev/null"; then
- validation_pass "Hyprland socket exists"
- elif ! ssh_cmd "pgrep -x Hyprland >/dev/null 2>&1"; then
- validation_skip "Hyprland not running (headless) — socket check not applicable"
- else
- validation_warn "Hyprland running but IPC socket not found"
- fi
-}
-
-validate_portal_dark_mode() {
- step "Checking Settings portal returns dark mode"
-
- # Check portals.conf exists and uses gtk for Settings
- local portals_conf="/home/cjennings/.config/xdg-desktop-portal/portals.conf"
- if ! ssh_cmd "test -f $portals_conf"; then
- validation_fail "portals.conf not found"
- attribute_issue "xdg-desktop-portal portals.conf missing" "archsetup"
- return 1
- fi
-
- local settings_backend=$(ssh_cmd "grep 'org.freedesktop.impl.portal.Settings' $portals_conf 2>/dev/null | cut -d= -f2")
- if [ "$settings_backend" = "none" ]; then
- validation_fail "Settings portal disabled (set to 'none')"
- attribute_issue "Settings portal disabled in portals.conf" "archsetup"
- return 1
- fi
-
- # Query the portal for color-scheme (requires portal services running)
- # Returns "v v u 1" for dark mode (1 = prefer-dark)
- local color_scheme=$(ssh_cmd "sudo -u cjennings busctl --user call org.freedesktop.portal.Desktop /org/freedesktop/portal/desktop org.freedesktop.portal.Settings Read 'ss' 'org.freedesktop.appearance' 'color-scheme' 2>/dev/null | grep -o 'u [0-9]' | cut -d' ' -f2")
-
- if [ "$color_scheme" = "1" ]; then
- validation_pass "Settings portal returns dark mode (color-scheme=1)"
- elif [ -z "$color_scheme" ] && ! ssh_cmd "pgrep -x Hyprland >/dev/null 2>&1"; then
- # No compositor → no graphical session bus to query. A socket-activated
- # xdg-desktop-portal process can exist even headless, so the compositor
- # is the real precondition (same condition as the socket check). The
- # conf-file checks above already validated what install controls.
- validation_skip "No compositor running (headless) — portal query not applicable"
- elif [ -z "$color_scheme" ]; then
- validation_warn "Could not query Settings portal (portal may not be running)"
- else
- validation_fail "Settings portal not returning dark mode (color-scheme=$color_scheme, expected 1)"
- attribute_issue "Settings portal not configured for dark mode" "archsetup"
- fi
-}
-
-validate_window_manager() {
- # Detect which desktop environment is installed and validate accordingly
- if ssh_cmd "pacman -Q hyprland &>/dev/null"; then
- section "Hyprland Desktop Environment"
- validate_hyprland_tools
- validate_hyprland_config
- validate_hyprland_socket
- validate_portal_dark_mode
- elif ssh_cmd "test -f /usr/local/bin/dwm"; then
- section "DWM Desktop Environment"
- validate_suckless_tools
- else
- validation_warn "No window manager detected (DESKTOP_ENV=none?)"
- fi
-}
-
-#-----------------------------------------------------------------------------
-# Essential Services Validations
-#-----------------------------------------------------------------------------
-
-validate_firewall() {
- step "Checking if firewall (ufw) is enabled"
- local status=$(ssh_cmd "systemctl is-enabled ufw.service 2>/dev/null || echo disabled")
- if [ "$status" = "enabled" ]; then
- validation_pass "UFW firewall is enabled"
- else
- validation_fail "UFW firewall not enabled"
- attribute_issue "UFW not enabled" "archsetup"
- fi
-}
-
-validate_dns_config() {
- step "Checking DNS-over-TLS configuration"
- if ssh_cmd "grep -q 'DNS=.*#' /etc/systemd/resolved.conf 2>/dev/null"; then
- validation_pass "DNS-over-TLS configured"
- else
- validation_warn "DNS-over-TLS may not be configured"
- fi
-}
-
-validate_avahi() {
- step "Checking avahi-daemon status"
- local status=$(ssh_cmd "systemctl is-enabled avahi-daemon.service 2>/dev/null || echo disabled")
- if [ "$status" = "enabled" ]; then
- validation_pass "avahi-daemon is enabled"
-
- # Full-stack mDNS test: ping hostname.local. QEMU user-mode (slirp,
- # 10.0.2.x) doesn't pass multicast, so mDNS genuinely can't resolve
- # there — only run the ping on real networking.
- if ssh_cmd "ip -4 addr show" 2>/dev/null | grep -q "10\.0\.2\."; then
- validation_skip "mDNS ping not possible on slirp networking (no multicast)"
- else
- local hostname=$(ssh_cmd "hostname")
- if ssh_cmd "ping -c 1 -W 2 ${hostname}.local" &>> "$LOGFILE"; then
- validation_pass "mDNS working (${hostname}.local responds to ping)"
- else
- validation_warn "mDNS ping failed (avahi may need time to propagate)"
- fi
- fi
- else
- # This might be OK if avahi was pre-installed
- validation_warn "avahi-daemon not enabled (may have been pre-configured)"
- fi
-}
-
-validate_fail2ban() {
- step "Checking fail2ban status"
- local status=$(ssh_cmd "systemctl is-enabled fail2ban.service 2>/dev/null || echo disabled")
- if [ "$status" = "enabled" ]; then
- validation_pass "fail2ban is enabled"
- else
- validation_fail "fail2ban not enabled"
- attribute_issue "fail2ban not enabled" "archsetup"
- fi
-}
-
-validate_networkmanager() {
- step "Checking NetworkManager status"
- local status=$(ssh_cmd "systemctl is-enabled NetworkManager.service 2>/dev/null || echo disabled")
- if [ "$status" = "enabled" ]; then
- validation_pass "NetworkManager is enabled"
- # Functional test
- if ssh_cmd "nmcli general status" &>> "$LOGFILE"; then
- validation_pass "NetworkManager is functional"
- else
- validation_warn "NetworkManager enabled but not responding"
- fi
- else
- validation_fail "NetworkManager not enabled"
- attribute_issue "NetworkManager not enabled" "archsetup"
- fi
-}
-
-#-----------------------------------------------------------------------------
-# Service-Specific Validations
-#-----------------------------------------------------------------------------
-
-validate_all_services() {
- section "Service Validations"
-
- # Core services (always expected)
- validate_service "sshd" "enabled" "active"
- validate_service "systemd-resolved" "enabled" "active"
- validate_service "ufw" "enabled" "" # VM lacks iptables modules, can't be active
- validate_service "fail2ban" "enabled" "active"
- validate_service "NetworkManager" "enabled" "active"
- validate_service "rngd" "enabled" "active"
- validate_service "cronie" "enabled" ""
- validate_service "atd" "enabled" ""
-
- # Cron job: log cleanup
- step "Checking log-cleanup cron job"
- local crontab_entry=$(ssh_cmd "sudo -u cjennings crontab -l 2>/dev/null | grep log-cleanup")
- if [ -n "$crontab_entry" ]; then
- validation_pass "log-cleanup cron job installed"
- else
- validation_fail "log-cleanup cron job not in crontab"
- attribute_issue "log-cleanup cron job missing from user crontab" "archsetup"
- fi
-
- # Timer services
- validate_service "reflector.timer" "enabled" ""
- validate_service "paccache.timer" "enabled" ""
-
- # Optional services (warn if missing, don't fail)
- validate_service_optional "avahi-daemon" "enabled"
- validate_service_optional "bluetooth" "enabled"
- validate_service_optional "cups" "enabled"
- validate_service_optional "docker" "enabled"
- validate_service_optional "tailscaled" "enabled"
- # Syncthing uses user service (not system), check lingering is enabled.
- # test -e, not ls: ls prints the path on success, so the old capture held
- # "path\nyes" and could never equal "yes" — the check warned on every
- # run, even with lingering correctly enabled.
- step "Checking user lingering for syncthing"
- local linger_enabled=$(ssh_cmd "test -e /var/lib/systemd/linger/cjennings && echo yes || echo no")
- if [ "$linger_enabled" = "yes" ]; then
- validation_pass "User lingering enabled for syncthing user service"
- else
- validation_warn "User lingering not enabled (syncthing may not autostart)"
- fi
-
- # Filesystem-specific
- validate_zfs_services
- validate_btrfs_services
-
- # Functional tests
- validate_service_functions
-}
-
-validate_service() {
- local service="$1"
- local expected_enabled="$2" # "enabled" or ""
- local expected_active="$3" # "active" or ""
-
- step "Checking $service"
-
- if [ -n "$expected_enabled" ]; then
- local enabled=$(ssh_cmd "systemctl is-enabled $service 2>/dev/null || echo disabled")
- if [ "$enabled" = "enabled" ]; then
- validation_pass "$service is enabled"
- else
- validation_fail "$service not enabled (got: $enabled)"
- attribute_issue "$service not enabled" "archsetup"
- return 1
- fi
- fi
-
- if [ -n "$expected_active" ]; then
- local active=$(ssh_cmd "systemctl is-active $service 2>/dev/null || echo inactive")
- if [ "$active" = "active" ]; then
- validation_pass "$service is active"
- else
- validation_fail "$service not active (got: $active)"
- attribute_issue "$service not active" "archsetup"
- return 1
- fi
- fi
-
- return 0
-}
-
-validate_service_optional() {
- local service="$1"
- local expected_enabled="$2"
-
- step "Checking optional service: $service"
-
- local enabled=$(ssh_cmd "systemctl is-enabled $service 2>/dev/null || echo disabled")
- if [ "$enabled" = "enabled" ]; then
- validation_pass "$service is enabled"
- else
- validation_warn "$service not enabled (optional)"
- fi
-}
-
-validate_zfs_services() {
- # Only check if ZFS is installed
- if ! ssh_cmd "which zfs" &>> "$LOGFILE"; then
- return 0
- fi
-
- step "Checking ZFS-specific services"
-
- validate_service_optional "sanoid.timer" "enabled"
-
- # Check for zfs-scrub timer (pool name varies)
- local scrub_enabled
- scrub_enabled=$(ssh_cmd "systemctl list-unit-files 'zfs-scrub*' 2>/dev/null | grep -c enabled" | tr -d '[:space:]')
- scrub_enabled=${scrub_enabled:-0}
- if [ "$scrub_enabled" -gt 0 ]; then
- validation_pass "ZFS scrub timer enabled"
- else
- validation_warn "ZFS scrub timer not found"
- fi
-}
-
-validate_btrfs_services() {
- # Only check if btrfs root
- if ! ssh_cmd "mount | grep 'on / ' | grep -q btrfs"; then
- return 0
- fi
-
- step "Checking btrfs-specific services"
- validate_service_optional "grub-btrfsd" "enabled"
-}
-
-validate_service_functions() {
- section "Service Functional Tests"
-
- # UFW functional test
- # NOTE: VM environment lacks iptables kernel modules, so UFW cannot activate.
- # We only verify it's enabled; active status requires real hardware.
- step "Testing UFW functionality"
- local ufw_enabled
- ufw_enabled=$(ssh_cmd "systemctl is-enabled ufw.service 2>/dev/null || echo disabled")
- if [ "$ufw_enabled" = "enabled" ]; then
- validation_pass "UFW is enabled (activation requires iptables kernel modules)"
- else
- validation_fail "UFW not enabled"
- attribute_issue "UFW not enabled" "archsetup"
- fi
-
- # fail2ban functional test
- step "Testing fail2ban functionality"
- if ssh_cmd "fail2ban-client status" &>> "$LOGFILE"; then
- validation_pass "fail2ban is responding"
- else
- validation_fail "fail2ban not responding"
- attribute_issue "fail2ban not functioning" "archsetup"
- fi
-
- # DNS resolution test
- step "Testing DNS resolution"
- if ssh_cmd "resolvectl query archlinux.org" &>> "$LOGFILE"; then
- validation_pass "DNS resolution working"
- else
- validation_warn "DNS resolution test failed (may be network issue)"
- fi
-
- # Docker functional test (if enabled)
- if ssh_cmd "systemctl is-enabled docker" &>> "$LOGFILE"; then
- step "Testing Docker functionality"
- if ssh_cmd "docker info" &>> "$LOGFILE"; then
- validation_pass "Docker is responding"
- elif ! ssh_cmd "systemctl is-active --quiet docker"; then
- # archsetup enables docker for next boot (enable, not enable --now,
- # by design — the daemon is heavy). Validation runs pre-reboot, so
- # enabled-but-not-started is the correct installed state.
- validation_skip "Docker enabled but not started (starts on boot by design)"
- else
- validation_warn "Docker active but not responding"
- fi
- fi
-}
-
-#-----------------------------------------------------------------------------
-# Developer Tools Validations
-#-----------------------------------------------------------------------------
-
-validate_emacs() {
- step "Checking if Emacs is installed"
- if ssh_cmd "which emacs" &>> "$LOGFILE"; then
- validation_pass "Emacs is installed"
-
- # Check if config exists
- if ssh_cmd "test -d /home/cjennings/.emacs.d"; then
- validation_pass "Emacs config directory exists"
-
- # Check user can access the directory
- local result
- result=$(ssh_cmd "sudo -u cjennings ls /home/cjennings/.emacs.d > /dev/null 2>&1 && echo OK || echo FAIL")
- if [ "$result" = "OK" ]; then
- validation_pass "Emacs config readable by user"
- else
- validation_fail "Emacs config not readable by user (permission issue)"
- attribute_issue "Emacs .emacs.d not readable by user" "archsetup"
- fi
- else
- validation_warn "Emacs config directory not found"
- fi
- else
- validation_fail "Emacs not found"
- attribute_issue "Emacs not installed" "archsetup"
- fi
-}
-
-validate_git_config() {
- step "Checking git installation"
- if ssh_cmd "which git" &>> "$LOGFILE"; then
- validation_pass "git is installed"
- else
- validation_fail "git not found"
- attribute_issue "git not installed" "archsetup"
- fi
-}
-
-validate_dev_tools() {
- step "Checking developer tools"
- local tools="python node npm go rustc"
- local missing=""
-
- for tool in $tools; do
- if ! ssh_cmd "which $tool" &>> "$LOGFILE"; then
- missing="$missing $tool"
- fi
- done
-
- if [ -z "$missing" ]; then
- validation_pass "Core dev tools installed"
- else
- validation_warn "Some dev tools missing:$missing"
- fi
-}
-
-#-----------------------------------------------------------------------------
-# System Configuration Validations
-#-----------------------------------------------------------------------------
-
-validate_zfs_config() {
- step "Checking ZFS configuration (if applicable)"
- if ssh_cmd "which zfs" &>> "$LOGFILE"; then
- # ZFS is installed, check for sanoid
- if ssh_cmd "which sanoid" &>> "$LOGFILE"; then
- validation_pass "ZFS with sanoid detected"
- else
- validation_warn "ZFS detected but sanoid not installed"
- fi
- else
- info "ZFS not installed (non-ZFS system)"
- fi
-}
-
-validate_boot_config() {
- step "Checking GRUB configuration"
- if ssh_cmd "test -f /boot/grub/grub.cfg" &>> "$LOGFILE"; then
- validation_pass "GRUB config exists"
- else
- validation_warn "GRUB config not found (may use different bootloader)"
- fi
-}
-
-validate_terminus_font() {
- step "Checking terminus-font installation"
- if ssh_cmd "pacman -Q terminus-font" &>> "$LOGFILE"; then
- validation_pass "terminus-font package installed"
- else
- validation_fail "terminus-font package not installed"
- attribute_issue "terminus-font not installed via pacman" "archsetup"
- fi
-}
-
-validate_mkinitcpio_hooks() {
- step "Checking mkinitcpio HOOKS configuration"
- local hooks=$(ssh_cmd "grep '^HOOKS=' /etc/mkinitcpio.conf")
- local is_zfs=$(ssh_cmd "findmnt -n -o FSTYPE / 2>/dev/null")
-
- if [ "$is_zfs" = "zfs" ]; then
- # ZFS system: must use udev, not systemd
- if echo "$hooks" | grep -q '\budev\b'; then
- validation_pass "ZFS system uses udev hook (correct)"
- elif echo "$hooks" | grep -q '\bsystemd\b'; then
- validation_fail "ZFS system uses systemd hook (will break boot)"
- attribute_issue "mkinitcpio uses systemd hook on ZFS system" "archsetup"
- else
- validation_warn "Could not determine init hook type"
- fi
- else
- # Non-ZFS: systemd hook is fine
- if echo "$hooks" | grep -q '\bsystemd\b'; then
- validation_pass "Non-ZFS system uses systemd hook"
- elif echo "$hooks" | grep -q '\budev\b'; then
- validation_pass "Non-ZFS system uses udev hook"
- fi
- fi
-}
-
-validate_initramfs_consolefont() {
- step "Checking console font in initramfs"
- local font_in_initramfs=$(ssh_cmd "lsinitcpio /boot/initramfs-linux*.img 2>/dev/null | grep -c 'consolefont.psf\\|ter-'")
-
- if [ "${font_in_initramfs:-0}" -gt 0 ]; then
- validation_pass "Console font included in initramfs"
- else
- validation_warn "Console font may not be in initramfs"
- fi
-}
-
-validate_nvme_module() {
- step "Checking NVMe module configuration"
- local has_nvme=$(ssh_cmd "ls /dev/nvme* 2>/dev/null | head -1")
-
- if [ -n "$has_nvme" ]; then
- # System has NVMe drives
- local modules=$(ssh_cmd "grep '^MODULES=' /etc/mkinitcpio.conf")
- if echo "$modules" | grep -q 'nvme'; then
- validation_pass "NVMe module in mkinitcpio MODULES"
- else
- validation_warn "NVMe system but nvme not in MODULES (may cause slow boot)"
- fi
- else
- info "No NVMe drives detected"
- fi
-}
-
-validate_autologin_config() {
- step "Checking autologin configuration"
- if ssh_cmd "test -f /etc/systemd/system/getty@tty1.service.d/autologin.conf" &>> "$LOGFILE"; then
- validation_pass "Autologin configured"
- else
- info "Autologin not configured (may be intentional)"
- fi
-}
-
-validate_gnome_keyring_setup() {
- step "Checking gnome-keyring pre-configuration"
- local keyring_dir="/home/cjennings/.local/share/keyrings"
-
- # Check directory exists
- if ! ssh_cmd "test -d $keyring_dir"; then
- validation_fail "Keyring directory not created"
- attribute_issue "gnome-keyring directory not pre-created" "archsetup"
- return 1
- fi
-
- # Check directory permissions (should be 700)
- local perms=$(ssh_cmd "stat -c '%a' $keyring_dir")
- if [ "$perms" != "700" ]; then
- validation_fail "Keyring directory has wrong permissions: $perms (expected 700)"
- attribute_issue "gnome-keyring directory wrong permissions" "archsetup"
- return 1
- fi
-
- # Check ownership
- local owner=$(ssh_cmd "stat -c '%U' $keyring_dir")
- if [ "$owner" != "cjennings" ]; then
- validation_fail "Keyring directory owned by $owner (expected cjennings)"
- attribute_issue "gnome-keyring directory wrong ownership" "archsetup"
- return 1
- fi
-
- # Check default file exists and contains "login"
- local default_keyring=$(ssh_cmd "cat $keyring_dir/default 2>/dev/null")
- if [ "$default_keyring" != "login" ]; then
- validation_fail "Default keyring not set to 'login' (got: '$default_keyring')"
- attribute_issue "gnome-keyring default not set to login" "archsetup"
- return 1
- fi
-
- validation_pass "gnome-keyring pre-configured (default=login, dir=700)"
-}
-
-#-----------------------------------------------------------------------------
-# Archsetup-Specific Validations
-#-----------------------------------------------------------------------------
-
-validate_archsetup_log() {
- step "Checking archsetup log for errors"
- local error_count
- # Use grep -h to suppress filenames, then wc -l to count total matches
- error_count=$(ssh_cmd "grep -h '^Error:' /var/log/archsetup-*.log 2>/dev/null | wc -l" | tr -d '[:space:]')
- error_count=${error_count:-0}
-
- if [ "$error_count" = "0" ]; then
- validation_pass "No errors in archsetup log"
- else
- validation_fail "Found $error_count errors in archsetup log"
- attribute_issue "Errors in archsetup log: $error_count" "archsetup"
- fi
-}
-
-validate_state_markers() {
- step "Checking archsetup state markers"
- local state_count=$(ssh_cmd "ls /var/lib/archsetup/state/ 2>/dev/null | wc -l")
-
- if [ "$state_count" -ge 12 ]; then
- validation_pass "All 12 installation steps completed"
- else
- validation_warn "Only $state_count/12 steps completed"
- fi
-}
-
-#=============================================================================
# ISSUE REPORTING
#=============================================================================
@@ -1139,18 +312,3 @@ EOF
info "Issue report saved: $report_file"
}
-#=============================================================================
-# MAIN VALIDATION ENTRY POINT
-#=============================================================================
-
-run_full_validation() {
- local output_dir="$1"
- local archzfs_inbox="${2:-}"
-
- run_all_validations
- analyze_log_diff "$output_dir"
- generate_issue_report "$output_dir" "$archzfs_inbox"
-
- # Return success if no failures
- [ $VALIDATION_FAILED -eq 0 ]
-}
diff --git a/scripts/testing/lib/vm-utils.sh b/scripts/testing/lib/vm-utils.sh
index d029d58..b85e773 100755
--- a/scripts/testing/lib/vm-utils.sh
+++ b/scripts/testing/lib/vm-utils.sh
@@ -11,13 +11,26 @@
# VM configuration defaults
VM_CPUS="${VM_CPUS:-4}"
-VM_RAM="${VM_RAM:-4096}" # MB
+# 8 GiB headroom for AUR builds: makepkg runs -j$VM_CPUS, and parallel cc1plus
+# (~700 MB each on heavy C++ packages) OOM-killed under the old 4 GiB default.
+VM_RAM="${VM_RAM:-8192}" # MB
VM_DISK_SIZE="${VM_DISK_SIZE:-50}" # GB
+# Filesystem profile: selects which base image + archangel config the harness
+# targets. "btrfs" is the historical default (its image name stays unsuffixed
+# so existing base images keep working); "zfs" gets its own image, since the
+# two on-disk layouts can't share a disk. Honoured by init_vm_paths below.
+FS_PROFILE="${FS_PROFILE:-btrfs}"
+
# SSH configuration
SSH_PORT="${SSH_PORT:-2222}"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10"
ROOT_PASSWORD="${ROOT_PASSWORD:-archsetup}"
+# Set by inject_root_key once a root key is authorized in the VM. When set, the
+# ssh/scp helpers add "-i <key>" so they keep working after archsetup hardens
+# sshd to PermitRootLogin prohibit-password (which kills root *password* login
+# but still allows key auth). Left unquoted at use sites, like SSH_OPTS.
+SSH_KEY_OPT="${SSH_KEY_OPT:-}"
# OVMF firmware paths
OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd"
@@ -37,9 +50,22 @@ init_vm_paths() {
local images_dir="${1:-$VM_IMAGES_DIR}"
[ -z "$images_dir" ] && fatal "VM_IMAGES_DIR not set"
+ case "$FS_PROFILE" in
+ btrfs|zfs) ;;
+ *) fatal "Invalid FS_PROFILE: $FS_PROFILE (must be 'btrfs' or 'zfs')" ;;
+ esac
+
VM_IMAGES_DIR="$images_dir"
- DISK_PATH="$VM_IMAGES_DIR/archsetup-base.qcow2"
- OVMF_VARS="$VM_IMAGES_DIR/OVMF_VARS.fd"
+ # btrfs keeps the legacy unsuffixed name; other profiles get a suffix so
+ # their images sit side by side without clobbering each other.
+ local img_suffix=""
+ [ "$FS_PROFILE" != "btrfs" ] && img_suffix="-$FS_PROFILE"
+ DISK_PATH="$VM_IMAGES_DIR/archsetup-base${img_suffix}.qcow2"
+ # Per-profile NVRAM: UEFI boot entries live here, outside the qcow2, so a
+ # disk-snapshot revert can't restore them. Sharing one file across profiles
+ # let a zfs run's ZFSBootMenu entries clobber the btrfs GRUB entry, leaving
+ # the btrfs base unbootable (no removable ESP fallback to recover from).
+ OVMF_VARS="$VM_IMAGES_DIR/OVMF_VARS${img_suffix}.fd"
PID_FILE="$VM_IMAGES_DIR/qemu.pid"
MONITOR_SOCK="$VM_IMAGES_DIR/qemu-monitor.sock"
SERIAL_LOG="$VM_IMAGES_DIR/qemu-serial.log"
@@ -351,7 +377,7 @@ wait_for_ssh() {
progress "Waiting for SSH on localhost:$SSH_PORT..."
while [ "$elapsed" -lt "$timeout" ]; do
- if sshpass -p "$password" ssh $SSH_OPTS -p "$SSH_PORT" root@localhost true 2>/dev/null; then
+ if sshpass -p "$password" ssh $SSH_OPTS $SSH_KEY_OPT -p "$SSH_PORT" root@localhost true 2>/dev/null; then
success "SSH is available"
return 0
fi
@@ -367,7 +393,7 @@ wait_for_ssh() {
vm_exec() {
local password="${1:-$ROOT_PASSWORD}"
shift
- sshpass -p "$password" ssh $SSH_OPTS \
+ sshpass -p "$password" ssh $SSH_OPTS $SSH_KEY_OPT \
-o ServerAliveInterval=30 -o ServerAliveCountMax=10 \
-p "$SSH_PORT" root@localhost "$@" 2>> "$LOGFILE"
}
@@ -379,7 +405,7 @@ copy_to_vm() {
local password="${3:-$ROOT_PASSWORD}"
step "Copying $(basename "$local_file") to VM:$remote_path"
- if sshpass -p "$password" scp $SSH_OPTS -P "$SSH_PORT" \
+ if sshpass -p "$password" scp $SSH_OPTS $SSH_KEY_OPT -P "$SSH_PORT" \
"$local_file" "root@localhost:$remote_path" >> "$LOGFILE" 2>&1; then
success "File copied to VM"
return 0
@@ -396,7 +422,7 @@ copy_from_vm() {
local password="${3:-$ROOT_PASSWORD}"
step "Copying $remote_file from VM"
- if sshpass -p "$password" scp $SSH_OPTS -P "$SSH_PORT" \
+ if sshpass -p "$password" scp $SSH_OPTS $SSH_KEY_OPT -P "$SSH_PORT" \
"root@localhost:$remote_file" "$local_path" >> "$LOGFILE" 2>&1; then
success "File copied from VM"
return 0
@@ -405,3 +431,31 @@ copy_from_vm() {
return 1
fi
}
+
+# inject_root_key <key_path>
+# Authorize a throwaway root key over the initial password session and switch
+# all the helpers above to key auth (sets SSH_KEY_OPT + ROOT_SSH_KEY). Call once,
+# right after wait_for_ssh and before running archsetup: archsetup sets
+# PermitRootLogin prohibit-password and reloads sshd partway through, which kills
+# root *password* login. Without a key in place first, every SSH after that step
+# fails and the run aborts before any validation. Key auth survives the hardening.
+# Targets root@$VM_IP on $SSH_PORT so it works for both the local VM runner
+# (VM_IP=localhost, port 2222) and the bare-metal runner (VM_IP=host, port 22).
+inject_root_key() {
+ local key="$1"
+ rm -f "$key" "$key.pub"
+ if ! ssh-keygen -t ed25519 -N "" -q -f "$key"; then
+ warn "Root key generation failed - run may break at sshd hardening"
+ return 1
+ fi
+ if sshpass -p "$ROOT_PASSWORD" ssh $SSH_OPTS -p "$SSH_PORT" "root@${VM_IP:-localhost}" \
+ "mkdir -p /root/.ssh && chmod 700 /root/.ssh && cat >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys" \
+ < "$key.pub" >> "$LOGFILE" 2>&1; then
+ SSH_KEY_OPT="-i $key"
+ export ROOT_SSH_KEY="$key"
+ success "Root SSH key authorized (survives sshd prohibit-password hardening)"
+ return 0
+ fi
+ warn "Root key authorization failed - run may break at sshd hardening"
+ return 1
+}
diff --git a/scripts/testing/maint-scenarios/10-journal-vacuum.sh b/scripts/testing/maint-scenarios/10-journal-vacuum.sh
new file mode 100644
index 0000000..b61de49
--- /dev/null
+++ b/scripts/testing/maint-scenarios/10-journal-vacuum.sh
@@ -0,0 +1,30 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: the journal vacuum executes against live journald at the TOML floor.
+#
+# What this proves: the TOML edit reaches the remedy's argv (vacuum target
+# and warn threshold are the same key), journalctl --vacuum-size runs for
+# real against live journald, and the metric grades ok against the edited
+# floor afterwards.
+#
+# What it deliberately does NOT prove: size reduction. The key is int-GB and
+# bottoms out at 1, vacuum only touches archived files, and a VM-sized
+# journal never nears 1 GB — a genuine reduction test would need journald
+# rate-limit surgery plus ~1 GB of spam per run. A pre/post usage comparison
+# is also unsound here: journald keeps landing in-flight writes (the spam is
+# async, and every ssh hop logs), so usage can grow across a correct vacuum.
+SCENARIO_DESC="journal vacuum runs at the TOML size floor"
+SCENARIO_GROUP="logs"
+SCENARIO_PROFILES="any"
+
+scenario_break() {
+ mexec "sed -i 's/^journal_disk_warn_gb *=.*/journal_disk_warn_gb = 1/' /root/.config/archsetup/maintenance-thresholds.toml" \
+ && mexec "for i in \$(seq 1 20); do head -c 900000 /dev/urandom | base64 | systemd-cat -t maint-scenario-spam; done; journalctl --rotate; journalctl --flush >/dev/null 2>&1 || true"
+}
+
+scenario_fix() {
+ mfix journal_vacuum
+}
+
+scenario_assert() {
+ massert_metric journal_disk ok
+}
diff --git a/scripts/testing/maint-scenarios/11-coredump-age-out.sh b/scripts/testing/maint-scenarios/11-coredump-age-out.sh
new file mode 100644
index 0000000..06d6fa0
--- /dev/null
+++ b/scripts/testing/maint-scenarios/11-coredump-age-out.sh
@@ -0,0 +1,21 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: coredump files beyond the forensic window are aged out.
+#
+# coredumpctl has no clean verb, so the remedy is a find -mtime +N -delete
+# over /var/lib/systemd/coredump. Break plants one file older than the
+# window and one fresh file; assert checks exactly the old one is gone.
+SCENARIO_DESC="aged coredump files removed, fresh ones kept"
+SCENARIO_GROUP="logs"
+SCENARIO_PROFILES="any"
+
+scenario_break() {
+ mexec "mkdir -p /var/lib/systemd/coredump && touch -d '30 days ago' '/var/lib/systemd/coredump/core.maintscenario-old.0.zst' && touch '/var/lib/systemd/coredump/core.maintscenario-new.0.zst'"
+}
+
+scenario_fix() {
+ mfix coredump_clean
+}
+
+scenario_assert() {
+ mexec "test ! -e '/var/lib/systemd/coredump/core.maintscenario-old.0.zst' && test -e '/var/lib/systemd/coredump/core.maintscenario-new.0.zst'"
+}
diff --git a/scripts/testing/maint-scenarios/20-cache-keep3.sh b/scripts/testing/maint-scenarios/20-cache-keep3.sh
new file mode 100644
index 0000000..a9a6ee5
--- /dev/null
+++ b/scripts/testing/maint-scenarios/20-cache-keep3.sh
@@ -0,0 +1,24 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: paccache -rk3 trims an installed package's cache to 3 versions.
+#
+# paccache decides from filenames, so five fake versions of a package that
+# IS installed (bash) stand in for a fat cache. The bootstrap may leave a
+# real bash package in the cache too, so the assert uses the ordering
+# invariant rather than a count: the two newest fakes survive any keep-3
+# window that contains them, and the two oldest fall outside it whether or
+# not a real (newer) version is cached alongside.
+SCENARIO_DESC="pacman cache trimmed to three versions per package"
+SCENARIO_GROUP="packages"
+SCENARIO_PROFILES="any"
+
+scenario_break() {
+ mexec "for v in 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5; do touch /var/cache/pacman/pkg/bash-\$v-1-x86_64.pkg.tar.zst; done; ls /var/cache/pacman/pkg/ | grep -c '^bash-'"
+}
+
+scenario_fix() {
+ mfix cache_clean
+}
+
+scenario_assert() {
+ mexec "test -e /var/cache/pacman/pkg/bash-1.0.5-1-x86_64.pkg.tar.zst && test -e /var/cache/pacman/pkg/bash-1.0.4-1-x86_64.pkg.tar.zst && test ! -e /var/cache/pacman/pkg/bash-1.0.2-1-x86_64.pkg.tar.zst && test ! -e /var/cache/pacman/pkg/bash-1.0.1-1-x86_64.pkg.tar.zst"
+}
diff --git a/scripts/testing/maint-scenarios/21-cache-uninstalled.sh b/scripts/testing/maint-scenarios/21-cache-uninstalled.sh
new file mode 100644
index 0000000..ea788e8
--- /dev/null
+++ b/scripts/testing/maint-scenarios/21-cache-uninstalled.sh
@@ -0,0 +1,20 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: paccache -ruk0 drops every cached version of uninstalled packages.
+#
+# Fake versions of a package that is NOT installed disappear entirely, while
+# an installed package's cached version survives the uninstalled-only pass.
+SCENARIO_DESC="cached versions of uninstalled packages dropped"
+SCENARIO_GROUP="packages"
+SCENARIO_PROFILES="any"
+
+scenario_break() {
+ mexec "touch /var/cache/pacman/pkg/maintghostpkg-2.1-1-x86_64.pkg.tar.zst /var/cache/pacman/pkg/maintghostpkg-2.2-1-x86_64.pkg.tar.zst /var/cache/pacman/pkg/bash-9.9-1-x86_64.pkg.tar.zst"
+}
+
+scenario_fix() {
+ mfix cache_clean_uninstalled
+}
+
+scenario_assert() {
+ mexec "test ! -e /var/cache/pacman/pkg/maintghostpkg-2.1-1-x86_64.pkg.tar.zst && test ! -e /var/cache/pacman/pkg/maintghostpkg-2.2-1-x86_64.pkg.tar.zst && test -e /var/cache/pacman/pkg/bash-9.9-1-x86_64.pkg.tar.zst"
+}
diff --git a/scripts/testing/maint-scenarios/22-orphan-remove.sh b/scripts/testing/maint-scenarios/22-orphan-remove.sh
new file mode 100644
index 0000000..8412128
--- /dev/null
+++ b/scripts/testing/maint-scenarios/22-orphan-remove.sh
@@ -0,0 +1,22 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: a real orphan is created, detected, and removed by name.
+#
+# Installing `tree` --asdeps with no dependents makes it a true orphan
+# (pacman -Qtdq lists it). The remedy is item-based: maint fix orphan_remove
+# tree. Assert covers both the package state and the metric verdict.
+SCENARIO_DESC="orphan package removed via the item-based remedy"
+SCENARIO_GROUP="packages"
+SCENARIO_PROFILES="any"
+
+scenario_break() {
+ mexec "pacman -S --noconfirm --asdeps tree && pacman -Qtdq | grep -qx tree"
+}
+
+scenario_fix() {
+ mfix orphan_remove tree
+}
+
+scenario_assert() {
+ mexec "! pacman -Qi tree >/dev/null 2>&1 && ! pacman -Qtdq | grep -qx tree" \
+ && massert_metric pkg_orphans ok
+}
diff --git a/scripts/testing/maint-scenarios/23-pacnew-delete.sh b/scripts/testing/maint-scenarios/23-pacnew-delete.sh
new file mode 100644
index 0000000..23cd8d1
--- /dev/null
+++ b/scripts/testing/maint-scenarios/23-pacnew-delete.sh
@@ -0,0 +1,22 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: a .pacnew file is deleted through the validated priv verb.
+#
+# pacdiff --output finds the planted file; the remedy takes the path as its
+# item and rm's it. A neighbouring non-pacnew file proves the verb's path
+# validation keeps the blast radius to the named file.
+SCENARIO_DESC="planted .pacnew removed by path"
+SCENARIO_GROUP="packages"
+SCENARIO_PROFILES="any"
+
+scenario_break() {
+ mexec "cp /etc/pacman.conf /etc/pacman.conf.pacnew && pacdiff --output | grep -q pacman.conf.pacnew"
+}
+
+scenario_fix() {
+ mfix pacnew_delete /etc/pacman.conf.pacnew
+}
+
+scenario_assert() {
+ mexec "test ! -e /etc/pacman.conf.pacnew && test -e /etc/pacman.conf" \
+ && massert_metric pkg_pacnew ok
+}
diff --git a/scripts/testing/maint-scenarios/30-unit-reset.sh b/scripts/testing/maint-scenarios/30-unit-reset.sh
new file mode 100644
index 0000000..07eed53
--- /dev/null
+++ b/scripts/testing/maint-scenarios/30-unit-reset.sh
@@ -0,0 +1,28 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: a failed unit's state is reset.
+#
+# A transient oneshot that exits 1 lands in systemctl --failed; the remedy
+# resets it and the failed list returns to its pre-break size. The unit name
+# rides the remedy's item argument, exercising the priv verb's unit-name
+# validation. The base image carries its own failed unit (grub-btrfsd — no
+# snapshot dirs in the VM), so the assert scopes to this scenario's unit and
+# the count delta, never the absolute failed list.
+SCENARIO_DESC="failed unit reset clears the failed list"
+SCENARIO_GROUP="systemd"
+SCENARIO_PROFILES="any"
+
+scenario_break() {
+ PRE_FAILED_COUNT=$(mexec "systemctl --failed --no-legend | wc -l") \
+ && mexec "systemd-run --unit=maint-scenario-fail --service-type=oneshot --no-block /bin/false; sleep 2; systemctl is-failed maint-scenario-fail.service"
+}
+
+scenario_fix() {
+ mfix unit_reset maint-scenario-fail.service
+}
+
+scenario_assert() {
+ local pre="$PRE_FAILED_COUNT"
+ unset PRE_FAILED_COUNT # don't leak scenario state past this scenario
+ mexec "! systemctl is-failed maint-scenario-fail.service >/dev/null 2>&1" \
+ && [ "$(mexec "systemctl --failed --no-legend | wc -l")" = "$pre" ]
+}
diff --git a/scripts/testing/maint-scenarios/31-fstrim-enable.sh b/scripts/testing/maint-scenarios/31-fstrim-enable.sh
new file mode 100644
index 0000000..2d548a1
--- /dev/null
+++ b/scripts/testing/maint-scenarios/31-fstrim-enable.sh
@@ -0,0 +1,22 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: fstrim.timer enabled and started from the disabled default.
+#
+# The base image ships util-linux with fstrim.timer disabled — the exact
+# state the metric flags. The remedy is systemctl enable --now.
+SCENARIO_DESC="fstrim.timer enabled and started"
+SCENARIO_GROUP="systemd"
+SCENARIO_PROFILES="btrfs"
+
+scenario_break() {
+ # Default state, but pin it so the scenario stays honest if a future
+ # base image enables the timer.
+ mexec "systemctl disable --now fstrim.timer >/dev/null 2>&1 || true; ! systemctl is-enabled fstrim.timer >/dev/null 2>&1"
+}
+
+scenario_fix() {
+ mfix fstrim_enable
+}
+
+scenario_assert() {
+ mexec "systemctl is-enabled fstrim.timer && systemctl is-active fstrim.timer"
+}
diff --git a/scripts/testing/maint-scenarios/32-cron-enable.sh b/scripts/testing/maint-scenarios/32-cron-enable.sh
new file mode 100644
index 0000000..39d4de6
--- /dev/null
+++ b/scripts/testing/maint-scenarios/32-cron-enable.sh
@@ -0,0 +1,20 @@
+# shellcheck shell=bash disable=SC2034 # sourced by run-maint-scenarios.sh
+# Scenario: cronie enabled and started (the stopped-cron posture).
+#
+# Bootstrap installs cronie but never enables it — the broken state is the
+# package's default. The remedy enables + starts the service.
+SCENARIO_DESC="cronie enabled and started from the stopped state"
+SCENARIO_GROUP="systemd"
+SCENARIO_PROFILES="any"
+
+scenario_break() {
+ mexec "systemctl disable --now cronie.service >/dev/null 2>&1 || true; ! systemctl is-active cronie.service >/dev/null 2>&1"
+}
+
+scenario_fix() {
+ mfix cron_enable
+}
+
+scenario_assert() {
+ mexec "systemctl is-enabled cronie.service && systemctl is-active cronie.service"
+}
diff --git a/scripts/testing/run-maint-nspawn.sh b/scripts/testing/run-maint-nspawn.sh
new file mode 100644
index 0000000..04d4a32
--- /dev/null
+++ b/scripts/testing/run-maint-nspawn.sh
@@ -0,0 +1,268 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
+# Run pacman-level maint remedy scenarios in a systemd-nspawn container
+# Author: Craig Jennings <craigmartinjennings@gmail.com>
+# License: GNU GPLv3
+#
+# The fast lane for the maint scenario suite: pure pacman-level cases (the
+# "packages" scenario group — cache trims, orphan removal, pacnew deletion)
+# don't need a booted machine, so they run in seconds against a pacstrap'd
+# rootfs instead of minutes against the VM. Everything else (systemd, logs)
+# needs pid 1 and journald — that stays in run-maint-scenarios.sh.
+#
+# The scenario files are shared with the VM runner and see the same helpers
+# (mexec/mmaint/mfix/massert_metric); only the transport differs — the
+# helper trio is deliberately duplicated between the two runners rather
+# than abstracted over ssh-vs-nspawn.
+#
+# The rootfs is cached at $NSPAWN_ROOT and rebuilt with --fresh. Building
+# and running need root (pacstrap, systemd-nspawn) via sudo.
+#
+# Usage: run-maint-nspawn.sh [--list] [--fresh]
+# --list print the validated scenario plan and exit (no root needed)
+# --fresh delete the cached rootfs and pacstrap a new one
+# Env: MAINT_SCENARIO_DIR scenario dir (default: maint-scenarios/)
+# MAINT_SRC maint package source tree
+# (default: ~/.dotfiles/maint/src/maint)
+# NSPAWN_ROOT rootfs dir (default: /var/tmp/archsetup-maint-nspawn)
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+
+source "$SCRIPT_DIR/lib/logging.sh"
+
+SCENARIO_DIR="${MAINT_SCENARIO_DIR:-$SCRIPT_DIR/maint-scenarios}"
+MAINT_SRC="${MAINT_SRC:-$HOME/.dotfiles/maint/src/maint}"
+THRESHOLDS_TOML="$PROJECT_ROOT/configs/maintenance-thresholds.toml"
+NSPAWN_ROOT="${NSPAWN_ROOT:-/var/tmp/archsetup-maint-nspawn}"
+NSPAWN_GROUP="packages"
+
+LIST_ONLY=false
+FRESH=false
+
+usage() {
+ echo "Usage: $0 [--list] [--fresh]"
+ echo " --list print the validated scenario plan and exit"
+ echo " --fresh rebuild the cached rootfs from scratch"
+ echo "Env: MAINT_SCENARIO_DIR, MAINT_SRC, NSPAWN_ROOT"
+}
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --list) LIST_ONLY=true; shift ;;
+ --fresh) FRESH=true; shift ;;
+ *) usage; exit 1 ;;
+ esac
+done
+
+# ─── Plan: the pacman-level subset ───────────────────────────────────
+# Same contract validation as the VM runner; the selection rule is the
+# group name — "packages" is pacman-level by construction.
+
+S_FILES=() S_NAMES=() S_DESCS=()
+
+_scenario_var() { # <file> <varname>
+ bash -c 'set -eu; source "$1" || exit 9; eval "printf %s \"\${$2-}\""' \
+ _probe "$1" "$2" 2>/dev/null
+}
+
+_validate_scenario() { # <file> -> fatal on contract violation
+ local f="$1" base probe_err
+ base=$(basename "$f")
+ probe_err=$(bash -c 'set -eu; source "$1"
+ : "${SCENARIO_DESC:?missing SCENARIO_DESC}"
+ : "${SCENARIO_GROUP:?missing SCENARIO_GROUP}"
+ : "${SCENARIO_PROFILES:?missing SCENARIO_PROFILES}"
+ for p in $SCENARIO_PROFILES; do
+ case "$p" in btrfs|zfs|any) ;; *)
+ echo "bad profile token: $p" >&2; exit 1 ;;
+ esac
+ done
+ declare -f scenario_break scenario_fix scenario_assert >/dev/null \
+ || { echo "missing scenario_break/fix/assert" >&2; exit 1; }' \
+ _probe "$f" 2>&1 >/dev/null) \
+ || fatal "scenario contract violation in $base: $probe_err"
+}
+
+build_plan() {
+ [ -d "$SCENARIO_DIR" ] || fatal "scenario dir not found: $SCENARIO_DIR"
+ local f base
+ for f in "$SCENARIO_DIR"/*.sh; do
+ [ -e "$f" ] || break
+ _validate_scenario "$f"
+ [ "$(_scenario_var "$f" SCENARIO_GROUP)" = "$NSPAWN_GROUP" ] || continue
+ base=$(basename "$f" .sh)
+ S_FILES+=("$f")
+ S_NAMES+=("${base#[0-9][0-9]-}")
+ S_DESCS+=("$(_scenario_var "$f" SCENARIO_DESC)")
+ done
+ [ ${#S_FILES[@]} -gt 0 ] \
+ || fatal "no '$NSPAWN_GROUP'-group scenarios in $SCENARIO_DIR"
+}
+
+print_plan() {
+ local i
+ echo "maint nspawn scenario plan (group: $NSPAWN_GROUP)"
+ for i in "${!S_FILES[@]}"; do
+ echo " ${S_NAMES[$i]} — ${S_DESCS[$i]}"
+ done
+}
+
+if [ "$LIST_ONLY" = "true" ]; then
+ LOGFILE=/dev/null
+ build_plan
+ print_plan
+ exit 0
+fi
+
+# ─── Run ─────────────────────────────────────────────────────────────
+
+TIMESTAMP=$(date +'%Y%m%d-%H%M%S')
+RESULTS_DIR="$PROJECT_ROOT/test-results/maint-nspawn-$TIMESTAMP"
+mkdir -p "$RESULTS_DIR"
+LOGFILE="$RESULTS_DIR/scenarios.log"
+init_logging "$LOGFILE"
+
+build_plan
+
+section "Maint Remedy Scenarios (nspawn): $TIMESTAMP"
+info "Rootfs: $NSPAWN_ROOT"
+info "Maint source: $MAINT_SRC"
+print_plan | tee -a "$LOGFILE"
+
+command -v pacstrap >/dev/null \
+ || fatal "pacstrap not found — pacman -S arch-install-scripts"
+command -v systemd-nspawn >/dev/null || fatal "systemd-nspawn not found"
+[ -d "$MAINT_SRC" ] || fatal "maint source tree not found: $MAINT_SRC"
+[ -f "$THRESHOLDS_TOML" ] || fatal "thresholds TOML not found: $THRESHOLDS_TOML"
+sudo -n true 2>/dev/null || fatal "needs passwordless sudo (pacstrap/nspawn)"
+
+# ─── Target helpers (available to scenario scripts) ──────────────────
+
+mexec() { # run a command in the container as root
+ sudo systemd-nspawn -q -D "$NSPAWN_ROOT" --pipe \
+ /bin/bash -c "$*" 2>> "$LOGFILE"
+}
+
+mmaint() {
+ mexec "cd /root/maint-pkg && MAINT_SUDO= PYTHONPATH=/root/maint-pkg python3 -m maint $*"
+}
+
+mfix() {
+ local id="$1"; shift || true
+ step "maint fix $id ${*:+$* }--dry-run"
+ mmaint fix "$id" "$@" --dry-run >> "$LOGFILE" 2>&1 \
+ || { error "dry-run failed: maint fix $id $*"; return 1; }
+ step "maint fix $id $*"
+ mmaint fix "$id" "$@" 2>&1 | tee -a "$LOGFILE"
+ return "${PIPESTATUS[0]}"
+}
+
+massert_metric() { # <metric-id> <expected-severity>
+ local mid="$1" want="$2" got
+ got=$(mmaint status --json | python3 -c "
+import json, sys
+env = json.load(sys.stdin)
+ms = [m for m in env['metrics'] if m['id'] == '$mid']
+print(ms[0]['severity'] if ms else 'MISSING')
+")
+ if [ "$got" = "$want" ]; then
+ success "metric $mid is $want"
+ return 0
+ fi
+ error "metric $mid: expected $want, got $got"
+ return 1
+}
+
+# ─── Rootfs ──────────────────────────────────────────────────────────
+
+if [ "$FRESH" = "true" ] && [ -d "$NSPAWN_ROOT" ]; then
+ step "Removing cached rootfs (--fresh)"
+ sudo rm -rf "$NSPAWN_ROOT"
+fi
+
+if [ ! -x "$NSPAWN_ROOT/usr/bin/python3" ]; then
+ section "Building rootfs (pacstrap)"
+ sudo mkdir -p "$NSPAWN_ROOT"
+ # shellcheck disable=SC2024 # the log is user-owned by design
+ sudo pacstrap -c -K "$NSPAWN_ROOT" base python pacman-contrib expac \
+ >> "$LOGFILE" 2>&1 || fatal "pacstrap failed (see $LOGFILE)"
+ success "Rootfs built"
+else
+ info "Reusing cached rootfs (--fresh to rebuild)"
+fi
+
+section "Installing maint tree + thresholds into the container"
+sudo rm -rf "$NSPAWN_ROOT/root/maint-pkg"
+sudo mkdir -p "$NSPAWN_ROOT/root/maint-pkg" "$NSPAWN_ROOT/root/.config/archsetup"
+sudo cp -r "$MAINT_SRC" "$NSPAWN_ROOT/root/maint-pkg/"
+sudo find "$NSPAWN_ROOT/root/maint-pkg" -name '__pycache__' -type d \
+ -exec rm -rf {} + 2>/dev/null || true
+sudo cp "$THRESHOLDS_TOML" \
+ "$NSPAWN_ROOT/root/.config/archsetup/maintenance-thresholds.toml"
+
+step "Smoke: maint status --json runs in the container"
+mmaint status --json > "$RESULTS_DIR/bootstrap-status.json" \
+ || fatal "maint status failed in the container"
+success "Container ready"
+
+# ─── Execution ───────────────────────────────────────────────────────
+
+PASS=() FAIL=()
+
+run_scenario() { # <index>
+ local i="$1" name="${S_NAMES[$1]}" f="${S_FILES[$1]}"
+ section "Scenario: $name — ${S_DESCS[$1]}"
+ # shellcheck disable=SC1090
+ source "$f"
+ local ok=true
+ step "break"
+ if ! scenario_break; then
+ error "$name: break step failed"
+ ok=false
+ fi
+ if [ "$ok" = "true" ]; then
+ step "fix"
+ if ! scenario_fix; then
+ error "$name: fix step failed"
+ ok=false
+ fi
+ fi
+ if [ "$ok" = "true" ]; then
+ step "assert"
+ if ! scenario_assert; then
+ error "$name: post-state assertion failed"
+ ok=false
+ fi
+ fi
+ unset -f scenario_break scenario_fix scenario_assert
+ unset SCENARIO_DESC SCENARIO_GROUP SCENARIO_PROFILES
+ if [ "$ok" = "true" ]; then
+ success "PASS: $name"
+ PASS+=("$name")
+ else
+ error "FAIL: $name"
+ FAIL+=("$name")
+ fi
+}
+
+for i in "${!S_FILES[@]}"; do
+ run_scenario "$i"
+done
+
+# ─── Report ──────────────────────────────────────────────────────────
+
+section "Results"
+for n in "${PASS[@]}"; do success "PASS $n"; done
+for n in "${FAIL[@]}"; do error "FAIL $n"; done
+info "Log: $LOGFILE"
+info "Rootfs kept for reuse: $NSPAWN_ROOT (--fresh to rebuild)"
+
+if [ ${#FAIL[@]} -gt 0 ]; then
+ error "${#FAIL[@]} scenario(s) failed, ${#PASS[@]} passed"
+ exit 1
+fi
+success "All ${#PASS[@]} scenarios passed"
+exit 0
diff --git a/scripts/testing/run-maint-scenarios.sh b/scripts/testing/run-maint-scenarios.sh
new file mode 100644
index 0000000..448f6d9
--- /dev/null
+++ b/scripts/testing/run-maint-scenarios.sh
@@ -0,0 +1,374 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
+# Run maintenance-console remedy scenarios in the test VM
+# Author: Craig Jennings <craigmartinjennings@gmail.com>
+# License: GNU GPLv3
+#
+# Layer 3 of the maint test strategy: each scenario breaks the VM in a known
+# way over ssh, runs the real `maint fix <id>` inside the VM, and asserts the
+# post-state. Scenarios live in maint-scenarios/*.sh and declare a GROUP;
+# scenarios in one group are non-conflicting and share a VM boot, and a
+# stop -> restore -> boot cycle runs only between groups (snapshot ops need
+# the VM stopped). A scenario that fails may leave residue behind for its
+# in-group successors -- the group restore boundary contains it, so read a
+# multi-failure group front to back.
+#
+# Boot plan:
+# 1. restore clean-install, boot, bootstrap (packages + maint tree + TOML)
+# 2. stop, snapshot maint-ready
+# 3. per group: boot, run scenarios, stop, restore maint-ready
+# 4. restore clean-install, delete maint-ready (base stays pristine)
+#
+# Usage: run-maint-scenarios.sh [--list] [--group NAME] [--keep]
+# --list print the validated batch plan and exit (no VM, no KVM needed)
+# --group run (or list) only the named group
+# --keep keep the VM running in its post-run state; skip the restores
+# Env: FS_PROFILE=btrfs|zfs base image + scenario profile filter
+# MAINT_SCENARIO_DIR scenario dir (default: maint-scenarios/)
+# MAINT_SRC maint package source tree
+# (default: ~/.dotfiles/maint/src/maint)
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+
+source "$SCRIPT_DIR/lib/logging.sh"
+source "$SCRIPT_DIR/lib/vm-utils.sh"
+
+SCENARIO_DIR="${MAINT_SCENARIO_DIR:-$SCRIPT_DIR/maint-scenarios}"
+MAINT_SRC="${MAINT_SRC:-$HOME/.dotfiles/maint/src/maint}"
+THRESHOLDS_TOML="$PROJECT_ROOT/configs/maintenance-thresholds.toml"
+ROOT_PASSWORD="archsetup"
+READY_SNAPSHOT="maint-ready"
+
+LIST_ONLY=false
+ONLY_GROUP=""
+KEEP_VM=false
+
+usage() {
+ echo "Usage: $0 [--list] [--group NAME] [--keep]"
+ echo " --list print the validated scenario plan and exit (no VM)"
+ echo " --group run only the named scenario group"
+ echo " --keep keep the VM running after the run (skip restores)"
+ echo "Env: FS_PROFILE=btrfs|zfs, MAINT_SCENARIO_DIR, MAINT_SRC"
+}
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --list) LIST_ONLY=true; shift ;;
+ --group) ONLY_GROUP="${2:?--group requires a value}"; shift 2 ;;
+ --keep) KEEP_VM=true; shift ;;
+ *) usage; exit 1 ;;
+ esac
+done
+
+# ─── Plan: enumerate, validate, group ────────────────────────────────
+# The plan layer is pure -- no VM, no KVM -- so --list works anywhere and
+# the unit suite (tests/maint-scenarios/) exercises it directly.
+
+# Parallel arrays over scenarios, in file order.
+S_FILES=() S_NAMES=() S_DESCS=() S_GROUPS=()
+# Groups in first-appearance order.
+GROUP_ORDER=()
+
+# Extract one declared var from a scenario file by sourcing it in a clean
+# subshell. Scenario files must only define vars + functions; any top-level
+# command fails here (no helpers exist), which is the contract.
+_scenario_var() { # <file> <varname>
+ bash -c 'set -eu; source "$1" || exit 9; eval "printf %s \"\${$2-}\""' \
+ _probe "$1" "$2" 2>/dev/null
+}
+
+_validate_scenario() { # <file> -> fatal on contract violation
+ local f="$1" base probe_err
+ base=$(basename "$f")
+ probe_err=$(bash -c 'set -eu; source "$1"
+ : "${SCENARIO_DESC:?missing SCENARIO_DESC}"
+ : "${SCENARIO_GROUP:?missing SCENARIO_GROUP}"
+ : "${SCENARIO_PROFILES:?missing SCENARIO_PROFILES}"
+ for p in $SCENARIO_PROFILES; do
+ case "$p" in btrfs|zfs|any) ;; *)
+ echo "bad profile token: $p" >&2; exit 1 ;;
+ esac
+ done
+ declare -f scenario_break scenario_fix scenario_assert >/dev/null \
+ || { echo "missing scenario_break/fix/assert" >&2; exit 1; }' \
+ _probe "$f" 2>&1 >/dev/null) \
+ || fatal "scenario contract violation in $base: $probe_err"
+}
+
+_profile_matches() { # <profiles> -> 0 if scenario runs under $FS_PROFILE
+ local p
+ for p in $1; do
+ [ "$p" = "any" ] || [ "$p" = "$FS_PROFILE" ] && return 0
+ done
+ return 1
+}
+
+build_plan() {
+ [ -d "$SCENARIO_DIR" ] || fatal "scenario dir not found: $SCENARIO_DIR"
+ local f base name desc group profiles seen_groups=" "
+ local any=false
+ for f in "$SCENARIO_DIR"/*.sh; do
+ [ -e "$f" ] || break
+ any=true
+ _validate_scenario "$f"
+ base=$(basename "$f" .sh)
+ name="${base#[0-9][0-9]-}"
+ desc=$(_scenario_var "$f" SCENARIO_DESC)
+ group=$(_scenario_var "$f" SCENARIO_GROUP)
+ profiles=$(_scenario_var "$f" SCENARIO_PROFILES)
+ _profile_matches "$profiles" || continue
+ [ -n "$ONLY_GROUP" ] && [ "$group" != "$ONLY_GROUP" ] && continue
+ S_FILES+=("$f"); S_NAMES+=("$name")
+ S_DESCS+=("$desc"); S_GROUPS+=("$group")
+ case "$seen_groups" in
+ *" $group "*) ;;
+ *) GROUP_ORDER+=("$group"); seen_groups="$seen_groups$group " ;;
+ esac
+ done
+ [ "$any" = "true" ] || fatal "no scenario files in $SCENARIO_DIR"
+ if [ -n "$ONLY_GROUP" ] && [ ${#S_FILES[@]} -eq 0 ]; then
+ fatal "no scenarios match group '$ONLY_GROUP' (profile $FS_PROFILE)"
+ fi
+ [ ${#S_FILES[@]} -gt 0 ] \
+ || fatal "no scenarios match profile $FS_PROFILE"
+}
+
+print_plan() {
+ local g i
+ echo "maint scenario plan (profile: $FS_PROFILE)"
+ for g in "${GROUP_ORDER[@]}"; do
+ echo "group $g:"
+ for i in "${!S_FILES[@]}"; do
+ [ "${S_GROUPS[$i]}" = "$g" ] \
+ && echo " ${S_NAMES[$i]} — ${S_DESCS[$i]}"
+ done
+ done
+}
+
+# --list needs no logging file, VM, or KVM. Give fatal() a LOGFILE target
+# only when we actually run.
+if [ "$LIST_ONLY" = "true" ]; then
+ LOGFILE=/dev/null
+ build_plan
+ print_plan
+ exit 0
+fi
+
+# ─── Run ─────────────────────────────────────────────────────────────
+
+TIMESTAMP=$(date +'%Y%m%d-%H%M%S')
+RESULTS_DIR="$PROJECT_ROOT/test-results/maint-$TIMESTAMP"
+mkdir -p "$RESULTS_DIR"
+LOGFILE="$RESULTS_DIR/scenarios.log"
+init_logging "$LOGFILE"
+init_vm_paths "$PROJECT_ROOT/vm-images"
+
+build_plan
+
+section "Maint Remedy Scenarios: $TIMESTAMP"
+info "Profile: $FS_PROFILE (image: $(basename "$DISK_PATH"))"
+info "Maint source: $MAINT_SRC"
+print_plan | tee -a "$LOGFILE"
+
+[ -f "$DISK_PATH" ] || fatal "base disk not found: $DISK_PATH — build it: FS_PROFILE=$FS_PROFILE $SCRIPT_DIR/create-base-vm.sh"
+snapshot_exists "$DISK_PATH" "clean-install" \
+ || fatal "snapshot 'clean-install' not found on $DISK_PATH"
+[ -d "$MAINT_SRC" ] || fatal "maint source tree not found: $MAINT_SRC"
+[ -f "$THRESHOLDS_TOML" ] || fatal "thresholds TOML not found: $THRESHOLDS_TOML"
+check_prerequisites || fatal "missing prerequisites"
+
+CLEANUP_DONE=0
+cleanup_scenarios() {
+ [ "$CLEANUP_DONE" = "1" ] && return 0
+ CLEANUP_DONE=1
+ [ "$KEEP_VM" = "true" ] && return 0
+ # Never silent: a failed restore/delete here leaves the base image dirty
+ # for the next `make test`, so name it even though the trap can't abort.
+ stop_qemu 2>/dev/null \
+ || echo "[!] cleanup: stop_qemu failed — VM may still hold the image" >&2
+ restore_snapshot "$DISK_PATH" "clean-install" 2>/dev/null \
+ || echo "[!] cleanup: clean-install restore FAILED — base image is dirty" >&2
+ if snapshot_exists "$DISK_PATH" "$READY_SNAPSHOT"; then
+ delete_snapshot "$DISK_PATH" "$READY_SNAPSHOT" 2>/dev/null \
+ || echo "[!] cleanup: stray '$READY_SNAPSHOT' snapshot left on the base image" >&2
+ fi
+}
+trap cleanup_scenarios EXIT
+
+# ─── Target helpers (available to scenario scripts) ──────────────────
+
+mexec() { # run a command in the VM as root
+ vm_exec "$ROOT_PASSWORD" "$@"
+}
+
+# maint invocation inside the VM. Root runs the verbs directly, so
+# MAINT_SUDO is empty (same switch the unit suites use).
+mmaint() {
+ mexec "cd /root/maint-pkg && MAINT_SUDO= PYTHONPATH=/root/maint-pkg python3 -m maint $*"
+}
+
+# Run one remedy: dry-run first (argv parity gate — the printed argv is the
+# arm-press string), then for real. Fails if either exits non-zero.
+mfix() {
+ local id="$1"; shift || true
+ step "maint fix $id ${*:+$* }--dry-run"
+ mmaint fix "$id" "$@" --dry-run >> "$LOGFILE" 2>&1 \
+ || { error "dry-run failed: maint fix $id $*"; return 1; }
+ step "maint fix $id $*"
+ mmaint fix "$id" "$@" 2>&1 | tee -a "$LOGFILE"
+ return "${PIPESTATUS[0]}"
+}
+
+# Assert a metric's severity in `maint status --json`.
+massert_metric() { # <metric-id> <expected-severity>
+ local mid="$1" want="$2" got
+ got=$(mmaint status --json | python3 -c "
+import json, sys
+env = json.load(sys.stdin)
+ms = [m for m in env['metrics'] if m['id'] == '$mid']
+print(ms[0]['severity'] if ms else 'MISSING')
+")
+ if [ "$got" = "$want" ]; then
+ success "metric $mid is $want"
+ return 0
+ fi
+ error "metric $mid: expected $want, got $got"
+ return 1
+}
+
+# ─── Bootstrap ───────────────────────────────────────────────────────
+
+boot_vm() {
+ start_qemu "$DISK_PATH" "disk" "" "none" || fatal "failed to start VM"
+ wait_for_ssh "$ROOT_PASSWORD" 180 || fatal "VM SSH not available"
+}
+
+bootstrap_vm() {
+ section "Bootstrap: packages + maint tree + thresholds"
+ step "Refreshing keyring + installing runtime deps"
+ # The base image can be months old: refresh the keyring first so the
+ # dep install doesn't die on expired signatures.
+ mexec "pacman -Sy --noconfirm archlinux-keyring" >> "$LOGFILE" 2>&1 \
+ || fatal "keyring refresh failed (network up?)"
+ mexec "pacman -S --noconfirm python pacman-contrib expac cronie" \
+ >> "$LOGFILE" 2>&1 || fatal "dep install failed"
+
+ step "Copying maint package tree"
+ local tarball
+ tarball=$(mktemp)
+ tar -C "$(dirname "$MAINT_SRC")" --exclude='__pycache__' -czf "$tarball" \
+ "$(basename "$MAINT_SRC")"
+ copy_to_vm "$tarball" "/tmp/maint-src.tgz" "$ROOT_PASSWORD"
+ rm -f "$tarball"
+ mexec "rm -rf /root/maint-pkg && mkdir -p /root/maint-pkg && tar -C /root/maint-pkg -xzf /tmp/maint-src.tgz && rm /tmp/maint-src.tgz" \
+ >> "$LOGFILE" 2>&1 || fatal "maint tree extract failed"
+
+ step "Installing thresholds TOML"
+ copy_to_vm "$THRESHOLDS_TOML" "/tmp/maintenance-thresholds.toml" \
+ "$ROOT_PASSWORD"
+ mexec "mkdir -p /root/.config/archsetup && mv /tmp/maintenance-thresholds.toml /root/.config/archsetup/maintenance-thresholds.toml" \
+ >> "$LOGFILE" 2>&1 || fatal "TOML install failed"
+
+ step "Smoke: maint status --json runs"
+ mmaint status --json > "$RESULTS_DIR/bootstrap-status.json" \
+ || fatal "maint status failed in the VM"
+ success "Bootstrap complete ($(python3 -c "
+import json; print(len(json.load(open('$RESULTS_DIR/bootstrap-status.json'))['metrics']))") metrics probed)"
+}
+
+# ─── Execution ───────────────────────────────────────────────────────
+
+PASS=() FAIL=()
+
+run_scenario() { # <index>
+ local i="$1" name="${S_NAMES[$1]}" f="${S_FILES[$1]}"
+ section "Scenario: $name — ${S_DESCS[$1]}"
+ # Sourced in the runner's shell so break/fix/assert see the helpers;
+ # unset afterwards so the next scenario can't inherit stale functions.
+ # shellcheck disable=SC1090
+ source "$f"
+ local ok=true
+ step "break"
+ if ! scenario_break; then
+ error "$name: break step failed"
+ ok=false
+ fi
+ if [ "$ok" = "true" ]; then
+ step "fix"
+ if ! scenario_fix; then
+ error "$name: fix step failed"
+ ok=false
+ fi
+ fi
+ if [ "$ok" = "true" ]; then
+ step "assert"
+ if ! scenario_assert; then
+ error "$name: post-state assertion failed"
+ ok=false
+ fi
+ fi
+ unset -f scenario_break scenario_fix scenario_assert
+ unset SCENARIO_DESC SCENARIO_GROUP SCENARIO_PROFILES
+ if [ "$ok" = "true" ]; then
+ success "PASS: $name"
+ PASS+=("$name")
+ else
+ error "FAIL: $name"
+ FAIL+=("$name")
+ fi
+}
+
+section "Preparing VM"
+stop_qemu 2>/dev/null || true
+step "Restoring clean-install snapshot"
+restore_snapshot "$DISK_PATH" "clean-install" || fatal "restore failed"
+boot_vm
+bootstrap_vm
+
+step "Freezing bootstrap as snapshot: $READY_SNAPSHOT"
+stop_qemu
+snapshot_exists "$DISK_PATH" "$READY_SNAPSHOT" \
+ && delete_snapshot "$DISK_PATH" "$READY_SNAPSHOT"
+create_snapshot "$DISK_PATH" "$READY_SNAPSHOT" || fatal "snapshot failed"
+
+first_group=true
+for g in "${GROUP_ORDER[@]}"; do
+ section "Group: $g"
+ if [ "$first_group" = "true" ]; then
+ first_group=false
+ else
+ step "Isolation boundary: restore $READY_SNAPSHOT"
+ stop_qemu
+ restore_snapshot "$DISK_PATH" "$READY_SNAPSHOT" || fatal "restore failed"
+ fi
+ boot_vm
+ for i in "${!S_FILES[@]}"; do
+ [ "${S_GROUPS[$i]}" = "$g" ] && run_scenario "$i"
+ done
+ stop_qemu
+done
+
+# ─── Report ──────────────────────────────────────────────────────────
+
+section "Results"
+for n in "${PASS[@]}"; do success "PASS $n"; done
+for n in "${FAIL[@]}"; do error "FAIL $n"; done
+info "Log: $LOGFILE"
+
+if [ "$KEEP_VM" = "true" ]; then
+ warn "--keep: VM left in post-run state; base image NOT restored"
+ warn "restore by hand: qemu-img snapshot -a clean-install $DISK_PATH"
+ CLEANUP_DONE=1
+else
+ cleanup_scenarios
+fi
+
+if [ ${#FAIL[@]} -gt 0 ]; then
+ error "${#FAIL[@]} scenario(s) failed, ${#PASS[@]} passed"
+ exit 1
+fi
+success "All ${#PASS[@]} scenarios passed"
+exit 0
diff --git a/scripts/testing/run-test-baremetal.sh b/scripts/testing/run-test-baremetal.sh
index ae88316..d22c424 100755
--- a/scripts/testing/run-test-baremetal.sh
+++ b/scripts/testing/run-test-baremetal.sh
@@ -20,13 +20,16 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# Source utilities
source "$SCRIPT_DIR/lib/logging.sh"
-source "$SCRIPT_DIR/lib/validation.sh"
+source "$SCRIPT_DIR/lib/validation.sh" # live helpers: ssh_cmd, capture_*_state, analyze_log_diff, generate_issue_report
+source "$SCRIPT_DIR/lib/vm-utils.sh" # inject_root_key + SSH_OPTS/SSH_KEY_OPT for key auth
+source "$SCRIPT_DIR/lib/testinfra.sh" # run_testinfra_validation (authoritative validator)
# Parse arguments
ROLLBACK_FIRST=false
ROLLBACK_AFTER=false
TARGET_HOST=""
ROOT_PASSWORD=""
+PORT="22"
usage() {
echo "Usage: $0 --host <hostname> --password <root_password> [options]"
@@ -36,6 +39,7 @@ usage() {
echo " --password <password> Root password for SSH"
echo ""
echo "Options:"
+ echo " --port <port> SSH port (default 22; use 2222 to target a test VM)"
echo " --rollback-first Roll back to genesis snapshots before running"
echo " --rollback-after Roll back to genesis snapshots after test (cleanup)"
echo " --validate-only Skip archsetup, only run validation checks"
@@ -55,6 +59,10 @@ while [[ $# -gt 0 ]]; do
ROOT_PASSWORD="${2:?--password requires a value}"
shift 2
;;
+ --port)
+ PORT="${2:?--port requires a value}"
+ shift 2
+ ;;
--rollback-first)
ROLLBACK_FIRST=true
shift
@@ -94,9 +102,16 @@ cleanup_baremetal() {
}
trap cleanup_baremetal EXIT
-# Override VM_IP for validation.sh ssh_cmd function
-# shellcheck disable=SC2034 # consumed by the sourced validation.sh
+# Connection globals consumed by ssh_cmd (validation.sh), inject_root_key
+# (vm-utils.sh), and run_testinfra_validation (testinfra.sh).
+# shellcheck disable=SC2034 # consumed by the sourced libraries
VM_IP="$TARGET_HOST"
+# shellcheck disable=SC2034
+SSH_PORT="$PORT"
+# Test-user source for testinfra (reads USERNAME); the bare-metal user is the
+# archsetup default, cjennings, same as the VM conf.
+# shellcheck disable=SC2034
+ARCHSETUP_VM_CONF="$SCRIPT_DIR/archsetup-vm.conf"
# Initialize logging
mkdir -p "$TEST_RESULTS_DIR"
@@ -109,8 +124,8 @@ info "Target: $TARGET_HOST"
# Test SSH connectivity
step "Testing SSH connectivity to $TARGET_HOST"
if ! sshpass -p "$ROOT_PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
- -o ConnectTimeout=10 "root@$TARGET_HOST" "echo connected" &>/dev/null; then
- fatal "Cannot connect to $TARGET_HOST via SSH"
+ -o ConnectTimeout=10 -p "$PORT" "root@$TARGET_HOST" "echo connected" &>/dev/null; then
+ fatal "Cannot connect to $TARGET_HOST:$PORT via SSH"
fi
success "SSH connection OK"
@@ -147,6 +162,15 @@ if $ROLLBACK_FIRST; then
success "Reconnected"
fi
+# Authorize a throwaway root key before archsetup hardens sshd. archsetup sets
+# PermitRootLogin prohibit-password and reloads sshd partway through, which kills
+# root *password* SSH; key auth survives it, so every later ssh_cmd and the
+# Testinfra sweep keep working. Placed after any genesis rollback so the key
+# isn't rolled away. Best-effort: a failure only risks the post-hardening steps.
+step "Authorizing throwaway root key (survives sshd hardening)"
+inject_root_key "$TEST_RESULTS_DIR/root_key" || \
+ warn "Continuing without an injected root key — SSH may fail after archsetup hardens sshd"
+
if ! $VALIDATE_ONLY; then
# Capture pre-install state
capture_pre_install_state "$TEST_RESULTS_DIR"
@@ -161,7 +185,7 @@ if ! $VALIDATE_ONLY; then
step "Transferring to $TARGET_HOST"
ssh_cmd "rm -rf /tmp/archsetup-test && mkdir -p /tmp/archsetup-test"
sshpass -p "$ROOT_PASSWORD" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
- "$BUNDLE_FILE" "root@$TARGET_HOST:/tmp/archsetup.bundle" >> "$LOGFILE" 2>&1
+ ${SSH_KEY_OPT:-} -P "$PORT" "$BUNDLE_FILE" "root@$TARGET_HOST:/tmp/archsetup.bundle" >> "$LOGFILE" 2>&1
step "Extracting on target"
ssh_cmd "cd /tmp && git clone /tmp/archsetup.bundle archsetup-test && rm /tmp/archsetup.bundle" >> "$LOGFILE" 2>&1
@@ -223,12 +247,12 @@ if ! $VALIDATE_ONLY; then
step "Copying archsetup log"
sshpass -p "$ROOT_PASSWORD" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
- "root@$TARGET_HOST:/var/log/archsetup-*.log" "$TEST_RESULTS_DIR/" 2>> "$LOGFILE" || \
+ ${SSH_KEY_OPT:-} -P "$PORT" "root@$TARGET_HOST:/var/log/archsetup-*.log" "$TEST_RESULTS_DIR/" 2>> "$LOGFILE" || \
warn "Could not copy archsetup log"
step "Copying archsetup output"
sshpass -p "$ROOT_PASSWORD" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
- "root@$TARGET_HOST:$REMOTE_LOG" "$TEST_RESULTS_DIR/archsetup-output.log" 2>> "$LOGFILE" || \
+ ${SSH_KEY_OPT:-} -P "$PORT" "root@$TARGET_HOST:$REMOTE_LOG" "$TEST_RESULTS_DIR/archsetup-output.log" 2>> "$LOGFILE" || \
warn "Could not copy output log"
# Capture post-install state
@@ -239,13 +263,14 @@ else
mkdir -p "$TEST_RESULTS_DIR/pre-install" "$TEST_RESULTS_DIR/post-install"
fi
-# Run validations
-run_all_validations
-validate_all_services
-
-# Additional ZFS-specific validations
-section "ZFS-Specific Validations"
-validate_zfs_services
+# Run validations. Testinfra is the authoritative validator (same as the VM
+# runner); its ZFS-conditional pytest checks cover what validate_zfs_services
+# used to, and it connects over the key authorized above. set +e because it
+# returns pytest's rc (non-zero on failures) and the report + summary below must
+# still run; the verdict is carried by testinfra_rc and the exit code at the end.
+set +e
+run_testinfra_validation "$TEST_RESULTS_DIR"
+testinfra_rc=$?
# Analyze logs if we ran archsetup
if ! $VALIDATE_ONLY; then
@@ -255,8 +280,8 @@ fi
# Generate reports
generate_issue_report "$TEST_RESULTS_DIR" "$ARCHZFS_INBOX"
-# Set validation result (TEST_PASSED is the boolean; VALIDATION_PASSED stays the counter)
-if [ "$VALIDATION_FAILED" -eq 0 ]; then
+# The run passes only if the Testinfra sweep passed.
+if [ "$testinfra_rc" -eq 0 ]; then
TEST_PASSED=true
else
TEST_PASSED=false
diff --git a/scripts/testing/run-test.sh b/scripts/testing/run-test.sh
index 9b47747..f962df3 100755
--- a/scripts/testing/run-test.sh
+++ b/scripts/testing/run-test.sh
@@ -24,6 +24,7 @@ source "$SCRIPT_DIR/lib/logging.sh"
source "$SCRIPT_DIR/lib/vm-utils.sh"
source "$SCRIPT_DIR/lib/network-diagnostics.sh"
source "$SCRIPT_DIR/lib/validation.sh"
+source "$SCRIPT_DIR/lib/testinfra.sh"
# Parse arguments
KEEP_VM=false
@@ -49,6 +50,9 @@ while [[ $# -gt 0 ]]; do
echo " --keep Keep VM in post-test state (for debugging)"
echo " --script Specify custom archsetup script to test"
echo " --snapshot Snapshot name to revert to (default: clean-install)"
+ echo ""
+ echo "Env: FS_PROFILE=btrfs|zfs (default btrfs) selects the base image"
+ echo " built by create-base-vm.sh. e.g. FS_PROFILE=zfs $0"
exit 1
;;
esac
@@ -99,6 +103,7 @@ init_logging "$LOGFILE"
init_vm_paths "$VM_IMAGES_DIR"
section "ArchSetup Test Run: $TIMESTAMP"
+info "Filesystem profile: $FS_PROFILE (image: $(basename "$DISK_PATH"))"
# Verify archsetup script exists
if [ ! -f "$ARCHSETUP_SCRIPT" ]; then
@@ -107,7 +112,11 @@ fi
# Check disk exists
if [ ! -f "$DISK_PATH" ]; then
- info "Create it first: ./scripts/testing/create-base-vm.sh"
+ if [ "$FS_PROFILE" = "btrfs" ]; then
+ info "Create it first: ./scripts/testing/create-base-vm.sh"
+ else
+ info "Create it first: FS_PROFILE=$FS_PROFILE ./scripts/testing/create-base-vm.sh"
+ fi
fatal "Base disk not found: $DISK_PATH"
fi
@@ -141,6 +150,13 @@ start_qemu "$DISK_PATH" "disk" "" "none" || fatal "Failed to start VM"
wait_for_ssh "$ROOT_PASSWORD" 120 || fatal "VM SSH not available"
stop_timer "boot"
+# Authorize a root key now, before archsetup runs. archsetup hardens sshd to
+# PermitRootLogin prohibit-password partway through, which breaks the harness's
+# root password SSH; key auth survives it. Without this, the run aborts mid-way
+# (before any validation) once the hardening step lands.
+inject_root_key "$TEST_RESULTS_DIR/root_key" || \
+ warn "Continuing without root key - run may break at the sshd hardening step"
+
# Run network diagnostics
if ! run_network_diagnostics; then
fatal "Network diagnostics failed - aborting test"
@@ -241,7 +257,8 @@ fi
# Poll for completion
step "Monitoring archsetup progress (polling every 30 seconds)..."
POLL_COUNT=0
-MAX_POLLS=180 # 90 minutes max (180 * 30 seconds)
+MAX_POLLS=300 # 150 minutes max (300 * 30 seconds); a full install with heavy
+ # AUR builds (e.g. vagrant) can exceed 90 min on a slow mirror
while [ $POLL_COUNT -lt $MAX_POLLS ]; do
# Check if archsetup process is still running
@@ -262,7 +279,7 @@ while [ $POLL_COUNT -lt $MAX_POLLS ]; do
done
if [ $POLL_COUNT -ge $MAX_POLLS ]; then
- error "ArchSetup timed out after 90 minutes"
+ error "ArchSetup timed out after 150 minutes"
ARCHSETUP_EXIT_CODE=124
else
# Get exit code from the remote log
@@ -308,18 +325,17 @@ copy_from_vm "/var/log/archsetup-installed-packages.txt" "$TEST_RESULTS_DIR/" "$
# Capture post-install state
capture_post_install_state "$TEST_RESULTS_DIR"
-# Run comprehensive validation
-# This uses the validation.sh library for all checks.
+# Run comprehensive validation (Testinfra/pytest is the primary validator; the
+# old shell run_all_validations sweep was retired once pytest reached parity).
#
# From here to the end of the script, errexit is disabled on purpose: the
-# validation functions are designed to fail-and-count (see VALIDATION_FAILED)
-# rather than abort, and the analysis/report-generation steps below can also
-# legitimately return non-zero. With `set -e` active, a single failed check
-# would kill the run before the test report is written or the VM is cleaned
-# up. Pass/fail is signalled explicitly by the exit code at the bottom.
+# analysis/report-generation steps below can legitimately return non-zero, and
+# with `set -e` active a single failed check would kill the run before the test
+# report is written or the VM is cleaned up. Pass/fail is signalled explicitly
+# by the exit code at the bottom.
set +e
-run_all_validations
-validate_all_services
+run_testinfra_validation "$TEST_RESULTS_DIR"
+testinfra_rc=$?
# Analyze log differences (pre vs post install)
analyze_log_diff "$TEST_RESULTS_DIR"
@@ -328,8 +344,8 @@ analyze_log_diff "$TEST_RESULTS_DIR"
# If base install issues found and archzfs inbox exists, create issue file
generate_issue_report "$TEST_RESULTS_DIR" "$ARCHZFS_INBOX"
-# Set validation result based on failure count
-if [ "$VALIDATION_FAILED" -eq 0 ]; then
+# The run passes only if the Testinfra sweep passed.
+if [ "$testinfra_rc" -eq 0 ]; then
TEST_PASSED=true
else
TEST_PASSED=false
diff --git a/scripts/testing/tests/conftest.py b/scripts/testing/tests/conftest.py
new file mode 100644
index 0000000..680c967
--- /dev/null
+++ b/scripts/testing/tests/conftest.py
@@ -0,0 +1,111 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Pytest + Testinfra config for archsetup post-install validation.
+
+These tests run on the *host* and connect to the freshly-installed VM over SSH
+(Testinfra provides the `host` fixture, parametrized from --hosts). This file
+adds two things the bespoke shell harness had that Testinfra does not:
+
+ - Failure attribution. Each check is marked with the layer that owns a
+ failure (archsetup | base_install | unknown), mirroring validation.sh's
+ attribute_issue. Failures are bucketed and written to --attribution-file
+ so run-test.sh can route base-install issues to the archzfs inbox as before.
+ - Tiering markers (smoke | integration) so `pytest -m smoke` is a fast gate.
+
+The `target_user` fixture supplies the account archsetup created; it reads
+ARCHSETUP_TEST_USER (set by run-test.sh from the VM conf) and defaults to the
+historical "cjennings".
+"""
+
+import os
+
+import pytest
+
+
+_ATTRIBUTION_BUCKETS = ("archsetup", "base_install", "unknown")
+_failures = {bucket: [] for bucket in _ATTRIBUTION_BUCKETS}
+
+
+def pytest_addoption(parser):
+ parser.addoption(
+ "--attribution-file",
+ action="store",
+ default=None,
+ help="write the failure attribution report (archsetup/base_install/unknown) here",
+ )
+
+
+def pytest_configure(config):
+ config.addinivalue_line(
+ "markers",
+ "attribution(bucket): layer that owns a failure — archsetup, base_install, or unknown",
+ )
+ config.addinivalue_line("markers", "smoke: fast subset (user, key packages, dotfiles present)")
+ config.addinivalue_line("markers", "integration: full post-install checks")
+
+
+@pytest.hookimpl(wrapper=True)
+def pytest_runtest_makereport(item, call):
+ report = yield
+ if report.when == "call" and report.failed:
+ marker = item.get_closest_marker("attribution")
+ bucket = marker.args[0] if (marker and marker.args) else "archsetup"
+ if bucket not in _failures:
+ bucket = "unknown"
+ _failures[bucket].append(item.nodeid)
+ return report
+
+
+def pytest_sessionfinish(session, exitstatus):
+ path = session.config.getoption("--attribution-file")
+ if not path:
+ return
+ with open(path, "w") as fh:
+ for bucket in _ATTRIBUTION_BUCKETS:
+ fh.write("[%s]\n" % bucket)
+ for nodeid in _failures[bucket]:
+ fh.write(" %s\n" % nodeid)
+
+
+@pytest.fixture(scope="session")
+def target_user():
+ """The account archsetup created in the VM under test."""
+ return os.environ.get("ARCHSETUP_TEST_USER", "cjennings")
+
+
+@pytest.fixture(scope="session")
+def home(target_user):
+ return "/home/%s" % target_user
+
+
+@pytest.fixture(scope="module")
+def zfs_root(host):
+ """True when the VM's root filesystem is ZFS (gates ZFS-specific checks)."""
+ return host.run("findmnt -n -o FSTYPE /").stdout.strip() == "zfs"
+
+
+@pytest.fixture(scope="module")
+def has_nvme(host):
+ """True when the VM exposes an NVMe device."""
+ return host.run("ls /dev/nvme0n1 2>/dev/null").rc == 0
+
+
+@pytest.fixture(scope="module")
+def hyprland_installed(host):
+ return host.package("hyprland").is_installed
+
+
+@pytest.fixture(scope="module")
+def dwm_installed(host):
+ return host.file("/usr/local/bin/dwm").exists
+
+
+@pytest.fixture(scope="module")
+def compositor_running(host):
+ """A graphical session is live (gates socket/portal checks that need one)."""
+ return host.run("pgrep -x Hyprland").rc == 0
+
+
+@pytest.fixture(scope="module")
+def on_slirp(host):
+ """QEMU user-mode networking (10.0.2.x) — no multicast, so mDNS can't work."""
+ return "10.0.2." in host.run("ip -4 addr show").stdout
diff --git a/scripts/testing/tests/test_archsetup.py b/scripts/testing/tests/test_archsetup.py
new file mode 100644
index 0000000..52fe3f7
--- /dev/null
+++ b/scripts/testing/tests/test_archsetup.py
@@ -0,0 +1,26 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: archsetup's own log and state markers.
+
+Parity port of validate_archsetup_log and validate_state_markers.
+"""
+
+import pytest
+
+
+EXPECTED_STATE_STEPS = 12
+
+
+@pytest.mark.attribution("archsetup")
+def test_no_errors_in_archsetup_log(host):
+ out = host.run("grep -h '^Error:' /var/log/archsetup-*.log 2>/dev/null | wc -l")
+ count = int((out.stdout.strip() or "0"))
+ assert count == 0, "archsetup log reported %d Error: lines" % count
+
+
+@pytest.mark.attribution("archsetup")
+def test_all_install_steps_completed(host):
+ out = host.run("ls /var/lib/archsetup/state/ 2>/dev/null | wc -l")
+ count = int((out.stdout.strip() or "0"))
+ assert count >= EXPECTED_STATE_STEPS, (
+ "only %d/%d install steps completed" % (count, EXPECTED_STATE_STEPS)
+ )
diff --git a/scripts/testing/tests/test_backups.py b/scripts/testing/tests/test_backups.py
new file mode 100644
index 0000000..07da5ec
--- /dev/null
+++ b/scripts/testing/tests/test_backups.py
@@ -0,0 +1,44 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: backup_system_file ran during a real install.
+
+Expansion coverage (P4). The unit suite (tests/backup-system-file/) covers the
+helper's logic; this confirms it actually fires end-to-end — archsetup leaves a
+<file>.archsetup.bak next to each pre-existing file it edits in place.
+
+These targets are edited unconditionally on every run (pacman.conf/makepkg.conf
+always sed'd, sudoers always appended), so their backups must exist.
+mkinitcpio.conf is edited only conditionally (the systemd-hook switch on
+non-ZFS, or the nvme module on NVMe systems), so it gets its own fixture-gated
+check below. Conditionally-edited files (locale.gen, geoclue, fstab) aren't
+asserted here since their edits depend on the base image.
+"""
+
+import pytest
+
+
+ALWAYS_BACKED_UP = [
+ "/etc/pacman.conf",
+ "/etc/makepkg.conf",
+ "/etc/sudoers",
+]
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("path", ALWAYS_BACKED_UP)
+def test_backup_created_for_edited_file(host, path):
+ bak = host.file(path + ".archsetup.bak")
+ assert bak.exists, "%s.archsetup.bak missing — backup_system_file did not fire" % path
+ assert bak.is_file
+
+
+@pytest.mark.attribution("archsetup")
+def test_backup_created_for_mkinitcpio(host, zfs_root, has_nvme):
+ # archsetup edits /etc/mkinitcpio.conf only when it has something to change:
+ # the systemd-hook switch (non-ZFS only) or adding the nvme module (NVMe
+ # systems). A ZFS root with no NVMe touches neither, so there's no backup.
+ if zfs_root and not has_nvme:
+ pytest.skip("ZFS root + no NVMe: archsetup doesn't edit mkinitcpio.conf")
+ bak = host.file("/etc/mkinitcpio.conf.archsetup.bak")
+ assert bak.exists, \
+ "/etc/mkinitcpio.conf.archsetup.bak missing — backup_system_file did not fire"
+ assert bak.is_file
diff --git a/scripts/testing/tests/test_boot.py b/scripts/testing/tests/test_boot.py
new file mode 100644
index 0000000..e442682
--- /dev/null
+++ b/scripts/testing/tests/test_boot.py
@@ -0,0 +1,83 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: boot, initramfs, and filesystem config.
+
+Parity port of validate_zfs_config, validate_boot_config,
+validate_mkinitcpio_hooks, validate_initramfs_consolefont, validate_nvme_module.
+Filesystem/hardware-specific checks are gated on fixtures.
+"""
+
+import pytest
+
+
+@pytest.mark.attribution("archsetup")
+def test_bootloader_installed(host, zfs_root):
+ # A ZFS root boots via ZFSBootMenu (archangel installs the EFI binary under
+ # /efi/EFI/ZBM), so there is no GRUB; a non-ZFS root uses GRUB.
+ if zfs_root:
+ assert host.file("/efi/EFI/ZBM/zfsbootmenu.efi").exists, \
+ "ZFS root must have the ZFSBootMenu EFI binary"
+ else:
+ assert host.file("/boot/grub/grub.cfg").exists, \
+ "non-ZFS root must have a GRUB config"
+
+
+@pytest.mark.attribution("archsetup")
+def test_mkinitcpio_hooks(host, zfs_root):
+ hooks = host.run("grep '^HOOKS=' /etc/mkinitcpio.conf").stdout
+ if zfs_root:
+ # ZFS must use the udev hook; the systemd hook breaks a ZFS boot.
+ assert " udev" in hooks or "(udev" in hooks, "ZFS root must use the udev hook"
+ assert "systemd" not in hooks, "ZFS root must not use the systemd hook"
+ else:
+ # Non-ZFS: either hook is acceptable.
+ assert ("systemd" in hooks) or ("udev" in hooks)
+
+
+@pytest.mark.attribution("archsetup")
+def test_console_font_configured(host, zfs_root):
+ # archsetup sets FONT=ter-132n in /etc/vconsole.conf on every run.
+ assert host.file("/etc/vconsole.conf").contains("^FONT=ter-132n"), \
+ "archsetup should set FONT=ter-132n in /etc/vconsole.conf"
+ # On non-ZFS it also rebuilds the initramfs (mkinitcpio -P) so the font is
+ # baked in for early boot. On ZFS that rebuild is skipped (the busybox ZFS
+ # hook is incompatible with the systemd-hook switch), so the font applies at
+ # the vconsole layer once userspace starts, not inside the initramfs.
+ if zfs_root:
+ return
+ # Pick the main initramfs (this fleet runs linux-lts, so the name is
+ # initramfs-linux-lts.img, not initramfs-linux.img); skip the fallback image.
+ img = host.run(
+ "ls /boot/initramfs-*.img 2>/dev/null | grep -v fallback | head -1"
+ ).stdout.strip()
+ assert img, "no initramfs image found under /boot"
+ out = host.run("lsinitcpio %s 2>/dev/null | grep -cE 'consolefont.psf|ter-'" % img)
+ assert int((out.stdout.strip() or "0")) > 0, "console font not found in %s" % img
+
+
+def test_nvme_module_when_nvme_present(host, has_nvme):
+ if not has_nvme:
+ pytest.skip("no NVMe device present")
+ modules = host.run("grep '^MODULES=' /etc/mkinitcpio.conf").stdout
+ assert "nvme" in modules, "NVMe system should list nvme in mkinitcpio MODULES"
+
+
+def test_zfs_has_sanoid(host):
+ if not host.exists("zfs"):
+ pytest.skip("ZFS not installed (non-ZFS system)")
+ assert host.exists("sanoid"), "ZFS system should have sanoid installed"
+
+
+def test_zfs_pre_pacman_snapshot_hook(host):
+ # archsetup installs a PreTransaction pacman hook + a self-pruning script so
+ # every pacman transaction is preceded by a rollback snapshot (configure_
+ # pre_pacman_snapshots, run late in boot_ux). ZFS-root only.
+ if not host.exists("zfs"):
+ pytest.skip("ZFS not installed (non-ZFS system)")
+ script = host.file("/usr/local/bin/zfs-pre-snapshot")
+ assert script.exists and script.is_file, "pre-pacman snapshot script missing"
+ assert script.mode & 0o111, "pre-pacman snapshot script is not executable"
+ hook = host.file("/etc/pacman.d/hooks/zfs-snapshot.hook")
+ assert hook.exists and hook.is_file, "zfs-snapshot.hook missing"
+ assert "PreTransaction" in hook.content_string, "hook not PreTransaction"
+ assert "/usr/local/bin/zfs-pre-snapshot" in hook.content_string, \
+ "hook does not exec the snapshot script"
diff --git a/scripts/testing/tests/test_config_applied.py b/scripts/testing/tests/test_config_applied.py
new file mode 100644
index 0000000..00c410e
--- /dev/null
+++ b/scripts/testing/tests/test_config_applied.py
@@ -0,0 +1,55 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: archsetup's in-place config edits actually took effect.
+
+Expansion coverage (P4). These assert the *content* archsetup writes, not just
+that a service is enabled — catching cases where a sed silently no-ops (e.g.
+ParallelDownloads, which current Arch ships uncommented so a "^#"-only match
+left it at 5).
+"""
+
+import pytest
+
+
+@pytest.mark.attribution("archsetup")
+def test_pacman_parallel_downloads(host):
+ line = host.run("grep -E '^ParallelDownloads' /etc/pacman.conf").stdout
+ assert "ParallelDownloads = 10" in line, "ParallelDownloads not set to 10 (got: %r)" % line
+
+
+@pytest.mark.attribution("archsetup")
+def test_pacman_color_enabled(host):
+ assert host.run("grep -qx Color /etc/pacman.conf").rc == 0
+
+
+@pytest.mark.attribution("archsetup")
+def test_pacman_multilib_enabled(host):
+ # -F: [multilib] is a literal section header, not a regex character class.
+ assert host.run("grep -Fxq '[multilib]' /etc/pacman.conf").rc == 0
+
+
+@pytest.mark.attribution("archsetup")
+def test_makepkg_parallel_make(host):
+ line = host.run("grep -E '^MAKEFLAGS' /etc/makepkg.conf").stdout
+ assert "-j" in line, "MAKEFLAGS not configured for parallel make (got: %r)" % line
+
+
+@pytest.mark.attribution("archsetup")
+def test_makepkg_options_trimmed(host):
+ opts = host.run("grep -E '^OPTIONS' /etc/makepkg.conf").stdout
+ assert "!debug" in opts and "purge" in opts, "makepkg OPTIONS not customized"
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("rel", ["dns.conf", "wifi-privacy.conf"])
+def test_networkmanager_dropin(host, rel):
+ assert host.file("/etc/NetworkManager/conf.d/%s" % rel).exists
+
+
+@pytest.mark.attribution("archsetup")
+def test_fail2ban_jail_local(host):
+ assert host.file("/etc/fail2ban/jail.local").exists
+
+
+@pytest.mark.attribution("archsetup")
+def test_reflector_config(host):
+ assert host.file("/etc/xdg/reflector/reflector.conf").exists
diff --git a/scripts/testing/tests/test_desktop.py b/scripts/testing/tests/test_desktop.py
new file mode 100644
index 0000000..6f79bfd
--- /dev/null
+++ b/scripts/testing/tests/test_desktop.py
@@ -0,0 +1,140 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: window manager + desktop integration.
+
+Parity port of validate_window_manager and its Hyprland/DWM branches, plus
+validate_autologin_config. Hyprland and DWM checks are gated on which DE the
+run installed; socket/portal-query checks are gated on a live compositor (the
+headless test VM has none).
+
+Note: validate_hyprland_tools historically checked `swww`, but archsetup now
+installs `awww` (swww successor) and `pacman -Q swww` no longer matches — so
+this checks awww. That divergence from the shell sweep is a correctness fix.
+"""
+
+import pytest
+
+
+HYPRLAND_TOOLS = [
+ "hyprland", "hypridle", "hyprlock", "waybar", "fuzzel",
+ "awww", "grim", "slurp", "gammastep", "foot",
+]
+
+HYPRLAND_CONFIGS = [
+ ".config/hypr/hyprland.conf",
+ ".config/hypr/hypridle.conf",
+ ".config/hypr/hyprlock.conf",
+ ".config/waybar/config",
+ ".config/fuzzel/fuzzel.ini",
+ ".config/gammastep/config.ini",
+]
+
+SUCKLESS_TOOLS = ["dwm", "st", "dmenu", "slock"]
+
+PORTALS_CONF = ".config/xdg-desktop-portal/portals.conf"
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("pkg", HYPRLAND_TOOLS)
+def test_hyprland_tool_installed(host, hyprland_installed, pkg):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)")
+ assert host.package(pkg).is_installed, "%s missing" % pkg
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("rel", HYPRLAND_CONFIGS)
+def test_hyprland_config_present(host, hyprland_installed, home, rel):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)")
+ assert host.file("%s/%s" % (home, rel)).exists, "%s missing" % rel
+
+
+@pytest.mark.attribution("archsetup")
+def test_live_update_guard_installed(host, hyprland_installed):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)")
+ guard = host.file("/usr/local/bin/hypr-live-update-guard")
+ assert guard.exists, "live-update guard script missing"
+ assert guard.mode & 0o111, "live-update guard not executable"
+ hook = host.file("/etc/pacman.d/hooks/hypr-live-update-guard.hook")
+ assert hook.exists, "live-update guard pacman hook missing"
+ assert "hypr-live-update-guard" in hook.content_string, \
+ "hook does not invoke the guard script"
+
+
+@pytest.mark.attribution("archsetup")
+def test_portal_settings_backend_not_disabled(host, hyprland_installed, home):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed")
+ conf = host.file("%s/%s" % (home, PORTALS_CONF))
+ assert conf.exists, "portals.conf missing"
+ line = host.run(
+ "grep org.freedesktop.impl.portal.Settings %s" % conf.path
+ ).stdout
+ assert "=none" not in line.replace(" ", ""), "Settings portal disabled (=none)"
+
+
+def test_portal_returns_dark_mode(host, hyprland_installed, compositor_running, target_user):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed")
+ if not compositor_running:
+ pytest.skip("no compositor running (headless) — portal query not applicable")
+ cmd = (
+ "sudo -u %s busctl --user call org.freedesktop.portal.Desktop "
+ "/org/freedesktop/portal/desktop org.freedesktop.portal.Settings Read "
+ "'ss' 'org.freedesktop.appearance' 'color-scheme'" % target_user
+ )
+ out = host.run(cmd).stdout
+ assert "u 1" in out, "Settings portal should report color-scheme=1 (dark)"
+
+
+def test_hyprland_socket(host, hyprland_installed, compositor_running):
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed")
+ if not compositor_running:
+ pytest.skip("Hyprland not running (headless) — socket check not applicable")
+ assert host.run("test -S /tmp/hypr/*/.socket.sock").rc == 0
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("tool", SUCKLESS_TOOLS)
+def test_suckless_tool_installed(host, dwm_installed, tool):
+ if not dwm_installed:
+ pytest.skip("DWM not installed (DESKTOP_ENV != dwm)")
+ assert host.file("/usr/local/bin/%s" % tool).exists, "%s missing" % tool
+
+
+def test_autologin_configured(host):
+ conf = host.file("/etc/systemd/system/getty@tty1.service.d/autologin.conf")
+ if not conf.exists:
+ pytest.skip("autologin not configured (AUTOLOGIN=no, may be intentional)")
+ assert conf.exists
+
+
+BT_PANEL_BINS = ["bt", "bt-panel", "bt-priv", "waybar-bt"]
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("name", BT_PANEL_BINS)
+def test_bt_panel_bin_stowed(host, hyprland_installed, home, name):
+ # Executable via either stow shape (per-file symlink or folded dir).
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)")
+ path = "%s/.local/bin/%s" % (home, name)
+ assert host.file(path).exists, "%s missing from ~/.local/bin" % name
+ assert host.run("test -x %s" % path).rc == 0, "%s not executable" % name
+
+
+@pytest.mark.attribution("archsetup")
+def test_bt_panel_wired(host, hyprland_installed, home):
+ # A fresh install lands the panel reachable: bar module, keybind, css.
+ if not hyprland_installed:
+ pytest.skip("Hyprland not installed (DESKTOP_ENV != hyprland)")
+ waybar = host.file("%s/.config/waybar/config" % home)
+ assert "custom/bluetooth" in waybar.content_string, \
+ "waybar config lacks the custom/bluetooth module"
+ hyprconf = host.file("%s/.config/hypr/hyprland.conf" % home)
+ assert "bt-panel" in hyprconf.content_string, \
+ "hyprland.conf lacks the bt-panel keybind"
+ assert host.file("%s/.config/themes/dupre/panel.css" % home).exists, \
+ "shared panel css missing from the stowed theme"
diff --git a/scripts/testing/tests/test_dotfiles.py b/scripts/testing/tests/test_dotfiles.py
new file mode 100644
index 0000000..cd6e474
--- /dev/null
+++ b/scripts/testing/tests/test_dotfiles.py
@@ -0,0 +1,19 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: dotfiles stowed for the user.
+
+Parity port of validate_dotfiles from validation.sh: .zshrc must be a symlink
+into the ~/.dotfiles stow tree, not broken, and readable by the user (not just
+root).
+"""
+
+import pytest
+
+
+@pytest.mark.attribution("archsetup")
+def test_zshrc_stowed_and_readable(host, target_user):
+ zshrc = host.file("/home/%s/.zshrc" % target_user)
+ assert zshrc.is_symlink, ".zshrc should be a stow symlink"
+ assert ".dotfiles/" in zshrc.linked_to, "symlink should point into ~/.dotfiles"
+ assert zshrc.exists, "symlink target must exist (not broken)"
+ # Readable by the user, not only root.
+ assert host.run("sudo -u %s test -r %s" % (target_user, zshrc.path)).rc == 0
diff --git a/scripts/testing/tests/test_hardening.py b/scripts/testing/tests/test_hardening.py
new file mode 100644
index 0000000..f12b0e6
--- /dev/null
+++ b/scripts/testing/tests/test_hardening.py
@@ -0,0 +1,50 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: security/system hardening archsetup applies.
+
+Expansion coverage (P4) — these were not in the original shell sweep. They
+assert the system-level changes archsetup makes in place: sshd root hardening,
+quiet kernel console, an emptied /etc/issue, the console font, and the EFI
+mount permission tightening.
+"""
+
+import pytest
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+def test_sshd_root_prohibit_password(host):
+ conf = host.file("/etc/ssh/sshd_config.d/10-hardening.conf")
+ assert conf.exists, "sshd hardening drop-in missing"
+ assert "PermitRootLogin prohibit-password" in conf.content_string
+
+
+@pytest.mark.attribution("archsetup")
+def test_quiet_printk_sysctl(host):
+ conf = host.file("/etc/sysctl.d/20-quiet-printk.conf")
+ assert conf.exists
+ assert "kernel.printk" in conf.content_string
+
+
+@pytest.mark.attribution("archsetup")
+def test_issue_emptied(host):
+ # archsetup truncates /etc/issue to drop the distro/date banner.
+ assert host.file("/etc/issue").size == 0
+
+
+@pytest.mark.attribution("archsetup")
+def test_console_font_configured(host):
+ assert "ter-132n" in host.file("/etc/vconsole.conf").content_string
+
+
+@pytest.mark.attribution("archsetup")
+def test_efi_mount_permissions_tightened(host):
+ # archsetup adds fmask/dmask to the /efi vfat line so it isn't world-readable.
+ fstab = host.file("/etc/fstab").content_string
+ efi_lines = [
+ ln for ln in fstab.splitlines()
+ if ln.strip() and not ln.lstrip().startswith("#")
+ and " /efi " in ln and " vfat " in ln
+ ]
+ if not efi_lines:
+ pytest.skip("no /efi vfat line in fstab")
+ assert all("fmask=" in ln for ln in efi_lines), "/efi mount not permission-tightened"
diff --git a/scripts/testing/tests/test_keyring.py b/scripts/testing/tests/test_keyring.py
new file mode 100644
index 0000000..99d322d
--- /dev/null
+++ b/scripts/testing/tests/test_keyring.py
@@ -0,0 +1,35 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: gnome-keyring pre-configuration.
+
+Parity port of validate_gnome_keyring_setup: the keyrings dir must exist, be
+mode 700, owned by the user, with the default keyring set to "login".
+"""
+
+import pytest
+
+
+@pytest.fixture(scope="session")
+def keyring_dir(home):
+ return "%s/.local/share/keyrings" % home
+
+
+@pytest.mark.attribution("archsetup")
+def test_keyring_dir_exists(host, keyring_dir):
+ assert host.file(keyring_dir).is_directory
+
+
+@pytest.mark.attribution("archsetup")
+def test_keyring_dir_mode_700(host, keyring_dir):
+ assert host.file(keyring_dir).mode == 0o700
+
+
+@pytest.mark.attribution("archsetup")
+def test_keyring_dir_owned_by_user(host, keyring_dir, target_user):
+ assert host.file(keyring_dir).user == target_user
+
+
+@pytest.mark.attribution("archsetup")
+def test_default_keyring_is_login(host, keyring_dir):
+ default = host.file("%s/default" % keyring_dir)
+ assert default.exists, "default keyring file missing"
+ assert default.content_string.strip() == "login"
diff --git a/scripts/testing/tests/test_packages.py b/scripts/testing/tests/test_packages.py
new file mode 100644
index 0000000..e0387d6
--- /dev/null
+++ b/scripts/testing/tests/test_packages.py
@@ -0,0 +1,109 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: package managers and key packages.
+
+Parity port of validate_yay_installed, validate_pacman_working,
+validate_terminus_font, validate_emacs, validate_git_config, validate_dev_tools.
+"""
+
+import pytest
+
+
+DEV_TOOLS = ["python", "node", "npm", "go", "rustc"]
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+def test_yay_installed(host):
+ assert host.exists("yay"), "yay binary not on PATH"
+
+
+@pytest.mark.attribution("archsetup")
+def test_yay_functional(host, target_user):
+ # yay must actually query the package DB as the user, not just exist.
+ assert host.run("sudo -u %s yay -Qi yay" % target_user).rc == 0
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("unknown")
+def test_pacman_functional(host):
+ assert host.package("base").is_installed
+
+
+@pytest.mark.attribution("archsetup")
+def test_terminus_font_installed(host):
+ assert host.package("terminus-font").is_installed
+
+
+@pytest.mark.attribution("archsetup")
+def test_emacs_installed(host):
+ assert host.exists("emacs")
+
+
+@pytest.mark.attribution("archsetup")
+def test_emacs_config_readable_by_user(host, target_user, home):
+ emacsd = host.file("%s/.emacs.d" % home)
+ if not emacsd.exists:
+ pytest.skip(".emacs.d not present (config dir optional on some profiles)")
+ assert emacsd.is_directory
+ assert host.run("sudo -u %s ls %s" % (target_user, emacsd.path)).rc == 0
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+def test_git_installed(host):
+ assert host.exists("git")
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("tool", DEV_TOOLS)
+def test_dev_tool_present(host, tool):
+ assert host.exists(tool), "dev tool %s missing from PATH" % tool
+
+
+BLUETOOTH_STACK = ["bluez", "bluez-utils"]
+VPN_STACK = ["wireguard-tools", "proton-vpn-cli", "tailscale"]
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("pkg", BLUETOOTH_STACK)
+def test_bluetooth_stack_installed(host, pkg):
+ assert host.package(pkg).is_installed
+
+
+# bt panel replaced blueman; zoom-web replaced zoom; the net panel's Tunnels
+# view + proton-vpn-cli replaced the GTK app (they can't run concurrently).
+RETIRED_PACKAGES = ["blueman", "zoom", "proton-vpn-gtk-app"]
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("pkg", RETIRED_PACKAGES)
+def test_retired_package_not_installed(host, pkg):
+ # A reappearance means an install step regressed.
+ assert not host.package(pkg).is_installed
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("pkg", VPN_STACK)
+def test_vpn_stack_installed(host, pkg):
+ assert host.package(pkg).is_installed
+
+
+@pytest.mark.attribution("archsetup")
+def test_tailscale_operator_granted(host, target_user):
+ # The installer grants operator so the net panel can toggle tailscale
+ # without sudo. Prefs only answer when the daemon is up.
+ if not host.service("tailscaled").is_running:
+ pytest.skip("tailscaled not running")
+ out = host.run("tailscale debug prefs")
+ assert out.rc == 0, "tailscale debug prefs failed"
+ assert '"OperatorUser": "%s"' % target_user in out.stdout
+
+
+@pytest.mark.attribution("archsetup")
+def test_eask_installed_user_local(host, home):
+ # Installed via npm -g --prefix ~/.local as the user; chime and
+ # linear-emacs shell out to it.
+ f = host.file("%s/.local/bin/eask" % home)
+ assert f.exists, "eask missing from ~/.local/bin"
+ npmrc = host.file("%s/.npmrc" % home)
+ assert npmrc.exists, ".npmrc (user npm prefix) not stowed"
diff --git a/scripts/testing/tests/test_services.py b/scripts/testing/tests/test_services.py
new file mode 100644
index 0000000..0ca3970
--- /dev/null
+++ b/scripts/testing/tests/test_services.py
@@ -0,0 +1,103 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: services, timers, and their functional health.
+
+Parity port of validate_firewall, validate_dns_config, validate_avahi,
+validate_fail2ban, validate_networkmanager, and validate_all_services /
+validate_service_functions.
+
+Mapping of the shell sweep's three outcomes:
+ - validation_fail (hard) -> assert
+ - validation_warn (soft) -> pytest.skip with the reason (visible, never red)
+ - validation_skip (precond)-> pytest.skip gated on a fixture
+"""
+
+import pytest
+
+
+# Required services: (name, must_be_active). ufw can't activate in the VM (no
+# iptables kernel modules), so it's enabled-only; cronie/atd are enabled-only too.
+REQUIRED_ENABLED_ACTIVE = ["sshd", "systemd-resolved", "fail2ban", "NetworkManager", "rngd"]
+REQUIRED_ENABLED_ONLY = ["ufw", "cronie", "atd"]
+REQUIRED_TIMERS = ["reflector.timer", "paccache.timer"]
+OPTIONAL_SERVICES = ["avahi-daemon", "bluetooth", "cups", "docker", "tailscaled"]
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("svc", REQUIRED_ENABLED_ACTIVE)
+def test_required_service_enabled_and_active(host, svc):
+ s = host.service(svc)
+ assert s.is_enabled, "%s should be enabled" % svc
+ assert s.is_running, "%s should be active" % svc
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("svc", REQUIRED_ENABLED_ONLY)
+def test_required_service_enabled(host, svc):
+ assert host.service(svc).is_enabled, "%s should be enabled" % svc
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("timer", REQUIRED_TIMERS)
+def test_required_timer_enabled(host, timer):
+ assert host.service(timer).is_enabled, "%s should be enabled" % timer
+
+
+@pytest.mark.parametrize("svc", OPTIONAL_SERVICES)
+def test_optional_service(host, svc):
+ # Optional: warn-if-missing in the shell sweep -> skip here so it never reds.
+ if not host.service(svc).is_enabled:
+ pytest.skip("%s not enabled (optional)" % svc)
+
+
+@pytest.mark.attribution("archsetup")
+def test_dns_over_tls_dropin_present(host):
+ # archsetup ships /etc/systemd/resolved.conf.d/dns-over-tls.conf.
+ assert host.file("/etc/systemd/resolved.conf.d/dns-over-tls.conf").exists
+
+
+@pytest.mark.attribution("archsetup")
+def test_fail2ban_responds(host):
+ assert host.run("fail2ban-client status").rc == 0
+
+
+@pytest.mark.attribution("archsetup")
+def test_networkmanager_responds(host):
+ assert host.run("nmcli general status").rc == 0
+
+
+@pytest.mark.attribution("archsetup")
+def test_log_cleanup_cron_installed(host, target_user):
+ out = host.run("sudo -u %s crontab -l" % target_user).stdout
+ assert "log-cleanup" in out, "log-cleanup entry missing from user crontab"
+
+
+@pytest.mark.attribution("archsetup")
+def test_syncthing_user_lingering_enabled(host, target_user):
+ # syncthing runs as a user service; lingering must be on for autostart.
+ assert host.file("/var/lib/systemd/linger/%s" % target_user).exists
+
+
+def test_dns_resolution(host):
+ # Network-dependent; advisory in the shell sweep. Skip on failure.
+ if host.run("resolvectl query archlinux.org").rc != 0:
+ pytest.skip("DNS resolution query failed (network-dependent)")
+
+
+def test_mdns_resolves(host, on_slirp):
+ # mDNS needs multicast, which QEMU slirp doesn't pass.
+ if on_slirp:
+ pytest.skip("mDNS not possible on slirp networking (no multicast)")
+ if not host.service("avahi-daemon").is_enabled:
+ pytest.skip("avahi-daemon not enabled")
+ hostname = host.run("hostname").stdout.strip()
+ assert host.run("ping -c 1 -W 2 %s.local" % hostname).rc == 0
+
+
+def test_docker_functional(host):
+ if not host.service("docker").is_enabled:
+ pytest.skip("docker not enabled")
+ if not host.service("docker").is_running:
+ # archsetup enables docker for next boot, not --now; pre-reboot this is correct.
+ pytest.skip("docker enabled but not started (starts on boot by design)")
+ assert host.run("docker info").rc == 0
diff --git a/scripts/testing/tests/test_users.py b/scripts/testing/tests/test_users.py
new file mode 100644
index 0000000..c0097ed
--- /dev/null
+++ b/scripts/testing/tests/test_users.py
@@ -0,0 +1,34 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+"""Post-install checks: the user account archsetup creates.
+
+Parity port of validate_user_created / validate_user_shell / validate_user_groups.
+"""
+
+import pytest
+
+
+# Groups archsetup adds: wheel (useradd -G), the usermod -aG set, and docker
+# (added later in the developer-workstation step).
+EXPECTED_GROUPS = [
+ "wheel", "sys", "adm", "network", "scanner", "power", "uucp",
+ "audio", "lp", "rfkill", "video", "storage", "optical", "users", "docker",
+]
+
+
+@pytest.mark.smoke
+@pytest.mark.attribution("archsetup")
+def test_user_exists(host, target_user):
+ assert host.user(target_user).exists
+
+
+@pytest.mark.attribution("archsetup")
+def test_user_shell_is_zsh(host, target_user):
+ # archsetup may set either path depending on how zsh resolves.
+ assert host.user(target_user).shell in ("/bin/zsh", "/usr/bin/zsh")
+
+
+@pytest.mark.attribution("archsetup")
+@pytest.mark.parametrize("group", EXPECTED_GROUPS)
+def test_user_in_group(host, target_user, group):
+ # Parametrized so a failure names the exact missing group.
+ assert group in host.user(target_user).groups