Files
ServerKit/scripts/test/test_update.sh
T
Juan DenisandClaude Opus 4.8 aa6ee2737f feat(update): Phase 2 — version-aware, dry-run-honest, rollback-safe updater
Updater robustness from the hardening plan §5:

- Version comparison before updating (G3): is_already_current compares the
  local VERSION to the target release tag (release mode) or the local checkout
  HEAD to the remote branch HEAD via `git ls-remote` (branch/main mode). When
  current, both the hybrid and docker flows print "Already up to date" and exit
  0 — unless --force. Offline/indeterminate always proceeds rather than wrongly
  blocking.

- Dry-run fidelity: new run_in_dir reports the working directory a command
  would run in, replacing the `run_or_dry cd X && run_or_dry compose ...` chain
  that printed compose commands with no cwd context.

- Rollback hardening: migrate_database now runs against the NEW slot's DB copy
  (slot-absolute path) instead of the /opt/serverkit symlink that still
  resolves to the live old slot — so a rollback hands the old code a
  byte-for-byte-unchanged database (PostgreSQL/shared DBs migrate directly, as
  before). rollback_docker guards `git reset` on a non-empty SHA with a clear
  warning. Both rollback paths now run a post-rollback health check and say
  plainly when even the rollback is unhealthy.

- Telemetry/logging: halt() always prints the full log path; both summaries
  gained an "old → new", mode (release/branch/main, +docker), and duration
  line, plus the log path.

Tests: test_update.sh gains versions_equal, is_already_current force/offline
short-circuits, and a flask-stub proof that migrate_database targets the new
slot's DB (T10). 10 passed / 2 skipped locally; shellcheck error+warning clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:31:03 -04:00

264 lines
12 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"
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"
for cmd in systemctl nginx npm curl; do
printf '#!/usr/bin/env bash\nexit 0\n' > "$STUB_BIN/$cmd"
done
# 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
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)"
)"
exp="$(readlink -f "$t/serverkit-a")|$t/serverkit-b"
if [ "$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.
# --------------------------------------------------------------------------
out="$(LAST_PHASE='Refreshing Configuration' report_failure 2 42 'grep ... serverkit.conf' 2>&1)"
if printf '%s' "$out" | grep -q 'Update aborted'; then
ok "report_failure emits a labelled 'Update aborted' diagnostic"
else
bad "report_failure produced no diagnostic"
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"
(
set -Eeuo pipefail
DRY_RUN=0
migrate_database "$t"
) >/dev/null 2>&1
saw="$(tr -d '\r' < "$FLASK_CAP" 2>/dev/null || true)"
if [ "$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 used [$saw], expected the slot-absolute new-slot DB path"
fi
rm -f "$STUB_BIN/flask"
# --------------------------------------------------------------------------
printf '\n%d passed, %d failed, %d skipped\n\n' "$PASS" "$FAIL" "$SKIP"
[ "$FAIL" -eq 0 ]