From 3c2d719c89430ec51abdb0f7996e6d0e6f9e711a Mon Sep 17 00:00:00 2001 From: Devinder Singh Date: Sat, 31 Oct 2026 16:22:46 +0100 Subject: [PATCH 1/5] feat: add schema v1 (migration 001) and run dbmate inside the API image --- .github/workflows/pipeline.yml | 54 ++++++++++- api/Dockerfile | 3 + api/bun.lock | 17 ++++ api/db/migrations/20261031140445_init.sql | 109 ++++++++++++++++++++++ api/package.json | 4 +- 5 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 api/db/migrations/20261031140445_init.sql diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 7eb5086..8bcc5d1 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -60,16 +60,64 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build (and on main, push) the image + # Every PR and every merge: build the image into the runner's own Docker, so it can be + # tested before anything is pushed. + - name: Build the image uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: api platforms: linux/amd64 # the VPS is amd64; GitHub's runners build that natively build-args: GIT_SHA=${{ github.sha }} - tags: ghcr.io/${{ github.repository_owner }}/relay-api:${{ github.sha }} - push: ${{ github.event_name == 'push' }} + tags: relay-api:smoke + load: true cache-from: type=gha cache-to: type=gha,mode=max + # Run the migrations with the image just built, the way the migrate Job runs them + # (uid 1000, read-only root, no capabilities, its 128 MiB limit), against a throwaway + # Postgres made by our own first-start script. A dbmate that can't start in the real + # image is found here, on the PR, instead of halfway through a deploy. + - name: Smoke-test the migrations inside the image + shell: bash # GitHub then adds -e and pipefail + run: | + docker network create smoke-net + docker run -d --name smoke-pg --network smoke-net \ + -e POSTGRES_PASSWORD=test -e RELAY_DB_PASSWORD=test \ + -v "$PWD/k8s/data/initdb:/docker-entrypoint-initdb.d:ro" \ + postgres:18.6@sha256:979bccfde35445db618c65107d27d08c6c21339d8dc3ad4c830c6633947be0e4 + for _ in $(seq 1 60); do + docker exec smoke-pg pg_isready -q -h 127.0.0.1 -U postgres && break + sleep 1 + done + migrate() { + docker run --rm --network smoke-net --read-only --user 1000:1000 --cap-drop ALL --memory 128m \ + -e DATABASE_URL='postgres://relay:test@smoke-pg:5432/relay?sslmode=disable' \ + relay-api:smoke bun run --bun db:migrate + } + echo "--- first run: applies every migration" + migrate + echo "--- second run: must find nothing to do" + migrate + tables=$(docker exec smoke-pg psql -X -U postgres -d relay -Atc \ + "select count(*) from pg_tables where schemaname = 'public' and tablename in + ('owners', 'api_keys', 'endpoints', 'events', 'deliveries', 'attempts')") + if [ "$tables" != 6 ]; then + echo "::error::expected the 6 tables of schema v1, found $tables" + exit 1 + fi + docker exec smoke-pg psql -X -U postgres -d relay -Atc \ + "select version from schema_migrations order by version" | sed 's/^/applied: /' + # Only after a merge to main: push the same image. Its layers come from the build above + # (the builder's cache), so this takes seconds and pushes what was just tested. + - name: Push the image (main only) + if: github.event_name == 'push' + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: api + platforms: linux/amd64 + build-args: GIT_SHA=${{ github.sha }} + tags: ghcr.io/${{ github.repository_owner }}/relay-api:${{ github.sha }} + push: true + cache-from: type=gha deploy-script: name: deploy-script-test diff --git a/api/Dockerfile b/api/Dockerfile index b5aa2fa..ea7704a 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -12,6 +12,9 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY package.json ./ COPY src ./src +# The migrations travel in the same image as the code that needs them, so a commit's image +# always carries exactly that commit's schema. The migrate Job runs "bun run --bun db:migrate". +COPY db ./db # The pipeline passes the commit; /version reports it, so we can see what's running. ARG GIT_SHA=dev diff --git a/api/bun.lock b/api/bun.lock index 92cac1f..37459eb 100644 --- a/api/bun.lock +++ b/api/bun.lock @@ -4,6 +4,7 @@ "workspaces": { "": { "dependencies": { + "dbmate": "2.37.0", "fastify": "5.12.7", "zod": "4.6.7", }, @@ -14,6 +15,20 @@ }, }, "packages": { + "@dbmate/darwin-arm64": ["@dbmate/darwin-arm64@2.37.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-KFejJ8U7lFCjqMhA0QXUwHVEnk98W7OY2hKlUkVmphkEzzMQL2L7/czBFCwfBZoR2OjK9+zJL6wzLD48ayLnzQ=="], + + "@dbmate/darwin-x64": ["@dbmate/darwin-x64@2.37.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-XZpK8mYHVqpj7Fc9xPg/ubs9abg0pIla+fLvDCtGaT3RmlaS3VvkyakGUv53wuoVwc3MpD8wBZCTR6TT9DMmgw=="], + + "@dbmate/linux-arm": ["@dbmate/linux-arm@2.37.0", "", { "os": "linux", "cpu": "arm" }, "sha512-r0j0/EK+5PP25P6Ss+OfUQU4SM9or9xSQgVZf3hfJMKFpus/sAt5lvP0FCHB0p6iwXmY9fvSE0v9A2HdIhVjOw=="], + + "@dbmate/linux-arm64": ["@dbmate/linux-arm64@2.37.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-KOJNhDoHOi/aauy9xjAS6sDBqGg62TiPcPUhh2GAgu4BUKDCOuU8X9XFKk2P9Wyj1i5pZWFUuC8AiOL3/50k/A=="], + + "@dbmate/linux-ia32": ["@dbmate/linux-ia32@2.37.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-HmDUVHvs2yWfzqMN5ljSie0MWxGcNzmS3d2LeR3BwT/SdQNiayoYCtIY1ItzL4FP0hCoyLIcA1HAe+3SSz/p7w=="], + + "@dbmate/linux-x64": ["@dbmate/linux-x64@2.37.0", "", { "os": "linux", "cpu": "x64" }, "sha512-tq9Y6aO+IeXvJ19/3Wt3BXMaqC+2WseSc5hRfIkErjdQV34ekCggAvfT5xO9oKWvXqFBUuhOLI/EGWA89ldVEA=="], + + "@dbmate/win32-x64": ["@dbmate/win32-x64@2.37.0", "", { "os": "win32", "cpu": "x64" }, "sha512-j66FzJqHRkqSA4aa8/Vyik34nXRpyRYKts80+8qchagreXfsrzfsD93IIAqfguAbRfBmDZab0ky0Ud9cf78R9Q=="], + "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.7", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^4.0.0" } }, "sha512-VP0yIfwlc1ohj733keYDqyHm1VWeP9VQxYomunhr90jkC4aNNibbkrRiaNgPgqj1ORxUenILR4qzA/ppYBNAtQ=="], "@fastify/deepmerge": ["@fastify/deepmerge@3.2.4", "", {}, "sha512-kc8GQq5gX9RRhw0rov3DMrN8wS8yRlxJRdDzsYwwdBV/wazuVgc/1K7CKL90tlvu3RlHan6Y0R0aE3nJtBz2eQ=="], @@ -48,6 +63,8 @@ "cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="], + "dbmate": ["dbmate@2.37.0", "", { "optionalDependencies": { "@dbmate/darwin-arm64": "2.37.0", "@dbmate/darwin-x64": "2.37.0", "@dbmate/linux-arm": "2.37.0", "@dbmate/linux-arm64": "2.37.0", "@dbmate/linux-ia32": "2.37.0", "@dbmate/linux-x64": "2.37.0", "@dbmate/win32-x64": "2.37.0" }, "bin": { "dbmate": "dist/cli.js" } }, "sha512-7by6flas+9vutBKUCmtHR9NBHCuZtLRfBA/AsqRlZHuApJb27ErqTFjngfIvjlfCDIyzc96YWBebWso6JcEUxA=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], diff --git a/api/db/migrations/20261031140445_init.sql b/api/db/migrations/20261031140445_init.sql new file mode 100644 index 0000000..5530a88 --- /dev/null +++ b/api/db/migrations/20261031140445_init.sql @@ -0,0 +1,109 @@ +-- migrate:up +-- Migration 001: schema v1 (step 2.4). Run by dbmate in the migrate Job before every rollout. +-- Rules for every migration (docs/decisions/0005-migrations-expand-contract.md): +-- only ADD things old code can live with; never edit a merged migration; fix forward. +-- +-- Every id is a UUIDv7 (built into Postgres 18): unique, ordered by time (so it also works as +-- the page cursor), and impossible to guess. Statuses are text + CHECK, not an enum: adding an +-- allowed value later is a small change, removing one from an enum is not. + +-- Give up after 5 s of WAITING for a lock instead of queueing every API query behind us. +-- (An empty database has no locks to wait for; every migration starts the same way.) +SET LOCAL lock_timeout = '5s'; + +-- A customer of Relay. +CREATE TABLE owners ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + name text NOT NULL CHECK (length(name) BETWEEN 1 AND 100), + created_at timestamptz NOT NULL DEFAULT now() +); + +-- An owner's API keys. Only a SHA-256 hash of the key is stored: a stolen database (or backup) +-- doesn't contain usable keys. Lookup is by hash, so no secret is ever compared character by +-- character. No "last used" column: updating it on every request would make this a hot row. +CREATE TABLE api_keys ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + owner_id uuid NOT NULL REFERENCES owners ON DELETE CASCADE, + prefix text NOT NULL, -- e.g. 'relay_live_8f2k', safe to show in the dashboard + key_hash bytea NOT NULL UNIQUE, -- sha256(the full key) + created_at timestamptz NOT NULL DEFAULT now(), + revoked_at timestamptz +); + +-- Where an owner wants events delivered. Deleting one is a soft delete (deleted_at), so old +-- deliveries keep showing its name and URL (docs/api.md, DELETE /v1/endpoints/:id). +-- The signing secret is stored in plain text on purpose: signing with HMAC needs the secret +-- itself, so it can't be hashed like an API key. Backups are protected instead (Phase 6). +CREATE TABLE endpoints ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + owner_id uuid NOT NULL REFERENCES owners ON DELETE CASCADE, + name text NOT NULL CHECK (length(name) BETWEEN 1 AND 100), + url text NOT NULL CHECK (length(url) <= 2048), + signing_secret text NOT NULL, -- 'whsec_' + base64(32 random bytes) + event_types text[] NOT NULL DEFAULT '{*}' CHECK (cardinality(event_types) BETWEEN 1 AND 20), + deleted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +-- An owner's endpoints that aren't deleted (the fan-out and the endpoint list read these). +CREATE INDEX endpoints_owner_live ON endpoints (owner_id) WHERE deleted_at IS NULL; + +-- An event a producer sent (POST /v1/events). The same Idempotency-Key from the same owner +-- can't be stored twice; events without a key (NULL) never collide, because NULLs are never +-- equal in a UNIQUE constraint. +CREATE TABLE events ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + owner_id uuid NOT NULL REFERENCES owners ON DELETE CASCADE, + type text NOT NULL CHECK (type ~ '^[a-z0-9][a-z0-9_.-]{0,99}$'), + payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'), + idempotency_key text CHECK (length(idempotency_key) <= 255), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT events_owner_idempotency_key UNIQUE (owner_id, idempotency_key) +); + +-- One event to one endpoint: the queue. Workers claim due rows with FOR UPDATE SKIP LOCKED and +-- a lease (Phase 4). While a row is leased, next_attempt_at is the lease's expiry and lease_id +-- is the fencing token: a result is only saved by the worker that still holds the lease. +CREATE TABLE deliveries ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + owner_id uuid NOT NULL REFERENCES owners ON DELETE CASCADE, + event_id uuid NOT NULL REFERENCES events ON DELETE CASCADE, + endpoint_id uuid NOT NULL REFERENCES endpoints ON DELETE CASCADE, + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'delivered', 'dead')), + attempts int NOT NULL DEFAULT 0, -- tries in the current cycle (replay resets it) + next_attempt_at timestamptz NOT NULL DEFAULT now(), + lease_id uuid, + last_status_code int, + last_error text, + delivered_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT deliveries_event_endpoint UNIQUE (event_id, endpoint_id) +); +-- The worker's claim query: "pending rows that are due, oldest first". Partial: only pending +-- rows are in it, so it stays small while delivered and dead rows pile up. +CREATE INDEX deliveries_due ON deliveries (next_attempt_at) WHERE status = 'pending'; +-- The delivery list (newest first, keyset paging on id), with and without the filters. +CREATE INDEX deliveries_owner_id ON deliveries (owner_id, id DESC); +CREATE INDEX deliveries_owner_status_id ON deliveries (owner_id, status, id DESC); +CREATE INDEX deliveries_endpoint_id ON deliveries (endpoint_id, id DESC); + +-- Every try at a delivery, kept forever (the dashboard's attempt timeline). The attempt number +-- is worked out when read (row_number()), so no counter has to be kept in step. +CREATE TABLE attempts ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + delivery_id uuid NOT NULL REFERENCES deliveries ON DELETE CASCADE, + started_at timestamptz NOT NULL, + duration_ms int NOT NULL, + status_code int, + error text, -- timeout | connect_failed | blocked_address | redirect | tls + response_excerpt text -- the first 1 KB of the receiver's answer +); +CREATE INDEX attempts_delivery_id ON attempts (delivery_id, id); + +-- migrate:down +-- Forward-only, on purpose (ADR 0005). "dbmate rollback" would run this section and then delete +-- this migration's row in schema_migrations; an empty section would leave every table in place +-- while marking it "not applied". Raising an error rolls the whole rollback back instead. +-- To start over on a laptop, throw the throwaway database away. +DO $$ BEGIN RAISE EXCEPTION 'forward-only: migrations are never rolled back; to reset, recreate the throwaway database'; END $$; diff --git a/api/package.json b/api/package.json index ca6c435..ccad527 100644 --- a/api/package.json +++ b/api/package.json @@ -6,9 +6,11 @@ "dev": "bun --watch src/server.ts", "start": "bun src/server.ts", "test": "bun test", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "db:migrate": "dbmate --wait --wait-timeout 60s --no-dump-schema --migration-lock --migration-lock-timeout 30s migrate" }, "dependencies": { + "dbmate": "2.37.0", "fastify": "5.12.7", "zod": "4.6.7" }, From e2e1c30321fec064e47a86c11da00fdf0a1d02e0 Mon Sep 17 00:00:00 2001 From: Devinder Singh Date: Sat, 31 Oct 2026 21:46:39 +0100 Subject: [PATCH 2/5] infra: run the migrate Job before every rollout, and wait for its verdict --- .github/workflows/pipeline.yml | 10 ++-- infra/server/relay-deploy.sh | 47 +++++++++++++++ infra/server/test-relay-deploy.sh | 98 +++++++++++++++++++++++++++++-- k8s/README.md | 1 + k8s/migrate/job.yaml | 63 ++++++++++++++++++++ k8s/platform/deployer.yaml | 16 ++++- 6 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 k8s/migrate/job.yaml diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 8bcc5d1..5c627c4 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -142,10 +142,12 @@ jobs: name: production url: https://api.myrelay.tech permissions: {} # needs no GitHub access at all: it only talks to our server - # Budget for two Deployments: 15 min lock + 6 min rollout + 9 min public check = 30 min. - # Failure path: 15 min lock + 6 min rollout + 6 min rollback = 27 min; no public check. - # Allow 5 extra minutes above the longer path for SSH, git, kubectl and job overhead. - timeout-minutes: 35 + # Budget for two Deployments, each deploy starting with the migrate Job (about 3 min at most): + # success: 15 min lock + 3 migrate + 6 rollout + 9 public check = 33 min + # failure: 15 min lock + (3 + 6) deploy + (3 + 6) rollback, no check = 33 min + # Plus 5 minutes for SSH, git, kubectl and job overhead. When the worker (a third + # Deployment) arrives in Phase 4, its failure path is 39 + 5 minutes: raise this to 45. + timeout-minutes: 40 steps: - name: Deploy the commit over SSH # Values arrive as environment variables, never pasted into the command itself. diff --git a/infra/server/relay-deploy.sh b/infra/server/relay-deploy.sh index c92c3f9..b60a8b9 100644 --- a/infra/server/relay-deploy.sh +++ b/infra/server/relay-deploy.sh @@ -50,6 +50,46 @@ show_state() { kubectl get events --sort-by=.lastTimestamp 2>&1 | tail -n 20 || true } +# Runs one commit's database migrations in a one-off Job (k8s/migrate/job.yaml, step 2.4), +# then waits for the Job's verdict. Returns 0 when the Job completed. Returns 1 when it failed, +# didn't finish in time, or its state couldn't be read: "couldn't tell" never counts as success. +# The Job gets a fresh name every time (generateName), and Kubernetes deletes it 1 hour after +# it finishes, because this account can't delete anything. +run_migrations() { + local commit="$1" job state + job=$(sed "s/__GIT_SHA__/$commit/g" "$REPO/k8s/migrate/job.yaml" \ + | kubectl create -f - -o name --request-timeout=10s) || return 1 + echo "migrating with $job" + + # Ask for the verdict every 2 s. We count tries instead of measuring time: 90 tries x 2 s + # waits at least 180 s (longer than the Job's own 150 s deadline + 10 s grace, so the Job + # always gives its verdict first), and the test harness's fake sleep then runs in milliseconds. + for _ in $(seq 1 90); do + # Only the conditions that are true, for example "SuccessCriteriaMet Complete" or + # "FailureTarget Failed". If we can't even read them, that's a failure, never a success. + state=$(kubectl get "$job" --request-timeout=10s \ + -o jsonpath='{.status.conditions[?(@.status=="True")].type}') || return 1 + # The spaces around the word make "Complete" match only as a whole word. + case " $state " in + *" Complete "*) + kubectl logs "$job" --request-timeout=10s --tail=50 || true + return 0 + ;; + *" Failed "*) + # "|| true": the pod may never have started (an image that can't be pulled), so there + # may be no log. Printing it must never change the result. + kubectl logs "$job" --request-timeout=10s --tail=50 || true + show_state + return 1 + ;; + esac + sleep 2 + done + + echo "relay-deploy: migrate Job $job didn't finish in time (it may still be running)" >&2 + return 1 +} + # Deploys one commit: its manifests in k8s/app/ with its images, then waits until every # Deployment it touched is ready. Returns 0 on success and 1 on any failure. # Every step ends with "|| return 1": inside a function that is called from "if", set -e @@ -65,6 +105,13 @@ deploy_commit() { return 1 fi + # Migrations first (from Phase 2): the database must have this commit's tables before any of + # its pods start. A failure stops here, before any pod changes, and the caller rolls back. + # Commits from before Phase 2 have no k8s/migrate/ and skip this, so rolling back to one works. + if [ -f "$REPO/k8s/migrate/job.yaml" ]; then + run_migrations "$commit" || return 1 + fi + # Apply every manifest, with the placeholder __GIT_SHA__ replaced by the commit. # The "---" line between files keeps them separate YAML documents. # -o name prints what was applied, for example "deployment.apps/relay-api". diff --git a/infra/server/test-relay-deploy.sh b/infra/server/test-relay-deploy.sh index e71687c..fdca7ba 100644 --- a/infra/server/test-relay-deploy.sh +++ b/infra/server/test-relay-deploy.sh @@ -24,24 +24,64 @@ case "\$*" in esac exit 0 EOF - # kubectl: apply notes that it ran and prints one Deployment (or fails if apply-fails exists); - # rollout status fails when the checked-out commit is listed in "broken"; - # get (pods, events) prints a line, as the real one would. + # kubectl: every call's first word goes to "calls", so tests can check the order. + # apply notes that it ran, and for which commit (applied-commits), and prints one Deployment + # (or fails if apply-fails exists); rollout status fails when the checked-out commit is + # listed in "broken". + # create (the migrate Job) reads the commit from the Job's label and answers + # job.batch/migrate-, or is refused when create-fails exists. + # get answers the Job's true conditions: "SuccessCriteriaMet Complete" unless + # job-state-<5 characters> says otherwise (an empty file = no verdict yet), or fails when + # get-job-fails exists. logs prints a line. get (pods, events) prints a line, as the real one. cat > "$T/bin/kubectl" <> "$T/calls" +job=""; for a in "\$@"; do case "\$a" in *migrate-*) job="\${a##*/}" ;; esac; done case "\$*" in - apply*) cat > /dev/null; touch "$T/applied"; [ -e "$T/apply-fails" ] && exit 1; echo deployment.apps/relay-api ;; + apply*) cat > /dev/null; touch "$T/applied"; cat "$T/checked-out" >> "$T/applied-commits" + [ -e "$T/apply-fails" ] && exit 1; echo deployment.apps/relay-api ;; "rollout status"*) grep -qx "\$(cat "$T/checked-out")" "$T/broken" && exit 1; exit 0 ;; - get*) echo "fake kubectl \$*" ;; + create*) commit=\$(sed -n 's/.*relay.myrelay.tech\/commit: "\([0-9a-f]*\)".*/\1/p') + [ -e "$T/create-fails" ] && { echo 'Error from server (Forbidden): jobs.batch is forbidden' >&2; exit 1; } + echo "job.batch/migrate-\${commit:0:5}" ;; + get*) if [ -n "\$job" ]; then + [ -e "$T/get-job-fails" ] && { echo "Unable to connect to the server: i/o timeout" >&2; exit 1; } + if [ -e "$T/job-state-\${job#migrate-}" ]; then cat "$T/job-state-\${job#migrate-}" + else echo "SuccessCriteriaMet Complete"; fi + else echo "fake kubectl \$*"; fi ;; + logs*) echo "fake migrate log of \$job" ;; esac EOF # flock: gets the lock, unless lock-busy exists (another deploy holds it the whole time). printf '#!/usr/bin/env bash\n[ -e "%s/lock-busy" ] && exit 1\nexit 0\n' "$T" > "$T/bin/flock" + # sleep: returns at once, so a "wait for the Job" loop runs its tries in milliseconds. + printf '#!/usr/bin/env bash\nexit 0\n' > "$T/bin/sleep" chmod +x "$T/bin/"* : > "$T/broken" } -run() { SSH_ORIGINAL_COMMAND="$1" PATH="$T/bin:$PATH" bash "$T/relay-deploy" > "$T/out" 2>&1; echo $?; } +# The same fake world, plus a migrate Job in the commit (k8s/migrate/job.yaml, from Phase 2). +setup_migrate() { + setup + mkdir -p "$T/home/relay/k8s/migrate" + printf 'kind: Job\nmetadata:\n labels:\n relay.myrelay.tech/commit: "__GIT_SHA__"\n' \ + > "$T/home/relay/k8s/migrate/job.yaml" +} + +# Runs the script with the fakes first in PATH, and prints its exit code. A watchdog stops it +# after 30 s, so a script that hangs (for example a wait loop that never ends) fails the test +# instead of hanging CI. The watchdog uses the real sleep: only the script's PATH has the fake. +run() { + SSH_ORIGINAL_COMMAND="$1" PATH="$T/bin:$PATH" bash "$T/relay-deploy" > "$T/out" 2>&1 & + local pid=$! tenths=0 + while kill -0 "$pid" 2>/dev/null; do + if [ "$tenths" -ge 300 ]; then + kill "$pid"; echo "(the watchdog stopped the script after 30 s: it hung)" >> "$T/out"; break + fi + sleep 0.1; tenths=$((tenths + 1)) + done + wait "$pid"; echo $? +} check() { # check "" "" "" if [ "$2" = "$3" ]; then passed=$((passed + 1)); echo " ok $1" @@ -105,5 +145,51 @@ check "exits 1" "$code" 1 check "says to re-run" "$(grep -q 're-run this job' "$T/out" && echo yes || echo no)" yes check "changes nothing" "$(exists "$T/checked-out")" no +# From Phase 2 a commit can carry a migrate Job, which must finish before anything is applied. +order() { grep -xE 'create|apply' "$T/calls" 2>/dev/null | tr '\n' ' '; } +applied_bad() { grep -qx $BAD "$T/applied-commits" 2>/dev/null && echo yes || echo no; } + +echo "11. The migrate Job completes" +setup_migrate; code=$(run $GOOD) +check "exits 0" "$code" 0 +check "migrates before it applies" "$(order)" "create apply " +check "says which Job" "$(grep -q 'migrating with job.batch/migrate-aaaaa' "$T/out" && echo yes || echo no)" yes + +echo "12. The migrate Job fails" +setup_migrate; echo $GOOD > "$T/home/.relay-last-good"; printf 'FailureTarget Failed' > "$T/job-state-bbbbb" +code=$(run $BAD) +check "exits 1" "$code" 1 +check "never applies the failing commit" "$(applied_bad)" no +check "deploys the last good commit again" "$(cat "$T/applied-commits" 2>/dev/null)" $GOOD +check "last good stays the same" "$(cat "$T/home/.relay-last-good")" $GOOD + +echo "13. A commit without k8s/migrate/ (from before Phase 2)" +setup; code=$(run $GOOD) +check "exits 0" "$code" 0 +check "creates no Job" "$(grep -cx create "$T/calls")" 0 +check "still applies" "$(exists "$T/applied")" yes + +echo "14. Creating the Job is refused (for example the Role lacks jobs)" +setup_migrate; echo $GOOD > "$T/home/.relay-last-good"; touch "$T/create-fails"; code=$(run $BAD) +check "exits 1" "$code" 1 +check "never applies the failing commit" "$(applied_bad)" no + +echo "15. The Job never reaches a verdict (it stays Pending)" +setup_migrate; : > "$T/job-state-bbbbb"; code=$(run $BAD) +check "exits 1" "$code" 1 +check "never applies the failing commit" "$(applied_bad)" no +check "says the Job may still be running" "$(grep -q 'may still be running' "$T/out" && echo yes || echo no)" yes + +echo "16. A failed Job shows why in the log" +setup_migrate; printf 'FailureTarget Failed' > "$T/job-state-bbbbb"; code=$(run $BAD) +check "prints the Job's log" "$(grep -q 'fake migrate log of migrate-bbbbb' "$T/out" && echo yes || echo no)" yes +check "prints the pods" "$(grep -q -- '--- pods' "$T/out" && echo yes || echo no)" yes +check "prints the latest events" "$(grep -q -- '--- latest events' "$T/out" && echo yes || echo no)" yes + +echo "17. Reading the Job's state fails (no connection)" +setup_migrate; echo $GOOD > "$T/home/.relay-last-good"; touch "$T/get-job-fails"; code=$(run $BAD) +check "exits 1, never success" "$code" 1 +check "never applies the failing commit" "$(applied_bad)" no + echo; echo "$passed passed, $failed failed" [ "$failed" -eq 0 ] diff --git a/k8s/README.md b/k8s/README.md index 2846be6..d59cc29 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -6,6 +6,7 @@ How each folder gets onto the cluster, and who may apply it. |---|---|---| | `k8s/platform/` | Cluster setup: Let's Encrypt issuers, Traefik settings, the `relay` namespace (with its Pod Security level), the `deployer` account, the Service policy for `relay` and the StorageClass `local-path-retain` | One of us, by hand, after the PR is reviewed | | `k8s/data/` (from step 2.3) | Postgres: its Service and StatefulSet (`postgres.yaml`) and its first-start script (`initdb/`). Later also the backup CronJob (Phase 6) | One of us, by hand, after the PR is reviewed. **Never the pipeline**: the `deployer` account can't create StatefulSets or Secrets, so a merged PR can't change the database's version or its disk. Order and recovery: [`infra/server/README.md`](../infra/server/README.md), section 6 | +| `k8s/migrate/` (from step 2.4) | The migrate Job: runs the commit's database migrations (dbmate) once per deploy | Only `relay-deploy`, with `kubectl create` (never `apply`: every deploy gets a fresh Job name), **before** `k8s/app/`. Kubernetes deletes finished Jobs after 1 hour, because the `deployer` account can't delete | | `k8s/app/` (from step 1.7) | Our app in namespace `relay`, the API and the dashboard: Deployments, Services, Ingresses, ConfigMaps | Only the pipeline, through `relay-deploy` | ## Rules diff --git a/k8s/migrate/job.yaml b/k8s/migrate/job.yaml new file mode 100644 index 0000000..7cdfd23 --- /dev/null +++ b/k8s/migrate/job.yaml @@ -0,0 +1,63 @@ +# The migrate Job (step 2.4): runs this commit's database migrations once, BEFORE any pod of +# the commit starts. Only relay-deploy creates it (with "kubectl create", never "apply"), +# after replacing __GIT_SHA__ with the commit. Don't apply this file by hand: the tag would be +# wrong, and "apply" can't use generateName. +# +# It uses the API's own image, so a commit's migrations always come with that commit's code. +# dbmate only runs the files the database hasn't seen yet, so most deploys find nothing to do. +apiVersion: batch/v1 +kind: Job +metadata: + # A fresh name for every deploy (migrate-x7k2p): re-running or rolling back the same commit + # never collides with an earlier Job, which the deployer account couldn't delete. + generateName: migrate- + namespace: relay + labels: + app.kubernetes.io/name: relay-migrate + relay.myrelay.tech/commit: "__GIT_SHA__" # find a commit's Jobs: -l relay.myrelay.tech/commit= +spec: + backoffLimit: 0 # a failed migration is never retried by itself; the deploy stops + activeDeadlineSeconds: 150 # a hard limit, which also ends a Job whose image can't be pulled + ttlSecondsAfterFinished: 3600 # Kubernetes deletes the finished Job (and its pod) after 1 hour + template: + metadata: + labels: + app.kubernetes.io/name: relay-migrate + spec: + restartPolicy: Never + automountServiceAccountToken: false # dbmate never talks to Kubernetes + # Kubernetes marks the Job "Failed" only once this pod has really stopped, so a short + # grace keeps that verdict quick: 150 s deadline + 10 s grace, well inside relay-deploy's + # wait of at least 180 s. + terminationGracePeriodSeconds: 10 + # Pod Security "restricted": the image's own "bun" user (uid 1000), as the API pods. + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: migrate + image: ghcr.io/devinder-dev/relay-api:__GIT_SHA__ + # The package.json script "db:migrate": dbmate ... migrate (see api/package.json). + # --bun: run dbmate's small Node launcher with Bun (the image has no real Node). + command: ["bun", "run", "--bun", "db:migrate"] + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: relay-db + key: url + # The CI smoke test runs this exact command with the same 128 MiB limit. + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true # dbmate writes nothing (--no-dump-schema) + capabilities: + drop: ["ALL"] diff --git a/k8s/platform/deployer.yaml b/k8s/platform/deployer.yaml index df1e321..5940c7e 100644 --- a/k8s/platform/deployer.yaml +++ b/k8s/platform/deployer.yaml @@ -32,8 +32,8 @@ metadata: namespace: relay --- # What "deployer" may do, only inside the "relay" namespace. -# Just what steps 1.7–1.8 need. Later phases add rules when they need them: -# jobs (migrations, phase 2), networkpolicies (phase 4), horizontalpodautoscalers (phase 8). +# Just what each step needs, added when it needs it: the app (1.7–1.8), the migrate Job (2.4). +# Still to come: networkpolicies (phase 4), horizontalpodautoscalers (phase 8). apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: @@ -66,6 +66,18 @@ rules: - apiGroups: ["networking.k8s.io"] resources: ["ingresses"] verbs: ["get", "list", "watch", "create", "update", "patch"] + # The migrate Job of each deploy (step 2.4): create it and follow it until it finishes. + # No update or patch (a Job can't be changed; every deploy creates a new one) and no delete + # (Kubernetes deletes finished Jobs itself, ttlSecondsAfterFinished in k8s/migrate/job.yaml). + - apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["get", "list", "watch", "create"] + # Read a pod's log, so a failed migration's error appears in the CI log. This covers every + # pod in relay, so nothing may ever log a secret (Postgres's first-start script makes sure + # its password isn't logged, k8s/data/initdb/10-create-relay.sh). + - apiGroups: [""] + resources: ["pods/log"] + verbs: ["get"] --- # Gives the Role above to the ServiceAccount. apiVersion: rbac.authorization.k8s.io/v1 From dff9a88f0880472a0b490e26063b4985d9ccdf80 Mon Sep 17 00:00:00 2001 From: Devinder Singh Date: Sat, 31 Oct 2026 22:24:11 +0100 Subject: [PATCH 3/5] docs: record the expand/contract rule for migrations (ADR 0006) --- api/db/migrations/20261031140445_init.sql | 4 +- docs/CONTEXT.md | 4 +- .../0006-migrations-expand-contract.md | 90 +++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 docs/decisions/0006-migrations-expand-contract.md diff --git a/api/db/migrations/20261031140445_init.sql b/api/db/migrations/20261031140445_init.sql index 5530a88..8a949b9 100644 --- a/api/db/migrations/20261031140445_init.sql +++ b/api/db/migrations/20261031140445_init.sql @@ -1,6 +1,6 @@ -- migrate:up -- Migration 001: schema v1 (step 2.4). Run by dbmate in the migrate Job before every rollout. --- Rules for every migration (docs/decisions/0005-migrations-expand-contract.md): +-- Rules for every migration (docs/decisions/0006-migrations-expand-contract.md): -- only ADD things old code can live with; never edit a merged migration; fix forward. -- -- Every id is a UUIDv7 (built into Postgres 18): unique, ordered by time (so it also works as @@ -102,7 +102,7 @@ CREATE TABLE attempts ( CREATE INDEX attempts_delivery_id ON attempts (delivery_id, id); -- migrate:down --- Forward-only, on purpose (ADR 0005). "dbmate rollback" would run this section and then delete +-- Forward-only, on purpose (ADR 0006). "dbmate rollback" would run this section and then delete -- this migration's row in schema_migrations; an empty section would leave every table in place -- while marking it "not applied". Raising an error rolls the whole rollback back instead. -- To start over on a laptop, throw the throwaway database away. diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 4be344b..9cb59ad 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -72,7 +72,7 @@ Chas Academy's "Avancerad fullstackutveckling, test och DevOps". **Data model v1** (Postgres 18.6, every id is a UUIDv7): `owners` (id, name) · `api_keys` (owner_id, prefix, key_hash = SHA-256 UNIQUE, revoked_at) · `endpoints` (owner_id, name, url, signing_secret `whsec_…`, event_types[] default `{*}`, -disabled_at) · `events` (owner_id, type, payload jsonb object, idempotency_key; UNIQUE (owner_id, +deleted_at = soft delete) · `events` (owner_id, type, payload jsonb object, idempotency_key; UNIQUE (owner_id, idempotency_key)) · `deliveries` (owner_id, event_id, endpoint_id, status pending|delivered|dead, attempts, next_attempt_at, lease_id, last_status_code, last_error, delivered_at; UNIQUE (event_id, endpoint_id); partial INDEX (next_attempt_at) WHERE status = 'pending') · `attempts` @@ -96,7 +96,7 @@ Both the API and the dashboard follow it; changes to it go through a PR first. | Web framework | **Fastify 5** ([decision 0001](decisions/0001-web-framework-fastify.md)): `buildApp()` + `listen({ host: "0.0.0.0" })`, pino logs, Zod validation, `@fastify/cors`, `inject()` tests | Hono (native on Bun, but new to us), Express, Elysia | | Database library | postgres.js behind one file `src/db.ts` | `Bun.sql` (ties the data layer to the new runtime), Drizzle | | API code structure | **Zod 4** for bodies, queries and env vars ([decision 0003](decisions/0003-api-code-structure.md)); layers routes → services → repositories; one Fastify error handler with one error shape; small files, one job each | Fastify JSON Schema, `fastify-type-provider-zod`, logic in route handlers | -| Migrations | dbmate (plain SQL), run by a Kubernetes Job before each rollout. Expand/contract rule | drizzle-kit, an initContainer (would rerun on every scale-up) | +| Migrations | dbmate (plain SQL, `api/db/migrations/`, inside the API image), run by the migrate Job (`k8s/migrate/`) that `relay-deploy` creates before each rollout. Forward-only, expand/contract ([decision 0006](decisions/0006-migrations-expand-contract.md)) | drizzle-kit, an initContainer (would rerun on every scale-up), `down` migrations on rollback | | Unit + integration tests | `bun test`; integration against a real Postgres (Docker locally, service container in GitHub Actions) | Testcontainers (no official Bun support), mocked DB | | Dashboard | **Vite + React + TypeScript, Tailwind CSS v4, React Router, axios** (one client in `src/api.ts`), plain React state with a small refresh loop while a delivery is pending, served by nginx ([decision 0002](decisions/0002-dashboard-stack.md)) | Next.js, plain CSS, `fetch`, TanStack Query | | E2E tests | Playwright, runs on Node in the dashboard folder | Cypress | diff --git a/docs/decisions/0006-migrations-expand-contract.md b/docs/decisions/0006-migrations-expand-contract.md new file mode 100644 index 0000000..5cea9a2 --- /dev/null +++ b/docs/decisions/0006-migrations-expand-contract.md @@ -0,0 +1,90 @@ +# 0006. Database migrations: forward-only, expand/contract + +- **Status:** Proposed +- **Date:** 2026-11-01 +- **Decided by:** Devinder Singh and Abdikhani Mohamed + +## Context + +Every deploy changes the database before the new pods start: `relay-deploy` creates a migrate +Job (`k8s/migrate/job.yaml`) that runs `dbmate migrate` from the commit's own image, and only +then applies `k8s/app/`. Two moments make that dangerous: + +1. **During a rollout**, old and new pods serve requests at the same time, against the same + tables (`maxSurge: 1`, `maxUnavailable: 0`). +2. **After a failed deploy**, `relay-deploy` brings back the last good commit's **code**, but the + database keeps its **new** shape. dbmate never runs migrations backwards. + +So a migration that breaks the old code breaks production twice: during every rollout, and again +exactly when the rollback is needed. Code can't protect against that. A rule that every migration +follows can, and it has to be written down so each migration can be checked against it in review. + +## Options considered + +| Option | For | Against | +|---|---|---| +| **Forward-only + expand/contract** (chosen) | Old and new code always work on the schema that's there; a rollback is just a redeploy | A rename or a removal takes several small releases | +| `down` migrations, run on rollback | One release per change | Runs untested SQL backwards in production; old code would still meet data written by new code; a `down` that drops a column destroys data | +| Two databases, switched over (blue-green) | Clean cut-over | Two Postgres kept in sync on one VPS: far more than this project needs | +| Care, without a written rule | No process | Relies on remembering, under time pressure | + +## Decision + +Migrations are **forward-only**, and every migration follows these rules: + +1. **Every migration works with the code that runs now *and* the code being deployed.** Both run + during a rollout, and after a rollback the old code runs on the new schema. +2. **Expand first:** add tables, nullable columns, columns with a default, new indexes. A new + constraint can be added as `NOT VALID` and validated later, but `NOT VALID` only skips the + *existing* rows: every new write is checked at once, so the rule must also accept what the + *previous* release writes. +3. **Contract later, in a separate deploy:** remove something only when the **last good release** + (the one `relay-deploy` would roll back to) no longer reads or writes it. +4. **Never in one deploy:** rename a column or table, change a column's type, drop something still + used, or add `NOT NULL` to a filled column without a default. +5. **Transactional migrations start with `SET LOCAL lock_timeout = '5s';`.** An `ALTER TABLE` + waits for its lock, and while it waits every new query queues behind it; giving up after 5 s + of *waiting* is better than freezing the API. It limits only the wait, not how long a statement + then holds the lock (a normal `CREATE INDEX` on a big table gets its lock at once and blocks + writes until it's done). So a change to a table that already has data says in its PR how long + it takes and what it locks, tried on a copy. +6. **Data backfills don't go in a migration.** They run separately, in batches, and their + completeness is checked before the next release relies on them. +7. **A merged migration is never edited.** dbmate won't run it again, so the edit would exist + only in git. Mistakes are fixed forward, with a new file. +8. **Rollback never runs `down`.** Every `-- migrate:down` section raises an error on purpose, so + an accidental `dbmate rollback` can't delete a migration's row in `schema_migrations` while + its tables stay (which would make the next `migrate` crash). +9. **No `-- migrate:up transaction:false` without a reason in the PR** (for example + `CREATE INDEX CONCURRENTLY`). Outside a transaction a failure can leave half a change: a + concurrent index can be left `INVALID` and must be dropped before retrying. `SET LOCAL` does + nothing there, so such a file sets `SET lock_timeout` for its session and resets it at the end. + +### Example: renaming `endpoints.name` to `label` + +| Release | Migration (runs first) | The code reads / writes | A rollback lands on | Safe because | +|---|---|---|---|---| +| R1 | Add `label`, nullable | Writes **both** columns, reads `name` | R0 (knows only `name`) | R0 ignores `label`, and `name` is still written | +| (backfill) | – | A batch job copies `name` → `label` for old rows, then checks that none is NULL | – | – | +| R2 | – | Writes both, reads **`label`** | R1 (reads `name`) | Both columns are still written, so either reader is right | +| R3 | Drop `NOT NULL` from `name`, if it has it | Writes and reads **only `label`** | R2 (reads `label`) | R2 reads `label`, which R3 keeps writing | +| R4 | **Drop `name`** (allowed: the last good release is R3, which never touches it) | Unchanged | R3 | R3 doesn't use `name` | + +R3 can't come straight after R1: a rollback from R3 would land on R1, which reads `name`, and the +rows R3 wrote have no `name`. Four releases look slow, but each one is small, and at no point does +a deploy or a rollback break. + +## Consequences + +- **Migration 001** creates every table in an empty database, so it needs no compatibility care. + It already follows the rules: it starts with `SET LOCAL lock_timeout`, and its `down` raises an + error. +- **Reviewing a PR that adds a migration** includes one question: "Is this expand-only, and does + the last good release still work with it?" +- **A failed migration** is undone by Postgres (each file runs in one transaction), the deploy + stops before any pod changes, and `relay-deploy` redeploys the last good commit. If two files + were pending and the second failed, the first stays applied, which is fine because each one is + expand-only on its own. +- **Resetting a database** on a laptop means throwing the throwaway container away, not running + `down`. +- **Possible later:** a CI check that refuses changes to merged migration files (rule 7). From 22883d8fa8e017b4adb9bddd0069eca386e13d0f Mon Sep 17 00:00:00 2001 From: Devinder Singh Date: Sat, 31 Oct 2026 22:30:50 +0100 Subject: [PATCH 4/5] docs: correct the date of ADR 0006 --- docs/decisions/0006-migrations-expand-contract.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions/0006-migrations-expand-contract.md b/docs/decisions/0006-migrations-expand-contract.md index 5cea9a2..04b88f2 100644 --- a/docs/decisions/0006-migrations-expand-contract.md +++ b/docs/decisions/0006-migrations-expand-contract.md @@ -1,7 +1,7 @@ # 0006. Database migrations: forward-only, expand/contract - **Status:** Proposed -- **Date:** 2026-11-01 +- **Date:** 2026-10-31 - **Decided by:** Devinder Singh and Abdikhani Mohamed ## Context From 121f66c49a4e77de190f661c7f99da898cc1bfa1 Mon Sep 17 00:00:00 2001 From: Devinder Singh Date: Sun, 1 Nov 2026 15:42:49 +0100 Subject: [PATCH 5/5] fix: address the review of #25 (drop updated_at, one source for the Postgres image) --- .github/workflows/pipeline.yml | 9 ++++++--- api/db/migrations/20261031140445_init.sql | 12 +++++++++--- api/scripts/postgres-image.sh | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) create mode 100755 api/scripts/postgres-image.sh diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 5c627c4..0f2b4b5 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -79,11 +79,14 @@ jobs: - name: Smoke-test the migrations inside the image shell: bash # GitHub then adds -e and pipefail run: | + # Production's image, read from k8s/data/postgres.yaml (one source of truth). On its own + # line, so a missing digest stops the step here with a clear message. + PG_IMAGE=$(bash api/scripts/postgres-image.sh) docker network create smoke-net docker run -d --name smoke-pg --network smoke-net \ -e POSTGRES_PASSWORD=test -e RELAY_DB_PASSWORD=test \ -v "$PWD/k8s/data/initdb:/docker-entrypoint-initdb.d:ro" \ - postgres:18.6@sha256:979bccfde35445db618c65107d27d08c6c21339d8dc3ad4c830c6633947be0e4 + "$PG_IMAGE" for _ in $(seq 1 60); do docker exec smoke-pg pg_isready -q -h 127.0.0.1 -U postgres && break sleep 1 @@ -129,8 +132,8 @@ jobs: # with fake git and kubectl (no server needed). shellcheck ships on GitHub's runner. - name: Test relay-deploy's decisions run: bash infra/server/test-relay-deploy.sh infra/server/relay-deploy.sh - - name: Lint the server scripts - run: shellcheck infra/server/*.sh + - name: Lint the shell scripts + run: shellcheck infra/server/*.sh api/scripts/*.sh deploy: name: deploy diff --git a/api/db/migrations/20261031140445_init.sql b/api/db/migrations/20261031140445_init.sql index 8a949b9..5a73c71 100644 --- a/api/db/migrations/20261031140445_init.sql +++ b/api/db/migrations/20261031140445_init.sql @@ -6,9 +6,14 @@ -- Every id is a UUIDv7 (built into Postgres 18): unique, ordered by time (so it also works as -- the page cursor), and impossible to guess. Statuses are text + CHECK, not an enum: adding an -- allowed value later is a small change, removing one from an enum is not. +-- No updated_at columns: nothing reads them, they aren't in docs/api.md, and without a trigger +-- they would silently mean created_at whenever an UPDATE forgot to set them. If something needs +-- one later, a migration adds the column and its trigger together (an additive change). -- Give up after 5 s of WAITING for a lock instead of queueing every API query behind us. -- (An empty database has no locks to wait for; every migration starts the same way.) +-- Tested with dbmate 2.37.0 on 1 Nov: a migration blocked by another session's lock gives up +-- after 5 s with 55P03, and nothing is applied or recorded, so the next deploy simply retries. SET LOCAL lock_timeout = '5s'; -- A customer of Relay. @@ -42,8 +47,7 @@ CREATE TABLE endpoints ( signing_secret text NOT NULL, -- 'whsec_' + base64(32 random bytes) event_types text[] NOT NULL DEFAULT '{*}' CHECK (cardinality(event_types) BETWEEN 1 AND 20), deleted_at timestamptz, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() + created_at timestamptz NOT NULL DEFAULT now() ); -- An owner's endpoints that aren't deleted (the fan-out and the endpoint list read these). CREATE INDEX endpoints_owner_live ON endpoints (owner_id) WHERE deleted_at IS NULL; @@ -64,6 +68,9 @@ CREATE TABLE events ( -- One event to one endpoint: the queue. Workers claim due rows with FOR UPDATE SKIP LOCKED and -- a lease (Phase 4). While a row is leased, next_attempt_at is the lease's expiry and lease_id -- is the fencing token: a result is only saved by the worker that still holds the lease. +-- So next_attempt_at is never NULL here, but the API shows it as null unless the status is +-- 'pending' (docs/api.md). That mapping lives in ONE place, the deliveries repository's read +-- query (Phase 3): CASE WHEN status = 'pending' THEN next_attempt_at END. CREATE TABLE deliveries ( id uuid PRIMARY KEY DEFAULT uuidv7(), owner_id uuid NOT NULL REFERENCES owners ON DELETE CASCADE, @@ -77,7 +84,6 @@ CREATE TABLE deliveries ( last_error text, delivered_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT deliveries_event_endpoint UNIQUE (event_id, endpoint_id) ); -- The worker's claim query: "pending rows that are due, oldest first". Partial: only pending diff --git a/api/scripts/postgres-image.sh b/api/scripts/postgres-image.sh new file mode 100755 index 0000000..a5881ad --- /dev/null +++ b/api/scripts/postgres-image.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Prints the Postgres image production runs, pinned by digest, for example +# postgres:18.6@sha256:979bccfde35445db618c65107d27d08c6c21339d8dc3ad4c830c6633947be0e4 +# One source of truth: the StatefulSet in k8s/data/postgres.yaml. Everything that starts a test +# Postgres (CI's migration smoke test, test-db.sh) asks this script, so a version bump there +# moves every test along with it, and no test can quietly keep checking an old image. +set -euo pipefail + +MANIFEST="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/k8s/data/postgres.yaml" + +image=$(sed -n 's/^ *image: *\(postgres:[^ ]*\).*/\1/p' "$MANIFEST" | head -n 1) +if [[ ! "$image" =~ ^postgres:[0-9.]+@sha256:[0-9a-f]{64}$ ]]; then + echo "❌ postgres-image: no digest-pinned postgres image in $MANIFEST" >&2 + exit 1 +fi +echo "$image"