Skip to content

fix(chart): render control-plane images from the last-refreshed digest so helm upgrade preserves the pin - #1013

Open
saqlainsyed007 wants to merge 1 commit into
developfrom
fix/199-seed-controlplane-digest-from-annotation
Open

fix(chart): render control-plane images from the last-refreshed digest so helm upgrade preserves the pin#1013
saqlainsyed007 wants to merge 1 commit into
developfrom
fix/199-seed-controlplane-digest-from-annotation

Conversation

@saqlainsyed007

@saqlainsyed007 saqlainsyed007 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

DRAFT for discussion — fix B of A+B for the image-drift class (RFC-0003 D6 egress rollout). Companion to #1008 (which fixes the image-refresh side). This one makes helm upgrade never revert the digest in the first place, closing the window #1008 only bounds.

Refs tracebloc/client-runtime#199

The problem (recap)

Control-plane images render as repo:tag + IfNotPresent when unpinned (per tracebloc.controlPlanePullPolicy, #569/#705 — deliberately, to survive an offline restart). image-refresh keeps them current out-of-band via kubectl set image repo@digest. But helm upgrade --reset-then-reuse-values (the hourly auto-upgrade) re-renders the bare :tag and drops that pin; on a node whose :tag layer is stale the pod then silently runs an old control-plane image (client-runtime#199: a pre-#416 jobs-manager under a sealed egress netpol on stg/prod). #1008 catches this on the next image-refresh tick (≤ ~15 min); this PR removes the revert entirely.

The fix (this PR — "B")

New tracebloc.controlPlaneDigest helper resolves the effective digest:

  1. operator values pin (images.<name>.digest) — wins, unchanged;
  2. else, when image-refresh is the update path (enabled + docker.io), the digest image-refresh last applied — read from the jobs-manager Deployment's tracebloc.io/last-refreshed-<image>-digest annotation via lookup;
  3. else "" (bare :tag).

Wired into all four control-plane image sites (jobs-manager api + pods-monitor, requests-proxy, resource-monitor). A helm upgrade now renders repo@<current-digest> instead of reverting to :tag.

Why it's safe / the caveats to review

  • No new RBAC. The lookup targets only the jobs-manager Deployment in the release namespace; the auto-upgrade SA's release-ns Role already grants that. Explicitly avoids the backend#2469 bootstrap-lockout rule (which is about new reads the SA can't already do).
  • lookup is empty during helm template / helm diff / the FIRST install → helper returns "":tag. That's correct there (nothing pinned yet; the node's fresh tag pull is the right image), but it means:
    • this behaviour can't be unit-tested (helm-unittest has no cluster) — the operator-pin path renders @digest (verified) and all existing unit tests pass, but the lookup-seed itself needs an e2e / real-cluster test (belongs in full-seal-e2e). Flagging that gap explicitly.
    • helm diff will show a :tag@digest churn that won't actually happen on apply — worth a note for anyone who diffs upgrades.
  • Validated: helm lint clean, helm template renders (tag when clusterless, @digest when pinned), 101/101 unit tests pass, Chart.yaml bumped.

Relationship to #1008

Independent, not stacked. #1008 (A) bounds the exposure to one tick and is the safer, self-contained fix; this (B) eliminates the window but has the bigger blast radius (lookup semantics above). Ideally land A first, then B after a cluster e2e confirms the seed. Reviewer: Shujaat (owns #569/#705); Asad for the pipeline view.

Not changing the pull policy

Always-when-unpinned would re-break #569/#705 (offline restart → ImagePullBackOff).

🤖 Generated with Claude Code


Note

Medium Risk
Changes how all four control-plane images render on every helm upgrade via cluster lookup; wrong or stale annotations could pin an unexpected digest, though operator pins still win and there is no new RBAC.

Overview
Adds tracebloc.controlPlaneDigest so control-plane workloads stop reverting to a floating :tag on helm upgrade after image-refresh has pinned them with kubectl set image (client-runtime#199).

Resolution order: operator images.*.digest (unchanged) → when image-refresh is active on docker.io, the digest from the jobs-manager Deployment’s tracebloc.io/last-refreshed-<image>-digest annotation via lookup → otherwise bare tag. That helper is wired into jobs-manager (init + api + pods-monitor), requests-proxy (jobs-manager annotation), and resource-monitor; imagePullPolicy uses the same effective digest as tracebloc.controlPlanePullPolicy.

Chart version 1.9.108 → 1.9.110. lookup is empty under helm template / first install, so behavior there stays :tag until a cluster exists and refresh has run.

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

…t so a helm upgrade preserves the pin (client-runtime#199)

Control-plane images render as repo:tag (IfNotPresent) when unpinned, and
image-refresh keeps them current out-of-band via kubectl set image repo@digest.
A helm upgrade --reset-then-reuse-values re-renders the bare tag and drops that
pin; on a node whose :tag cache is stale the pod then silently runs an OLD
image. New tracebloc.controlPlaneDigest helper seeds the effective digest from
the jobs-manager Deployment last-refreshed-<image>-digest annotation via lookup,
so a re-render renders repo@digest instead. Operator pins still win; lookup is
empty during helm template/diff/first install (-> :tag, correct there). No new
RBAC (auto-upgrade SA already reads the Deployment in the release ns).

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

Copy link
Copy Markdown
Contributor Author

✅ Validated on a real cluster (the piece that can't be unit-tested)

lookup is empty during helm template/CI, so B's core behaviour was previously unproven. Ran a non-mutating helm upgrade --reset-then-reuse-values --dry-run=server (the one mode where lookup reads the live cluster; persists nothing) against the dev fleet (tb-client-dev-templates, release tracebloc, ns tracebloc-templates), whose jobs-manager Deployment carries the real last-refreshed-* annotations.

Before (current develop chart) vs after (this PR), same command:

image current chart this PR
jobs-manager docker.io/tracebloc/jobs-manager:dev …@sha256:8cda7b64…
pods-monitor …/pods-monitor:dev …@sha256:a944cbc4…
resource-monitor …/resource-monitor:dev …@sha256:a9341b6d…

Each rendered digest matches the Deployment's tracebloc.io/last-refreshed-<image>-digest annotation exactly. So a helm upgrade under this chart renders repo@digestpreserving image-refresh's pin instead of reverting to :tag, which is the whole fix. No RBAC/forbidden error on the server-side render, consistent with the auto-upgrade SA's release-ns Role already covering the read.

Remaining caveat unchanged: helm diff will show a :tag@digest churn that won't happen on apply (lookup empty in diff). Marking ready for review.

@shujaatTracebloc shujaatTracebloc 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.

Changes requested. The mechanism is right and the fallback is genuinely safe — I confirmed helm template with no cluster renders :tag + IfNotPresent at all four sites and exits 0 (aks and bm), so the Helm chart renders gate is fine, and your no-new-RBAC claim checks out: the auto-upgrade Role is * on * in the release namespace. CI is green; the org scan's CI=FAILURE is a superseded-run artifact (a CANCELLED Helm unit tests alongside two SUCCESS runs on the same sha).

Two things I think have to change before this lands, and one merge-order point.

1. BLOCKING — the annotation reaches image: with no validation (_helpers.tpl:532). Every one of the 11 digest keys that can reach an image: field is schema-guarded by ^(sha256:[a-f0-9]{64})?$--set images.jobsManager.digest=sha256:deadbeefcafe is rejected outright, I checked. The annotation path has no equivalent, and tracebloc.image drops the tag when a digest is present, so a malformed value renders an unstartable ref that helm cannot detect (the kubelet reports InvalidImageName; the apiserver accepts the spec happily).

This is already live in the repo: scripts/tests/e2e-auto-upgrade.sh:195 writes tracebloc.io/last-refreshed-jobs-manager-digest=sha256:e2e-sentinel, then runs helm upgrade --reuse-values and --reset-then-reuse-values against the working-tree chart — and line 278 asserts the annotation is still there afterwards. With defaults (imageRefresh.enabled: true, docker.io, no operator pin) branch 2 fires and renders docker.io/tracebloc/jobs-manager@sha256:e2e-sentinel onto the init container, api and requests-proxy. That script's only readiness wait is kubectl wait ... nodes, and it never asserts a control-plane image ref — so Fleet auto-upgrade E2E (k3d) passed on this head while doing exactly that. A sha256:[a-f0-9]{64} guard in the helper closes it and is unit-testable with no cluster, which also partly answers your "can't be tested" caveat.

2. BLOCKING — imageRefresh.suspend is never consulted. tracebloc.imageRefreshEnabled gates only on not $ir.enabled, but suspend is a real key (values.yaml:1833, consumed at image-refresh-cronjob.yaml:702). With enabled: true, suspend: true — supported, and the state the local-k3d recipe leaves behind — the CronJob never runs, yet the helper still treats refresh as the live update path and pins the last annotation permanently. It hurts most on the DaemonSet: before this change a newly joined node pulled the current :tag; after it, the node pulls a frozen digest and silently runs a stale build with nothing reporting it. That is the same "silently runs an OLD control-plane image" class as client-runtime#199, arriving from the other direction.

3. MERGE ORDER — B depends on A, it does not merely pair with it. I'd said A and B compose, and in the steady state they do: with recorded == latest, B renders that digest and A's live-spec check reads on-digest and no-ops. But the annotation LAGS the live spec in two reachable states, and in both, B pulls the workload backwards:

  • a rollout that times out — set image has moved the spec, set -e exits before the annotate, so spec = NEW and annotation = OLD;
  • helm rollback, which re-applies a stored manifest rather than re-rendering, so it restores the digest that was live at that revision's render time. Answering the rollback question directly: yes, a rendered digest means a rollback can undo a security refresh.

Both are self-healing only because A's live-spec reconcile re-pins within a tick. Without A, recorded == latest no-ops and the stale digest sticks until the next upstream publish. So A first is a correctness precondition, not a preference — worth stating in the header next to the lookup caveats.

Non-blocking, but I'd like them addressed or written down:

4. This narrows #569's offline-restart guarantee, and the body doesn't mention it. Keeping the pull policy is necessary but not sufficient: IfNotPresent only helps when the REFERENCE is cache-satisfiable. A node that pulled :tag -> sha256:OLD satisfies repo@sha256:OLD from cache but cannot satisfy repo@sha256:NEW. Since the first-observation path records latest WITHOUT re-imaging, we can now render a digest the node has never pulled: online that is the fix working, offline it turns "restarts on a stale image" into "cannot start". Same root cause makes a side-loaded k3d image unpullable — k3d image import stores a tag alias with no resolvable digest, and the mirror gate doesn't help because a local k3d install IS docker.io.

5. First-observation roll. The script records without re-imaging precisely because "rewriting repo:tag to repo@digest for identical content would roll every pod on install". This reintroduces that roll through helm: first tick writes the annotations, the next upgrade changes all four pod specs for a zero-content change — recreating jobs-manager (strategy: Recreate on RWO PVCs, the #545 wedge) and every resource-monitor pod on every node. Not hourly, to be fair: auto-upgrade only fires on a chart-version bump. Relatedly, the helper's doc says "the digest image-refresh last APPLIED" — on this path it is what refresh last OBSERVED and deliberately did not apply. Worth correcting; it changes how a reader reasons about the value.

6. A --set env.CLIENT_ENV=stg switch becomes inert on the image: the annotation key isn't env-scoped and the tag is dropped when a digest is present, so the edge renders the other env's digest while every label reads stg. It self-corrects next tick unless the switch leaves the Deployment unsettled — then the settled guard skips forever and each upgrade re-pins the old env's digest.

7. Seven uncached lookups per render (5 in jobs-manager, 1 each in requests-proxy and resource-monitor), and a non-NotFound error RAISES rather than returning empty — failing the whole release, which is what resource-monitor-daemonset.yaml:22-27 documents for backend#2469. The RBAC half is fine as you argued; the transient-apiserver-error half is a new failure mode for a render that used to work offline, and unlike the metrics-server lookup there's no escape hatch equivalent to nodeAgents.metricsServerPreflight: false.

8. Routing the effective digest into controlPlanePullPolicy (:277, :912) is a no-op: wherever the helper can return non-empty, imageRefreshEnabled && docker.io already held, so the policy was already IfNotPresent. Harmless, just two more lookups.

On the Helm 4 server-side-apply question from the last round: I said this would get strictly worse with the chart rendering a digest, and I want to withdraw that. The premise was wrong — repo:tag is itself a rendered value for .image, so helm has always declared and owned that field; that ownership is exactly why the #199 revert happens. This changes the VALUE helm writes, not the ownership set, and adds no manager. On the next 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. Before, it reclaimed and wrote the wrong value; after, the right one in the steady state. So: neutral on ownership, better on outcome, with the lag window in (3) as the exception. Also relevant for @LukasWodka: the fleet path is Helm 3 today — autoUpgrade pins alpine/helm 3.16.4 and CI's render job pins v3.15.4 — so SSA ownership doesn't reach the fleet until someone bumps that tag to 4.x.

On testing: I mutated the helper to always return "" and 5 tests across the jobs_manager / requests_proxy / resource_monitor suites go red, so the operator-pin branch is genuinely covered (695 pass clean). Branch 2 has nothing that can redden, as you say — but I don't think it needs a new suite in full-seal-e2e. The rig already exists: e2e-auto-upgrade.sh runs on real k3d, already seeds that exact annotation, and already runs both upgrade paths. It needs one assertion on the rendered image ref (and, given finding 1, a real 64-hex digest instead of the sentinel).

What I could NOT verify here, stated plainly: I have no cluster, so branch 2 — the lookup path, the whole point of the PR — was never actually executed by me; every claim about it is read from the template and from the CronJob's annotation writer. No k3d locally, so the side-loaded-digest hazard in (4) is reasoned from the tag-alias behaviour, not reproduced. And no Helm 4 server-side-apply rig, so the ownership analysis above is from managedFields semantics plus the fact that the revert demonstrably happens today (which proves helm reclaims the field rather than erroring) — not from an observed conflict.

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