The e2e stack shared production's Postgres — same server, same database, same table. Seeded robots sat in `users` beside real clinicians, and anything a test wrote, or a migration under test changed, landed on real data. Nothing about "run the tests" should be able to reach an account belonging to a person. Now it has a Postgres and a Redis of its own, both on tmpfs: created empty on every run, held in RAM, gone on teardown. scripts/e2e.sh is one command that recreates the stack, seeds it, runs the browser and leaves the app up at 127.0.0.1:3553 so it can be clicked around in, with the report served at :3554. Two bugs fell out of it immediately, both of which only a database that did not already exist could have found: The schema could not be built from nothing. The entrypoint migrated before the app created its baseline tables, so the first migration failed on saved_encounters not existing. It never showed because every database this has ever run against already had the baseline. Then, one layer down, 1777800000000_generated-images creates a table with a foreign key to learning_content — which the baseline stopped creating when Learning Hub was removed. Restoring into a brand-new database could not have booted. The entrypoint now stands aside when the database is empty and lets the app do it in the order it already gets right, and the foreign key is only created where its target is. All 20 migrations replay from empty, producing the same 23 tables production has. Configuration lives in the database, so a throwaway one starts at defaults — 14 settings against production's 49. That is why every model picker was empty: models.custom did not exist. The tests were right and the environment was incomplete, so the seed now states what the suite depends on, with fictional model ids: a test should not pass because of a setting somebody changed on the live system last week. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
150 lines
6.5 KiB
Bash
Executable file
150 lines
6.5 KiB
Bash
Executable file
#!/bin/sh
|
|
# Container entrypoint. Optionally fetches secrets from OpenBao before
|
|
# starting the app. Backwards compatible: if OPENBAO_ADDR is unset (e.g. e2e
|
|
# container, local dev with a populated .env), the vault step is skipped
|
|
# and the process starts with whatever's already in the environment.
|
|
#
|
|
# When OPENBAO_ADDR is set, OPENBAO_ROLE_ID + OPENBAO_SECRET_ID are required.
|
|
# The entrypoint logs in via AppRole, fetches kv/ped-ai/prod, exports each
|
|
# key as an env var, and then unsets the auth material before execing the
|
|
# real command so the Node process doesn't carry them.
|
|
|
|
set -eu
|
|
|
|
if [ -n "${OPENBAO_ADDR:-}" ]; then
|
|
if [ -z "${OPENBAO_ROLE_ID:-}" ] || [ -z "${OPENBAO_SECRET_ID:-}" ]; then
|
|
echo "[entrypoint] FATAL: OPENBAO_ADDR is set but OPENBAO_ROLE_ID or OPENBAO_SECRET_ID is missing." >&2
|
|
exit 1
|
|
fi
|
|
|
|
export BAO_ADDR="${OPENBAO_ADDR}"
|
|
echo "[entrypoint] authenticating to OpenBao at ${OPENBAO_ADDR} via AppRole..."
|
|
|
|
BAO_TOKEN="$(bao write -field=token auth/approle/login \
|
|
role_id="${OPENBAO_ROLE_ID}" \
|
|
secret_id="${OPENBAO_SECRET_ID}" 2>&1)"
|
|
if [ -z "${BAO_TOKEN}" ] || printf '%s' "${BAO_TOKEN}" | grep -qi error; then
|
|
echo "[entrypoint] FATAL: AppRole authentication failed:" >&2
|
|
echo "${BAO_TOKEN}" >&2
|
|
exit 1
|
|
fi
|
|
export BAO_TOKEN
|
|
|
|
SECRET_PATH="${OPENBAO_KV_PATH:-kv/ped-ai/prod}"
|
|
echo "[entrypoint] fetching secrets from ${SECRET_PATH}..."
|
|
SECRET_JSON="$(bao kv get -format=json "${SECRET_PATH}" 2>/dev/null | jq -c '.data.data' 2>/dev/null || true)"
|
|
if [ -z "${SECRET_JSON}" ] || [ "${SECRET_JSON}" = "null" ]; then
|
|
echo "[entrypoint] FATAL: no secrets returned from ${SECRET_PATH}." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Export each key/value as a shell-safe env var — but ONLY if the key
|
|
# isn't already set by docker (env_file / environment: block). This
|
|
# lets a docker-compose override win over the OpenBao value, which is
|
|
# needed for e2e (TURNSTILE_SECRET_KEY="" / SMTP_HOST="") and any
|
|
# environment-specific override.
|
|
#
|
|
# Pattern: write jq output to a temp file, then while-read in the main
|
|
# shell so exports persist (pipes into while run in a subshell and lose
|
|
# them). Pre-snapshot env keys and skip those already defined.
|
|
_PRESET_KEYS_FILE=$(mktemp)
|
|
env | cut -d= -f1 | sort -u > "$_PRESET_KEYS_FILE"
|
|
|
|
_SECRET_ASSIGNS=$(mktemp)
|
|
printf '%s' "${SECRET_JSON}" | jq -r 'to_entries[] | "\(.key)\t\(.value | @sh)"' > "$_SECRET_ASSIGNS"
|
|
|
|
_APPLIED_COUNT=0
|
|
_SKIPPED_COUNT=0
|
|
while IFS="$(printf '\t')" read -r _K _VAL_QUOTED; do
|
|
if [ -z "$_K" ]; then continue; fi
|
|
if grep -qxF "$_K" "$_PRESET_KEYS_FILE"; then
|
|
_SKIPPED_COUNT=$((_SKIPPED_COUNT + 1))
|
|
else
|
|
eval "export $_K=$_VAL_QUOTED"
|
|
_APPLIED_COUNT=$((_APPLIED_COUNT + 1))
|
|
fi
|
|
done < "$_SECRET_ASSIGNS"
|
|
rm -f "$_PRESET_KEYS_FILE" "$_SECRET_ASSIGNS"
|
|
echo "[entrypoint] applied ${_APPLIED_COUNT} secrets; ${_SKIPPED_COUNT} already set by docker (kept override)"
|
|
|
|
# Bootstrap credentials are no longer needed in the Node process env.
|
|
unset OPENBAO_ROLE_ID OPENBAO_SECRET_ID BAO_TOKEN
|
|
|
|
SECRET_COUNT="$(printf '%s' "${SECRET_JSON}" | jq -r 'keys | length')"
|
|
echo "[entrypoint] ✅ loaded ${SECRET_COUNT} secrets from OpenBao"
|
|
else
|
|
echo "[entrypoint] OPENBAO_ADDR not set — using existing environment (legacy .env path)"
|
|
fi
|
|
|
|
# ── Schema migrations ────────────────────────────────────────────────
|
|
# The code and the schema it needs ship inside the same image, so they have to
|
|
# arrive together. Applying them by hand meant a deploy could put new code in
|
|
# front of an old schema and only find out at the first request.
|
|
#
|
|
# node-pg-migrate takes a Postgres advisory lock, so two containers starting at
|
|
# once cannot both apply. The one that loses the race is not an error — it
|
|
# waits for the winner and looks again — so a rolling restart does not fail.
|
|
#
|
|
# Set RUN_MIGRATIONS=false to start without touching the schema (a read-only
|
|
# replica, or recovering from a bad migration by hand).
|
|
if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
|
|
if [ -z "${DATABASE_URL:-}" ]; then
|
|
echo "[entrypoint] FATAL: RUN_MIGRATIONS is on but DATABASE_URL is not set." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# A database with nothing in it is the one case where migrating here is
|
|
# wrong. The schema has two layers: src/db/database.js creates the baseline
|
|
# tables on first connect, and the migrations are written to layer on top —
|
|
# the earliest of them alters saved_encounters, which only the baseline
|
|
# creates. Run first against an empty database and they fail on a table that
|
|
# does not exist yet.
|
|
#
|
|
# So: empty database, stand aside and let the app do it, which it already
|
|
# does in the right order (initDatabase, then runMigrations). Existing
|
|
# database, migrate here exactly as before, so a deploy still cannot put new
|
|
# code in front of an old schema. Unreachable, carry on into the loop below,
|
|
# which is what already handles a Postgres still opening its socket.
|
|
#
|
|
# This is why restoring into a brand-new database could not boot.
|
|
if [ "$(node scripts/schema-state.js 2>/dev/null)" = "empty" ]; then
|
|
echo "[entrypoint] database is empty — the app will create the baseline and migrate on top of it"
|
|
RUN_MIGRATIONS=false
|
|
fi
|
|
fi
|
|
|
|
if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
|
|
|
|
_MIGRATE_ATTEMPT=1
|
|
_MIGRATE_MAX=${MIGRATION_ATTEMPTS:-10}
|
|
while : ; do
|
|
echo "[entrypoint] applying migrations (attempt ${_MIGRATE_ATTEMPT}/${_MIGRATE_MAX})..."
|
|
_MIGRATE_OUT="$(node_modules/.bin/node-pg-migrate up 2>&1)" && {
|
|
printf '%s\n' "${_MIGRATE_OUT}"
|
|
echo "[entrypoint] ✅ schema is up to date"
|
|
break
|
|
}
|
|
printf '%s\n' "${_MIGRATE_OUT}" >&2
|
|
|
|
# Losing the advisory lock, or racing a database that is still opening its
|
|
# listening socket, are both worth another look. Anything else is a real
|
|
# migration failure and must stop the deploy rather than serve on a schema
|
|
# that does not match the code.
|
|
if printf '%s' "${_MIGRATE_OUT}" | grep -qiE "advisory lock|ECONNREFUSED|starting up|Connection terminated"; then
|
|
if [ "${_MIGRATE_ATTEMPT}" -ge "${_MIGRATE_MAX}" ]; then
|
|
echo "[entrypoint] FATAL: could not apply migrations after ${_MIGRATE_MAX} attempts." >&2
|
|
exit 1
|
|
fi
|
|
_MIGRATE_ATTEMPT=$((_MIGRATE_ATTEMPT + 1))
|
|
sleep 3
|
|
continue
|
|
fi
|
|
|
|
echo "[entrypoint] FATAL: migration failed. Refusing to start on a schema that does not match this build." >&2
|
|
exit 1
|
|
done
|
|
else
|
|
echo "[entrypoint] RUN_MIGRATIONS=false — starting without checking the schema"
|
|
fi
|
|
|
|
exec "$@"
|