vps-health-api/scripts/boot-from-copy.sh
le king fu 156ca818ca
All checks were successful
CI / verification (pull_request) Successful in 19s
CI / verification (push) Successful in 20s
ci: pin the review follow-ups of the CI workflow (#33)
- Forbid `continue-on-error` in ci.yml: that mutation left every test green,
  and a failing step would turn the commit status green in silence.
- boot-from-copy.sh refuses a port already held by something else (any curl
  exit other than 7). Without it, the wait loop could reach the neighbour and
  assert on a server it never started — the new test reproduces exactly that
  when the check is removed.
- Pin every require of the COPY'd runtime modules at column 0, with no
  dynamic import: the boot check only calls /health, so it only proves the
  modules loaded at startup.

Fixes #33

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:54:47 -04:00

155 lines
6.9 KiB
Bash

#!/bin/sh
# boot-from-copy.sh — start the API from exactly the files the image would hold.
#
# The Dockerfile COPY lists runtime files one by one. A module missing from that
# list passes every test (the tests run from the full checkout) and only crashes
# the container at redeploy, with MODULE_NOT_FOUND. That is what #11 had to fix
# by hand for metrics.js.
#
# A real `docker build` would need the Docker socket inside the CI job, which on
# the shared instance-scoped runner makes every workflow of every repository
# root on the production VPS. So this script rebuilds the image's file set
# instead: it copies the COPY sources into an empty directory, runs the CMD
# there, and asserts the server answers. In CI it runs in `node:22-alpine`, the
# image's own base.
#
# Usage : sh scripts/boot-from-copy.sh (from the repository root)
# BOOT_PORT=<port> sh scripts/boot-from-copy.sh (default port 3097)
# Exit 0 if the server boots and answers, 1 otherwise — including when the port
# is already held by something else (pick another one with BOOT_PORT).
set -eu
PORT="${BOOT_PORT:-3097}"
TOKEN="boot-from-copy-token"
DOCKERFILE="Dockerfile"
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ -f "$DOCKERFILE" ] || fail "no $DOCKERFILE here — run from the repository root"
# --- Parse COPY -------------------------------------------------------------
# Only the plain shell form is understood. Anything else (flags, JSON array,
# several COPY lines) fails loudly rather than being half-parsed into a
# check that verifies nothing.
COPY_LINES=$(grep -cE '^[[:space:]]*COPY[[:space:]]' "$DOCKERFILE" || true)
[ "$COPY_LINES" = "1" ] || fail "expected exactly one COPY line, found $COPY_LINES"
COPY_ARGS=$(grep -E '^[[:space:]]*COPY[[:space:]]' "$DOCKERFILE" | sed -E 's/^[[:space:]]*COPY[[:space:]]+//')
case "$COPY_ARGS" in
--*) fail "COPY flags are not supported by this check: $COPY_ARGS" ;;
\[*) fail "COPY JSON form is not supported by this check: $COPY_ARGS" ;;
esac
# shellcheck disable=SC2086 # word splitting is the point: one word per source
set -- $COPY_ARGS
[ "$#" -ge 2 ] || fail "COPY needs at least one source and a destination: $COPY_ARGS"
# --- Parse CMD --------------------------------------------------------------
# Taken from the Dockerfile rather than hardcoded, so the check follows the
# entrypoint if it ever changes. Exec (JSON array) form only.
CMD_LINE=$(grep -E '^[[:space:]]*CMD[[:space:]]' "$DOCKERFILE" | tail -1 | sed -E 's/^[[:space:]]*CMD[[:space:]]+//')
CMD_SH=$(printf '%s' "$CMD_LINE" | node -e '
let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
let a;
try { a = JSON.parse(s); } catch { process.exit(1); }
if (!Array.isArray(a) || !a.length || !a.every((x) => /^[A-Za-z0-9._\/-]+$/.test(x))) process.exit(1);
process.stdout.write(a.join(" "));
});
') || fail "CMD must be an exec-form JSON array of plain words: $CMD_LINE"
# --- Port must be free -----------------------------------------------------
# If something already listens on $PORT, our server dies of EADDRINUSE, but the
# wait loop below can reach the neighbour first and the assertions would then
# check a server we never started. So refuse up front: the port counts as free
# only when curl gets "connection refused" (exit 7). Any other exit — an HTTP
# answer (0), a listener that accepts and stalls (28), a non-HTTP service
# (52, 56) — means it is taken. --noproxy keeps an http_proxy from answering
# in the port's place.
command -v curl >/dev/null 2>&1 || fail "curl is required"
RC=0
curl -s -o /dev/null --noproxy '*' --max-time 2 "http://127.0.0.1:$PORT/" || RC=$?
[ "$RC" = "7" ] || fail "port $PORT is already taken (curl exit $RC); set BOOT_PORT to a free port"
# --- Build the image's file set ---------------------------------------------
APP_DIR=$(mktemp -d)
LOG="$APP_DIR.log"
SERVER_PID=""
trap '[ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null; rm -rf "$APP_DIR" "$LOG"; true' EXIT
N=0
while [ "$#" -gt 1 ]; do
[ -f "$1" ] || fail "COPY source $1 does not exist in the repository"
cp "$1" "$APP_DIR/"
N=$((N + 1))
shift
done
[ "$N" -gt 0 ] || fail "no COPY source found"
echo "Image file set ($N): $(ls "$APP_DIR" | tr '\n' ' ')"
# mktemp creates the directory 0700: the `node` user could not even enter it
# and the failure would read EACCES instead of the MODULE_NOT_FOUND we look for.
chmod 755 "$APP_DIR"
chmod 644 "$APP_DIR"/*
# --- Start ------------------------------------------------------------------
# The image runs as `node`. In the CI container the job is root, so drop to
# `node` like the image does; on a workstation, run as whoever we are.
# LOGTO_HEALTH_URL points at a closed local port: no external network needed,
# the Logto check reports "down" and /health still answers 200.
# busybox `su` cannot preserve the environment, hence the inline variables.
ENV_INLINE="PORT=$PORT HEALTH_TOKEN=$TOKEN LOGTO_HEALTH_URL=http://127.0.0.1:9/"
if [ "$(id -u)" = "0" ]; then
id node >/dev/null 2>&1 || fail "running as root but no 'node' user to drop to"
su node -s /bin/sh -c "cd '$APP_DIR' && exec env $ENV_INLINE $CMD_SH" >"$LOG" 2>&1 &
else
(cd "$APP_DIR" && exec env $ENV_INLINE $CMD_SH) >"$LOG" 2>&1 &
fi
SERVER_PID=$!
UP=""
i=0
while [ "$i" -lt 50 ]; do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "--- server log ---" >&2
cat "$LOG" >&2
fail "the server died at startup (see its log above)"
fi
if curl -s -o /dev/null "http://127.0.0.1:$PORT/health"; then
UP=yes
break
fi
sleep 0.2
i=$((i + 1))
done
if [ -z "$UP" ]; then
echo "--- server log ---" >&2
cat "$LOG" >&2
fail "the server did not answer on :$PORT within 10 s"
fi
# --- Assert -----------------------------------------------------------------
CODE=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/health")
[ "$CODE" = "401" ] || fail "GET /health without token: expected 401, got $CODE"
echo "ok GET /health without token -> 401"
BODY=$(curl -s -w '\n%{http_code}' -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:$PORT/health")
CODE=$(printf '%s' "$BODY" | tail -1)
[ "$CODE" = "200" ] || fail "GET /health with token: expected 200, got $CODE"
echo "ok GET /health with token -> 200"
# metrics.js swallows a failed `df` parse and reports zeros. Production runs on
# Alpine, whose busybox df is what this catches. In CI the figure is the job
# container's overlay root, not the production disk: a failure here means the
# parsing broke, never that a disk is full.
printf '%s' "$BODY" | sed '$d' | node -e '
let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
const b = JSON.parse(s);
if (!(b.disk && b.disk.totalGB > 0)) { console.error("disk:", JSON.stringify(b.disk)); process.exit(1); }
if (!(b.memory && b.memory.totalGB > 0)) { console.error("memory:", JSON.stringify(b.memory)); process.exit(1); }
});
' || fail "/health reported empty disk or memory metrics"
echo "ok /health disk and memory metrics are non-zero"
echo "boot-from-copy: PASS"