Files
jorgecuadros-platform/docker/api-entrypoint.sh
T
rmancinasandClaude Opus 5 5a277f4885
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m3s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m32s
feat(deploy): apply migrations at api container start
`prisma migrate deploy` ran in one place only: a workflow step on the Gitea
runner, which has to reach the target host's MySQL on 3306 directly. Two
paths went around it:

  - `skip_migrate=true`, the documented answer for when the runner cannot
    reach 3306, left the schema a release behind with nothing to catch it.
    The mismatch surfaced later as a column-not-found at runtime rather than
    as a failed deploy.
  - A container brought back by `restart: unless-stopped` after a host
    reboot, or a stack re-applied by hand in Portainer, never runs the
    workflow at all.

docker/api-entrypoint.sh becomes the api image's ENTRYPOINT: migrate, then
exec node. If the migration fails the container exits non-zero and the API
never listens — serving against a schema that does not match the code is
worse than being down, because the failures are partial and silent (a write
to a missing column breaks one feature while the rest looks healthy).

This does not replace the workflow step and is not a substitute for it. That
step still runs FIRST, while the old code is serving, which is the order
expand/contract migrations are designed around. `migrate deploy` is
idempotent, so on the normal path the container's run is a no-op query.

Behaviour:

  RUN_MIGRATIONS=false     skip and start anyway; plumbed through both app
                           stack files, for a schema moved by hand
  DATABASE_URL unset       refuse to start, and say why
  P1001 (unreachable)      retry, default 20 x 3s -- a cold db container, and
                           galactus's MagicDNS lookup right after a reboot
  anything else            exit at once; retrying a broken migration only
                           delays the same error. P3005 prints the
                           `migrate resolve --applied 0000_init` hint the
                           workflow step already printed.

Only P1001 retries, so a genuinely broken migration is not buried under a
minute of noise.

Both stacks are replicas: 1 and must stay so for an unrelated reason (the
servicios email sweep has no DB lock). The old comment claiming migrations
must not run per-container because "N replicas would race" is dropped: they
would not corrupt anything, since Prisma takes a database advisory lock and
the losers find nothing pending -- they would only each pay the wait.

The prisma CLI is already in the runtime layer (the image copies
/repo/node_modules wholesale), but which of the two plausible .bin paths
carries it is an implementation detail of pnpm's hoisted linker, so the
entrypoint accepts either and the Dockerfile asserts one exists at BUILD
time. A missing CLI breaks the image build, not a production boot.

Verified by running the entrypoint against stubbed prisma binaries: clean
run, P3005, P1001-to-exhaustion, P1001-then-recovery, RUN_MIGRATIONS=false,
missing DATABASE_URL, missing CLI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:17:01 -07:00

103 lines
3.9 KiB
Bash

#!/bin/sh
# Apply pending Prisma migrations, then hand off to the API.
#
# WHY THE CONTAINER AND NOT THE DEPLOY WORKFLOW
#
# The workflow still has its own `prisma migrate deploy` step and that is not
# redundant: it runs BEFORE the new images are pulled, i.e. while the OLD code
# is still serving, which is the order expand/contract migrations are designed
# around (see docs/DEPLOY_AND_MIGRATIONS.md). Doing it here as well closes the
# gaps that step cannot:
#
# - The runner has to reach MySQL directly. When it cannot, the deploy is run
# with `skip_migrate=true` and the schema silently does not move — the app
# then boots against a schema that is one release behind, which surfaces
# later as a column-not-found at runtime rather than as a failed deploy.
# - A container restarted by `restart: unless-stopped` after a host reboot,
# or a stack re-applied by hand in Portainer, never goes through the
# workflow at all.
#
# `migrate deploy` is idempotent, so running it in both places costs one
# no-op query on the normal path.
#
# THE API DOES NOT START IF THE MIGRATION FAILS. That is deliberate: serving
# against a schema that does not match the code is worse than being down,
# because the failures it produces are partial and silent (a write to a column
# that does not exist yet fails for one feature while the rest of the app looks
# healthy). The container exits non-zero and Docker's restart policy retries.
set -e
SCHEMA=/repo/packages/database/prisma/schema.prisma
log() { echo "[entrypoint] $*"; }
if [ "${RUN_MIGRATIONS:-true}" != "true" ]; then
log "RUN_MIGRATIONS=${RUN_MIGRATIONS} — skipping migrations, starting the API"
exec "$@"
fi
if [ -z "${DATABASE_URL}" ]; then
log "DATABASE_URL is unset; cannot migrate." >&2
log "Set it, or set RUN_MIGRATIONS=false if you migrate out of band." >&2
exit 1
fi
# pnpm's hoisted linker normally puts the CLI in the root .bin, but the
# workspace package keeps its own link too. Accept either rather than pinning
# a layout detail of the installer — the Dockerfile asserts at build time that
# one of these exists, so a missing CLI breaks the image build, not a deploy.
PRISMA=""
for candidate in /repo/node_modules/.bin/prisma \
/repo/packages/database/node_modules/.bin/prisma; do
if [ -x "$candidate" ]; then
PRISMA="$candidate"
break
fi
done
if [ -z "$PRISMA" ]; then
log "prisma CLI not found in this image; cannot migrate." >&2
exit 1
fi
# Retry ONLY a connection failure (P1001). On a full bring-up the database
# container can still be starting, and on galactus the API additionally has to
# resolve the host's MagicDNS name — a lookup that is unreliable for the first
# moments after a host reboot (see the dns block in the app compose file, and
# docs/DEPLOY_AND_MIGRATIONS.md).
#
# Every other failure exits immediately. Retrying a migration that is actually
# broken just delays the same error behind a minute of noise, and P3005 in
# particular needs a human.
attempt=1
max="${MIGRATE_MAX_ATTEMPTS:-20}"
delay="${MIGRATE_RETRY_SECONDS:-3}"
while : ; do
log "prisma migrate deploy (attempt ${attempt}/${max})"
if output=$("$PRISMA" migrate deploy --schema "$SCHEMA" 2>&1); then
printf '%s\n' "$output"
log "migrations up to date"
break
fi
printf '%s\n' "$output" >&2
if ! printf '%s' "$output" | grep -q 'P1001'; then
log "migrate deploy FAILED — refusing to start the API." >&2
if printf '%s' "$output" | grep -q 'P3005'; then
log "P3005: the database has tables but no migration history. This is a" >&2
log "database that predates Prisma migrations. Baseline it ONCE with:" >&2
log " npx prisma@5 migrate resolve --applied 0000_init --schema $SCHEMA" >&2
fi
exit 1
fi
if [ "$attempt" -ge "$max" ]; then
log "database unreachable after ${max} attempts — giving up." >&2
exit 1
fi
attempt=$((attempt + 1))
sleep "$delay"
done
exec "$@"