2026-06-26 06:29:22 -04:00
|
|
|
#!/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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 03:31:14 -04:00
|
|
|
# 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 06:29:22 -04:00
|
|
|
# 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"
|
2026-08-09 03:31:14 -04:00
|
|
|
local fails="" id first=1
|
2026-06-26 06:29:22 -04:00
|
|
|
for id in $(target_apps); do
|
|
|
|
|
[[ "$(app_state "$id")" == "running" ]] || continue # only cycle running apps
|
2026-08-09 03:31:14 -04:00
|
|
|
# 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
|
2026-08-08 20:58:32 -04:00
|
|
|
# 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; }
|
2026-06-26 06:29:22 -04:00
|
|
|
wait_state "$id" stopped 120 || { fails+="$id:stop "; }
|
2026-08-08 20:58:32 -04:00
|
|
|
rpc_result package.start "{\"id\":\"$id\"}" >/dev/null 2>&1 \
|
|
|
|
|
|| { fails+="$id:start-rpc "; continue; }
|
2026-06-26 06:29:22 -04:00
|
|
|
wait_state "$id" running 240 || { fails+="$id:start "; continue; }
|
2026-08-08 20:58:32 -04:00
|
|
|
rpc_result package.restart "{\"id\":\"$id\"}" >/dev/null 2>&1 \
|
|
|
|
|
|| { fails+="$id:restart-rpc "; continue; }
|
2026-06-26 06:29:22 -04:00
|
|
|
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; }
|
|
|
|
|
}
|