#!/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 "$@"