Skip to content

fix(image-refresh): gate the fresh-install re-pin roll on a digest-applied marker - #1033

Merged
saqlainsyed007 merged 5 commits into
developfrom
fix/3556-fresh-install-digest-gate
Sep 10, 2026
Merged

fix(image-refresh): gate the fresh-install re-pin roll on a digest-applied marker#1033
saqlainsyed007 merged 5 commits into
developfrom
fix/3556-fresh-install-digest-gate

Conversation

@saqlainsyed007

@saqlainsyed007 saqlainsyed007 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

On recorded == latest with the workload on :tag, the image-refresh loop could not distinguish a fresh install (first-observation recorded a digest but applied nothing) from a helm re-render that reverted an already-applied pin — so every fresh install paid the shared #563 flap path (a rollout status on the resource-monitor DaemonSet, whose desiredNumberScheduled counts every node under tolerations: Exists) plus a strategy: Recreate downtime on jobs-manager, for byte-identical content the install had just pulled.

The gate skips the fresh-install roll only on positive evidence, via two durable per-image markers:

  • tracebloc.io/first-observed-<image> — stamped by the first-observation arm (we watched this workload born on :tag here).
  • tracebloc.io/digest-applied-<image> — stamped alongside the digest record only after a re-image rollout succeeds.

Decision in the off-digest arm:

  • first_observed present and applied absent → fresh install → leave on :tag (IfNotPresent-safe); the first genuine upstream digest change pins it.
  • applied present → established edge → roll (the client-runtime#199 helm-revert repair).
  • neither marker → pre-marker / legacy edge → roll (repair), then stamp applied. This is the key correction from review: the upgrade shipping this chart reverts the whole existing fleet to :tag, so skipping on marker absence alone would strand every pre-marker edge on a possibly-stale :tag until the next upstream digest change. A marker read error (vs genuine absence) skips the tick and retries — fail-closed.

Related

Split from #1008 (item 2 — the fresh-install digest gate). Item 1 from that split (the latched-flap Pass-0 annotation drop on an off-digest tick) is a separate fix and is not in this PR.

Type of change

  • Bug fix

Test plan

  • bats scripts/tests/image-refresh-repin-on-revert.bats13/13. Cases for all three shapes: fresh (first-observed only → no roll), established (applied → roll), legacy (neither → repair roll). Harness gained a 6th arg + a get_annotation stub modelling absent-vs-error.
  • helm unittest ./client729/729 (image_refresh guard updated to the two-marker code shapes).
  • make check — green. shellcheck -s sh on the rendered script — unchanged (only the pre-existing intentional SC2086 word-split notes).
  • Merged current develop (which had advanced to chart 1.9.111); bumped client/Chart.yaml1.9.112 for the chart-version guard.

Deployment notes

Behavioural change scoped to fresh installs only: a freshly-installed edge stays on the floating :tag (IfNotPresent) instead of being pinned to a digest ~one interval post-install; it is pinned on the first genuine upstream digest change. Established and pre-marker/legacy edges are unaffected — the client-runtime#199 helm-revert repair still runs for both.

Checklist

  • Tests added / updated and passing locally
  • Docs updated if behavior or config changed (header + in-script comments rewritten for the two-marker gate)
  • No secrets / credentials in the diff
  • Cross-repo issues use owner-qualified form

Note

Medium Risk
Changes control-plane image reconciliation and rollout gating fleet-wide; logic is heavily tested but a mistake could skip needed re-pins or trigger extra DaemonSet/jobs-manager rollouts on upgrade.

Overview
Fixes image-refresh so fresh installs no longer pay an expensive digest re-pin rollout when the registry digest is unchanged but the workload still runs the floating :tag.

The CronJob script now uses two per-image deployment annotations (first-observed on first observation, digest-applied after a successful re-image). When recorded == latest and the live image is off-digest, it skips rolling only if both markers show a fresh install (first_observed set, applied absent). Established edges (helm reverted an applied pin) and legacy edges (neither marker, e.g. fleet upgrading into this chart) still get the client-runtime#199 repair roll so the fleet is not left on a possibly stale tag.

Fail-closed behavior: digest and marker annotation reads no longer use || true; API/jq errors skip the image for that tick instead of mis-stamping first_observed. Chart version 1.9.112; helm-unittest and bats tests lock the gate and three edge shapes (fresh / established / legacy).

Reviewed by Cursor Bugbot for commit 5962509. Bugbot is set up for automated code reviews on this repo. Configure here.

…plied marker (backend#3556)

On `recorded == latest` with the workload on `:tag`, the loop could not tell a
genuine fresh install (first-observation recorded the digest but applied nothing)
from a helm re-render that reverted an already-applied pin. It re-pinned both,
so every fresh install paid the shared #563 flap path (resource-monitor
DaemonSet under `tolerations: Exists`) and a `strategy: Recreate` downtime on
jobs-manager for byte-identical content the install had just pulled.

Add a durable per-image `tracebloc.io/digest-applied-<image>` marker, written
alongside the digest record only after a re-image rollout succeeds (never on the
first-observation record). Its ABSENCE gates the off-digest re-pin out: a fresh
install is left on `:tag` (IfNotPresent-safe) until the first genuine upstream
digest change pins it. The client-runtime#199 helm-revert repair is unaffected
-- that path has the marker set, so it still rolls. Marker and digest record
share one annotate, so a later `recorded == latest` tick sees both or neither.

Item 2 of backend#3556 (split from client#1008). Item 1 (the latched-flap
Pass-0 annotation drop) is a separate fix, not included here.

Tests:
- image-refresh-repin-on-revert.bats: new "fresh install does NOT roll" case;
  the existing revert case now carries the applied marker (established edge).
- image_refresh_test.yaml: render guard locking the marker derive/read/write.

Header comments updated to describe the gate (the old "fresh edge reproducible
~one interval post-install" and "a follow-up may gate this" notes are now false).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread client/templates/image-refresh-cronjob.yaml

@LukasWodka LukasWodka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Read at 36c6d0c. The intent is right and the mechanics of the marker are clean: derived once per image, read only in the off-digest arm, written in the same annotate as the digest record and only after a successful rollout, with the first-observation arm writing neither. The bats pair reddens on an inverted or deleted gate, which is the proof that matters. Three things before this can go in, one of them a design gap.

1. The gate has no evidence for the fleet that already exists. The marker is written in exactly one place (:781, the re-image path), so every edge pinned before this chart version has no marker. The header's own premise (:634-637) is that the auto-upgrade re-renders the Deployment back to repo:tag and discards the pin. So the upgrade that ships this chart reverts every established edge to :tag, and on the next tick each one has recorded == latest, a workload on :tag, and no marker, which is byte-for-byte the fresh-install shape. They all take the continue at :762 and sit on :tag until the next upstream digest change. On :dev that is minutes; on :prod it can be weeks, and on a node whose :tag layer is stale it is the exact exposure the client-runtime#199 repair exists to close, fleet-wide, for one hop. "Established edges (any edge that has ever applied a digest) are unaffected" in the body and the header is not true for any edge that applied its digest before this version. A backfill in the no-op arm does not help either, because those edges are already reverted before their first post-upgrade tick.

The fail-closed shape is to skip only on positive evidence. Have the first-observation arm (:628) also write a tracebloc.io/first-observed-<image>=1 marker, and gate the skip at :762 on first-observed present and digest-applied absent. Then: a workload with neither marker is a pre-marker edge and keeps the repair roll; a new install has first-observed only and is skipped; an established edge has digest-applied and rolls. Three bats cases fall out of that (legacy neither, fresh first-observed only, established applied), and the "unaffected" claim becomes true rather than asserted.

2. Public repo, private tracker. The Related section names the private backend tracker twice (owner-qualified and bare), and the same reference appears in the new shipped header, the in-script comments, the log line at :763, the helm unittest comment and the bats comments. This repo is public. Please strip it from the title and body (a #1008 split note carries the context on its own) and, since this PR introduces every one of those comment occurrences, from the shipped comments too.

3. Chart version gate. chart content ⇒ Chart.yaml version bump is red: the template changed and client/Chart.yaml is still 1.9.110, while develop is already at 1.9.111. Bump to 1.9.112.

Happy to approve once those land and mutation-check, Unit tests and Bugbot are green.

Syed Is Saqlain and others added 2 commits September 10, 2026 18:14
…refs; bump chart

Addresses review on #1033.

1. Migration gap (High, Bugbot + LukasWodka): the single digest-applied marker
   is written only by this version, so every edge pinned by an earlier version
   has none. The upgrade shipping this chart re-renders the Deployment back to
   :tag (the same client-runtime#199 premise), so on the next tick each such edge
   is byte-for-byte the fresh-install shape and, under the old gate, took the skip
   -- stranding the whole existing fleet on a possibly-stale :tag until the next
   upstream digest change. Fix (Lukas's shape): skip only on POSITIVE evidence.
   Add first-observed-<image>, stamped by the first-observation arm; skip the
   off-digest roll iff first_observed present AND applied absent. Three shapes now
   fall out: fresh (first_observed only) -> skip; established (applied) -> roll;
   pre-marker/legacy (neither) -> roll (repair), which then stamps applied. Reads
   are fail-closed: a marker read ERROR skips the tick and retries. New bats case
   for the legacy shape; existing fresh/established cases updated; render guard
   updated to the two-marker code shapes.

2. Public repo: stripped the private tracker ref from the shipped header, the
   in-script comments, the log line, the helm-unittest and bats comments, and the
   PR title/body -- the #1008 split note carries the context.

3. Chart version guard: bump client/Chart.yaml 1.9.111 -> 1.9.112 (merged develop
   first, which had advanced to 1.9.111).

Verified: image-refresh-repin-on-revert.bats 13/13; helm unittest 729/729;
make check green; shellcheck unchanged (only the pre-existing intentional SC2086).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@saadqbal
saadqbal requested a review from LukasWodka September 10, 2026 14:24
The chart-version guard requires version and appVersion to move together
(_helpers.tpl feeds appVersion into app.kubernetes.io/version); the earlier
bump moved version only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f0856ce. Configure here.

Comment thread client/templates/image-refresh-cronjob.yaml
@saqlainsyed007

Copy link
Copy Markdown
Contributor Author

Thanks @LukasWodka — all three addressed, pushed to f0856ce.

1. Pre-marker fleet (the design gap). Fixed exactly as you prescribed: skip only on positive evidence. Added tracebloc.io/first-observed-<image>, stamped by the first-observation arm; the off-digest skip now fires iff first_observed present and applied absent. The three shapes fall out:

So the upgrade hop no longer strands the existing fleet, and "unaffected" is now true rather than asserted. Marker reads are fail-closed: a read error (as opposed to genuine absence) skips the tick and retries, matching the unreadable-have stance. Three bats cases cover legacy/fresh/established.

2. Public repo / private tracker. Stripped from the title, body, shipped header, in-script comments, the log line, and the helm-unittest + bats comments — the #1008 split note carries the context.

3. Chart version. Merged current develop (1.9.111) and bumped client/Chart.yaml version and appVersion to 1.9.112 in lockstep.

Green on f0856ce: Unit tests, Helm unit tests, mutation-check, bugbot / review, and the chart-version guard all pass.

…n't mis-stamp first-observed

Bugbot Medium on #1008. The first-observation arm now stamps first_observed,
which suppresses the client-runtime#199 repair on later ticks. But the digest
record was still read with `recorded="$(get_annotation "$key" || true)"`, which
collapses a transient kubectl/jq ERROR into "" -- so on a legacy edge (which HAS
a recorded digest) a read error would fall into the first-observation arm, stamp
first_observed, and strand that edge on :tag until the next upstream digest change.

Read fail-closed instead: get_annotation returns non-zero on a read error and
zero+empty only on genuine absence, so `if ! recorded=...; then skip; fi` makes
the first-observation arm reachable only on a real absence. Render guard asserts
the guarded read is present and the fail-open `|| true` form is gone.

Verified: image_refresh helm-unittest 39/39; repin bats 13/13; shellcheck
unchanged (pre-existing SC2086 only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@saqlainsyed007

Copy link
Copy Markdown
Contributor Author

bugbot run

@LukasWodka LukasWodka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-read at 5962509. All three asks from my first pass are in, and the fail-open read Bugbot caught after my second pass is now closed too.

  • The gate skips only on positive evidence. The first-observation arm stamps tracebloc.io/first-observed-<image> beside the digest record; the off-digest arm skips the roll only when first-observed is present AND digest-applied is absent, rolls an established edge (applied present), and rolls a pre-marker edge (neither) so the fleet upgrading into this chart is repaired once rather than stranded. The bats PRE-MARKER / legacy edge case asserts the roll, the repair log line, the pin and the applied stamp, and that the fresh-install log line is absent.
  • The digest-record read is fail-closed. recorded="$(get_annotation "$key" || true)" was the one read that could still collapse a kubectl or jq error into "absent" and walk a legacy edge into the first-observation arm, where the new marker would then suppress the repair for good. It is now if ! recorded="$(get_annotation "$key")"; then … continue; fi, skipping the image for this tick and retrying, the same stance the two marker reads and the settled guard already take. I checked get_annotation's contract on this head: success with empty output on genuine absence, non-zero only on a read failure, so the guard cannot mistake absence for an error. The Helm unit test pins both the guarded form present and the || true form absent, so a revert reddens. The remaining || true annotation reads (pending_attempt and the three stale markers) are best-effort by design, with the fail-closed authority documented at the restart block, so I did not ask for them to change.
  • Private tracker refs are gone from the title, body, shipped header, in-script comments, log lines and test comments; the #1008 split note carries the context.
  • client/Chart.yaml is 1.9.112 over develop's 1.9.111.

Approving once the suite, mutation-check, the version-bump gate and Bugbot are green on this head.

@saqlainsyed007
saqlainsyed007 merged commit d180f2a into develop Sep 10, 2026
95 of 96 checks passed
@saqlainsyed007
saqlainsyed007 deleted the fix/3556-fresh-install-digest-gate branch September 10, 2026 16:42
LukasWodka added a commit that referenced this pull request Sep 10, 2026
… same 1.9.112 that #1032 already shipped

The image-refresh CronJob fix (#1033) bumped Chart.yaml 1.9.111 -> 1.9.112 on its own
branch, and so did the control-plane trim (#1032). Both merged; the second bump was a
no-op, so develop's chart version now equals staging's while the templates differ, and
the chart-version guard rightly refuses the staging promotion. This bump carries #1033
to 1.9.113. No template change.
LukasWodka added a commit that referenced this pull request Sep 10, 2026
… same 1.9.112 that #1032 already shipped (#1040)

The image-refresh CronJob fix (#1033) bumped Chart.yaml 1.9.111 -> 1.9.112 on its own
branch, and so did the control-plane trim (#1032). Both merged; the second bump was a
no-op, so develop's chart version now equals staging's while the templates differ, and
the chart-version guard rightly refuses the staging promotion. This bump carries #1033
to 1.9.113. No template change.
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