Repository navigation
infra: Postgres 18.6 on a disk that survives deletion - #22
Conversation
93ca32e to
494f93b
Compare
AbdikhaniAbdirashid
left a comment
There was a problem hiding this comment.
Read all six files against docs/CONTEXT.md and the Phase 2 plan, and verified three claims rather than taking them on trust. Approving — the notes below are one wording fix and two questions, none of them blocking.
The digest pin, checked
image: postgres:18.6@sha256:979bccf… — I went looking for a mismatch here and found something worth writing down:
tag 18.6 today, index digest sha256:3a4264520e12…
your pin, index digest sha256:979bccfde354…
Different, which looked wrong for a minute. But your digest answers 200 and is a valid 16-platform OCI index, and the linux/amd64 manifest inside it is
sha256:f4b419e350fd98d4a8429603295208976dea4c8fb8326d818d346d3e29f1db6f
— byte-identical to the amd64 manifest the tag points at today. So the tag has been re-pushed since you pinned it (new attestations or other platforms), the bytes our amd64 server runs have not, and the pin is doing exactly its job. "Pinned to its exact bytes" is literally true for the platform we run.
The ConfigMap is required, and that is the safe choice
This was the failure I went looking for: forget step 3, Postgres starts on an empty volume, the init script never runs, and you get a database with no relay login — section 6c, which costs the volume.
It can't happen. The init volume has no optional: true, so a missing postgres-init leaves the pod in ContainerCreating with a FailedMount event. Nothing is initialised, nothing is lost. 6c is reachable only by the script itself erroring, which is what 6c says.
Worth stating because the apply order has four manual steps and this is the one whose omission would be expensive — and it is the one that can't bite.
enableServiceLinks: false
Non-obvious and right, so: the Service is named postgres, and with service links on, Kubernetes injects POSTGRES_PORT, POSTGRES_SERVICE_HOST and friends into every container in the namespace. The postgres entrypoint reads variables with exactly that prefix. Turning the links off removes a whole class of "why is the entrypoint behaving oddly" before it can happen.
One wording fix
RELAY_DB_PASSWORD is commented # read once, by the first-start script. It is read once by the script, but it stays in the pod's environment for the container's whole life — and section 6b depends on that: \getenv pw RELAY_DB_PASSWORD is how the stored password gets reset without touching a command line. The comment reads as "and then it's gone", which would make someone later think 6b can't work, or worse, try to remove the variable. Something like # the first-start script reads this; 6b reads it again when a Secret is lost would match the runbook.
Two questions
-
1Gi memory, no liveness probe. Agreed on no liveness — a crash recovery that gets killed and restarted in a loop is worse than waiting. But that also means an OOM kill is the one way the container dies unexpectedly, and Phase 8 is a k6 load test pushing connections against
max_connections=100. 100 backends at a few MB of work_mem each plus shared buffers can get close to 1Gi. Is 1Gi the number you want to walk into the load test with, or should Phase 8 revisit it? The VPS has 8 GB and the API asks for 128Mi × 2, so there is room if you want more headroom now. -
The deletion drill. Step 5 of your post-merge list is the only thing that actually proves Retain works, and nobody has run it yet — everything else in this PR is verified, that one is a promise. Please post the output: the marker row surviving the pod, the StatefulSet and the PVC, and the kept PV re-attached with
volumeName. That is also the evidence for my Phase 6 claim that Retain is not a backup, so I would like to be able to point at it on Wednesday.
Order and recovery
The apply table and 6a–6c read well and I could follow them without the code. Three things I checked and agree with: Postgres scaled to 0 before deleting the claim (a running StatefulSet would make a new empty disk for postgres-0), never removing the pvc-protection finalizer, and asking the other person before 6c since it deletes the volume.
And thank you for writing down the two limits of the relay guards in k8s/README.md — having them in the repo rather than in a PR comment is what makes them sayable out loud on Wednesday.
Approving. Ping me when the drill output is up and I will read it.
|
Applied and drilled, on 1 Nov. Thanks for the careful review, especially the digest check: that's Applied (runbook section 6, steps 1–4, from
The deletion drill, in the order of runbook 6a, with a marker row in the admin database (not Afterwards, the Your wording fix: you're right, Your question about 1Gi: yes, Phase 8 revisits it with numbers, not a guess. Today's budget is |
> **Replaces #25**, which GitHub closed when its base branch `infra/postgres` was deleted after > #22 merged (it couldn't be reopened after the rebase). Same branch, same commits, now on `main`. > Abdikhani's review of #25 and its answers are below. ## What Step 2.4 of Phase 2: every deploy runs the commit's database migrations first, before any pod changes. | Part | Files | What | |---|---|---| | 2.4a | `api/db/migrations/…_init.sql`, `api/package.json` + `bun.lock`, `api/Dockerfile` | Migration 001 (schema v1, with `deleted_at` for the soft delete and named indexes; `down` raises an error on purpose). dbmate 2.37.0, pinned, inside the API image, run as `bun run --bun db:migrate` (`dbmate … migrate`) | | 2.4a | `.github/workflows/pipeline.yml` | `api-image` builds the image, **smoke-tests the migrations inside it** (uid 1000, read-only root, 128 MiB, against a throwaway Postgres made by our first-start script), and only then pushes, on main | | 2.4b | `k8s/migrate/job.yaml` | The migrate Job: `generateName`, `backoffLimit: 0`, deadline 150 s, pod grace 10 s, TTL 1 h, Pod Security `restricted` | | 2.4b | `infra/server/relay-deploy.sh` | `run_migrations`: create the Job, then ask for its verdict every 2 s, at most 90 times (≥ 180 s). `Complete` → continue; `Failed` / no verdict / can't read → print why, return 1, and the existing rollback takes over. Commits without `k8s/migrate/` skip this, so a rollback to Phase 1 still works | | 2.4b | `k8s/platform/deployer.yaml` | The Role gets `jobs` (get, list, watch, create: no update, no delete) and `pods/log` (get) | | 2.4b | `infra/server/test-relay-deploy.sh` | Cases 11–17, a fake `sleep`, and a 30 s watchdog so a hanging script fails the test instead of CI | | 2.4b | `pipeline.yml` | Deploy timeout 35 → 40 min (the migrate step adds up to 3 min, twice on a rollback) | | 2.4c | `docs/decisions/0006-migrations-expand-contract.md` | The rule: forward-only, expand first, contract only what the last good release no longer uses. With the rename example in four releases | | docs | `k8s/README.md`, `docs/CONTEXT.md` | A row for `k8s/migrate/`; `deleted_at` and the migrations row in CONTEXT | ## Tested before the PR - Locally against Postgres 18.6: - the first `db:migrate` creates the 6 tables, owned by `relay`, and a second run does nothing; - `dbmate rollback` is refused and the history stays; - a file broken halfway leaves nothing behind; - with two new files and the second broken, the first stays applied. - The CI smoke test, run locally with the real image: exit 0. - `test-relay-deploy.sh`: **49 passed, 0 failed**. A Job that stays Pending creates exactly 1 Job, reads its status 90 times, then gives up with "may still be running". - Server dry runs: the Job is valid (the server invents its name), and only the Role's two new rules change. shellcheck, actionlint and `bun audit` are clean. ## Changed after the review of #25 - **No `updated_at` columns** (on `endpoints` and `deliveries`). Nothing kept them current, so without a trigger they would quietly have meant `created_at` whenever an `UPDATE` forgot them. Nothing reads them, and they aren't in `docs/api.md`. If something needs one later, a migration adds the column and its trigger together, which is an additive change and safe under ADR 0006. Editing migration 001 itself is fine here: it has never run in production, only in throwaway databases. ADR 0006's "never edit a merged migration" is about migrations that have. - **`next_attempt_at` written down:** never NULL in the table (while a row is leased, it holds the lease's expiry), but `null` in the API unless the status is `pending`. That mapping lives in one place, the deliveries repository's read query in Phase 3 (`CASE WHEN status = 'pending' THEN next_attempt_at END`). The comment is in the migration, and it's an input to the Phase 3 plan. - **`lock_timeout` tested with dbmate 2.37.0**, against a real lock: - one session held a lock on a table; - a migration with our header tried to `ALTER` it; - it gave up after 5 s with `55P03 canceling statement due to lock timeout`; - the column wasn't added, and the migration wasn't recorded as applied, so the next deploy simply retries. dbmate runs each migration in a transaction unless it's marked `transaction:false`. And even without that, Postgres runs several statements sent together as one implicit transaction, so `SET LOCAL` can't silently do nothing. The result is noted in the migration's header. - **One source for the Postgres image:** `api/scripts/postgres-image.sh` prints the digest-pinned image from `k8s/data/postgres.yaml`. The smoke test uses it instead of a hardcoded digest, and so will `test-db.sh` in #26. A manifest without a digest makes the step fail with a clear message (tested). shellcheck now also checks `api/scripts/`. - **Checked again:** - on a fresh Postgres (production's image, the first-start script), migration 001 applies as `relay`, and a second run finds nothing to do; - 6 tables, all owned by `relay`, 0 `updated_at` columns. ## After merging (an admin, in this order; output posted here) The merge itself deploys with the **old** installed script, which doesn't migrate yet. That's harmless: nothing in `k8s/app/` changes. Then: 1. Apply `k8s/platform/deployer.yaml` **first**, and check as the deployer: `create jobs` and `get pods/log` → yes; `delete jobs`, `patch jobs`, `get secrets` → no. (The other way round, the new script couldn't create its Job, and the rollback would fail the same way.) 2. Install the new `relay-deploy` (`install -o root -g root -m 755`) and `diff` it against git. 3. Deploy the same commit by hand (`sudo -u deploy env SSH_ORIGINAL_COMMAND=<sha> relay-deploy`). This runs migration 001: `migrating with job.batch/migrate-…`, then `Applying: …_init.sql`. 4. Check `schema_migrations` and the tables as `relay`. One hour later, the old Jobs are gone. 5. Negative tests: a hand-made Job with an image that doesn't exist ends as `Failed` (`DeadlineExceeded`), and the legacy rollback test (Phase 1's commit `a78934c…`, full SHA) still rolls back with 0 failed requests. ## For the reviewer - Is the expand/contract rule clear enough that you could apply it to one of your own changes? - `pods/log` lets the deployer read every pod's log in `relay`. Acceptable, given that nothing logs a secret?
….6) (#26) ## What Step 2.6 of Phase 2: the API connects to Postgres, and the tests run against a real one. **Stacked on #27** (base `feat/schema-migrations`; #27 replaces #25): the integration tests need migration 001. Before #27 merges, this PR's base is switched to `main`; then it gets one rebase. ## Why it works this way - **The API waits for the database before it listens** (at most 60 s, by the clock). Readiness never checks the database: one database blip would take every pod out at once (the point from the review of #15). A pod with a wrong `DATABASE_URL` never listens, so its startupProbe fails, the rollout fails and `relay-deploy` rolls back. - **Waiting can't fix a wrong login**, so `28P01` (wrong password), `28000` (login not allowed) and `3D000` (no such database) stop it at once. Everything else (refused, timeout, `57P03` "starting up") is retried every 2 s. Each try is limited to the time that is left (at most 5 s). A try that runs over is abandoned, not cancelled: postgres.js 3.4.9's `cancel()` drops the promise of its own cancel request, so a failed cancel would go uncaught. - **A database connection error answers `503 service_unavailable` with `Retry-After: 5`**, in our usual error shape. Every other database error stays a 500, including `57014` (a query stopped by `statement_timeout`): slow SQL is our bug, not an outage, and must not look like one. - **postgres.js 3.4.9**, pinned (4.0.0 came out on 29 Oct). Pool 5 per pod, idle 30 s, `connect_timeout` 5 s, `statement_timeout` 5 s, `application_name` `relay-api`. - **Signal handlers are set up before the wait**, not after `listen`: bun is process 1 in the container, and Linux ignores SIGTERM for process 1 unless it has a handler. Shutdown is `app.close()`, then `sql.end({ timeout: 5 })`. ## Changes - **API:** `config.ts` (`DATABASE_URL` required, `DB_POOL_MAX` 1–20, default 5), `db.ts` (new), `server.ts`, `error-handler.ts`, the comment in `routes/health.ts`; unit tests with a fake database and a fake clock. - **Tests against a real Postgres:** `api/scripts/test-db.sh` with `db:up`, `test:db`, `db:down` and `test:unit`. A throwaway container on `127.0.0.1:55432`, with production's image (the digest is read from `k8s/data/postgres.yaml`) and the same first-start script, so the tests log in as `relay`. `db:down` removes only the container the script made (it carries a label). It takes production's image from `api/scripts/postgres-image.sh` (#27), the same script the CI smoke test uses. `api/test/integration/`: the connection tests and the schema tests. - **CI:** `api-test` runs `db:up`, then `test:db`. shellcheck also checks `api/scripts/`. - **Kubernetes:** `k8s/app/deployment.yaml`: `DATABASE_URL` from Secret `relay-db` (key `url`); a startupProbe (45 × 2 s = 90 s, more than the API's own 60 s wait); liveness without the 15 s delay; `terminationGracePeriodSeconds: 30` written out, with what has to fit inside it. - **Docs:** `docs/api.md` (readiness never checks the database; the new 503), `docs/CONTEXT.md`, the error table in ADR 0003. ## One change from the reviewed plan CI starts the test Postgres with `docker run`, the same way as the existing image smoke test, instead of a `services:` container. Services start **before** checkout, so they can't mount `k8s/data/initdb/`, and the first-start script would not run the way production runs it. ## How it was checked - **59 tests pass against the real Postgres, logged in as `relay`.** They include: - `relay` isn't a superuser; - our settings arrive on every connection; - a wrong password stops at the first answer with `28P01` (no retry is logged); - `pg_sleep(6)` is cancelled at 5 s with `57014`; - a connection ended with `pg_terminate_backend` comes back (a pool of exactly one connection); - one valid row per table, two NULL idempotency keys don't collide, the limits themselves (20 event types, a 255-character key) are accepted, and deleting an owner cascades; - 14 rule-breaking rows, each refused with the right SQLSTATE (`23514`, `23505`, `23503`, `23502`). - **Negative checks:** - without `DATABASE_URL`, the integration tests fail (exit 1) and never skip; - without `DATABASE_URL`, the API stops at once (exit 1, `→ at DATABASE_URL`). - **On the cluster**, a server-side dry run of the Deployment is accepted with no Pod Security warnings. The same file with a misspelled probe field is refused (`--validate=strict`). - **Other checks:** - `tsc --strict` is clean, and so is shellcheck; - Snyk Code: 0 findings in our code; - `bun audit`: 0 vulnerabilities. ## Changed after the review - **No cancel on a timed-out try** (above). A unit test checks that a late error after the timeout is still handled. - **503 codes listed by name:** - added `57P02` (Postgres crashed), `EPIPE`, `EHOSTUNREACH` and `ENETUNREACH`; - "every `08…`" was replaced by the five connection codes by name; - `08P01` (protocol violation, a driver bug) and `08007` ("did the commit happen? unknown") now stay a 500: "try again" could save a write twice. - **The clock:** `waitForDatabase` uses `performance.now()`, which never jumps when the system clock is set. - **`db:up` always starts fresh.** It removes its own old container first, so an older image, first-start script or port binding can't be reused. - **No stopwatch checks in the integration tests.** They would be flaky on a slow runner; the SQLSTATE and "no retry was logged" prove the same things. - **The `terminationGracePeriodSeconds` comment is corrected:** the 5 s preStop runs inside the 30 s, before SIGTERM. - **From the review of #22:** the comment on `RELAY_DB_PASSWORD` in `k8s/data/postgres.yaml` now says it stays in the pod's env on purpose, because runbook 6b reads it again. It's only a comment, so Postgres doesn't need to be applied again. ## Before it can go live It needs #22 applied (done on 1 Nov: the Secret `relay-db` and Postgres) and #27's migration 001. Without the Secret, the new pods can't start: the rollout fails and rolls back (safe, but red). The order: 1. #22 merged and applied (done); 2. #27 merged (its deploy runs migration 001); 3. this PR rebased and merged. **Not in this PR:** - `docs/CONTEXT.md`'s "Where we are": updated at the end of Phase 2. - The API doesn't read any table yet: Phase 3 gives the routes the pool with the first repository.
## What Closes Phase 2 in `docs/CONTEXT.md`'s "Where we are": what is live, and the tests that proved it. ## Phase 2, as merged and applied - **#21:** guards on namespace `relay` (Pod Security `restricted`, ClusterIP-only Services). - **#22:** Postgres 18.6 on a disk that is kept on delete, applied by hand, with the `relay` login. - **#27** (replaces #25): migration 001, and the migrate Job before every rollout (ADR 0006). - **#26:** the API waits for the database before it listens; Postgres in the tests. ## The live evidence, posted on the PRs - **#22:** a login from another pod as `relay`, a wrong password refused, 5432 closed from outside, and the deletion drill: the data survived deleting the pod, the StatefulSet and the claim. - **#27:** the Role checked both ways, the installed script identical to git, and migration 001 applied by its Job. Two negative tests: - a Job that can't start fails within 150 s; - a rollback to Phase 1's release under load: 123,904 requests, 0 failed. - **#26 (live 2 Nov):** both pods log `✅ database answered` before they listen; `/health/ready` answers 200 from outside. Docs only.
Rebased onto
mainafter #20 and #21.What
Steps 2.1–2.3 of Phase 2: Postgres 18.6 on a disk that survives deletion. Nothing here is
applied by the pipeline: the
deployeraccount can't create StorageClasses, Secrets orStatefulSets, on purpose.
k8s/platform/storageclass-retain.yamllocal-path-retain: like k3s'slocal-path, butreclaimPolicy: Retain. Deleting the claim (or the namespace) keeps the data folder instead ofrm -rf-ing itinfra/server/bootstrap-secrets.shpostgres-auth(superuser password) andrelay-db(the app's password + itsDATABASE_URL) on the server, only if missing, fromopenssl rand -hex 32. Prints onlycreated/kept; values never on a command line, in output or in a filek8s/data/postgres.yamlpostgres(ClusterIP only) + StatefulSet:postgres:18.6pinned by digest, uid 999, Pod Securityrestricted, read-only root,max_connections=100, readinesspg_isreadyover TCP, no liveness probe, 60 s grace, a 10Gi claim onlocal-path-retaink8s/data/initdb/10-create-relay.shrelay(not a superuser) and the databaserelayowned by it. Password via psql\getenv+:'pw', andlog_min_error_statement = panicso a failing statement can't write the password to Postgres's logDocs:
k8s/README.md: a row fork8s/data/("never the pipeline"), and the StorageClass in theplatform row. Also your two limits from infra: guard namespace relay (Pod Security restricted, ClusterIP-only Services) #21: the policy only sees Services (a Traefik
IngressRouteTCPwouldn't be checked), and both guards cover onlyrelay.infra/server/README.md: section 6 becomes the real data section:must come from another pod;
volumeName; neverremove finalizers;
ALTER ROLEfrom the pod's own env with psql\getenv. Tested: a password with',;,$,"works, the old one is refused, andthere are 0 lines of it in the log;
/version == origin/maincheck is valid becauseevery merge is built and deployed.
Why these choices
local-pathcan't be changed (reclaimPolicyis immutable, andk3s re-applies its own copy). Retain protects against
kubectlmistakes, not against losingthe server's disk; only backups do that (Phase 6).
folder.
POSTGRES_USER=relaywould make it one, able to run shellcommands on the database server (
COPY … TO PROGRAM).Tested before the PR
Locally in Docker (the same digest, uid 999, read-only root, no capabilities):
',;,",\,$and an attemptedCREATE TABLE pwned(): thelogin works with exactly that password, one character less is refused, and no
pwnedtableexists;
CREATE ROLE: the password appears 0 times in Postgres's log. Controlwithout the
SET: the full statement and password are logged.bootstrap-secrets.shin a throwaway namespace: first runcreated×2, second runkept×2 withunchanged
resourceVersions, keyspassword/password,url, no 64-hex string in any output.A fake-kubectl test: when the check can't reach the server, it stops before any
create.Postgres in a throwaway namespace
pg-testwith the same Pod Security labels(
restricted, v1.36), filled bybootstrap-secrets.sh, then deleted:None, no NodePorturllogged in as relay in relaypassword authentication failedrelay's flags (superuser, createrole, createdb, replication)COPY … TO PROGRAMasrelaypermission deniedFound while testing: the image's
pg_hba.conftrusts the local socket and127.0.0.1without a password; only connections from other pods need one. So login checks run from a
separate pod. Only admins can
kubectl execintopostgres-0(the deployer has no exec).After merging (an admin, in this order, output posted here)
k8s/platform/storageclass-retain.yaml→kubectl get scshowslocal-path-retain … Retain,local-pathstill the default.ssh relay 'bash -s' < infra/server/bootstrap-secrets.sh→created×2.postgres-initfrom10-create-relay.sh(the command is in the header ofpostgres.yaml), thenpostgres.yaml.relay, plus from outside:nc -z -G 3 81.88.23.71 443succeeds andnc -z -G 3 81.88.23.71 5432fails.StatefulSet, and the PVC (Postgres scaled to 0 first, then the kept PV re-attached with
volumeName).For the reviewer