Only the port pre-check of scripts/boot-from-copy.sh carried --noproxy. The wait loop and the two assertions still honoured http_proxy, so on a workstation with a proxy configured the script talked to the proxy instead of the server it had just started, and failed loudly. Export NO_PROXY='*' and no_proxy='*' at the top of the script, which covers every curl call, and drop the now-redundant --noproxy. The new test boots the script for real behind a fake proxy that answers everything and records what reaches it. It first proves the proxy is live (a bare curl with the same environment reaches it), then requires that nothing of the boot check reaches it and that the script passes. Red on the previous script (the wait loop and the first assertion went through the proxy), red again when the export is removed. 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
|
|
# 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"
|