#!/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= 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 # Every curl below targets this machine's loopback, and a proxy from the # environment (http_proxy, ALL_PROXY) would answer in its place: the port check # would probe the proxy instead of the port, the wait loop and the assertions # would talk to the proxy instead of the server started here. Both spellings, # since curl reads the lowercase one first and an inherited no_proxy would win. export NO_PROXY='*' no_proxy='*' 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. No proxy answers in the port's place: NO_PROXY # is exported at the top. command -v curl >/dev/null 2>&1 || fail "curl is required" RC=0 curl -s -o /dev/null --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"