mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
After the migrations the entrypoint ensures healthlog_app, a login role with row access to the app's schema, MAINTAIN on measurements and ownership of the pgboss schema, and starts the app with a DATABASE_URL for it in place of the owner's. The role cannot alter or drop tables, create roles, schemas or functions in the app's schema, read server files or run programs, or write the migration history. The password is an HMAC of a fixed label under API_TOKEN_HMAC_KEY, so a separate worker container derives the same one, and it is sent as a SCRAM verifier. Every boot re-applies the grants in one transaction under an advisory lock, and drops as the owner the indexes the maintenance job may no longer drop. When the owner cannot create roles, the server is older than Postgres 17, or the role cannot log in, the boot says why and the app stays on the owner as before. DB_APP_ROLE=off skips it.
129 lines
5.7 KiB
Bash
129 lines
5.7 KiB
Bash
#!/bin/sh
|
|
set -e
|
|
|
|
# ── Validate required env vars ───────────────────────────────
|
|
# We do this in the entrypoint instead of in docker-compose.yml's
|
|
# environment block (which used to use the ${VAR:?error} syntax)
|
|
# because some hosting platforms parse compose files eagerly and
|
|
# end up storing the fallback error string as the literal value.
|
|
# Validating here guarantees a clear, deploy-platform-agnostic
|
|
# failure message before we touch the database.
|
|
missing=""
|
|
for var in DATABASE_URL API_TOKEN_HMAC_KEY; do
|
|
eval "value=\$$var"
|
|
if [ -z "$value" ]; then
|
|
missing="$missing $var"
|
|
fi
|
|
done
|
|
if [ -n "$missing" ]; then
|
|
echo "HealthLog: ERROR — required env vars not set:$missing" >&2
|
|
echo "HealthLog: see .env.example for the full list and how to generate each value." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# ENCRYPTION_KEYS supersedes the legacy single key, so validate the same
|
|
# fail-closed shape the runtime crypto loader accepts before touching the DB.
|
|
if ! node -e '
|
|
const validKey = (value) => {
|
|
if (typeof value !== "string") return false;
|
|
if (/^[0-9a-fA-F]{64}$/.test(value)) return true;
|
|
if (!/^[A-Za-z0-9+/=]+$/.test(value)) return false;
|
|
try { return Buffer.from(value, "base64").length === 32; }
|
|
catch { return false; }
|
|
};
|
|
const rawKeyring = (process.env.ENCRYPTION_KEYS || "").trim();
|
|
if (rawKeyring) {
|
|
let keyring;
|
|
try { keyring = JSON.parse(rawKeyring); }
|
|
catch { throw new Error("ENCRYPTION_KEYS is invalid JSON"); }
|
|
if (!keyring || typeof keyring !== "object" || Array.isArray(keyring)) {
|
|
throw new Error("ENCRYPTION_KEYS is invalid: expected an object map");
|
|
}
|
|
const entries = Object.entries(keyring);
|
|
if (
|
|
entries.length === 0 ||
|
|
entries.some(([id, value]) =>
|
|
!/^[A-Za-z0-9_-]{1,32}$/.test(id) || !validKey(value)
|
|
)
|
|
) {
|
|
throw new Error("ENCRYPTION_KEYS is invalid: every entry must be a valid 32-byte key");
|
|
}
|
|
const activeId =
|
|
(process.env.ENCRYPTION_ACTIVE_KEY_ID || "").trim() ||
|
|
(entries.length === 1 ? entries[0][0] : "");
|
|
if (!activeId || !Object.prototype.hasOwnProperty.call(keyring, activeId)) {
|
|
throw new Error("ENCRYPTION_ACTIVE_KEY_ID must name an entry in ENCRYPTION_KEYS");
|
|
}
|
|
} else if (!validKey((process.env.ENCRYPTION_KEY || "").trim())) {
|
|
throw new Error("ENCRYPTION_KEY is missing or invalid");
|
|
}
|
|
'; then
|
|
echo "HealthLog: ERROR — encryption configuration is invalid." >&2
|
|
echo "HealthLog: set a valid ENCRYPTION_KEY or ENCRYPTION_KEYS with ENCRYPTION_ACTIVE_KEY_ID." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# ── Wait for PostgreSQL ──────────────────────────────────────
|
|
MAX_RETRIES=30
|
|
RETRY=0
|
|
|
|
echo "HealthLog: Waiting for database..."
|
|
until node -e "
|
|
const { createConnection } = require('net');
|
|
const url = new URL(process.env.DATABASE_URL);
|
|
const sock = createConnection(Number(url.port) || 5432, url.hostname);
|
|
sock.on('connect', () => { sock.destroy(); process.exit(0); });
|
|
sock.on('error', () => process.exit(1));
|
|
" 2>/dev/null; do
|
|
RETRY=$((RETRY + 1))
|
|
if [ "$RETRY" -ge "$MAX_RETRIES" ]; then
|
|
echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting." >&2
|
|
exit 1
|
|
fi
|
|
sleep 1
|
|
done
|
|
echo "HealthLog: Database is reachable."
|
|
|
|
# ── Run Prisma migrations ────────────────────────────────────
|
|
# Say how many migrations this image is about to apply, with a reminder to
|
|
# back up first. Reporting only: it never stops the boot.
|
|
node scripts/pending-migrations.mjs || true
|
|
# When migrations are pending, dump the database into DB_BACKUP_DIR first, so
|
|
# the state before them can be restored (docs/ops/disaster-recovery.md). No
|
|
# directory that survives the container (none writable, or the image's own
|
|
# without the backups volume): a warning, and the boot goes on. A kept
|
|
# directory and a failed dump: the boot stops here, before anything is
|
|
# migrated.
|
|
if ! node scripts/db-dump.mjs pre-migration; then
|
|
echo "HealthLog: ERROR — not migrating without the dump. Free disk space or fix the error above and start again." >&2
|
|
echo "HealthLog: to migrate without a dump this once, set DB_BACKUP_BEFORE_MIGRATE=false." >&2
|
|
exit 1
|
|
fi
|
|
# `migrate-deploy.mjs` runs `prisma migrate deploy` and retries only a
|
|
# migration that timed out on a lock and is marked safe to re-run; any other
|
|
# failure stops the boot with Prisma's exit code (docs/ops/deploy.md).
|
|
echo "HealthLog: Running database migrations..."
|
|
NODE_PATH=/opt/prisma-cli/node_modules \
|
|
node scripts/migrate-deploy.mjs
|
|
echo "HealthLog: Migrations complete."
|
|
|
|
# ── The app's database role ──────────────────────────────────
|
|
# Everything above ran as the owner DATABASE_URL names. The app itself
|
|
# connects as `healthlog_app`, which reads and writes rows and runs the queue
|
|
# but cannot change the schema (scripts/db-app-role.mjs says what it may and
|
|
# may not do, and docs/self-hosting/database-role.md how to opt out). The
|
|
# script hands the role's URL over on descriptor 3 only, never on stdout or
|
|
# stderr; nothing handed over means it said why and the app stays on the
|
|
# owner, as before. The owner's URL is replaced, not kept beside it, so it
|
|
# does not reach the app's environment.
|
|
app_database_url="$(node scripts/db-app-role.mjs 3>&1 1>&2)" || app_database_url=""
|
|
if [ -n "$app_database_url" ]; then
|
|
DATABASE_URL="$app_database_url"
|
|
export DATABASE_URL
|
|
fi
|
|
unset app_database_url
|
|
|
|
# ── Start application ────────────────────────────────────────
|
|
echo "HealthLog: Starting application..."
|
|
exec "$@"
|