fix(image-refresh): re-pin the digest when a helm re-render reverts the workload to :tag - #1008
fix(image-refresh): re-pin the digest when a helm re-render reverts the workload to :tag#1008saqlainsyed007 wants to merge 7 commits into
Conversation
…he workload to :tag (client-runtime#199) image-refresh only re-pinned on a registry digest CHANGE. After `helm upgrade --reset-then-reuse-values` (the hourly auto-upgrade) re-renders the workload back to repo:tag and discards the `set image repo@digest` pin, the next tick saw `recorded == latest` and no-op`d -- leaving the workload on the bare tag (IfNotPresent), where a stale node :tag layer silently runs an OLD control-plane image. That is how a pre-#416 jobs-manager ran under a sealed egress netpol on the stg/prod fleets. Read the live workload image in the recorded==latest branch and re-pin when it is not repo@latest. shellcheck-clean; helm-unittest guard added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve the Chart.yaml version-bump collision: keep 1.9.109 (one patch above develop 1.9.108) for both version and appVersion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
bugbot run |
…ot just the api container (backend#199) Bugbot Medium on #1008: the no-op decision for tracebloc/jobs-manager read only the deployment api container. When it already matched repo@latest the loop continued and never wrote rp_set_args -- so a tick that pinned the api then died before the requests-proxy rollout, or a helm re-render that reverted only the proxy, left the proxy on :tag and later ticks skipped it forever off the api match alone. Read the requests-proxy proxy container too (when it follows the jobs-manager digest) and fall through to the re-image path -- which re-derives both jm_set_args and rp_set_args -- when it is off want. Guard the fall-through logs so they stay accurate. Regression-guarded in the script test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
bugbot run |
LukasWodka
left a comment
There was a problem hiding this comment.
Read the whole script around the change, the auto-upgrade script, the RBAC, the templates' container names, and kubectl's set image no-change path. The mechanism is correct and I'd ship A without waiting for B. Two asks before I approve, one design answer since you asked for one, and one question.
Design decision: keep the fresh-install widening. The second-tick pin is not only churn — it also fixes a reinstall onto a node whose :tag layer is already stale: under IfNotPresent that install runs the old image until the next registry publish, which is the same class this PR closes. The "ever-pinned marker" alternative would leave that case open. One extra rollout ~15 min post-install (jobs-manager is Recreate, and the resource-monitor DaemonSet rolls every node) is a fair price for an edge that is reproducible from t+15m — but it must be written down as the new contract (ask 2), because today the header promises the opposite.
Thrash check (for the record): no hourly loop. auto-upgrade only runs helm upgrade when a new chart version is published (current == latest → exit 0), so the revert happens once per chart release, and A adds one rollout ≤15 min after it. Steady state is a true no-op: kubectl set image with an unchanged ref produces an empty patch and no rollout. Worth a sentence in the body — "the hourly auto-upgrade" reads as an hourly revert.
Question — persistent mismatch. The comparison is exact string equality against ${IMAGE_REGISTRY}/${repo}@${latest}. On a cluster with a mutating admission webhook that rewrites image refs to an internal mirror (not unusual behind hospital proxies), have never equals want: every tick re-pins, the webhook rewrites again, the generation bumps, and after three ticks the #563 flap lockout freezes refresh for all images — where before this PR the same cluster re-imaged only on a real digest change. Do we know no customer cluster does this? If not, either normalise the registry prefix before comparing, or treat "still != want on the tick after a re-pin" as "environment rewrites refs, stop" rather than as another attempt.
Ask 1 — a behavioural test, not a text-presence guard. The new helm-unittest asserts check that workload_image_for_repo(), have="$(…, proxy_off_digest and the no-op log line appear in the script. Invert the comparison and they all still pass. This repo already has the harness for exactly this script — scripts/tests/image-refresh-stale-pin.bats / image-refresh-skip-streak.bats extract the rendered branch and stub get_annotation/kubectl. Please add the same for this branch: (live :tag, recorded == latest) → falls through with restart_needed=1; (api and proxy both @latest) → "no-op"/continue; (api @latest, proxy :tag) → falls through with the proxy line; (unreadable) → whatever you decide below. || return 1-hardened like the others.
Ask 2 — the header now contradicts the code. "TWO KNOWN, BOUNDED LIMITATIONS → 1. HELM RE-RENDER" still says "This tick will NOT re-pin … the loop no-ops", and the "First-tick contract" paragraph still says a fresh edge "runs repo:tag until the first real digest change"; values.yaml's "exits without touching anything" bullet says the same. All are false after this PR, and the header is what the next reader trusts. Rewrite (1) as bounded to one tick, the first-tick paragraph as "pins on the tick after first observation", and drop the "DESIGN NOTE (for review) … see PR discussion" block from the shipped ConfigMap once the decision above is taken — a customer operator cannot follow a PR thread.
Two small ones, no need to re-request:
- The helper comment says "fail-safe, same stance as
get_annotation" —get_annotationis fail-closed (non-zero → do not assume absent, skip). This helper is fail-open (empty → re-assert). Fail-open is acceptable here because the re-assert is idempotent and rolls nothing when already pinned, but a read failure then logs "a helm re-render reverted the pin" and burns an attempt on a healthy edge; skipping the image for that tick with a "could not read live image" line (the stance the SKIP_KEY read already takes) would be cleaner. Say which you chose. "-- a helm re-render reverted the pin onto :${IMAGE_TAG}"will print on every fresh install's second tick, where nothing reverted. Neutral wording: "workload is not on the pinned digest (fresh install, or a helm re-render reverted the pin)".
Verified along the way: container names match the templates; RBAC already grants get on the deployment and the resource-monitor DaemonSet; requests-proxy is rendered unconditionally so the proxy read can't hit NotFound; the Bugbot partial-re-pin fix is correct. No manifest or copy-catalog regen applies to this template.
|
Two more things surfaced after I posted, both worth folding into the revision: 1. A failed re-pin is one-shot and leaves the attempt counter stuck. 2. Helm 4 server-side apply conflict on installer-run edges. The installer pins Helm 4 ( Neither changes my verdict — both are yours to weigh in the revision. |
… unreadable, and align the docs (backend#199) Addresses LukasWodka's review on #1008. Ask 1 — a BEHAVIOURAL test, not text-presence. Adds scripts/tests/image-refresh-repin-on-revert.bats, which extracts the shipped `recorded == latest` branch from the rendered chart and drives it with the registry HEAD and the two live-workload reads stubbed: a `:tag` revert re-pins, api+proxy both on the digest no-ops, a proxy-only revert re-pins the proxy, an unreadable read skips the tick, and a registry-prefix rewrite is not a revert. Inverting the comparison reddens these, which the helm-unittest text asserts could not detect. Design question — persistent mismatch under a mutating image webhook. Compare on the @sha256 DIGEST, not the whole reference: a webhook that rewrites the registry prefix to an internal mirror keeps the digest, so a prefix-only rewrite no longer reads as a revert -- which otherwise re-pinned every tick and tripped the #563 flap lockout for all control-plane images after three ticks. A genuine revert to `:tag` carries no @sha256 and still re-pins. Nit — unreadable live image now SKIPS the re-pin this tick and retries, instead of re-asserting (which burned a #563 flap attempt on a healthy edge and logged a revert that may not have happened). The two helper comments are corrected to this fail-closed stance, and the "reverted the pin" log is neutral wording that also covers the fresh-install case. Ask 2 — the header no longer contradicts the code. Rewrites the HELM RE-RENDER limitation as HANDLED (bounded to one tick), the first-tick contract as "pins on the tick after first observation", and the two matching values.yaml bullets; and drops the "DESIGN NOTE (for review)" block from the shipped ConfigMap, since a customer operator cannot follow a PR thread. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks @LukasWodka — all four addressed in Ask 1 — behavioural test, not text-presence. Added Question — persistent mismatch under a mutating image webhook. Taken the robust option: compare on the @sha256 digest ( Nit (fail-open vs fail-closed). Chose skip-on-unreadable: an unreadable live image now skips the re-pin this tick and retries, rather than re-asserting (which burned a flap attempt on a healthy edge and logged a revert that may not have happened). Both helper comments now say fail-closed, matching Ask 2 — header ↔ code. Rewrote the Verified: |
|
bugbot run |
…'t no-op it (backend#199) Bugbot High on #1008: a re-pin does `set image` (which moves the spec to repo@digest immediately) then waits on `rollout status`. If that rollout times out on requests-proxy or the resource-monitor DaemonSet -- both OUTSIDE the top-of-tick settled guard, which only covers jobs-manager -- the tick exits under `set -e` with ATTEMPT_KEY still raised, and the next tick's live-image check reads the (already-moved) spec as on-digest and no-ops. The stuck rollout is never retried and stale :tag pods keep running while the CronJob stays green. The digest-CHANGED path self-heals here (recorded stays old until the rollout settles, so it retries); the re-pin path could not, because recorded is already == latest. Read ATTEMPT_KEY once before the loop: a raised value means a prior re-image never reached its success-reset, so the "both on digest" no-op re-enters the re-image path instead of continuing. `rollout status` then runs again -- a settled workload resets the counter (idempotent `set image`, one fast status call), a genuinely stuck one advances the counter to the #563 flap lockout, which SURFACES it rather than hiding it. Best-effort read: unreadable/absent is treated as 0, since the restart block's own read stays the fail-closed authority. Adds two bats cases: on-digest + raised ATTEMPT_KEY re-runs the rollout (restart_needed=1, no no-op); on-digest + no pending attempt stays a clean no-op. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
bugbot run |
LukasWodka
left a comment
There was a problem hiding this comment.
Re-reviewed at 74f1a14. Ran the new bats file locally (8/8), then mutated the script six ways — inverted the api digest comparison, dropped the pending-attempt retry, dropped the proxy digest check, removed both unreadable-skips, and compared the whole ref instead of the digest — and each mutation reddened exactly the test written for it. helm unittest 696/696, the two sibling image-refresh suites 30/30, shellcheck on the rendered script clean. No installer script under scripts/ changed, so no manifest regen applies.
Closed from my last pass:
- Behavioural test — yes. Extracted from the rendered chart, stubbed live reads,
|| return 1throughout. Exactly the shape I asked for. - Header/
values.yamlnow match the code; the review-only design note is out of the shipped ConfigMap. - Digest-only comparison (
${have##*@}) handles the mirror-prefix rewrite case; bats case 8 pins it. - The stuck
ATTEMPT_KEYcase is handled by re-entering the rollout when the counter is raised; bats cases 3/4 pin both sides.
Still open — the one thing between this and approve:
- Helm 4 server-side-apply field ownership. No change or reply on this. The installer pins Helm v4.2.3, whose
--force-conflictsis opt-in, and after this PR every edge haskubectl-setowning all four.imagefields within ~30 min of install and again ≤15 min after every re-render. Please confirm whether the stored apply method survives the in-cluster Helm 3 auto-upgrade; if it does, either document--force-conflictsas mandatory on operator-runhelm upgrade/--forcereinstalls (a line indocs/MIGRATIONS.mdnext to the existinglimits.cpucase is enough), or move the pin to a shared field manager. Not asking for companion B here — just the confirmation and the doc line.
Nits, no re-request needed:
- The new comments,
values.yamltext, bats header and the three latest commit subjects cite issue 199 under a different repo than the PR body'sRefsline, the original commit and the pre-existing comment nearworkload_image_for_repo. One of them is wrong — pick one. - Worth one header sentence: with the pending-attempt retry, a DaemonSet rollout that can never settle (a permanently NotReady node) reaches the flap lockout ~45 min after install rather than at the next digest change. Same class the digest-change path already had, just earlier — I think that is the right trade, but say so.
- The
pending_attemptread and its numeric sanitisation sit above the loop, so the harness injects the value rather than exercising the read; fine for now. - PR body still says "DRAFT for discussion" and "1.9.107 → 1.9.108"; it is 1.9.109 now.
Four checks were still pending when I read the gate (Cursor Bugbot, bugbot / review, Prereqs — rockylinux:9, mutation-check). Approval follows once they are green and the field-manager question has an answer.
saadqbal
left a comment
There was a problem hiding this comment.
Mechanism's right and I'd ship this independent of #1013. Traced it end to end: set image writes the pin, the auto-upgrade re-render drops it back to :tag, and the recorded == latest branch now reads the live spec and falls through to the re-image path — one tick of exposure instead of "until the next publish". I extracted the shipped branch from this head and drove it for pods-monitor and resource-monitor as well as jobs-manager, and all three re-pin correctly, so the class is covered rather than one workload short. The bats file is the real thing too — it drives the rendered branch with the reads stubbed, so an inverted comparison reddens.
One thing I'd want closed before I approve, and it's Bugbot's still-open thread rather than something new. The fresh-install widening routes every new edge through the shared #563 flap path, and the resource-monitor rollout status needs every node Ready. One permanently-NotReady node times out three consecutive ticks, FLAP_KEY latches, and refresh stops for all control-plane images with no auto-resume — the exact hazard the activeDeadlineSeconds comment further down already calls out as "looks healthy from outside". Pre-PR that edge just sat on :tag until a real publish, so this is a new way to wedge an install on day one. I think the trade is defensible, but it needs the header sentence Lukas asked for plus an operator-facing line on clearing tracebloc.io/refresh-attempt, because right now that remediation exists only inside a log message on an edge that has already stopped refreshing. Not counting a re-pin toward ATTEMPT_KEY would also close it.
Smaller, same root: with the digest stable, restart_needed is now 1 on a flap-latched edge, so the tick exits at the flap branch before the annotate_args write — the stale-pin-* bookkeeping quietly stops being written or cleared. Cosmetic next to the above, but silent.
And #1013 bumps Chart.yaml too, so whichever lands second conflicts there. Worth picking the order now rather than at merge.
shujaatTracebloc
left a comment
There was a problem hiding this comment.
Reviewed at 74f1a14. The mechanism is right and I agree with @LukasWodka and @saadqbal that A is worth shipping independently of #1013 — recorded == latest proving only that the REGISTRY moved, never that the workload runs it, is a real class of bug and reconciling from the live spec is the right shape. The bats file is the genuine article: it extracts the branch from the RENDERED chart and stubs the live reads, so an inverted comparison reddens, and every standalone assertion carries || return 1. Comparing on ${have##*@} closes the mirror-webhook thrash cleanly. I am requesting changes on three things in the new code, one of which I do not think anyone has raised yet.
Verified before writing this: chart guard satisfied (version AND appVersion both 1.9.109, in lockstep; both gate checks green). The single non-green check is Helm unit tests CANCELLED on run 34341907087 — superseded 20s later by run 34341939853 on the SAME head sha, which passed. gh pr checks (deduped) shows it as pass. That is rollup noise, not a red; it is not blocking from my side. Same artifact on #1013, whose rollup also reads FAILURE with zero real failures.
BLOCKING 1 — the forced retry is a no-op exactly when it claims to act. (New.) The pending_attempt > 0 branch logs "re-running the rollout to resolve it or surface it via the flap guard" (line 670), but once ATTEMPT_KEY >= MAX_REFRESH_ATTEMPTS the guard at line 754 annotates FLAP_KEY and exit 0s BEFORE any set image / rollout status runs. So in the latched state it neither resolves the unfinished rollout nor surfaces anything new — it only forces restart_needed=1 and suppresses the rest of the tick. Please gate it as [ "$pending_attempt" -gt 0 ] && [ "$pending_attempt" -lt "$MAX_REFRESH_ATTEMPTS" ]. That single change also fixes blocking 2.
BLOCKING 2 — a latched flap now permanently suppresses the annotation write. @saadqbal called this "cosmetic but silent"; I read it as worse than cosmetic. pending_attempt is read once at line 480 and never reset in the loop, so while a flap is latched every tick takes the forced branch, sets restart_needed=1 at line 690, and exits at line 754 — which is BEFORE the if [ -n "$annotate_args" ] write at line 836. Dropped every tick, forever: the first-observation last-refreshed-<image>-digest record (so an image on first observation re-does first observation every 15 min) and every tracebloc.io/stale-pin-* clear or write (so an operator who removes a stale values pin never sees the finding clear). FLAP_KEY is also re-annotated ~96x/day. Pre-PR these ticks had restart_needed=0 and the annotate block ran, so this is a regression this PR introduces, not a pre-existing one. Either take the fix in blocking 1, or move the annotate_args write above the restart block.
BLOCKING 3 — the fresh-install widening, with the numbers. This is the open Bugbot thread and @saadqbal's point, and I land on the same side, but the PR body's "one extra rollout ~one interval post-install" understates it and should be corrected before merge. I checked the two templates: resource-monitor-daemonset.yaml is RollingUpdate / maxUnavailable 10% with tolerations: [{operator: Exists}], so desiredNumberScheduled counts every node including NotReady and cordoned ones and rollout status can never settle on a fleet with one dead node — three ticks (~45 min after install, default 15m schedule) latch the SHARED MAX_REFRESH_ATTEMPTS=3 lockout and stop refresh for ALL control-plane images, with no auto-resume, while the CronJob stays green. And jobs-manager-deployment.yaml is strategy: Recreate with a 60s grace period, so the extra rollout is full jobs-manager downtime plus wait-for-mysql, for byte-identical content, on every fresh install and again after every chart-version bump. Combined with blocking 2, a brand-new edge can wedge on day one and then silently stop recording anything. I am not asking you to reverse the design decision — Lukas's argument about the reinstall-onto-stale-:tag case is good and I agree the widening earns its keep. I am asking for either the "have we ever applied a digest here?" gate you offered, or: the header sentence Lukas asked for, an operator-facing line on clearing tracebloc.io/refresh-attempt (that remediation currently exists only inside a log message on an edge that has already stopped refreshing), and not counting a re-pin toward ATTEMPT_KEY.
Minor — workload_image_for_repo has no *) arm. An unmatched case returns empty with status 0, and the new caller reads empty as "unreadable" and continues while logging "API read error / container absent". So a fourth repo added to the set -- list, or a container renamed in a template, silently kills the re-pin for that image on every tick while the log blames the API. The comment says the names are "contractual ... keep in sync" but nothing enforces it. Suggest *) log "BUG: no live-image reader for $1"; return 1 ;;.
Nits, no re-request needed:
- Lines 310 and 601 both say "the hourly auto-upgrade", but line 67 of this same file says "not an hourly revert" and
auto-upgrade-cronjob.yaml:232exits 0 at "already at latest" behind the chart-version gate. A reader taking the new comments at face value would size the blast radius as two Recreate rollouts an hour. Same wording issue Lukas flagged in the body. - Still
backend#199in five new places (:57,:102,:466,values.yaml:1723, the bats header) and in the last three commit subjects, while the body and the pre-existing comments sayclient-runtime#199. Lukas raised this; still open. The closing-ref gate itself is fine — the body ref is owner-qualified and uses "Refs" with no closing verb, andset-status / closing-refis green. - PR body still says "DRAFT for discussion" and "1.9.107 -> 1.9.108"; it is 1.9.109.
One question, and I want to be upfront that I could NOT verify it here — I have no cluster and no k3d in this pass, so this is a mechanism argument, not an observation. The mirror gate (IMAGE_REGISTRY != docker.io -> exit 0) correctly makes the reconcile inert on mirrored and airgapped installs, so those never get pinned — good, and the right fail-closed call. And a node that genuinely pulled from docker.io by tag HAS that digest in containerd, so repo@digest under IfNotPresent is satisfiable. The case I am unsure about is a SIDE-LOADED image (a locally built image seeded with k3d image import / ctr images import) on a cluster left at global.imageRegistry=docker.io: the store holds a local manifest under a tag alias, not docker.io's digest, so the pinned ref is not resolvable and IfNotPresent falls through to a pull — ImagePullBackOff if the edge is offline. Pre-PR that was reachable only when docker.io actually published; post-PR the second tick makes it deterministic ~15 min after every fresh install. Scope-limiting fact in your favour: k3d image import appears nowhere in this repo, so it is not a documented install path and this may be purely a local-dev / local-e2e concern rather than a customer one. Can you confirm that no supported install seeds control-plane images out-of-band without setting global.imageRegistry? If the local dev loop does, one sentence in the header is enough.
On #1013: they compose rather than contradict, and I would land A first. B renders the digest from the last-refreshed-* annotation, and in A's recorded == latest branch the annotation equals latest, so A reads on-digest and no-ops — no fight. Two notes for ordering. First, the Chart.yaml numbers are already sequenced (this PR 1.9.109 from 1.9.108, #1013 1.9.110 from 1.9.108), so there is no version clash, just a textual conflict on that one line for whichever lands second — trivial rebase, not the problem @saadqbal's note implies. Second, and more substantive: Lukas's still-open Helm 4 server-side-apply field-ownership question gets strictly WORSE with B landed, because helm then actively renders a value for the same .image field that kubectl-set writes, instead of leaving it at :tag. Worth answering that before B rather than after.
Not blocking on my side: the superseded-run CI noise, and the pre-existing-skew follow-up.
… stops suppressing the annotation write (backend#199) blocking 1+2 (@shujaatTracebloc on #1008): the pending_attempt>0 forced re-run is a no-op once ATTEMPT_KEY latches -- the flap guard exit 0s before any set image/rollout status -- and worse, every latched tick then skips the annotation write (first-observation records, stale-pin clears) forever. Gate it on pending_attempt < MAX_REFRESH_ATTEMPTS so a latched image falls to the no-op path and the tick completes. blocking 3: state the fresh-install re-pin cost honestly in the header (the resource-monitor DaemonSet rollout can latch the shared lockout on a NotReady-node fleet; jobs-manager Recreate downtime). bats 9/9. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed all three in ea89c76. Blocking 1 & 2 — fixed with the one gate you named. The forced-retry is now Blocking 3 — took the header route you offered. The first-tick-contract header now states the cost honestly: the re-pin enters the shared #563 flap path, On the |
|
bugbot run |
shujaatTracebloc
left a comment
There was a problem hiding this comment.
Withdrawing one thing I said in my review above, having now reviewed B (#1013) properly. My change-request on this PR stands unchanged — findings 1 and 2 are unaffected.
What I got wrong: I said Lukas's Helm 4 server-side-apply field-ownership question "gets strictly WORSE with B landed, because helm then actively renders a value for the same .image field that kubectl-set writes, instead of leaving it at :tag."
The premise is false. repo:tag is a rendered value for .image — helm has always declared and owned that field, and that ownership is exactly why the client-runtime#199 revert happens in the first place. B changes the value helm writes, never the ownership set; it adds no manager. Concretely, on the next helm upgrade after a kubectl set image, helm reclaims the field and writes what the chart rendered — it does not error and does not preserve the digest. Pre-B it reclaims and writes the wrong value (:tag, i.e. this bug); post-B it reclaims and writes the right one. So B is neutral on ownership and better on outcome, not worse.
One fact that defuses the question further, for @LukasWodka: the fleet path is Helm 3 today — autoUpgrade pins alpine/helm 3.16.4 (values.yaml:1624-1626) and CI's render job pins v3.15.4 — so server-side-apply ownership does not reach the fleet at all until someone bumps that tag to 4.x.
And one thing I said too weakly, which reviewing B sharpened. I wrote that A and B "compose rather than contradict" and that I "would land A first". The composition claim holds in the steady state, but "A first" is stronger than a preference — it is a correctness precondition for B. B's authority is the last-refreshed-* annotation, and that annotation lags the live spec in two reachable states: a rollout that times out (set image has moved the spec, set -e exits before the annotate) and helm rollback (which re-applies a stored manifest rather than re-rendering). In both, B renders the older digest and pulls the workload backwards. What heals it is precisely A's live-spec reconcile, within one tick. Without A, recorded == latest no-ops and the stale digest sticks until the next upstream publish.
So the ordering in my review was right, but the reason matters more than I gave it credit for — and it is an argument for getting A's two blockers closed rather than parking A behind B.
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit ea89c76. Configure here.
shujaatTracebloc
left a comment
There was a problem hiding this comment.
Re-reviewed at ea89c76. All three blockers are closed — thanks for the fast turnaround, and for taking the gate rather than the bigger refactor. Verified locally rather than read off the diff: 9/9 bats in a throwaway worktree, helm unittest 38/38, shellcheck -S warning -x clean on the rendered script.
Blocking 1 — CLOSED. Line 672 now reads [ "$pending_attempt" -gt 0 ] && [ "$pending_attempt" -lt "$MAX_REFRESH_ATTEMPTS" ], so a latched image falls to the no-op arm and continues. The log at 693 now fires only when there is budget left, i.e. only when the restart block genuinely reaches set image + rollout status — text and behaviour agree. The new bats case is mutation-proof, not text-matching: I dropped the < MAX bound, and separately weakened -lt to -le, and case 5 reddens on each while the other eight stay green. It enforces the bound, not its presence.
Blocking 2 — CLOSED as filed. With the gate, a latched tick keeps restart_needed=0, the restart block at 749 is skipped, and control reaches the annotate_args write at 859. I traced both artefacts by hand: a first-observation record (607) and a stale-pin clear (535/563/587) accumulated by another image both survive a latched tick. The permanent suppression is gone.
One correction to my own review: I offered "or move the annotate_args write above the restart block" as an alternative. That was wrong, and you were right not to take it — the comment at 745 says why: annotating the digest before a successful rollout status would let a failed rollout freeze the workload on the old image, because the next tick then reads recorded == latest. Withdrawn.
The residual, for the record and not for this PR: the flap guard's exit 0 at 790 still precedes the write at 859, so a tick that is BOTH off-digest and latched still drops that tick's Pass 0 annotations. The mechanism is pre-existing — on develop, any digest-change tick under a latched flap does the same — and the real fix is to split annotate_args: the stale-pin clears can move above the restart block, the key=latest digest records cannot. Its own issue, not a blocker here.
Blocking 3 — CLOSED on the documentation option. The header at 106-119 states the cost with the numbers: the shared MAX_REFRESH_ATTEMPTS lockout, desiredNumberScheduled counting every node under tolerations: Exists, three ticks / ~45 min at the 15m schedule, refresh stopped for ALL control-plane images while the CronJob stays green, and jobs-manager's strategy: Recreate downtime for byte-identical content. values.yaml 1718-1730 carries the operator-facing version. The remediation line is reachable and names the key verbatim (786: clear the tracebloc.io/refresh-attempt annotation to re-arm refresh).
The third sub-item I asked for — a re-pin not counting toward ATTEMPT_KEY — is not in, and I accept the deferral. @LukasWodka's point on PRRT_kwDOMzdex86gnkwj holds: the same counter and the same DaemonSet wait were already reachable at the first upstream digest publish, so this PR moves when the lockout becomes reachable, not whether. Please file the "have we ever applied a digest here?" gate as a real issue rather than leaving it a promise in a comment — it is the thing that removes the fresh-install roll outright.
One thing the gate itself introduced — the last item, and it is one line.
restart_needed is the only path into the flap guard at 749, and 788 is the only writer of FLAP_KEY. So on a tick where pending_attempt >= MAX_REFRESH_ATTEMPTS, every image now falls to the no-op arm, restart_needed stays 0, the guard never runs, and:
FLAP_KEYis never written;- the "MANUAL ATTENTION NEEDED" WARN at 786 never prints;
- 695 logs "digest unchanged and workload already on the pinned digest; no-op" on the exact tick the script knows an earlier re-image never settled.
Traced on your own fresh-install-with-one-cordoned-node case: tick 2 re-pins and the DaemonSet rollout status times out (ATTEMPT_KEY=1); ticks 3 and 4 force the retry to 2 then 3; from tick 5 on, refresh is dead for every control-plane image, FLAP_KEY is absent, and the CronJob is green and quiet — until the next upstream digest publish, which is the "can be never" this PR exists to fix. Before this push that state was loud (the forced branch set restart_needed=1, so 786/788 fired every tick) at the cost of dropping the annotation write; the gate fixes the drop and takes the noise with it. The comment you added at 676-681 asserts the opposite — "advancing it to the #563 flap lockout, which SURFACES the stuck rollout rather than hiding it" — which is true of the bounded arm and no longer true of the latched one. And it is exactly what #1964 wrote down as the rule for this file: "images did not update" must never be inferable only from the Job's colour.
Suggested, in the latched arm before the continue:
if [ "$pending_attempt" -ge "$MAX_REFRESH_ATTEMPTS" ]; then
log " WARN: workload is on the pinned digest but ${ATTEMPT_KEY}=${pending_attempt} (>= MAX_REFRESH_ATTEMPTS=${MAX_REFRESH_ATTEMPTS}) -- FLAP LATCHED: image refresh is STOPPED for ALL control-plane images and does not auto-resume. MANUAL ATTENTION NEEDED: clear the ${ATTEMPT_KEY} annotation on deployment/${DEPLOYMENT_NAME} to re-arm refresh."
kubectl annotate deployment -n "$RELEASE_NAMESPACE" "$DEPLOYMENT_NAME" \
"${FLAP_KEY}=${pending_attempt}" --overwrite --request-timeout=15s
fi
log " digest unchanged and workload already on the pinned digest; no-op"
continueA bats case asserting the WARN at pending=3 would pin it; the harness already carries MAX_REFRESH_ATTEMPTS=3.
Still open from my last pass, all non-blocking:
workload_image_for_repo(338) still has no*)arm. Unreachable today — theset --list holds exactly those three repos — but an unmatchedcasereturns empty with status 0, which the caller at 657 reads as "unreadable" andcontinues while logging "API read error / container absent". It pairs with the other half: the two new unreadable skips at 658/662 have no counter and no ceiling, unlike every other cannot-read path in this file (SKIP_KEY/MAX_SKIP_TICKSat 395-423, orexit 1at 396/430), so a persistently-empty read disables the re-pin forever and green.*) log "BUG: no live-image reader for $1"; return 1 ;;plus a streak annotation mirroringSKIP_KEYcloses both.- 323 and 613-614 still say "the hourly auto-upgrade", against 67 of this same file ("not an hourly revert") and
auto-upgrade-cronjob.yaml231-233, whichexit 0s whenLATEST = CURRENT. It overstates the exposure the header asks reviewers to price. backend#199should beclient-runtime#199, unchanged since my last pass, in five places:image-refresh-cronjob.yaml57, 102, 479;values.yaml1723;image-refresh-repin-on-revert.bats5. Plus this commit's subject. A barebackend#199points at an unrelated issue in another repo.image-refresh-repin-on-revert.bats13 namesproxy_off_digest; the script and the helm-unittest guard both useproxy_on_digest.- The PR body still opens "DRAFT for discussion" and says "Chart.yaml bumped 1.9.107 -> 1.9.108" while
Chart.yamlis 1.9.109. This becomes the squash-merge message.
CI: fully green at ea89c76 — 53 pass, 4 skipped, nothing failing or pending, and all three Bugbot threads resolved. For the record, bugbot / review went red earlier on this head and it was an artifact of its own wait window, not a finding: the gate gave up at 900s with Bugbot still IN_PROGRESS (its log records p50 164s / max 635s over 40 runs), Bugbot then finished at 13:19 with "no new issues", and the re-run passed in 6s.
So the only thing between this and my approval is that one WARN/FLAP_KEY line. Push it and I approve on sight — nothing else is outstanding from me, and I am not asking for the non-blocking list above to be a condition (though the PR-body sweep is worth doing before the squash).
LukasWodka
left a comment
There was a problem hiding this comment.
Re-reviewed at ea89c76. Ran image-refresh-repin-on-revert.bats 9/9 plus the two sibling image-refresh suites (16/16, 14/14), helm unittest 696/696, shellcheck on the rendered script clean. Mutated the new gate two ways — dropped the < MAX bound, and weakened -lt to -le — and case 5 reddened on each while the other eight stayed green, so the test enforces the bound rather than its presence. The round-2 mutations still bite (inverted digest comparison → 7/9 red; forced-retry branch unreachable → case 3 red). No installer script under scripts/ changed, so no manifest regen applies.
Closed from my last pass:
- Helm 4 server-side-apply field ownership — closed, no change needed. Two facts settle it. The in-cluster auto-upgrade runs
alpine/helm:3.16.4(values.yaml:1624-1626), so server-side-apply ownership does not reach the fleet path at all until that tag moves to 4.x. And the operator-run Helm 4 path is already documented:docs/MIGRATIONS.md§ "helm upgradeaborts with a server-side apply conflict" namesimage-refresh'skubectl-setpins in the conflict list, says--force-conflictsreverts them andimage-refreshre-pins on its next tick, and mandates--server-side=truebecause a rollback (including the auto-upgrade's automatic one) reverts the stored apply method to client-side. That is the line I was asking for; it was already there. Thanks @shujaatTracebloc for the Helm 3 pointer. - Header sentence on the fresh-install trade — closed. Lines 106-119 state it with the numbers: the shared flap path,
desiredNumberScheduledcounting every node undertolerations: Exists, three ticks / ~45 min at the 15m schedule, refresh stopped for all control-plane images while the CronJob stays green, and jobs-manager'sRecreatedowntime for byte-identical content. That is the trade I accepted on the Bugbot thread, now written down. Please file the "have we ever applied a digest here?" gate as a real issue rather than a promise in a comment. < MAXgate — correct. The forced retry runs for1..MAX-1and stops exactly where the guard at line 777 would haveexit 0'd, so a latched tick keepsrestart_needed=0, skips the restart block and reaches theannotate_argswrite. First-observation records and stale-pin clears land again. Case 5 pins it.
Endorsing @shujaatTracebloc's one open item — this is the last thing before I approve too. With the gate, the latched on-digest state is now silent: FLAP_KEY is only written at line 788 and the MANUAL ATTENTION NEEDED WARN only prints at 786, both inside the restart_needed -eq 1 block that a latched tick never enters. Traced on the header's own case: tick 2 re-pins and the DaemonSet rollout times out (refresh-attempt=1), ticks 3-4 force the retry to 2 then 3, and from tick 5 every image logs "digest unchanged and workload already on the pinned digest; no-op", refresh is dead for all control-plane images, no annotation says so, and the CronJob is green — until the next upstream publish, which is the "can be never" this PR exists to fix. On develop that state is unreachable (a fresh install never re-pins), so it is new here. The comment at 676-681 ("advances it to the flap lockout, which surfaces it") is now true only of the bounded arm. This is exactly the rule #1964 wrote for this file: "images did not update" must never be inferable only from the Job's colour. The fix is the one-liner Shujaat posted — in the else arm, when pending_attempt >= MAX_REFRESH_ATTEMPTS, print the WARN naming the refresh-attempt clear and annotate FLAP_KEY=${pending_attempt} before the no-op continue — plus a bats case asserting the WARN at pending=3 (the harness already carries MAX_REFRESH_ATTEMPTS=3). Approve on sight once that lands.
Nits, unchanged since last pass — worth sweeping before the squash, not a re-request:
- The issue-199 citation is still split between two repos:
image-refresh-cronjob.yaml57/102/479,values.yaml1723, the bats header line 5 and the four latest commit subjects cite one repo; the PR bodyRefsline, the first commit,cronjob331/618,values.yaml476/587 and bats line 9 citeclient-runtime#199. This repo is public — the wrong one points readers at an unrelated issue in a repo they cannot see. Pickclient-runtime#199. image-refresh-repin-on-revert.bats:13namesproxy_off_digest; the script and the helm-unittest guard useproxy_on_digest.- Lines 323 and 614 say "the hourly auto-upgrade"; line 67 of the same file says "not an hourly revert", and the auto-upgrade exits 0 at latest. Overstates the exposure the header asks a reader to price.
- PR body still opens "DRAFT for discussion" and says "1.9.107 → 1.9.108";
Chart.yamlis 1.9.109. This becomes the squash-merge message. values.yaml1718-1730 describes the pin-on-next-tick contract without the flap cost the header now states; one clause would keep the operator-facing text honest too.
CI fully green at ea89c76 (53 pass, 4 path-filtered skips), all three Bugbot threads resolved. Holding at comment only because Shujaat's change-request already carries the block above; nothing else is outstanding from me.
…up tests (client-runtime#199) Addresses @LukasWodka's review on #1013. RECONCILES A REVIEWER CONFLICT (@shujaatTracebloc). shujaat's earlier BLOCKING-2 asked that imageRefresh.suspend UN-PIN — render :tag + Always so a newly joined node re-pulls the current tag. LukasWodka's point 1 shows that is wrong: suspend is used to FREEZE an edge during an incident, and un-pinning re-renders jobs-manager (Recreate), requests-proxy and the resource-monitor DaemonSet onto :tag/Always — three unplanned rollouts plus a downgrade to whatever :tag points at now — contradicting the values.schema.json promise that suspend "pause[s] without removing the resources". Decision: suspend stops POLLING, not un-pinning. Removed the (not $ir.suspend) gate from both tracebloc.controlPlaneDigest and tracebloc.controlPlanePullPolicy, so a suspended edge keeps rendering the last-observed @digest + IfNotPresent. This still answers shujaat's newly-joined-node concern: that node pulls the frozen @digest — a real, previously-resolved image — not a missing one. Also in this push (LukasWodka's other points): - Add client/tests/control_plane_digest_test.yaml — a clusterless suite that fakes `lookup` via helm-unittest 0.5.2's kubernetesProvider. Covers the five image sites (init, api, pods-monitor, requests-proxy-follows-jobs-manager, resource-monitor), operator-pin beats the annotation, malformed -> :tag, the suspend-keeps-pin decision, and the disabled/mirror/first-install :tag edges. Mutation-verified: discarding the lookup reddens the digest cases, re-adding the suspend gate reddens the suspend case, neutering the regexMatch reddens the malformed case — the exact clusterless ceiling shujaat noted, now lifted. - Remove the duplicate/misleading pull-policy cases appended to jobs_manager_test.yaml (the L1236 verbatim dup of L1130 and the now-wrong suspend->Always case, whose block comment wrongly claimed the helper "must NOT render that digest"); the private backend#199 ref in that comment goes with it. - Docs the PR made false: image-refresh CronJob "HELM RE-RENDER" limitation now marked RESOLVED as of 1.9.110; SEAL-CHECK.md no longer tells operators to pin values to survive a revert the chart no longer performs. - First-observation churn documented in the CronJob first-tick contract; helper docstring "last APPLIED" -> "last OBSERVED"; #569 offline-guarantee narrowing noted at controlPlanePullPolicy; MIGRATIONS.md server-side-apply gets a line that from 1.9.110 a Helm 4 .image conflict signals annotation/live lag. - e2e-auto-upgrade.sh: the unpullable sentinel now documented and asserted explicitly (path 1 spec-only check + Init:ImagePullBackOff-by-design comment) so the next author is not surprised. Merge order unchanged: B stays behind A (#1008). Chart stays 1.9.110. helm lint clean; helm unittest 706/706 (41 suites); helm template renders :tag clusterless and @digest on the operator pin; shellcheck + bash -n clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Refs tracebloc/client-runtime#199
The bug (the script already documents it)
image-refreshre-pins a control-plane image only when the registry digest changes (recorded != latest). But the header's own note (case 1, "HELM RE-RENDER") acknowledges thathelm upgrade --reset-then-reuse-values— the hourlyauto-upgrade— re-renders the Deployment back torepo:tagand discards the liveset image repo@digestpin, and that the script "will NOT re-pin: the annotation still records that digest, sorecorded == latest… converges … on the next digest change."That "next digest change" can be never. In the meantime the workload sits on the bare
:tag(IfNotPresentpertracebloc.controlPlanePullPolicy), and on a node whose:taglayer is stale, it silently runs an old image. Concretely: a pre-#416 jobs-manager ran under the already-sealed egress netpol on the stg/prod fleets (HF-offline flags silently not injected) until I hard-pinned the digest in values. dev didn't hit it (its node pulled fresh), so it's cache-dependent and non-deterministic.The fix (this PR — "A")
In the
recorded == latestbranch, don't unconditionally no-op: read the live workload image (workload_image_for_repo) and re-pin when it isn'trepo@latest. So the tick after any helm re-render restores the digest, instead of waiting for the next registry publish. Bounds the exposure to ≤ one interval (~15 min).get_annotationalready gets the Deployment in the release ns; the node-agents Role is*/*/*for the DaemonSet). Respects thebackend#2469"NO NEW READS" bootstrap-lockout rule.shellcheck -S warning -xclean on the rendered script;helm-unittestguard added (38/38 pass); Chart.yaml bumped 1.9.107 → 1.9.108.Design decision to confirm (in the code comment too)
This also converges a fresh install to a digest pin on the first tick where the workload is still on
:tag— one extra rollout ~one interval post-install, a deliberate widening of the header's "first-tick contract." That contract avoided install churn, but by staying on:tagit left exactly the steady-state stale-:tagexposure this closes. If the install-churn cost is unwanted, gate the re-pin on a per-image "have we ever applied a digest here?" marker so it fires only on genuine reverts — I can switch to that; it trades immediacy on already-pinned fleets for a clean install.Not this PR — the companion "B" (fully closes the window)
A alone bounds the window to one tick; it doesn't eliminate it. B makes
helm upgradenever revert in the first place — e.g. the Deployment/DaemonSet templates seed the image digest from thetracebloc.io/last-refreshed-*-digestannotation viahelm lookup, so a re-render rendersrepo@<current-digest>(empty on fresh install/CI → bare tag, fine). Bigger blast radius (lookupis empty duringhelm template/diff), so it's worth its own review — say the word and I'll draft it.Explicitly NOT changing the pull policy
Always-when-unpinned would re-break #569/#705 (offline Docker/WSL restart →ImagePullBackOff). The fix is coordinating image-refresh's digest with the helm re-render, not the pull policy.🤖 Generated with Claude Code
Note
Medium Risk
Changes edge control-plane rollout behavior (jobs-manager Recreate, resource-monitor DaemonSet flap path) on fresh installs and after chart bumps; incorrect live-image logic could cause extra rollouts or miss stale tags on cached nodes.
Overview
Fixes image-refresh so control-plane workloads are not left on a bare
:tagafter fleet helm auto-upgrade re-renders deployments and drops an earlierkubectl set imagedigest pin (backend#199 / client-runtime#199).When the recorded registry digest matches the latest, the cron script now reads live deployment/daemonset image refs (
workload_image_for_repo,requests_proxy_image) and re-pins if the spec is not on that digest—bounded to about one cron interval (~15m). Digest comparison uses the@sha256suffix so registry-prefix mirrors are not treated as reverts. requests-proxy is checked alongside jobs-managerapiso partial re-pins cannot look “unchanged” forever. ATTEMPT_KEY handling retries unfinished rollouts when the spec already shows the digest but rollout never settled, without forcing retries once flap lockout has latched.Docs/comments and values.yaml describe the widened first-install path: record on first tick, pin on the next when still on
:tag. Chart 1.9.109; helm-unittest regex guards plus new bats (image-refresh-repin-on-revert.bats) assert behavior, not just script text.Reviewed by Cursor Bugbot for commit ea89c76. Bugbot is set up for automated code reviews on this repo. Configure here.