Files
ServerKit/scripts/test/test_update.sh
T
Juan DenisandClaude Fable 5 4af3df1136 fix(nginx): never-cache the SPA entrypoint; warn on stale :3847 panel vhosts
A test box's panel domain kept serving a Jun 25 bundle after the Jul 3
update — the domain's custom vhost still proxied the retired :3847
frontend container, and the old bundle's calls to deleted legacy
marketplace endpoints read as 'Failed to load extensions'. It looked
exactly like a browser/CDN cache problem (it wasn't — cf-cache-status
was DYNAMIC; the origin itself was stale).

- both stock confs: expires -1 on the SPA entrypoint (emits
  Cache-Control: no-cache WITHOUT discarding server-level security
  headers, unlike add_header-in-location) — a cached index.html
  references hashed assets that vanish on the next blue/green switch
- update.sh refresh_config now runs report_stale_panel_vhosts: warns,
  naming the file, when any enabled vhost still proxies :3847;
  observation-only per R1, silent on a missing sites-enabled dir,
  added to the fresh-box loop per policy
- proving tests: entrypoint no-cache asserted for both confs; stale
  vhost flagged (and clean one not); missing-dir silence (42 passed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:48:15 -04:00

903 lines
42 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#
# Unit tests for scripts/update.sh — runs in seconds, no server, no deploy.
#
# update.sh is source-able: when sourced it defines every function and then
# returns *before* the run block (the BASH_SOURCE guard). That lets us exercise
# the config-refresh + deployment-detection logic against throwaway fixtures
# instead of a real /etc and a real cloud box — which is what made this script
# so painful to get right.
#
# Each unit-under-test runs in a subshell that re-enables `set -Eeuo pipefail`,
# so a regression of the kind that bit 1.7.0 (an unguarded command silently
# aborting under set -e) is caught here as a failed assertion.
#
# Run: bash scripts/test/test_update.sh
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
UPDATE_SH="$SCRIPT_DIR/../update.sh"
# Shared stub factories + the fresh-box fixture builder.
# shellcheck source=stubs.sh
source "$SCRIPT_DIR/stubs.sh"
PASS=0
FAIL=0
SKIP=0
ok() { PASS=$((PASS + 1)); printf ' \033[32m✔\033[0m %s\n' "$1"; }
bad() { FAIL=$((FAIL + 1)); printf ' \033[31m✘\033[0m %s\n' "$1"; }
skip() { SKIP=$((SKIP + 1)); printf ' \033[33m∼\033[0m %s (skipped)\n' "$1"; }
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# --------------------------------------------------------------------------
# Stub the external commands the functions may shell out to, so the tests
# never touch the host's nginx/systemd/docker.
# --------------------------------------------------------------------------
STUB_BIN="$WORK/bin"
mkdir -p "$STUB_BIN"
make_stub_ok "$STUB_BIN" systemctl nginx npm curl
# docker stub: `docker ps ...` lists the fixture container names; anything else
# (image inspect/tag/compose/...) is a harmless no-op.
cat > "$STUB_BIN/docker" <<'EOF'
#!/usr/bin/env bash
case "${1:-}" in
ps) for n in ${SERVERKIT_TEST_CONTAINERS:-}; do printf '%s\n' "$n"; done ;;
*) exit 0 ;;
esac
EOF
chmod +x "$STUB_BIN"/*
export PATH="$STUB_BIN:$PATH"
# --------------------------------------------------------------------------
# Source update.sh (functions only). Keep logging off and point the install
# dir at the sandbox so the derived DIR_A/DIR_B land under $WORK.
# --------------------------------------------------------------------------
export SERVERKIT_NO_LOG=1
export SERVERKIT_DIR="$WORK/opt/serverkit"
# shellcheck disable=SC1090
source "$UPDATE_SH"
set +e +u # hand control back to the harness; tests re-arm set -e per subshell
printf '\nupdate.sh unit tests\n\n'
# --------------------------------------------------------------------------
# T1 — the headline regression: refresh_config must NOT die when the live
# nginx has no serverkit.conf (HTTP-only boxes). This is the exact 1.7.0
# silent-death that left the updater stuck reporting the old version.
# --------------------------------------------------------------------------
t="$WORK/t1"
mkdir -p "$t/nginx/sites-available" "$t/nginx/sites-enabled" "$t/target/nginx/sites-available"
printf 'http {\n}\n' > "$t/nginx/nginx.conf"
printf 'server { listen 80; }\n' > "$t/target/nginx/sites-available/serverkit-insecure.conf"
if (
set -Eeuo pipefail
NGINX_DIR="$t/nginx"; LETSENCRYPT_DIR="$t/le"; SYSTEMD_DIR="$t/sysd"; CONFIG_DIR="$t/cfg"; DRY_RUN=0
refresh_config "$t/target"
) >/dev/null 2>&1; then
ok "refresh_config survives a missing serverkit.conf (the 1.7.0 silent-death bug)"
else
bad "refresh_config DIED on a missing serverkit.conf — the set -e/pipefail regression is back"
fi
# --------------------------------------------------------------------------
# T2 — refresh_config still works when a serverkit.conf with a real cert path
# is present (the grep finds a match).
# --------------------------------------------------------------------------
t="$WORK/t2"
mkdir -p "$t/nginx/sites-available" "$t/nginx/sites-enabled" "$t/target/nginx/sites-available" "$t/le/live/example.com"
printf 'http {\n}\n' > "$t/nginx/nginx.conf"
printf 'ssl_certificate %s/live/example.com/fullchain.pem;\n' "$t/le" > "$t/nginx/sites-available/serverkit.conf"
printf 'server { listen 80; }\n' > "$t/target/nginx/sites-available/serverkit-insecure.conf"
if (
set -Eeuo pipefail
NGINX_DIR="$t/nginx"; LETSENCRYPT_DIR="$t/le"; SYSTEMD_DIR="$t/sysd"; CONFIG_DIR="$t/cfg"; DRY_RUN=0
refresh_config "$t/target"
) >/dev/null 2>&1; then
ok "refresh_config handles a present serverkit.conf with a cert path"
else
bad "refresh_config failed with a present serverkit.conf"
fi
# --------------------------------------------------------------------------
# T3 — deployment-shape detection (the bug that made 1.7.0 take the wrong path
# on an all-Docker box).
# --------------------------------------------------------------------------
t="$WORK/t3"; mkdir -p "$t/install"
touch "$t/install/docker-compose.yml"
if (
set -Eeuo pipefail
INSTALL_DIR="$t/install"
export SERVERKIT_TEST_CONTAINERS="serverkit-backend serverkit-frontend"
is_docker_deployment
); then
ok "is_docker_deployment → docker when compose + container and no host venv"
else
bad "is_docker_deployment should pick the docker path for an all-Docker box"
fi
mkdir -p "$t/install/venv/bin"
printf '#!/bin/sh\n' > "$t/install/venv/bin/python"; chmod +x "$t/install/venv/bin/python"
if (
set -Eeuo pipefail
INSTALL_DIR="$t/install"
export SERVERKIT_TEST_CONTAINERS="serverkit-backend"
is_docker_deployment
); then
bad "is_docker_deployment should fall back to hybrid when a host venv exists"
else
ok "is_docker_deployment → hybrid when a host venv exists (precedence)"
fi
# --------------------------------------------------------------------------
# T4 — blue/green slot resolution.
# --------------------------------------------------------------------------
t="$WORK/t4"; mkdir -p "$t/serverkit-a" "$t/serverkit-b"
ln -sfn "$t/serverkit-a" "$t/serverkit" 2>/dev/null || true
if [ ! -L "$t/serverkit" ]; then
skip "active/next slot flip — symlinks unsupported here (works on Linux CI)"
else
exp="$(readlink -f "$t/serverkit-a")|$t/serverkit-b"
if ! res="$(
set -Eeuo pipefail
INSTALL_DIR="$t/serverkit"; DIR_A="$t/serverkit-a"; DIR_B="$t/serverkit-b"
printf '%s|%s' "$(active_real_dir)" "$(next_real_dir)"
)"; then
bad "active/next slot resolution aborted under set -Eeuo pipefail"
elif [ "$res" = "$exp" ]; then
ok "active/next slot flip (A active → B is next)"
else
bad "active/next slot wrong: got [$res] expected [$exp]"
fi
fi
# --------------------------------------------------------------------------
# T5 — the loud-failure reporter actually emits a labelled diagnostic.
# --------------------------------------------------------------------------
# UPDATE_LOG pinned non-empty so the "full log:" line is part of what T5
# asserts; the empty-UPDATE_LOG return-0 contract is covered by the fresh-box
# loop below (report_failure is in UPDATE_OBSERVERS).
if out="$(LAST_PHASE='Refreshing Configuration' UPDATE_LOG='/dev/null' \
report_failure 2 42 'grep ... serverkit.conf' 2>&1)" \
&& printf '%s' "$out" | grep -q 'Update aborted'; then
ok "report_failure emits a labelled 'Update aborted' diagnostic"
else
bad "report_failure produced no diagnostic (or returned non-zero)"
fi
# --------------------------------------------------------------------------
# T6 — self-update bootstrap skips cleanly under each opt-out, and never
# re-execs (would replace this test process) when there is nothing to do.
# --------------------------------------------------------------------------
self_update_skips() {
# Each guard runs in a subshell with set -e; a clean return keeps the test
# process alive, and any stray `exec` would visibly break the harness.
( set -Eeuo pipefail; SERVERKIT_UPDATER_REEXECED=1; DRY_RUN=0; maybe_reexec_latest_updater ) &&
( set -Eeuo pipefail; SERVERKIT_NO_SELF_UPDATE=1; DRY_RUN=0; maybe_reexec_latest_updater ) &&
( set -Eeuo pipefail; DRY_RUN=1; maybe_reexec_latest_updater ) &&
( set -Eeuo pipefail; DRY_RUN=0; SERVERKIT_OFFLINE_TARBALL=/x; maybe_reexec_latest_updater )
}
if self_update_skips >/dev/null 2>&1; then
ok "self-update no-ops under re-exec/opt-out/dry-run/offline guards"
else
bad "self-update guard returned non-zero (would block or loop the updater)"
fi
# --------------------------------------------------------------------------
# T7 — the run lock refuses a second concurrent update.
# --------------------------------------------------------------------------
if command -v flock >/dev/null 2>&1; then
lock="$WORK/update.lock"
( flock -n 9 || exit 1; sleep 3 ) 9>"$lock" & # hold the lock
held=$!
sleep 0.3
if ( set -Eeuo pipefail; LOCK_FILE="$lock"; DRY_RUN=0; acquire_update_lock ) >/dev/null 2>&1; then
bad "acquire_update_lock should refuse while the lock is held"
else
ok "acquire_update_lock refuses a concurrent run while locked"
fi
kill "$held" 2>/dev/null || true; wait "$held" 2>/dev/null || true
if ( set -Eeuo pipefail; LOCK_FILE="$WORK/free.lock"; DRY_RUN=0; acquire_update_lock ) >/dev/null 2>&1; then
ok "acquire_update_lock succeeds when the lock is free"
else
bad "acquire_update_lock failed on a free lock"
fi
else
skip "run-lock test — flock unavailable here (runs on Linux CI)"
fi
# --------------------------------------------------------------------------
# T8 — version comparison: versions_equal ignores a leading "v".
# --------------------------------------------------------------------------
if ( set -Eeuo pipefail; versions_equal v1.7.1 1.7.1 ) && \
( set -Eeuo pipefail; versions_equal 1.7.1 1.7.1 ) && \
! ( set -Eeuo pipefail; versions_equal 1.7.0 1.7.1 ); then
ok "versions_equal matches across a leading 'v' and rejects mismatches"
else
bad "versions_equal comparison is wrong"
fi
# --------------------------------------------------------------------------
# T9 — is_already_current short-circuits to "proceed" (non-zero) under --force
# and offline, without any network/git access.
# --------------------------------------------------------------------------
if ( set -Eeuo pipefail; FORCE_UPDATE=1; is_already_current ); then
bad "is_already_current must proceed (non-zero) under --force"
else
ok "is_already_current proceeds under --force (skips the version check)"
fi
if ( set -Eeuo pipefail; FORCE_UPDATE=0; SERVERKIT_OFFLINE_TARBALL=/x; is_already_current ); then
bad "is_already_current must proceed (non-zero) when offline"
else
ok "is_already_current proceeds when offline (can't compare)"
fi
# --------------------------------------------------------------------------
# T10 — the rollback-safety fix: migrate_database must run the migration
# against the NEW slot's database copy (slot-absolute path), never the
# /opt/serverkit symlink that still resolves to the live old slot. A flask
# stub captures the DATABASE_URL the migration actually used.
# --------------------------------------------------------------------------
t="$WORK/t10/serverkit-b"
mkdir -p "$t/venv/bin" "$t/backend/instance"
: > "$t/venv/bin/activate" # sourceable no-op
: > "$t/backend/instance/serverkit.db" # the slot's DB copy
printf 'DATABASE_URL=sqlite:///opt/serverkit/backend/instance/serverkit.db\n' > "$t/.env"
FLASK_CAP="$WORK/t10/flask-saw-dburl"
cat > "$STUB_BIN/flask" <<EOF
#!/usr/bin/env bash
printf '%s' "\${DATABASE_URL:-NONE}" > "$FLASK_CAP"
exit 0
EOF
chmod +x "$STUB_BIN/flask"
mig_rc=0
(
set -Eeuo pipefail
DRY_RUN=0
migrate_database "$t"
) >/dev/null 2>&1 || mig_rc=$?
saw="$(tr -d '\r' < "$FLASK_CAP" 2>/dev/null || true)"
if [ "$mig_rc" -eq 0 ] && [ "$saw" = "sqlite:///$t/backend/instance/serverkit.db" ]; then
ok "migrate_database targets the new slot's DB, leaving the old slot untouched"
else
bad "migrate_database rc=$mig_rc, used [$saw], expected rc 0 + the slot-absolute new-slot DB path"
fi
rm -f "$STUB_BIN/flask"
# --------------------------------------------------------------------------
# T11 — zero-downtime regression: reload_nginx_graceful must RELOAD a running
# nginx and must NEVER stop it. Host nginx fronts every managed app, so a stop
# during a panel update used to black out unrelated sites. A recording systemctl
# stub (PATH-prepended ahead of the global stub) captures every invocation.
# --------------------------------------------------------------------------
t="$WORK/t11"; mkdir -p "$t/bin"
CALL_LOG="$t/calls.log"; : > "$CALL_LOG"
cat > "$t/bin/systemctl" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$*" >> "$CALL_LOG"
exit 0 # is-active --quiet nginx → running
EOF
cat > "$t/bin/nginx" <<EOF
#!/usr/bin/env bash
printf 'nginx %s\n' "\$*" >> "$CALL_LOG"
exit 0 # nginx -t passes
EOF
chmod +x "$t/bin"/*
if (
set -Eeuo pipefail
export PATH="$t/bin:$PATH"
DRY_RUN=0
reload_nginx_graceful
) >/dev/null 2>&1; then
if grep -q 'reload nginx' "$CALL_LOG" && ! grep -q 'stop nginx' "$CALL_LOG"; then
ok "reload_nginx_graceful reloads a running nginx and never stops it (zero-downtime)"
else
bad "reload_nginx_graceful must reload (not stop) nginx; saw: $(tr '\n' ';' < "$CALL_LOG")"
fi
else
bad "reload_nginx_graceful returned non-zero against a healthy running nginx"
fi
# --------------------------------------------------------------------------
# T12 — when nginx is NOT running, reload_nginx_graceful starts it (instead of
# reloading a dead service) and still never issues a stop. The is-active gate
# reports inactive on its first probe, then active so wait_for_service returns
# immediately (keeps the test sub-second).
# --------------------------------------------------------------------------
t="$WORK/t12"; mkdir -p "$t/bin"
CALL_LOG="$t/calls.log"; : > "$CALL_LOG"; : > "$t/probe"
cat > "$t/bin/systemctl" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$*" >> "$CALL_LOG"
if [ "\$*" = "is-active --quiet nginx" ]; then
n=\$(cat "$t/probe" 2>/dev/null || echo 0); echo \$((n + 1)) > "$t/probe"
[ "\$n" -ge 1 ] && exit 0 || exit 1 # 1st probe: down → start branch; then up
fi
exit 0
EOF
chmod +x "$t/bin"/*
if (
set -Eeuo pipefail
export PATH="$t/bin:$PATH"
DRY_RUN=0
reload_nginx_graceful
) >/dev/null 2>&1; then
if grep -q 'start nginx' "$CALL_LOG" && ! grep -q 'stop nginx' "$CALL_LOG" \
&& ! grep -q 'reload nginx' "$CALL_LOG"; then
ok "reload_nginx_graceful starts a stopped nginx (never reloads a dead unit, never stops)"
else
bad "reload_nginx_graceful should start (not reload/stop) a dead nginx; saw: $(tr '\n' ';' < "$CALL_LOG")"
fi
else
bad "reload_nginx_graceful returned non-zero while starting a stopped nginx"
fi
# --------------------------------------------------------------------------
# T13 — guard against the old behaviour creeping back: the update.sh source must
# not contain a literal `systemctl stop nginx`. The forward and rollback paths
# both route nginx through reload_nginx_graceful now.
# --------------------------------------------------------------------------
if grep -nq 'systemctl stop nginx' "$UPDATE_SH"; then
bad "update.sh still contains 'systemctl stop nginx' — apps would black out on update"
else
ok "update.sh never stops nginx (no 'systemctl stop nginx' anywhere)"
fi
# --------------------------------------------------------------------------
# T14 — the panel frontend is served statically from $INSTALL_DIR/frontend/dist.
# refresh_config must repoint the shipped `root` (default /opt/serverkit) at a
# customised SERVERKIT_DIR, or a custom install dir would 404 the whole panel
# after an upgrade.
# --------------------------------------------------------------------------
t="$WORK/t14"
mkdir -p "$t/nginx/sites-available" "$t/nginx/sites-enabled" "$t/target/nginx/sites-available"
printf 'http {\n}\n' > "$t/nginx/nginx.conf"
printf 'server {\n root /opt/serverkit/frontend/dist;\n location / { try_files $uri /index.html; }\n}\n' \
> "$t/target/nginx/sites-available/serverkit-insecure.conf"
t14_rc=0
(
set -Eeuo pipefail
NGINX_DIR="$t/nginx"; LETSENCRYPT_DIR="$t/le"; SYSTEMD_DIR="$t/sysd"; CONFIG_DIR="$t/cfg"; DRY_RUN=0
INSTALL_DIR="$WORK/opt/serverkit" # non-default → substitution must fire
refresh_config "$t/target"
) >/dev/null 2>&1 || t14_rc=$?
installed="$t/nginx/sites-available/serverkit-insecure.conf"
if [ "$t14_rc" -eq 0 ] \
&& grep -q "root $WORK/opt/serverkit/frontend/dist;" "$installed" \
&& ! grep -q "root /opt/serverkit/frontend/dist;" "$installed"; then
ok "refresh_config repoints the static-frontend root at a custom SERVERKIT_DIR"
else
bad "refresh_config rc=$t14_rc or did not rewrite the frontend dist root: $(grep -n root "$installed" | tr '\n' ';')"
fi
# --------------------------------------------------------------------------
# T15 — the shipped nginx sites serve the SPA statically (host nginx, no
# container): each must carry a frontend/dist root + a try_files SPA fallback
# and must NOT proxy the retired frontend container on :3847.
# --------------------------------------------------------------------------
SK_ROOT="$SCRIPT_DIR/../.."
for f in serverkit.conf serverkit-insecure.conf; do
cfg="$SK_ROOT/nginx/sites-available/$f"
if grep -q 'frontend/dist' "$cfg" && grep -q 'try_files' "$cfg" \
&& ! grep -q '127.0.0.1:3847' "$cfg"; then
ok "$f serves the SPA statically (dist root + try_files, no :3847 proxy)"
else
bad "$f is not a clean static-serve config (still proxying :3847?)"
fi
# The HTML entrypoint must revalidate every load (expires -1 → no-cache):
# a cached index.html references hashed assets that vanish on the next
# blue/green switch, bricking the panel behind any caching layer.
if grep -q 'expires -1;' "$cfg"; then
ok "$f marks the SPA entrypoint no-cache (expires -1)"
else
bad "$f lets the HTML entrypoint be cached — stale index.html breaks after a slot switch"
fi
done
# --------------------------------------------------------------------------
# T15b — report_stale_panel_vhosts: a leftover custom vhost proxying the
# retired :3847 frontend container serves a permanently stale panel bundle
# (2026-07-03 incident: looked exactly like a browser/CDN cache problem).
# The check must WARN and name the file — and never abort, even with no
# sites-enabled dir at all (fresh box; also covered by the loop below).
# --------------------------------------------------------------------------
t="$WORK/t15b"; mkdir -p "$t/nginx/sites-enabled"
printf 'server {\n location / {\n proxy_pass http://127.0.0.1:3847;\n }\n}\n' \
> "$t/nginx/sites-enabled/legacy-panel.conf"
printf 'server {\n root /opt/serverkit/frontend/dist;\n}\n' \
> "$t/nginx/sites-enabled/clean.conf"
if out="$( set -Eeuo pipefail; NGINX_DIR="$t/nginx"; INSTALL_DIR="/opt/serverkit"
report_stale_panel_vhosts 2>&1 )" \
&& printf '%s' "$out" | grep -q 'legacy-panel.conf' \
&& ! printf '%s' "$out" | grep -q 'clean.conf'; then
ok "report_stale_panel_vhosts warns about (only) vhosts proxying :3847"
else
bad "report_stale_panel_vhosts missed the stale vhost or flagged a clean one: [$out]"
fi
if out="$( set -Eeuo pipefail; NGINX_DIR="$t/nowhere"; INSTALL_DIR="/opt/serverkit"
report_stale_panel_vhosts 2>&1 )" && [ -z "$out" ]; then
ok "report_stale_panel_vhosts is silent (rc 0) with no sites-enabled dir"
else
bad "report_stale_panel_vhosts failed or spoke on a missing dir: [$out]"
fi
# --------------------------------------------------------------------------
# T16 — app-uptime verification: discover_app_upstreams must extract the unique
# set of app container upstreams from the per-app nginx location snippets (this
# is the list the updater probes to prove apps stayed up).
# --------------------------------------------------------------------------
t="$WORK/t16"; mkdir -p "$t/loc"
printf 'location /app1 { proxy_pass http://127.0.0.1:8001; }\n' > "$t/loc/app1.conf"
printf 'location /app2 { proxy_pass http://127.0.0.1:8002/; }\nlocation /app2b { proxy_pass http://127.0.0.1:8001; }\n' > "$t/loc/app2.conf"
if ! res="$( set -Eeuo pipefail; APP_LOCATIONS_DIR="$t/loc"; discover_app_upstreams | tr '\n' ',' )"; then
bad "discover_app_upstreams aborted on a populated app-locations directory"
elif [ "$res" = "127.0.0.1:8001,127.0.0.1:8002," ]; then
ok "discover_app_upstreams extracts the unique app upstreams from location snippets"
else
bad "discover_app_upstreams returned [$res], expected the two unique upstreams"
fi
# T16b — empty app-locations directory must not abort under set -euo pipefail.
mkdir -p "$t/empty"
if res="$( set -Eeuo pipefail; APP_LOCATIONS_DIR="$t/empty"; discover_app_upstreams )"; then
if [ -z "$res" ]; then
ok "discover_app_upstreams tolerates an empty app-locations directory"
else
bad "discover_app_upstreams returned [$res] for an empty directory, expected empty"
fi
else
bad "discover_app_upstreams aborted on an empty app-locations directory"
fi
# --------------------------------------------------------------------------
# T17 — report_app_uptime_regressions flags an app that was reachable before the
# update and is not after (and ignores one that was already down), returning
# non-zero so the operator is warned; the clean case returns success.
# --------------------------------------------------------------------------
before=$'127.0.0.1:8001 up\n127.0.0.1:8002 up\n127.0.0.1:8003 down'
after=$'127.0.0.1:8001 up\n127.0.0.1:8002 down\n127.0.0.1:8003 down'
if ( set -Eeuo pipefail; report_app_uptime_regressions "$before" "$after" ) >/dev/null 2>&1; then
bad "report_app_uptime_regressions should flag the app that went up->down"
else
ok "report_app_uptime_regressions flags an app that went down across the update"
fi
if ( set -Eeuo pipefail; report_app_uptime_regressions "$before" "$before" ) >/dev/null 2>&1; then
ok "report_app_uptime_regressions passes when every app that was up is still up"
else
bad "report_app_uptime_regressions should pass when nothing regressed"
fi
# --------------------------------------------------------------------------
# T18 — preserve_installed_plugins (#48): user-installed plugin dirs are
# carried into the new tree; dirs the new tree already ships are NOT
# overwritten; __pycache__ is skipped; and the function never dies under
# set -e (it runs inside deploy_source/deploy_release).
# --------------------------------------------------------------------------
t="$WORK/t18"
mkdir -p "$t/old/backend/app/plugins/third-party" \
"$t/old/frontend/src/plugins/third-party" \
"$t/old/backend/app/plugins/shipped-plugin" \
"$t/old/backend/app/plugins/__pycache__" \
"$t/new/backend/app/plugins/shipped-plugin"
printf 'user-code\n' > "$t/old/backend/app/plugins/third-party/blueprint.py"
printf 'user-ui\n' > "$t/old/frontend/src/plugins/third-party/index.jsx"
printf 'old-copy\n' > "$t/old/backend/app/plugins/shipped-plugin/__init__.py"
printf 'stale\n' > "$t/old/backend/app/plugins/__pycache__/x.pyc"
printf 'repo-copy\n' > "$t/new/backend/app/plugins/shipped-plugin/__init__.py"
if (
# One && chain, not sequential statements: bash suppresses set -e inside
# an if-condition subshell, so only an explicit chain makes every
# assertion gate the result (same fix as test_install.sh T16).
set -Eeuo pipefail
preserve_installed_plugins "$t/old" "$t/new" \
&& [ -f "$t/new/backend/app/plugins/third-party/blueprint.py" ] \
&& [ -f "$t/new/frontend/src/plugins/third-party/index.jsx" ] \
&& [ ! -d "$t/new/backend/app/plugins/__pycache__" ] \
&& grep -q repo-copy "$t/new/backend/app/plugins/shipped-plugin/__init__.py"
) >/dev/null 2>&1; then
ok "preserve_installed_plugins carries user plugins forward without clobbering repo-shipped ones"
else
bad "preserve_installed_plugins lost a user plugin, clobbered a shipped one, or died under set -e"
fi
# --------------------------------------------------------------------------
# T19 — U1: backup pruning must be a no-op on an empty/missing BACKUP_DIR.
# The old `ls -t <glob> | tail | xargs` prune exited 2 on an unmatched glob,
# aborting the updater AFTER a successful update on any box with no prior
# backups. Also prove the retention itself: newest 10 kept, oldest removed.
# --------------------------------------------------------------------------
t="$WORK/t19"; mkdir -p "$t/empty" "$t/ret"
prune_survives() {
( set -Eeuo pipefail; BACKUP_DIR="$t/empty"
prune_old_backups 'serverkit-tree-*' 10 \
&& prune_old_backups 'serverkit-pre-upgrade-*.db' 10 ) &&
( set -Eeuo pipefail; BACKUP_DIR="$t/gone"; prune_old_backups 'serverkit-tree-*' 10 ) &&
( set -Eeuo pipefail; BACKUP_DIR="$t/empty"; INSTALL_DIR="$t/no-install"; DRY_RUN=0; cleanup )
}
if prune_survives >/dev/null 2>&1; then
ok "cleanup/prune are a clean no-op on an empty or missing BACKUP_DIR (post-update abort bug)"
else
bad "backup pruning DIED on an empty/missing BACKUP_DIR — the ls-glob abort is back"
fi
for i in 01 02 03 04 05 06 07 08 09 10 11 12; do
mkdir -p "$t/ret/serverkit-tree-2026$i"
touch -d "2026-01-$i 12:00:00" "$t/ret/serverkit-tree-2026$i"
done
( set -Eeuo pipefail; BACKUP_DIR="$t/ret"; prune_old_backups 'serverkit-tree-*' 10 ) >/dev/null 2>&1
remaining="$(find "$t/ret" -mindepth 1 -maxdepth 1 -name 'serverkit-tree-*' | wc -l)"
if [ "$remaining" -eq 10 ] && [ ! -d "$t/ret/serverkit-tree-202601" ] \
&& [ ! -d "$t/ret/serverkit-tree-202602" ] && [ -d "$t/ret/serverkit-tree-202612" ]; then
ok "prune_old_backups keeps the newest 10 backups and removes the oldest"
else
bad "prune retention wrong: $remaining left, expected the newest 10"
fi
# --------------------------------------------------------------------------
# T20 — U2: download_release's stdout is CAPTURED by its caller
# (tarball="\$(download_release ...)"), so it must print exactly one line — the
# tarball path — with every step/good/warn routed to stderr. A progress line on
# stdout used to hand tar a 4-line "filename", breaking every --release update.
# --------------------------------------------------------------------------
t="$WORK/t20"; mkdir -p "$t/bin" "$t/payload"
case "$(uname -m)" in
x86_64|amd64) tarch="amd64" ;;
aarch64|arm64) tarch="arm64" ;;
*) tarch="" ;;
esac
if [ -z "$tarch" ] || ! command -v sha256sum >/dev/null 2>&1; then
skip "download_release stdout purity — needs sha256sum + a known arch (runs on Linux CI)"
else
printf 'fake-release-bytes\n' > "$t/payload/tarball"
sha="$(sha256sum "$t/payload/tarball" | cut -d' ' -f1)"
printf '%s serverkit-v9.9.9-linux-%s.tar.gz\n' "$sha" "$tarch" > "$t/payload/checksums.txt"
# curl stub that actually materialises the -o target, so the mirror path
# runs end-to-end (download + checksum verification) without a network.
cat > "$t/bin/curl" <<EOF
#!/usr/bin/env bash
out=""; url=""
while [ \$# -gt 0 ]; do
case "\$1" in
-o) out="\$2"; shift 2 ;;
-*) shift ;;
*) url="\$1"; shift ;;
esac
done
case "\$url" in
*checksums.txt) cp "$t/payload/checksums.txt" "\$out" ;;
*) cp "$t/payload/tarball" "\$out" ;;
esac
EOF
chmod +x "$t/bin/curl"
out="$(
set -Eeuo pipefail
export PATH="$t/bin:$PATH"
SERVERKIT_OFFLINE_TARBALL=""
SERVERKIT_MIRROR_URL="http://mirror.invalid"
download_release v9.9.9 2>"$t/stderr.txt"
)"
rc=$?
if [ "$rc" -eq 0 ] && [ -n "$out" ] && [ -f "$out" ] \
&& [ "$(printf '%s' "$out" | wc -l)" -eq 0 ] \
&& grep -q 'Downloading release tarball' "$t/stderr.txt"; then
ok "download_release captures to a single existing path (progress → stderr)"
else
bad "download_release polluted its captured stdout (rc=$rc): [$out]"
fi
off="$t/offline.tar.gz"; printf 'x' > "$off"
if ! out2="$( set -Eeuo pipefail; SERVERKIT_OFFLINE_TARBALL="$off"; download_release v9.9.9 2>/dev/null )"; then
bad "download_release (offline tarball) aborted under set -Eeuo pipefail"
elif [ "$out2" = "$off" ]; then
ok "download_release (offline tarball) returns exactly the tarball path"
else
bad "download_release (offline) returned [$out2], expected [$off]"
fi
fi
# --------------------------------------------------------------------------
# T21 — U5/U8: a health-check-triggered rollback halts, which fires the EXIT
# trap (cleanup_on_exit) — that trap must NOT run a second rollback on top of
# the one that just finished. And the rollback itself must never abort
# mid-flight: a failed daemon-reload still has to end in a started backend.
# --------------------------------------------------------------------------
t="$WORK/t21"; mkdir -p "$t/bin" "$t/prev"
CALL_LOG="$t/calls.log"; : > "$CALL_LOG"
cat > "$t/bin/systemctl" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$*" >> "$CALL_LOG"
[ "\$1" = "daemon-reload" ] && exit 1 # fails mid-rollback (U8)
if [ "\$1" = "is-active" ]; then
grep -q '^start ' "$CALL_LOG" && exit 0 # active once a start was issued
exit 1 # inactive before that
fi
exit 0
EOF
chmod +x "$t/bin/systemctl"
(
set -Eeuo pipefail
export PATH="$t/bin:$PATH"
DRY_RUN=0
INSTALL_DIR="$t/serverkit"
PREVIOUS_DIR="$t/prev"
# atomic_switch needs real symlinks (unsupported on this fs — see T4); the
# unit under test is rollback's re-entry discipline, so stub the switch.
atomic_switch() { printf 'switched-to %s\n' "$1" >> "$CALL_LOG"; }
trap cleanup_on_exit EXIT
rollback
) > "$t/out.txt" 2>&1
n="$(grep -c 'rolling back to previous slot' "$t/out.txt")"
if [ "$n" = "1" ]; then
ok "rollback runs exactly once through the halt → EXIT-trap path (no double rollback)"
else
bad "rollback transcript appeared $n time(s), expected exactly once"
fi
if grep -q '^daemon-reload' "$CALL_LOG" && grep -q '^start serverkit' "$CALL_LOG"; then
ok "rollback survives a failed daemon-reload and still starts the backend (never aborts mid-flight)"
else
bad "rollback aborted mid-flight on a failed daemon-reload; calls: $(tr '\n' ';' < "$CALL_LOG")"
fi
# --------------------------------------------------------------------------
# T22 — U4: nginx down AND refusing to start must not abort the updater
# post-switch (which would then abort AGAIN inside the rollback's EXIT trap).
# reload_nginx_graceful warns and returns 0 no matter what nginx does.
# --------------------------------------------------------------------------
t="$WORK/t22"; mkdir -p "$t/bin"
CALL_LOG="$t/calls.log"; : > "$CALL_LOG"
cat > "$t/bin/systemctl" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$*" >> "$CALL_LOG"
[ "\$1" = "start" ] && exit 1 # nginx refuses to start
if [ "\$1" = "is-active" ]; then
grep -q '^start ' "$CALL_LOG" && exit 0 || exit 1
fi
exit 0
EOF
chmod +x "$t/bin/systemctl"
if (
set -Eeuo pipefail
export PATH="$t/bin:$PATH"
DRY_RUN=0
reload_nginx_graceful
) >/dev/null 2>&1; then
ok "reload_nginx_graceful never propagates a failed nginx start (post-switch abort averted)"
else
bad "reload_nginx_graceful aborted when nginx was down and refused to start"
fi
# --------------------------------------------------------------------------
# T23 — U13: refresh_config runs `systemctl daemon-reload` pre-switch; a
# failing reload (degraded systemd, container, chroot) must warn, not abort.
# --------------------------------------------------------------------------
t="$WORK/t23"
mkdir -p "$t/nginx/sites-available" "$t/nginx/sites-enabled" "$t/target/nginx/sites-available" "$t/bin"
printf 'http {\n}\n' > "$t/nginx/nginx.conf"
printf 'server { listen 80; }\n' > "$t/target/nginx/sites-available/serverkit-insecure.conf"
cat > "$t/bin/systemctl" <<'EOF'
#!/usr/bin/env bash
[ "$1" = "daemon-reload" ] && exit 1
exit 0
EOF
chmod +x "$t/bin/systemctl"
if (
set -Eeuo pipefail
export PATH="$t/bin:$PATH"
NGINX_DIR="$t/nginx"; LETSENCRYPT_DIR="$t/le"; SYSTEMD_DIR="$t/sysd"; CONFIG_DIR="$t/cfg"; DRY_RUN=0
refresh_config "$t/target"
) >/dev/null 2>&1; then
ok "refresh_config warns (not aborts) when systemctl daemon-reload fails"
else
bad "refresh_config aborted on a failing daemon-reload"
fi
# --------------------------------------------------------------------------
# T24 — U9: migrate_database used to `cd` into the new slot's backend and leak
# that cwd into the main shell for the rest of the update. It now runs the
# activation + cd in a subshell, leaving the caller's cwd untouched.
# --------------------------------------------------------------------------
t="$WORK/t24/serverkit-b"
mkdir -p "$t/venv/bin" "$t/backend/instance"
: > "$t/venv/bin/activate"
: > "$t/backend/instance/serverkit.db"
printf 'DATABASE_URL=sqlite:////opt/serverkit/backend/instance/serverkit.db\n' > "$t/.env"
printf '#!/usr/bin/env bash\nexit 0\n' > "$STUB_BIN/flask"
chmod +x "$STUB_BIN/flask"
exp="$(cd "$WORK" && pwd)"
t24_rc=0
res="$(
# && chain: this capture sits on the left of `||`, where bash suppresses
# set -e even inside the substitution — sequential statements would let a
# failing cd/migrate slip through ungated.
set -Eeuo pipefail
DRY_RUN=0
cd "$WORK" \
&& migrate_database "$t" >/dev/null 2>&1 \
&& pwd
)" || t24_rc=$?
rm -f "$STUB_BIN/flask"
if [ "$t24_rc" -eq 0 ] && [ "$res" = "$exp" ]; then
ok "migrate_database leaves the caller's cwd untouched (no cd leak)"
else
bad "migrate_database rc=$t24_rc or leaked its cd: caller cwd is now [$res]"
fi
# --------------------------------------------------------------------------
# T25 — U12: the EXIT trap (cleanup_on_exit) must preserve the script's exit
# code AND return 0 — returning \$rc re-fired the ERR trap from inside the
# trap, appending a second, misleading "Update aborted" report.
# --------------------------------------------------------------------------
t="$WORK/t25"; mkdir -p "$t"
(
set -Eeuo pipefail
trap 'printf "ERR-REFIRED\n"' ERR
trap cleanup_on_exit EXIT
DRY_RUN=1
exit 3
) > "$t/out.txt" 2>&1
rc=$?
if [ "$rc" -eq 3 ] && ! grep -q 'ERR-REFIRED' "$t/out.txt"; then
ok "cleanup_on_exit preserves the exit code and never re-fires the ERR trap"
else
bad "cleanup_on_exit changed rc to $rc or re-fired the ERR trap"
fi
# --------------------------------------------------------------------------
# T26 — regression guards baked into the source (same spirit as T13): the
# audited failure shapes must never creep back in.
# --------------------------------------------------------------------------
if ! grep -qE '^[^#]*ls -t "\$BACKUP_DIR"' "$UPDATE_SH"; then # code lines only — the fix's comment cites the old idiom
ok "update.sh never prunes backups via an ls-glob (aborts on empty BACKUP_DIR)"
else
bad "an 'ls -t \"\$BACKUP_DIR\"' prune is back — unmatched globs abort under pipefail"
fi
if grep -q 'required+=(npm)' "$UPDATE_SH"; then
ok "preflight requires npm for source-mode updates (no mid-update discovery)"
else
bad "npm is missing from the source-mode preflight required-tools list"
fi
if grep -qF '${ORIG_ARGS[@]+"${ORIG_ARGS[@]}"}' "$UPDATE_SH"; then
ok "self-update re-exec guards the empty-ORIG_ARGS expansion (set -u on bash < 4.4)"
else
bad "the re-exec expands ORIG_ARGS unguarded — fatal with no args under set -u on old bash"
fi
if ! grep -qF 'frontend.*Up' "$UPDATE_SH"; then
ok "health_check no longer probes the retired frontend container"
else
bad "the vestigial 'frontend container Up' check is still in update.sh"
fi
# --------------------------------------------------------------------------
# T27 — the fresh-minimal-box loop. On 2026-07-02 a production update aborted
# because discover_app_upstreams() grep'd an EMPTY /etc/nginx/serverkit-
# locations dir (the default state of every fresh install) and set -Eeuo
# pipefail killed the bare APP_BASELINE="$(snapshot_app_reachability)"
# assignment. The function HAD a unit test — but only with a populated
# fixture. This loop runs EVERY observation/discovery/snapshot/report/probe
# function against the emptiest valid world a fresh box presents (zero apps,
# zero containers, no optional confs, dead network, empty state), under the
# script's own set -Eeuo pipefail.
#
# POLICY: every new observation/discovery/snapshot/report function added to
# scripts/update.sh MUST be appended here (add args/disposition to the case
# table when needed). Dispositions:
# must0 — output is captured by assignment somewhere (X="$(fn)"), so a
# non-zero exit IS the outage: the function must exit 0.
# pred — used only in conditional position (if fn; ...), where a clean
# non-zero answer is legitimate: it must merely return normally
# (no set -u crash, no signal death).
# --------------------------------------------------------------------------
FRESH="$WORK/freshbox"
make_fresh_box_fixture "$FRESH"
UPDATE_OBSERVERS=(
local_version versions_equal remote_release_tag is_already_current
version_gate active_real_dir next_real_dir discover_app_upstreams
probe_app_upstreams snapshot_app_reachability report_app_uptime_regressions
locate_python wait_for_service quick_health is_docker_deployment
snapshot_docker_state report_failure report_stale_panel_vhosts
)
loop_fail=0
for fn in "${UPDATE_OBSERVERS[@]}"; do
args=(); mode=must0
case "$fn" in
versions_equal) args=(1.7.0 v1.7.0) ;;
report_failure) args=(2 42 'grep conf') ;;
report_app_uptime_regressions) args=("" "") ;;
wait_for_service) args=(serverkit inactive 1) ;;
quick_health) args=(1); mode=pred ;;
is_already_current|is_docker_deployment|locate_python) mode=pred ;;
esac
out="$(
{
set -Eeuo pipefail
export PATH="$FRESH/bin:$PATH"
INSTALL_DIR="$FRESH/opt/serverkit"; SERVERKIT_DIR="$INSTALL_DIR"
DIR_A="$FRESH/opt/serverkit-a"; DIR_B="$FRESH/opt/serverkit-b"
APP_LOCATIONS_DIR="$FRESH/etc/nginx/serverkit-locations"
NGINX_DIR="$FRESH/etc/nginx"; CONFIG_DIR="$FRESH/etc/serverkit"
BACKUP_DIR="$FRESH/var/backups"; LOG_DIR="$FRESH/var/log"
DRY_RUN=0; FORCE_UPDATE=0; USE_RELEASE=0
RELEASE_VERSION=""; TARGET_BRANCH=""; SERVERKIT_OFFLINE_TARBALL=""
"$fn" ${args[@]+"${args[@]}"}
} </dev/null 2>&1
)"
rc=$?
if [ "$mode" = "must0" ] && [ "$rc" -ne 0 ]; then
bad "fresh-box loop: $fn exited $rc on a zero-app box: [$(printf '%s' "$out" | tail -c 160)]"
loop_fail=1
elif [ "$mode" = "pred" ] && { [ "$rc" -gt 128 ] || printf '%s' "$out" | grep -q 'unbound variable'; }; then
bad "fresh-box loop: predicate $fn crashed (rc=$rc) on a zero-app box: [$(printf '%s' "$out" | tail -c 160)]"
loop_fail=1
fi
done
if [ "$loop_fail" = "0" ]; then
ok "fresh-box loop: all ${#UPDATE_OBSERVERS[@]} observation/discovery functions survive a zero-app box"
fi
# --------------------------------------------------------------------------
# T28 — the best-effort contract as an executable promise: the app-uptime
# verification composed exactly the way update.sh's run block wires it —
# APP_BASELINE="$(snapshot_app_reachability)"
# ... stop/switch/start ...
# phase "Verifying App Uptime"
# APP_AFTER="$(snapshot_app_reachability)"
# report_app_uptime_regressions "$APP_BASELINE" "$APP_AFTER" || true
# — must exit 0 under set -Eeuo pipefail on (a) a fresh zero-app box (the
# exact 2026-07-02 world) and (b) a box where an app genuinely regressed:
# the report warns loudly, but the `|| true` keeps verification from failing
# an already-healthy update. The outage commit made that promise only in
# prose; this test makes it executable.
# --------------------------------------------------------------------------
t="$WORK/t28"; mkdir -p "$t/binup" "$t/bindown" "$t/loc"
make_stub_ok "$t/binup" curl # every upstream answers → "up"
make_stub_curl_fail22 "$t/bindown" # every upstream refuses → "down"
t28_rc=0
out="$(
{
# && chain: this capture sits on the left of `||`, where bash
# suppresses set -e even inside the substitution. The chain gates on
# every statement, which is exactly what set -e does to the real
# (sequential) run block.
set -Eeuo pipefail
export PATH="$FRESH/bin:$PATH"
APP_LOCATIONS_DIR="$FRESH/etc/nginx/serverkit-locations"; DRY_RUN=0
APP_BASELINE="$(snapshot_app_reachability)" \
&& phase "Verifying App Uptime" \
&& APP_AFTER="$(snapshot_app_reachability)" \
&& { report_app_uptime_regressions "$APP_BASELINE" "$APP_AFTER" || true; }
} 2>&1
)" || t28_rc=$?
if [ "$t28_rc" -eq 0 ] && printf '%s' "$out" | grep -q 'No managed apps'; then
ok "contract: the run block's uptime-verification chain exits 0 on a fresh zero-app box"
else
bad "contract: uptime verification FAILED a fresh-box update (rc=$t28_rc): [$out]"
fi
printf 'location /app { proxy_pass http://127.0.0.1:8009; }\n' > "$t/loc/app.conf"
t28_rc=0
out="$(
{
set -Eeuo pipefail
APP_LOCATIONS_DIR="$t/loc"; DRY_RUN=0
export PATH="$t/binup:$PATH" # app reachable pre-switch
APP_BASELINE="$(snapshot_app_reachability)" \
&& export PATH="$t/bindown:$PATH" `# app dead post-switch → regression` \
&& phase "Verifying App Uptime" \
&& APP_AFTER="$(snapshot_app_reachability)" \
&& { report_app_uptime_regressions "$APP_BASELINE" "$APP_AFTER" || true; }
} 2>&1
)" || t28_rc=$?
if [ "$t28_rc" -eq 0 ] && printf '%s' "$out" | grep -q 'DOWN now'; then
ok "contract: a real app regression warns loudly but never fails the healthy update"
else
bad "contract: regression path rc=$t28_rc (must be 0, with the loud warning): [$out]"
fi
# --------------------------------------------------------------------------
printf '\n%d passed, %d failed, %d skipped\n\n' "$PASS" "$FAIL" "$SKIP"
[ "$FAIL" -eq 0 ]