Skip to content

infra: Postgres 18.6 on a disk that survives deletion - #22

Merged
devinder-dev merged 4 commits into
mainfrom
infra/postgres
Nov 1, 2026
Merged

devinder-dev merged 4 commits into
mainfrom
infra/postgres

Conversation

@devinder-dev

@devinder-dev devinder-dev commented Oct 31, 2026 •

Copy link
Copy Markdown
Owner

Rebased onto main after #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 deployer account can't create StorageClasses, Secrets or
StatefulSets, on purpose.

Step File What
2.1 k8s/platform/storageclass-retain.yaml A second StorageClass, local-path-retain: like k3s's local-path, but reclaimPolicy: Retain. Deleting the claim (or the namespace) keeps the data folder instead of rm -rf-ing it
2.2 infra/server/bootstrap-secrets.sh Creates the Secrets postgres-auth (superuser password) and relay-db (the app's password + its DATABASE_URL) on the server, only if missing, from openssl rand -hex 32. Prints only created/kept; values never on a command line, in output or in a file
2.3 k8s/data/postgres.yaml Headless Service postgres (ClusterIP only) + StatefulSet: postgres:18.6 pinned by digest, uid 999, Pod Security restricted, read-only root, max_connections=100, readiness pg_isready over TCP, no liveness probe, 60 s grace, a 10Gi claim on local-path-retain
2.3 k8s/data/initdb/10-create-relay.sh Runs once, on an empty volume: creates the login relay (not a superuser) and the database relay owned by it. Password via psql \getenv + :'pw', and log_min_error_statement = panic so a failing statement can't write the password to Postgres's log

Docs:

  • k8s/README.md: a row for k8s/data/ ("never the pipeline"), and the StorageClass in the
    platform row. Also your two limits from infra: guard namespace relay (Pod Security restricted, ClusterIP-only Services) #21: the policy only sees Services (a Traefik
    IngressRouteTCP wouldn't be checked), and both guards cover only relay.
  • infra/server/README.md: section 6 becomes the real data section:
    • the apply order (StorageClass → secrets → ConfigMap → Postgres), and why a login check
      must come from another pod;
    • 6a re-attaching a kept disk: scale to 0 first, then re-bind with volumeName; never
      remove finalizers;
    • 6b a lost Secret with a kept disk: ALTER ROLE from the pod's own env with psql
      \getenv. Tested: a password with ', ;, $, " works, the old one is refused, and
      there are 0 lines of it in the log;
    • 6c a first start that failed halfway.
  • Your half-sentence from docs: close Phase 1 with the review points and the live test results #20 in "Reboots": the /version == origin/main check is valid because
    every merge is built and deployed.

Why these choices

  • Retain, in our own class: local-path can't be changed (reclaimPolicy is immutable, and
    k3s re-applies its own copy). Retain protects against kubectl mistakes, not against losing
    the server's disk; only backups do that (Phase 6).
  • A StatefulSet: a Deployment's rolling update would start a second Postgres on the same data
    folder.
  • Applied by an admin: a merged PR can't change the database's major version or its disk.
  • The app is not a superuser: POSTGRES_USER=relay would make it one, able to run shell
    commands on the database server (COPY … TO PROGRAM).
  • No liveness probe: it could kill Postgres in the middle of a crash recovery, in a loop.
  • Readiness over TCP: on the first start a temporary server answers only on the local socket.

Tested before the PR

Locally in Docker (the same digest, uid 999, read-only root, no capabilities):

  • a password containing ', ;, ", \, $ and an attempted CREATE TABLE pwned(): the
    login works with exactly that password, one character less is refused, and no pwned table
    exists;
  • a forced failing CREATE ROLE: the password appears 0 times in Postgres's log. Control
    without the SET: the full statement and password are logged.

bootstrap-secrets.sh in a throwaway namespace: first run created ×2, second run kept ×2 with
unchanged resourceVersions, keys password / 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-test with the same Pod Security labels
(restricted, v1.36), filled by bootstrap-secrets.sh, then deleted:

Check Result
Pod / PVC / Service Ready, 0 restarts; Bound; ClusterIP None, no NodePort
Pod Security No warning on apply, 0 PodSecurity events
Version, checksums, max_connections 18.6, on, 100
Login from another pod with the Secret's url logged in as relay in relay
Wrong password from another pod password authentication failed
relay's flags (superuser, createrole, createdb, replication) all false
COPY … TO PROGRAM as relay permission denied
Delete the pod "received fast shutdown request" (the image's SIGINT reaches Postgres), shut down in 26 ms, back with the same data

Found while testing: the image's pg_hba.conf trusts the local socket and 127.0.0.1
without a password; only connections from other pods need one. So login checks run from a
separate pod. Only admins can kubectl exec into postgres-0 (the deployer has no exec).

After merging (an admin, in this order, output posted here)

  1. k8s/platform/storageclass-retain.yaml → kubectl get sc shows local-path-retain … Retain,
    local-path still the default.
  2. ssh relay 'bash -s' < infra/server/bootstrap-secrets.sh → created ×2.
  3. The ConfigMap postgres-init from 10-create-relay.sh (the command is in the header of
    postgres.yaml), then postgres.yaml.
  4. The checks above, now in relay, plus from outside: nc -z -G 3 81.88.23.71 443 succeeds and
    nc -z -G 3 81.88.23.71 5432 fails.
  5. The deletion drill, before any real data: a marker row survives deleting the pod, the
    StatefulSet, and the PVC (Postgres scaled to 0 first, then the kept PV re-attached with
    volumeName).

For the reviewer

  • Anything that should be applied by the pipeline instead of by hand, or the other way round?
  • Is "trusted inside the pod, password from outside" acceptable to you?
  • The 10Gi is only a label: local-path doesn't enforce sizes. Disk usage is watched in Phase 7.

@AbdikhaniAbdirashid AbdikhaniAbdirashid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. 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.

  2. 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.

@devinder-dev
devinder-dev merged commit 8cf5af3 into main Nov 1, 2026
4 checks passed
@devinder-dev
devinder-dev deleted the infra/postgres branch November 1, 2026 14:16
@devinder-dev

Copy link
Copy Markdown
Owner Author

Applied and drilled, on 1 Nov. Thanks for the careful review, especially the digest check: that's
exactly the kind of thing that looks wrong for good reasons.

Applied (runbook section 6, steps 1–4, from main after the merge), then checked from outside:

  • local-path-retain with reclaim Retain, and local-path stays the default; the claim is
    Bound to a Retain disk.
  • postgres-0's log: 10-create-relay.sh: created the login relay and its database.
  • A login from a separate pod with the Secret's url (never printed) works:
    relay | relay | super=false | createrole=false | createdb=false. The same url with a wrong
    password is refused: FATAL: password authentication failed for user "relay". The check pod
    passed restricted and was deleted afterwards.
  • The Service is headless ClusterIP. From outside, 5432 is closed, and 443 answers as the
    control, so the check itself works.

The deletion drill, in the order of runbook 6a, with a marker row in the admin database (not
in relay's). The script stopped at the first surprise; there was none:

== 0. start: 2026-11-01T14:20:50Z
   disk pvc-123cc608-4860-4e67-86a6-ea0677bf9560 (10Gi, ReadWriteOnce), reclaim=Retain
== 1. wrote a marker row in the admin database
   marker survived: "written 2026-11-01T14:20:51Z before the drill"
== 2. delete pod postgres-0
   a new pod (uid 3785cf20, was d36feefe)
   marker survived: "written 2026-11-01T14:20:51Z before the drill"
== 3. delete the StatefulSet, then apply it again
   the claim stayed: Bound, disk pvc-123cc608-4860-4e67-86a6-ea0677bf9560
   marker survived: "written 2026-11-01T14:20:51Z before the drill"
== 4. scale to 0, delete the claim (Retain must keep the disk)
   postgres-0 is gone
   after deleting the claim, the disk is still there: pvc-123cc608-4860-4e67-86a6-ea0677bf9560 Released
   freed it for one new claim (removed only its old claimRef): Available
   new claim: Bound, disk pvc-123cc608-4860-4e67-86a6-ea0677bf9560
   the same disk as in step 0
   marker survived: "written 2026-11-01T14:20:51Z before the drill"
== 5. clean up and final state
   marker table dropped
   sts postgres: 1/1 ready
   disk pvc-123cc608-4860-4e67-86a6-ea0677bf9560: Bound, reclaim=Retain
== done: 2026-11-01T14:21:07Z

Afterwards, the relay login is still there (relay|f), and the marker table is gone. So Retain
survives a deleted pod, a deleted StatefulSet and a deleted claim. As you say for Phase 6, it is
not a backup: it doesn't help if the disk itself is lost.

Your wording fix: you're right, RELAY_DB_PASSWORD stays in the pod's env, and 6b depends on
it. I didn't push it here, so your approval stays valid; it goes into #26 with the comment you
suggested.

Your question about 1Gi: yes, Phase 8 revisits it with numbers, not a guess. Today's budget is
48 connections in normal use (66 worst case) of the 100. With the default shared_buffers
(128 MB) and work_mem (4 MB per sort or hash, per query), that stays well under 1Gi at our
query sizes. The k6 test will measure it (kubectl top and pg_stat_activity during the peak),
and we raise the limit if the peak passes about 70 % (the VPS has room). An OOM kill wouldn't
lose committed data: Postgres replays its WAL on the restart. But it would cut every connection,
so we don't want to find it in the demo.

devinder-dev added a commit that referenced this pull request Nov 1, 2026
> **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?
devinder-dev added a commit that referenced this pull request Nov 2, 2026
….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.
AbdikhaniAbdirashid pushed a commit that referenced this pull request Nov 3, 2026
## 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants