Two fixes from the first full 125-test run (121 ok / 4 not ok).
1. The Quadlet unit test still asserted Restart=on-failure for backends,
which 8908fb4f deliberately changed. All 32 units on the node are
Restart=always; only archy-btcpay-db showed up because `fail` aborts at
the first offender. The old rationale — "backends need on-failure so an
operator `systemctl stop` stays stopped" — was wrong twice over:
systemd never applies Restart= to a unit stopped via `systemctl stop`,
and because quadlet renders --rm, a cleanly-exited container is deleted
and on-failure never brings it back (bitcoind exits 0 on SIGTERM, so
backends vanished). Verified on-device before that change: `podman stop
bitcoin-knots` came back in 12s, a dashboard stop stayed stopped 90s.
Now asserts Restart=always, with the corrected reasoning in-place.
2. all-apps-lifecycle cycled ten apps back-to-back and manufactured the
contention that then failed later tests: load went 10.8 at preflight to
17.6 mid-loop, and btcpay's recovery stretched from 52s on a quiet box
to 216s and then 512s, defeating waits of 180s and 300s. Widening waits
cannot win when the run sets the load they must survive, so the loop now
drains between apps — waits for load1 under 2x nproc, capped at 90s.
ARCHY_APP_SETTLE_SECS=0 disables; ARCHY_APP_SETTLE_LOAD overrides.
Verified: run.sh use-quadlet-backends-install → 6/6 (was 1 failure).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
202 lines
9.2 KiB
Bash
202 lines
9.2 KiB
Bash
#!/usr/bin/env bats
|
|
# tests/lifecycle/bats/all-apps-lifecycle.bats
|
|
#
|
|
# DESTRUCTIVE per-app lifecycle matrix across EVERY installed app (breadth) —
|
|
# the active counterpart to the read-only all-apps-matrix.bats and the ~8 deep
|
|
# per-app suites. For each installed, NON-protected app it drives:
|
|
# stop → verify stopped → start → verify running → restart → verify running
|
|
# and, when ARCHY_ALLOW_CASCADE_DESTRUCTIVE=1, a FULL TEARDOWN:
|
|
# uninstall (full, removes data) → verify GONE from My Apps (no #13 ghost) →
|
|
# reinstall from the node catalog → verify running.
|
|
#
|
|
# Reinstall spec source: the node catalog (default /opt/archipelago/web-ui/
|
|
# catalog.json), whose `.apps[]` entries carry {dockerImage, containerConfig} —
|
|
# exactly what package.install needs. Multi-container stacks (immich, mempool,
|
|
# netbird, btcpay, indeedhub) ignore dockerImage internally but still require it,
|
|
# and route to their orchestrator/stack handler; the catalog entry is enough to
|
|
# trigger the reinstall. An app with no catalog entry is skipped (logged), not
|
|
# failed — there's no spec to reinstall it from.
|
|
#
|
|
# ── PROTECTED apps (NEVER touched — neither cycled nor torn down) ────────────
|
|
# - chain state, expensive to resync: bitcoin*, electrumx/electrs
|
|
# - WALLET / financial state, teardown = IRREVERSIBLE fund/credential loss:
|
|
# lnd, btcpay*, fedimint*
|
|
# The user asked to protect only bitcoin + electrum; the wallet-bearing apps
|
|
# are protected by DEFAULT here for safety (a full uninstall destroys their
|
|
# seed/channel/guardian state). Override the entire set with
|
|
# ARCHY_MATRIX_PROTECT="space separated ids" to tear them down too — you WILL
|
|
# lose their data.
|
|
#
|
|
# ── Gating ──────────────────────────────────────────────────────────────────
|
|
# lifecycle tier → ARCHY_ALLOW_DESTRUCTIVE=1
|
|
# teardown tier → ARCHY_ALLOW_CASCADE_DESTRUCTIVE=1
|
|
# Both skip otherwise, so this file is inert in a normal run. ON-NODE ONLY
|
|
# (reads catalog.json on disk + drives the local package lifecycle).
|
|
#
|
|
# This is a HEAVY suite: a full teardown of ~15-20 apps re-pulls images and can
|
|
# run for a long time. Intended as an explicit, supervised coverage pass, not a
|
|
# per-iteration gate step.
|
|
|
|
load '../lib/rpc.bash'
|
|
|
|
CATALOG="${ARCHY_CATALOG:-/opt/archipelago/web-ui/catalog.json}"
|
|
|
|
# Protected — see header. Override with ARCHY_MATRIX_PROTECT to change the set.
|
|
PROTECT="${ARCHY_MATRIX_PROTECT:-bitcoin-knots bitcoin-core bitcoin electrumx electrs mempool-electrs lnd btcpay-server btcpayserver btcpay fedimint fedimint-clientd fedimint-gateway}"
|
|
|
|
setup_file() {
|
|
: "${ARCHY_PASSWORD:?Set ARCHY_PASSWORD env var to the UI password}"
|
|
export ARCHY_FORCE_LOGIN=1
|
|
rpc_login
|
|
unset ARCHY_FORCE_LOGIN
|
|
}
|
|
|
|
teardown_file() {
|
|
rpc_logout_local
|
|
}
|
|
|
|
is_protected() {
|
|
local id="$1" p
|
|
for p in $PROTECT; do [[ "$p" == "$id" ]] && return 0; done
|
|
return 1
|
|
}
|
|
|
|
get_package_data() {
|
|
rpc_result server.get-state '{}' 2>/dev/null | jq -c '.data["package-data"] // {}'
|
|
}
|
|
|
|
# Canonical app ids the catalog can (re)install.
|
|
catalog_ids() {
|
|
jq -r '(.apps // [])[].id' "$CATALOG" 2>/dev/null
|
|
}
|
|
|
|
# Installed primary apps we will exercise: catalog ids present in My Apps,
|
|
# minus the protected set. (Catalog-scoped so we skip sub-containers like
|
|
# immich_postgres that surface as their own package-data entries.)
|
|
target_apps() {
|
|
local pd; pd=$(get_package_data)
|
|
local id
|
|
for id in $(catalog_ids); do
|
|
echo "$pd" | jq -e --arg i "$id" 'has($i)' >/dev/null 2>&1 || continue
|
|
is_protected "$id" && continue
|
|
echo "$id"
|
|
done
|
|
}
|
|
|
|
# Top-level state of an app in My Apps, or "absent" when the entry is gone.
|
|
app_state() {
|
|
get_package_data | jq -r --arg i "$1" '.[$i].state // "absent"'
|
|
}
|
|
|
|
# Poll My Apps until app $1 reaches state $2 (or "absent"); $3 = timeout secs.
|
|
wait_state() {
|
|
local id="$1" target="$2" timeout="${3:-180}"
|
|
local deadline=$(( $(date +%s) + timeout ))
|
|
while (( $(date +%s) < deadline )); do
|
|
[[ "$(app_state "$id")" == "$target" ]] && return 0
|
|
sleep 3
|
|
done
|
|
echo "wait_state: $id never reached '$target' (last='$(app_state "$id")') within ${timeout}s" >&2
|
|
return 1
|
|
}
|
|
|
|
# Let the node drain before cycling the NEXT app.
|
|
#
|
|
# Without this the suite competes with itself. Cycling ten apps back-to-back
|
|
# drove this 4-core node from load 10.8 at preflight to 17.6 mid-loop, and the
|
|
# contention then landed on whatever ran next: btcpay's recovery stretched from
|
|
# 52s measured on a quiet box to 216s and then 512s, blowing through waits of
|
|
# 180s and 300s. Widening those waits is a losing game, because the run itself
|
|
# sets the load they have to survive — so pace the source instead.
|
|
#
|
|
# Waits for load1 to fall below the ceiling, capped so a genuinely busy node
|
|
# can't stall the suite forever. ARCHY_APP_SETTLE_SECS=0 disables it.
|
|
settle_between_apps() {
|
|
local cap="${ARCHY_APP_SETTLE_SECS:-90}"
|
|
(( cap > 0 )) || return 0
|
|
local cores ceiling
|
|
cores=$(nproc 2>/dev/null || echo 4)
|
|
ceiling="${ARCHY_APP_SETTLE_LOAD:-$(( cores * 2 ))}"
|
|
local deadline=$(( $(date +%s) + cap )) load1
|
|
while (( $(date +%s) < deadline )); do
|
|
load1=$(awk '{print $1}' /proc/loadavg)
|
|
awk -v l="$load1" -v m="$ceiling" 'BEGIN { exit !(l < m) }' && return 0
|
|
sleep 5
|
|
done
|
|
echo "# settle: load1 $load1 still >= $ceiling after ${cap}s — continuing anyway" >&3
|
|
}
|
|
|
|
# Build a package.install payload for $1 from the catalog, or fail (no spec).
|
|
catalog_install_payload() {
|
|
local id="$1" img cfg
|
|
img=$(jq -r --arg i "$id" '(.apps // [])[] | select(.id==$i) | .dockerImage // empty' "$CATALOG")
|
|
[[ -n "$img" ]] || return 1
|
|
cfg=$(jq -c --arg i "$id" '(.apps // [])[] | select(.id==$i) | .containerConfig // null' "$CATALOG")
|
|
if [[ "$cfg" == "null" ]]; then
|
|
jq -nc --arg id "$id" --arg img "$img" '{id:$id, dockerImage:$img}'
|
|
else
|
|
jq -nc --arg id "$id" --arg img "$img" --argjson cfg "$cfg" '{id:$id, dockerImage:$img, containerConfig:$cfg}'
|
|
fi
|
|
}
|
|
|
|
# ────────────────────────────────────────────────────────────────────
|
|
@test "prerequisites: catalog present and at least one target app" {
|
|
[[ "${ARCHY_ALLOW_DESTRUCTIVE:-0}" == "1" ]] || skip "ARCHY_ALLOW_DESTRUCTIVE not set"
|
|
[[ -f "$CATALOG" ]] || { echo "# catalog not found: $CATALOG" >&3; false; }
|
|
run target_apps
|
|
[ "$status" -eq 0 ]
|
|
[ -n "$output" ] || { echo "# no non-protected installed apps to exercise" >&3; false; }
|
|
echo "# protected (skipped): $PROTECT" >&3
|
|
echo "# targets ($(echo "$output" | wc -w)): $(echo $output)" >&3
|
|
}
|
|
|
|
@test "lifecycle: stop → start → restart every non-protected app" {
|
|
[[ "${ARCHY_ALLOW_DESTRUCTIVE:-0}" == "1" ]] || skip "ARCHY_ALLOW_DESTRUCTIVE not set"
|
|
local fails="" id first=1
|
|
for id in $(target_apps); do
|
|
[[ "$(app_state "$id")" == "running" ]] || continue # only cycle running apps
|
|
# Drain before each app after the first. Placed at the TOP of the body so it
|
|
# still runs when a previous iteration bailed out via `continue`.
|
|
(( first )) && first=0 || settle_between_apps
|
|
# Each rpc_result must be allowed to fail without aborting the loop.
|
|
# rpc_result returns non-zero whenever the response carries .error, and
|
|
# under bats' errexit a bare call ends the test right there — so a single
|
|
# transient RPC hiccup killed the run BEFORE the $fails summary below could
|
|
# name the app. That is exactly what happened on 2026-08-08: the whole test
|
|
# died at package.stop with no indication of which of the ten targets it was
|
|
# (it was mempool, and the same call succeeded by hand moments later).
|
|
rpc_result package.stop "{\"id\":\"$id\"}" >/dev/null 2>&1 \
|
|
|| { fails+="$id:stop-rpc "; continue; }
|
|
wait_state "$id" stopped 120 || { fails+="$id:stop "; }
|
|
rpc_result package.start "{\"id\":\"$id\"}" >/dev/null 2>&1 \
|
|
|| { fails+="$id:start-rpc "; continue; }
|
|
wait_state "$id" running 240 || { fails+="$id:start "; continue; }
|
|
rpc_result package.restart "{\"id\":\"$id\"}" >/dev/null 2>&1 \
|
|
|| { fails+="$id:restart-rpc "; continue; }
|
|
wait_state "$id" running 240 || { fails+="$id:restart "; }
|
|
done
|
|
[[ -z "$fails" ]] || { echo "# lifecycle failures: $fails" >&3; false; }
|
|
}
|
|
|
|
@test "teardown: full uninstall (no ghost) → reinstall every non-protected app" {
|
|
[[ "${ARCHY_ALLOW_CASCADE_DESTRUCTIVE:-0}" == "1" ]] || skip "ARCHY_ALLOW_CASCADE_DESTRUCTIVE not set"
|
|
local fails="" skipped="" id payload
|
|
for id in $(target_apps); do
|
|
if ! payload=$(catalog_install_payload "$id"); then
|
|
skipped+="$id "
|
|
continue
|
|
fi
|
|
rpc_result package.uninstall "{\"id\":\"$id\"}" >/dev/null 2>&1
|
|
# No ghost: the entry must leave My Apps (the #13 class). 71cc9ac4 bounds the
|
|
# teardown so this can no longer hang indefinitely.
|
|
if ! wait_state "$id" absent 300; then
|
|
fails+="$id:ghost "
|
|
continue
|
|
fi
|
|
rpc_result package.install "$payload" >/dev/null 2>&1
|
|
wait_state "$id" running 420 || fails+="$id:reinstall "
|
|
done
|
|
[[ -n "$skipped" ]] && echo "# skipped (no catalog spec to reinstall from): $skipped" >&3
|
|
[[ -z "$fails" ]] || { echo "# teardown failures: $fails" >&3; false; }
|
|
}
|