diff --git a/implementations/acr-control-plane/Dockerfile b/implementations/acr-control-plane/Dockerfile index cc11a41..3a518dc 100644 --- a/implementations/acr-control-plane/Dockerfile +++ b/implementations/acr-control-plane/Dockerfile @@ -19,6 +19,11 @@ RUN pip install --no-cache-dir --no-index --find-links=/wheels acr-control-plane COPY src/ ./src/ COPY policies/ ./policies/ COPY alembic.ini ./ +# Migration entrypoint shared by the K8s init container, the docker-compose +# `acr-migrate` one-shot service, and CI. Installed on PATH so it can be +# invoked simply as `run-migrations` from any container using this image. +COPY scripts/run-migrations.sh /usr/local/bin/run-migrations +RUN chmod +x /usr/local/bin/run-migrations ENV PYTHONPATH=/app/src ENV PYTHONUNBUFFERED=1 diff --git a/implementations/acr-control-plane/deploy/k8s/base/gateway-deployment.yaml b/implementations/acr-control-plane/deploy/k8s/base/gateway-deployment.yaml index 14cc1b3..7540c4f 100644 --- a/implementations/acr-control-plane/deploy/k8s/base/gateway-deployment.yaml +++ b/implementations/acr-control-plane/deploy/k8s/base/gateway-deployment.yaml @@ -25,6 +25,37 @@ spec: matchLabels: app: acr-gateway topologyKey: kubernetes.io/hostname + # Apply database migrations before the gateway container starts. + # Uses the same image so we never have to maintain a separate + # migration build. Alembic is idempotent — replicas racing each + # other are safe (the loser sees the schema already at head). + initContainers: + - name: alembic-upgrade + image: acr-gateway:1.0.0 + imagePullPolicy: IfNotPresent + command: ["run-migrations"] + envFrom: + - configMapRef: + name: acr-gateway-config + - secretRef: + name: acr-gateway-secret + securityContext: + runAsNonRoot: true + runAsUser: 1000 + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + volumeMounts: + - name: tmp + mountPath: /tmp containers: - name: gateway image: acr-gateway:1.0.0 diff --git a/implementations/acr-control-plane/docker-compose.yml b/implementations/acr-control-plane/docker-compose.yml index 18b0ad9..46c438b 100644 --- a/implementations/acr-control-plane/docker-compose.yml +++ b/implementations/acr-control-plane/docker-compose.yml @@ -106,6 +106,30 @@ services: cpus: "0.1" memory: 64M + # ── ACR Migrations (one-shot) ────────────────────────── + # Runs `alembic upgrade head` against the database, then exits cleanly. + # The gateway service waits for this to finish (service_completed_successfully) + # before starting, so the schema is always up-to-date and migrations cannot + # be forgotten when running the stack locally or in CI. + acr-migrate: + build: + context: . + dockerfile: Dockerfile + command: ["run-migrations"] + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-acr}:${POSTGRES_PASSWORD:-acr_dev_password}@postgres:5432/${POSTGRES_DB:-acr_control_plane} + ACR_ENV: ${ACR_ENV:-development} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + depends_on: + postgres: + condition: service_healthy + restart: "no" + deploy: + resources: + limits: + cpus: "0.5" + memory: 256M + # ── ACR Gateway (main service) ───────────────────────── acr-gateway: build: @@ -144,6 +168,8 @@ services: condition: service_healthy acr-killswitch: condition: service_healthy + acr-migrate: + condition: service_completed_successfully healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/acr/health"] interval: 10s diff --git a/implementations/acr-control-plane/scripts/run-migrations.sh b/implementations/acr-control-plane/scripts/run-migrations.sh new file mode 100755 index 0000000..22d3272 --- /dev/null +++ b/implementations/acr-control-plane/scripts/run-migrations.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env sh +# Apply all pending Alembic migrations and exit. +# +# Used by: +# * the K8s gateway initContainer (deploy/k8s/base/gateway-deployment.yaml) +# * the docker-compose `acr-migrate` one-shot service +# * CI / local development +# +# Idempotent: re-running on an up-to-date schema is a no-op. Multiple +# replicas racing each other are safe — Alembic wraps each migration in a +# transaction, so the loser of the race observes the schema as already at +# head and exits 0. + +set -eu + +# Resolve the application root regardless of where the script is invoked +# from. The runtime image stores the app at /app, but local devs may run +# this from anywhere inside the repo. +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +APP_ROOT="$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd)" + +# /app inside the runtime image, repo path locally — both contain alembic.ini. +if [ -f "/app/alembic.ini" ]; then + APP_ROOT="/app" +fi + +cd "${APP_ROOT}" + +if [ -z "${DATABASE_URL:-}" ]; then + echo "[run-migrations] FATAL: DATABASE_URL is not set" >&2 + exit 2 +fi + +echo "[run-migrations] applying alembic upgrade head against ${DATABASE_URL%%@*}@" +alembic -c "${APP_ROOT}/alembic.ini" upgrade head +echo "[run-migrations] migrations complete"