The self-check comment of the continue-on-error detector said that requiring a colon after the word loses the anchor row only. That holds when a closing quote may sit before the colon; with the colon right after the word, the quoted-key row goes too. Measured: 2 rows red with the colon right after the word, 1 with a closing quote allowed, 3 with the original anchored regex. The port check of boot-from-copy.sh named NO_PROXY as what keeps a proxy out, while curl reads no_proxy first: it now names both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
162 lines
7.3 KiB
Bash
162 lines
7.3 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
|
|
|
|
# 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
|
|
# and NO_PROXY are 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"
|