1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
|
#!/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"
source "$SCRIPT_DIR/lib/maint-scenario.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=()
_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=()
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
|