vps-health-api/scripts/boot-from-copy.sh
le king fu 4e7879da6c
All checks were successful
CI / verification (pull_request) Successful in 21s
CI / verification (push) Successful in 19s
ci: add a Forgejo Actions workflow with a boot-from-COPY check (#24)
Nothing verified this repository until now. The workflow runs on every pull
request (no branches filter, so intermediate stack links get a status) and on
pushes to main, in node:22-alpine, the Dockerfile's own base: npm ci, npm test,
the socket-level pass, then scripts/boot-from-copy.sh.

The script stands in for a docker build, which would need the Docker socket in
the instance-scoped runner and so root on the production VPS for every
workflow. It starts the CMD from the COPY sources alone and fails on the
MODULE_NOT_FOUND that #11 had to fix by hand. __tests__/ci.test.js pins the
workflow and what the script assumes of the Dockerfile.

Refs #24

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

140 lines
5.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)
# Exit 0 if the server boots and answers, 1 otherwise.
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"
# --- 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"