Skip to content

Commit 24a1ef0

Browse files
authored
fix(#341): pin dev by digest so pods converge; symmetric convergence check (#342)
* fix(#341): pin dev by digest (convergent) + symmetric convergence check dev pinned the moving :main tag with imagePullPolicy: Always, which is non-convergent: Always re-resolves :main -> digest independently per pod at each (re)start, so pods started seconds apart or restarted later (liveness/eviction/cron) silently drift onto different builds. This is the exact cross-pod skew dev's >=2 replicas exist to surface, yet it was invisible: AGENTS.md documented convergence verification for prod only. - k8s/dev-deployment.yaml: pin main@sha256:<digest>, IfNotPresent (like prod) - AGENTS.md: dev redeploy is now a promote-by-digest flow; convergence check is stated once for both dev and prod, with the Terminating-zombie caveat (never force-delete). * fix(#341): refresh the dev pin to current :main; document a docker-less digest lookup The pin in this PR was read 18 days ago. :main has moved five builds since, and dev has been hand-pinned twice in the meantime to keep up — most recently to sha256:f9be5dae (git_sha 19b3c70, current main tip), which is what the live deployment and both serving pods run right now. Merging the stale sha256:1b0f133f would have made git disagree with the cluster and silently rolled dev back 18 days on the next `kubectl apply` — the same class of drift this PR exists to prevent, just slower. Re-read the digest from the registry and pin what dev actually runs, so the merge is a no-op against the live deployment. Also document the registry-v2 fallback for reading a digest. The `imagetools inspect` line assumes a docker CLI, which a JupyterLab session doesn't have, and the GHCR packages API needs a read:packages scope our gh logins generally lack — so the documented step was not executable from the environment the rollout often gets driven from. The curl form works anywhere, with a note to validate it against prod's existing pin before trusting it.
1 parent 19b3c70 commit 24a1ef0

2 files changed

Lines changed: 52 additions & 11 deletions

File tree

AGENTS.md

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,24 @@ release tags. The image is the unit of release.
8282
- `:<git-sha>` — immutable; one per commit.
8383
- `:vX.Y.Z` — immutable; built on release tags. **prod** pins this (by digest, below).
8484

85-
**Merge to `main` → redeploy dev:**
86-
```
87-
kubectl apply -f k8s/dev-deployment.yaml
88-
kubectl rollout restart deployment/dev-duckdb-mcp -n biodiversity
89-
```
90-
dev pins `:main` with `imagePullPolicy: Always`, so the restart pulls the freshest build.
91-
**Wait for the `docker.yml` run on your merge to go green first** — rolling before the
92-
image is pushed gives `ImagePullBackOff`.
85+
**Merge to `main` → redeploy dev (promote by digest — same discipline as prod):**
86+
Dev pins an immutable `:main@sha256:…` (tag for humans, digest enforced), **not** the
87+
bare moving `:main` tag. `:main` + `imagePullPolicy: Always` is *non-convergent*: `Always`
88+
re-resolves the mutable `:main` → digest independently per pod at each (re)start, and
89+
`:main` moves on every push to `main` plus the weekly cron. So pods brought up seconds
90+
apart, or any single later liveness/eviction/crash/cron restart, silently drift onto
91+
different builds — the exact cross-pod skew dev's ≥2 replicas exist to catch (issue #341).
92+
Pinning a digest makes every dev pod run one reproducible build.
93+
94+
1. **Wait for the `docker.yml` run on your merge to go green first** — rolling before the
95+
image is pushed gives `ImagePullBackOff`.
96+
2. Read the freshly-built `:main` digest from the build run's job summary, or:
97+
`docker buildx imagetools inspect ghcr.io/boettiger-lab/mcp-data-server:main --format '{{.Manifest.Digest}}'`
98+
3. Set `image: ghcr.io/boettiger-lab/mcp-data-server:main@sha256:<digest>` in
99+
`k8s/dev-deployment.yaml` (keep `imagePullPolicy: IfNotPresent`).
100+
4. `kubectl apply -f k8s/dev-deployment.yaml`
101+
5. `kubectl rollout restart deployment/dev-duckdb-mcp -n biodiversity`
102+
6. Verify convergence (below) — this is dev's canary job; do not skip it.
93103

94104
**Tag a release → redeploy prod (promote by digest):**
95105
1. `git tag vX.Y.Z && git push origin vX.Y.Z`, then wait for `docker.yml` to build `:vX.Y.Z`.
@@ -105,11 +115,33 @@ disagree, the digest wins). **Never apply prod while the manifest points at an i
105115
hasn't built yet** — the rollout stalls on `ImagePullBackOff`. `kubectl apply` must precede
106116
`rollout restart`; a git push alone does not update the cluster.
107117

108-
Verify all prod replicas converge on a single digest after rollout:
118+
**No `docker` CLI (e.g. a JupyterLab session)?** `imagetools inspect` needs it, and the
119+
GHCR packages API needs a `read:packages` token most of our `gh` logins don't carry. An
120+
anonymous pull token against the registry v2 API works anywhere `curl` does — substitute
121+
`main` or `vX.Y.Z` for `<tag>`:
122+
```
123+
repo=boettiger-lab/mcp-data-server
124+
tok=$(curl -s "https://ghcr.io/token?scope=repository:$repo:pull" \
125+
| python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")
126+
curl -sI -H "Authorization: Bearer $tok" \
127+
-H "Accept: application/vnd.oci.image.index.v1+json" \
128+
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
129+
"https://ghcr.io/v2/$repo/manifests/<tag>" | grep -i '^docker-content-digest:'
130+
```
131+
Sanity-check the method before trusting it: run it on the tag prod already pins and
132+
confirm the digest matches `k8s/deployment.yaml`.
133+
134+
**Verify all replicas converge on a single digest after rollout** (both dev and prod —
135+
dev is the multi-replica canary, so its convergence matters as much as prod's). Swap the
136+
label for the deployment you rolled (`duckdb-mcp` for prod, `dev-duckdb-mcp` for dev):
109137
```
110138
kubectl -n biodiversity get pods -l app=duckdb-mcp \
111139
-o custom-columns='NAME:.metadata.name,IMAGE:.status.containerStatuses[0].imageID'
112140
```
141+
Every live (non-`Terminating`) pod must report the **same** `imageID` digest, and it must
142+
match the digest pinned in the manifest. Stuck `Terminating` zombie pods on unreachable
143+
NRP nodes are out of the Service's endpoints, so ignore them here — **never** force-delete
144+
them (NRP ops policy).
113145

114146
---
115147

k8s/dev-deployment.yaml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,17 @@ spec:
2323
values: ["us-west"]
2424
containers:
2525
- name: server
26-
image: ghcr.io/boettiger-lab/mcp-data-server:main
27-
imagePullPolicy: Always
26+
# Pinned by digest (not the moving :main tag) so every dev pod converges
27+
# on one reproducible build. :main + imagePullPolicy: Always is
28+
# non-convergent: Always re-resolves the mutable :main → digest
29+
# independently per pod at each (re)start, so pods brought up seconds
30+
# apart — or a single later liveness/eviction/cron restart — silently
31+
# drift onto different builds with no signal, exactly the cross-pod skew
32+
# dev's ≥2 replicas exist to catch. Bump this digest on redeploy (see
33+
# AGENTS.md → Rollout workflow). The tag prefix is for humans; the digest
34+
# is enforced. See issue #341.
35+
image: ghcr.io/boettiger-lab/mcp-data-server:main@sha256:f9be5daeb9af3d96dc77a10d78c557e911c687d5091b1e513eb8b6068b048144
36+
imagePullPolicy: IfNotPresent
2837
env:
2938
- name: STAC_CATALOG_URL
3039
value: "https://s3-west.nrp-nautilus.io/public-data/stac/catalog.json"

0 commit comments

Comments
 (0)