scripts/supervisor-smoke.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
#
# Smoke test of a BUILT updater/recovery supervisor image, run the way docker-compose.yml and
# docker-compose.release.yml run it: working dir /project with a project dir bind-mounted there (and
# no server.js in it), a /backups volume, no GitHub repo configured. Guards #27 — a relative
# entrypoint resolved against a compose `working_dir` crash-looped the supervisor on every Docker
# deploy — and checks the routes Caddy proxies to it plus the tools apply.sh/rollback.sh need. A second
# run with APPLY_SUPPORTED=0 (what setup.sh and the native installer set) checks one-click apply and rollback are
# refused and that the recovery page stops offering Roll back; that run also sets GITHUB_REPO with GitHub unreachable
# (--network none, api.github.com pinned to a closed loopback port so it fails fast instead of waiting on DNS), so
# the startup poll takes the fetch-error path, which must be reported in /update/status, not crash the process.
# Needs only a Docker daemon (no host ports published; probes run inside the container).
# The docker compose files themselves are parsed by scripts/edge-check.sh (`docker compose config -q`), not here.
#
# Usage: scripts/supervisor-smoke.sh <image> e.g. scripts/supervisor-smoke.sh mdmesh-supervisor:ci-local
set -euo pipefail
IMG="${1:?usage: scripts/supervisor-smoke.sh <image>}"
# Unique per run, even across CI jobs sharing one daemon ($$ alone can repeat across PID namespaces), so parallel
# runs never probe or `docker rm -f` each other's containers. The label marks leftovers from a SIGKILLed run.
BASE_NAME="mdmesh-supervisor-smoke-${GITHUB_RUN_ID:-local}-$$-$(od -An -N4 -tx4 /dev/urandom | tr -d ' ')"
NAME="$BASE_NAME" # the container the helpers below probe
PROJ="$(mktemp -d)"
cleanup() { docker rm -f "$BASE_NAME" "$BASE_NAME-noapply" >/dev/null 2>&1 || true; rm -rf "$PROJ"; }
trap cleanup EXIT
printf 'COMPOSE_PROJECT_NAME=mdmesh\n' > "$PROJ/.env" # a deploy dir holds .env + compose files, never server.js
PASS=0
pass() { echo " ok $*"; PASS=$((PASS + 1)); }
fail() {
echo " FAIL $*"
echo "--- container logs ---"; docker logs "$NAME" 2>&1 | tail -20
exit 1
}
running() { [ "$(docker inspect -f '{{.State.Running}}' "$NAME" 2>/dev/null)" = true ]; }
in_c() { docker exec "$NAME" "$@"; }
code() { in_c curl -s -o /dev/null -w '%{http_code}' -m 5 "$@"; }
# start <name> [extra docker-run args…]: run the image compose-shaped as <name> and wait for /healthz.
start() {
NAME="$1"; shift
docker run -d --name "$NAME" --label mdmesh.smoke=1 -w /project -v "$PROJ:/project" --tmpfs /backups \
-e GITHUB_REPO= -e CURRENT_VERSION=0.0.0 -e COMPOSE_PROJECT_NAME=mdmesh "$@" "$IMG" >/dev/null
local up=0
for _ in $(seq 1 30); do
running || fail "$NAME exited (code $(docker inspect -f '{{.State.ExitCode}}' "$NAME")) during startup — see logs (#27: the entrypoint must not depend on the working dir)"
if in_c curl -fsS -m 2 http://127.0.0.1:9000/healthz >/dev/null 2>&1; then up=1; break; fi
sleep 1
done
[ "$up" = 1 ] || fail "/healthz never answered on :9000"
}
start "$BASE_NAME"
pass "starts under -w /project and answers /healthz"
check() { local what="$1"; shift; if "$@"; then pass "$what"; else fail "$what"; fi; }
# Capture bodies first (grep -q on a pipe can SIGPIPE curl under pipefail once a body outgrows the pipe buffer).
body() { in_c curl -fsS -m 5 "$@"; }
status_json() { grep -q '"applySupported":true' <<<"$(body http://127.0.0.1:9000/update/status)"; }
# server.js serves the recovery page for EVERY unmatched path (it is also Caddy's handle_errors fallback), so this
# probe cannot test routing. It asserts the served CONTENT: the data-apply marker recoveryPage() stamps on the page,
# plus the Roll back card that marker gates.
recovery_page() { local b; b="$(body http://127.0.0.1:9000/recovery)"
grep -q '<body data-apply="1">' <<<"$b" && grep -q 'id="rbcard"' <<<"$b"; }
apk_route() { [ "$(code http://127.0.0.1:9000/update/agent.apk)" = 404 ]; }
apply_gated() { [ "$(code -X POST http://127.0.0.1:9000/update/apply)" = 403 ]; }
toolchain() { in_c sh -c 'command -v bash && command -v minisign && command -v pg_dump && command -v psql && docker compose version' >/dev/null; }
check "/update/status serves JSON (apply supported)" status_json
check "/recovery serves the recovery page, marked apply-supported (Roll back offered)" recovery_page
check "/update/agent.apk answers (404: no verified release yet)" apk_route
check "/update/apply is CSRF-gated (403 without the console header)" apply_gated
check "recovery token generated on /backups" in_c test -s /backups/recovery.token
check "apply.sh/rollback.sh executable, release pubkey baked" \
in_c sh -c 'test -x /app/apply.sh && test -x /app/rollback.sh && test -s /app/minisign.pub'
check "bash, minisign, pg_dump/psql, docker compose present" toolchain
# Source and native installs set APPLY_SUPPORTED=0 — the console must be told, apply/rollback refused outright,
# and the recovery page must hide Roll back (CSS keyed on the marker) and show the manual update steps instead.
# Its startup poll gets a repo but no network: poll() must record the fetch error in /update/status and keep serving.
docker rm -f "$NAME" >/dev/null
start "$BASE_NAME-noapply" -e APPLY_SUPPORTED=0 -e GITHUB_REPO=mdmesh-smoke/unreachable \
--network none --add-host api.github.com:127.0.0.1
poll_error() { local s=""
for _ in $(seq 1 15); do
s="$(body http://127.0.0.1:9000/update/status)" || return 1
grep -q '"error":"not polled yet"' <<<"$s" || break
sleep 1
done
running && grep -q '"checkedAt":[0-9]' <<<"$s" && grep -q '"error":"[^"]' <<<"$s" \
&& ! grep -qE '"error":"(not polled yet|GITHUB_REPO not set)"' <<<"$s"; }
check "startup poll against an unreachable repo reports its fetch error in /update/status and keeps running" poll_error
status_noapply() { grep -q '"applySupported":false' <<<"$(body http://127.0.0.1:9000/update/status)"; }
apply_501() { [ "$(code -X POST http://127.0.0.1:9000/update/apply)" = 501 ]; }
rollback_501() { [ "$(code -X POST -H 'X-MDMesh-Console: 1' http://127.0.0.1:9000/update/rollback)" = 501 ]; }
recovery_noapply() { local b; b="$(body http://127.0.0.1:9000/recovery)"
grep -q '<body data-apply="0">' <<<"$b" \
&& grep -qF 'body[data-apply="0"] #rbcard{display:none}' <<<"$b" \
&& grep -qF 'git pull && ./setup.sh' <<<"$b" \
&& grep -qF 'git pull && sudo ./setup.sh --native' <<<"$b"; }
check "APPLY_SUPPORTED=0: /update/status reports applySupported:false" status_noapply
check "APPLY_SUPPORTED=0: /update/apply refused with 501" apply_501
check "APPLY_SUPPORTED=0: /update/rollback refused with 501" rollback_501
check "APPLY_SUPPORTED=0: recovery page hides Roll back and shows the manual update steps" recovery_noapply
echo "supervisor smoke: $PASS passed"