Repository navigation
infra: roll back to the last good commit and wait for every Deployment - #14
Conversation
…15) Step 1.7: the API's Kubernetes manifests in `k8s/app/`. This PR doesn't deploy anything. The pipeline's deploy job comes in 1.8, once #12 (the image) and #14 (rollback) are merged. ## What's in it - **`deployment.yaml`:** - 2 pods (VG1). - Rolling update with `maxSurge: 1, maxUnavailable: 0`, so there are never fewer than 2 ready pods (VG2). - Image `relay-api:__GIT_SHA__`, filled in by `relay-deploy`. - Probes, and a `preStop` sleep of 5 s. - Requests 100m CPU and 128Mi memory, limit 256Mi (the 1.3 load test peaked at 93 MB). - Runs as uid 1000 (not root), with no privilege escalation, all capabilities dropped, and the default seccomp profile. - **`service.yaml`:** port 80 → the pods' port named `http`. - **`ingress.yaml`:** `api.myrelay.tech`, with a `letsencrypt-prod` certificate in namespace `relay`. ## The probes - **Readiness:** `/health/ready` every 5 s. After 3 failures (about 15 s), the pod is taken out of the Service but keeps running. - **Liveness:** `/health/live` every 20 s, starting after 15 s. After 3 failures (about 60 s), the container is restarted. Liveness is the more patient one because a restart is more drastic than losing traffic. It also never checks the database: a database outage must not restart every pod. ## Why `runAsUser: 1000` The image says `USER bun`, which is a name. Kubernetes can only verify `runAsNonRoot` against a number. With just the name, the pod would fail with `CreateContainerConfigError`. ## Tested - **A server-side dry run as the `deployer` account itself** (`sudo -u deploy kubectl … --dry-run=server`): all three files were accepted. A dry run is authorized like a real request, so this proves the pipeline may apply exactly these files. - **The probe paths against the real image.** A loop reads every `path:` from `deployment.yaml` and curls the running container: `200 /health/ready`, `200 /health/live`. The dry run alone can't catch a wrong path: it also passed with `/ready` and `/health`, which both answer 404. ## At the first deploy (1.8) The hello app in `default` also claims `api.myrelay.tech`, so we delete it by hand at that moment, because two Ingresses for one host would compete. cert-manager then issues a new certificate for namespace `relay`. ## Added after an independent review of Phases 0–1 - **`automountServiceAccountToken: false`:** the API never talks to Kubernetes, so its pods get no token at all. - **`readOnlyRootFilesystem: true`:** tested with the real image (`--read-only --cap-drop ALL --user 1000:1000`). `/health/ready`, `/health/live` and `/version` answer 200, and `docker stop` exits 0 in 0.15 s. - **A comment on why there's no CPU limit:** a limit would throttle the API even when the server has CPU to spare. The request is what scheduling, and later the HPA, use. - The server-side dry run still passes.
AbdikhaniAbdirashid
left a comment
There was a problem hiding this comment.
Approving. The rollback is a real improvement over rollout undo, and the test script is the part I liked most — being able to check the script's decisions with no server and no cluster is what made this reviewable at all. Line comments on four things, none blocking.
What I checked myself
- Ran
bash infra/server/test-relay-deploy.sh infra/server/relay-deploy.sh: 26 passed, 0 failed, matching your description. - Gate 1 before the lock, gate 2 inside it. The
git fetchsits afterflock, which is what matters — two deploys can't move the shared checkout under each other. - The injection case genuinely stops:
aaa…; touch /tmp/pwnedis refused and nothing is checked out. - Test 9 is the one I'd have missed. The last-good note feeds
gitandsed, GNU sed can run commands, and re-applying gate 1 to the note closes that. Good catch. - The events rule in
deployer.yamlmatches whatshow_stateactually calls, and the Role is still confined torelaywith no cluster-wide verbs.
Not a line comment, because it's about us rather than the code: two manual steps after merge — deployer.yaml applied by hand, then the script reinstalled. I checked the server and it's still on the old version. Can we agree who does both, and when? If the RBAC step is missed, everything looks fine until a rollout fails and the events call is denied — exactly when we need the output.
Questions
- When the dashboard's manifests land in
k8s/app/, every deploy will wait on both Deployments even when only one changed. Is that intended? I think yes — it's simpler, and it's honest about what "deployed" means — but it doubles the worst case, which feeds into my comment on line 30. kubectl applywithout--prune, and the README says we delete by hand. What happens the first time we rename something ink8s/app/? The old object stays and keeps serving. Worth naming that in the README as the case to watch for?
| # 2. One deploy at a time: a second deploy waits here for up to 5 minutes. | ||
| # The lock is released by itself when the script ends, even if it crashes. | ||
| exec 9>/home/deploy/.relay-deploy.lock | ||
| flock --wait 300 9 || fail "another deploy is still running" |
There was a problem hiding this comment.
The one number I'd like changed.
This waits 5 minutes, but a deploy can hold the lock for much longer. One deploy_commit waits up to 180 s per Deployment, and k8s/app/ will hold two (API and dashboard), so 6 minutes. A failed deploy plus its rollback is two of those — up to 12 minutes — while the next deploy gives up after 5 and exits 1.
So merging two PRs close together, with the first one failing, turns the second build red for a reason that has nothing to do with its code. We'll be merging a lot in the week before the demo.
Either raise --wait above the worst case, or make "someone else holds the lock" a distinct, obviously-retryable message rather than a plain failure.
| # Wait until each Deployment it touched has its new pods ready (the API, the dashboard, | ||
| # and later the worker), so one that never gets ready can't pass as a success. | ||
| for deployment in $(echo "$applied" | grep '^deployment'); do | ||
| if ! kubectl rollout status "$deployment" --timeout=180s; then |
There was a problem hiding this comment.
Is 180 s enough for a cold image pull? The first deploy of a new image pulls ~84 MB of base layers, and with maxUnavailable: 0 the pods roll one at a time, so it's two sequential pull-and-start cycles.
Probably fine, but a timeout that is occasionally too short is worse than one that's clearly generous: it triggers a rollback of a deploy that would have worked.
| # them must never change the result. | ||
| show_state() { | ||
| echo "--- pods" | ||
| kubectl get pods -o wide || true |
There was a problem hiding this comment.
Not a bug — a note for the next reader. This and rollout status below run without -n relay, so I went looking for a bug. It's fine: setup-deploy-user.sh sets namespace: relay on the kubeconfig context.
A one-line comment saying the namespace comes from the kubeconfig would save the next person that hunt. If that line in setup-deploy-user.sh ever changes, every one of these commands silently targets the wrong namespace.
| return 1 | ||
| fi | ||
| done | ||
| } |
There was a problem hiding this comment.
Small: no closing return 0. The function currently returns the for loop's status, which is 0, so it works today.
But the contract is "0 on success, 1 on any failure", and right now that holds only because the last statement happens to be a loop. Add a line after done and the function can start lying about its result. One return 0 makes it true by construction.
|
Thanks, and good that you ran the harness yourself. All four line comments are fair. They go into one small follow-up PR, opened today:
Questions
The two manual steps: I'll do both right after merging, today: |
|
Both manual steps are done (deployer.yaml first, then the script), checked from outside: The follow-up PR with your four line comments comes next. |
Follow-up to #14, from Abdikhani's review of it. ## `infra/server/relay-deploy.sh` - **Lock (line 30):** waits 15 minutes instead of 5, which is longer than the worst case of a deploy plus its rollback. If it still can't get the lock, it says "nothing was changed, re-run this job", so the failure is obviously retryable. In CI this rarely waits at all, because the pipeline's `concurrency` group already runs main's deploys one after the other. The lock protects against an admin deploying by hand at the same moment. The deploy job's timeout goes from 20 to 30 minutes in #16. - **Namespace (line 43):** a comment saying every kubectl call gets namespace `relay` from the kubeconfig's context, set by `setup-deploy-user.sh`. - **`return 0` (line 81):** `deploy_commit` now ends with an explicit `return 0`, so "0 on success" holds by construction. - **180 s per rollout:** unchanged on purpose. With one node, an image is downloaded once, and each commit only changes our small code layers. The first deploy's log will show the real time. ## `infra/server/test-relay-deploy.sh` - **Test 10:** when another deploy holds the lock, the script exits 1, tells you to re-run, and changes nothing. Result: `29 passed, 0 failed`, and `shellcheck` is clean. ## `k8s/README.md` - **Renames** are the dangerous case without `--prune`: the old object stays and keeps serving. A rename = the new name in the PR, plus deleting the old object right after it merges. - **The rule from your review:** server and platform changes are applied by the one who merges, right after merging, and the check output is posted on the PR. After merging: install the script again and post the `diff` check here.
Step 1.8: every merge to main is deployed, then checked from outside. Rebased onto main now that #12, #14 and #15 are merged. **Merge only after the switch-over below.** ## What changes - **`api.yml` → `pipeline.yml`.** It now deploys too, and the dashboard's jobs will join it later (`needs:` only works inside one workflow file). - **No path filter on `main`.** Every merge is a release: a deploy needs an image tagged with exactly the merged commit, even when only `k8s/app/` or the docs changed. - **New job `deploy-script-test`** on every PR and merge. It runs `infra/server/test-relay-deploy.sh` (relay-deploy's gates and rollback, with fake git and kubectl) and `shellcheck` on the server scripts. The deploy needs it, so a broken deploy script never reaches the server. - **New job `deploy`**, after `api-image` and `deploy-script-test`, only on a merge to main: - **`environment: production`:** the only way to get `DEPLOY_SSH_KEY`, and only on main. - **`permissions: {}`:** no GitHub access at all. - **`timeout-minutes: 30`:** the worst case is the server's lock (15 min, #19) plus a rollout and a rollback. - **Step 1:** writes the key and our server's `known_hosts`, then `ssh … deploy@<vps> "$GITHUB_SHA"` (`StrictHostKeyChecking=yes`, `BatchMode=yes`). The exit code of `relay-deploy` becomes the step's. - **Step 2:** asks `https://api.myrelay.tech/version` until it answers this commit, for up to 36 × 5 s, because the first deploy also waits for a new certificate. - **`timeout-minutes: 10`** on `api-test` and `api-image` (from the #12 review). - **`k8s/hello/` is deleted**, together with its row in `k8s/README.md`. The API's Ingress takes over `api.myrelay.tech`. ## Tested - `actionlint` 1.7.7 (with shellcheck on the `run:` scripts): no findings. - The new job's two steps, run locally: `26 passed, 0 failed`, and `shellcheck` is clean. - The version check, run on its own with `bash -eo pipefail` against a local container: - the right SHA → passes; - an old SHA → keeps asking, then fails; - nothing answering → "no answer" on every try, then fails. - The rebase was rehearsed in a throwaway clone first: the 3 commits replayed onto main without conflicts. ## Already done before merging - #12, #14 and #15 merged. - `deployer.yaml` applied: the deployer can read events, and still can't read Secrets or delete. - `relay-deploy` installed, byte-identical to main. - The image is pullable without credentials (ADR 0004, #18), and the server has already pulled `relay-api:a8274c1`. ## Switch-over (right before merging) 1. Delete the hello app from `default`: the Ingress, Service and Deployment, plus its leftover TLS Secret `api-myrelay-tech-tls`. `api.myrelay.tech` is down for about 1–2 minutes, once. 2. Merge → the pipeline tests, builds, deploys → `/version` answers the commit. ## After the first deploy - **A real rollback:** deploy #15's merge commit `a78934c` by hand. It has no image, so the rollout fails and `relay-deploy` redeploys the last good commit, while a curl loop shows only 200s. - **Zero downtime:** a load loop during a normal deploy (merging #19) should show no failed requests. - Then make `api-test` and `deploy-script-test` required checks.
Follow-up to #13, from Abdikhani's review. It changes what
relay-deploydoes when a deploy fails, before the pipeline starts deploying in step 1.8.What changes
/home/deploy/.relay-last-good. If a later rollout fails, it deploys that commit again the same way (checkout, apply, wait). So ConfigMaps, Services and Ingresses go back together with the image.kubectl rollout undoonly reverted the pod template.kubectl apply -o namereports, not onlyrelay-api. That covers the dashboard, and later the worker.deploy_commit. Every step in it ends with|| return 1, becauseset -eis off inside a function that is called fromif.k8s/README.md:k8s/app/too;infra/server/test-relay-deploy.sh: tests the script's decisions with fakegit,kubectlandflock. No server and no cluster needed.The three endings of a failed deploy
All of them exit 1, so the CI job turns red, because the commit that was asked for isn't live:
Tested
bash infra/server/test-relay-deploy.sh infra/server/relay-deploy.sh→ 11 passed, 0 failed:kubectl applyfails → exit 1, and it's not recorded as good.Test 5 guards the
set -etrap. Without|| return 1on the apply line, it fails: exit 0, and the broken commit is saved as last good.The real rollback gets tested in 1.8, with a deliberately broken commit.
After merging
Install the script on the server again (see "install or update" in
k8s/README.md), then check that the two copies match:ssh relay 'cat /usr/local/bin/relay-deploy' | diff - infra/server/relay-deploy.shAdded after an independent review of Phases 0–1
whoami, an injection, an uppercase SHA and a SHA with a trailing space all stop at gate 1, with nothing checked out.deployerRole gets read-onlyget/liston events (k8s/platform/deployer.yaml), which one of us applies by hand after merging.gitandsed, and GNU sed can even run commands (e), so only a 40-hex value may reach it.26 passed, 0 failed, andshellcheckis clean on all three scripts.After merging, also apply
deployer.yamlby hand, before reinstalling the script.