fix(k8s): name artifacts by image reference when the runtime reports an image ID - #1204
Conversation
…an image ID containerd names an image record by the reference it was pulled with, so an image pulled by digest has no tagged name in the store and the runtime reports the image ID instead. The reporter keyed the digests map on that value, naming the artifact after a bare sha256. Fall back to the pod's own references when the runtime has no name: this container's image in the pod spec, which keeps the tag, then the image ID, which at least keeps the repository. The fingerprint is unaffected either way, since it always comes from cs.ImageID. Closes #1203
|
Claude finished @dangrondahl's task in 4m 8s —— View job Review: fix(k8s): name artifacts by image reference when the runtime reports an image ID
Static analysis
VerdictThe fix is sound and the reasoning in the description matches what the code does. Three details I checked and liked:
Also: FindingsNeither is a blocker; nothing here needs to hold the merge.
Also worth a thought, no change requested
No security or performance concerns — a handful of reference parses per container per snapshot, against regexes compiled once at package init. |
Review feedback on #1204. Emit the tagged name the runtime would have reported had it held the image under its tag, rather than the spec reference verbatim. A digest-pinned pod on a node with a cold image cache and the same pod on a warm one now produce the same artifact name, instead of nginx:1.25@sha256:... and docker.io/library/nginx:1.25 for one image. The digest is dropped from the name: the fingerprint already records it, and the two are separate values that can disagree while an in-place image update is rolling. internal/cloudrun resolves digest-pinned images to a tag-shaped name for the same reason. ParseNormalizedNamed reads a bare digest as a repository with a tag, turning sha256:8dd77ef... into docker.io/library/sha256 tagged 8dd77ef..., so normalizedTagName gates on isNamed first. An untagged reference falls through to the image ID rather than defaulting to :latest, a name the image never had. Also close the empty-name case: a container status with no image name and a bare-digest image ID was reported under an empty map key.
Follow-up review of 30ea69e (b9cdc71..30ea69e)Finding 1 — empty Finding 2 — digest-in-name mismatch with fingerprint, and untested non-matching-container branch New issues in the touched lines: 1 inline comment posted (test-coverage gap noted above under Finding 1; no functional bug found in the production code itself). |
The empty-name case used an ImageID that trims to a named reference, so artifactName returned at the isNamed check above the branch the test claimed to cover. Reverting the fix left it passing. Use a bare-digest ImageID, which now fails without the fix. The doc comments on artifactName and normalizedTagName had grown longer than the functions, restating the fallback order the code already shows and carrying review history. Keep what is not visible in the code: why the runtime sometimes reports no name, and why the digest is dropped.
| if isNamed(cs.Image) { | ||
| return cs.Image | ||
| } |
There was a problem hiding this comment.
Not a defect — a scope note on the "one image gets one name" property, so it's a decision rather than an accident.
Normalization only runs on the fallback path, so the property holds across nodes that report a bare image ID. The other shape the runtime reports for a digest-pinned pod is a digest-form named reference (docker.io/library/nginx@sha256:…), which containerd does produce when it holds the image under the reference the pod pinned. That is Named, so it returns here verbatim, and the same image on a cold-cache node in the same deployment resolves to docker.io/library/nginx:1.27 via normalizedTagName. Same fingerprint, two names — the split this PR is closing, just one rung up.
Leaving rule 1 alone is the conservative choice and I'd keep it: normalizing cs.Image too would rename artifacts for every user currently seeing repo@sha256:…, which is a much wider blast radius than the bare-sha256: case in #1203. Worth a sentence in the artifactName doc comment noting the fallback is where the normalization happens, so the next reader doesn't assume the invariant is unconditional.
Two small things while in here:
reference.TrimNamed(ref).Name() + ":" + tagged.Tag()(line 134) hand-rolls whatreference.WithTag(reference.TrimNamed(ref), tagged.Tag())returns fromString(). Same output, and it can't drift from the library's formatting.- An unparseable-but-non-empty
cs.Imagenow loses to a namedImageIDrather than being used as the name, sinceisNamedis false for anythingParseAnyReferencerejects. That's an improvement, but it does make the closing comment ("the runtime's name still beats no name at all") narrower than it reads — it beats no name, not a readable image ID.
| { | ||
| name: "a bare image ID falls back to the spec image, normalized to its tag", | ||
| pod: podWithSpec("pod", []string{nginxDigest}, containerStatus{imageID, nginxImageID}), | ||
| wantDigests: map[string]string{"docker.io/library/nginx:1.25": nginxSha}, | ||
| }, | ||
| { | ||
| name: "the fallback name matches what the runtime reports for the same image", | ||
| pod: podWithSpec("pod", []string{"nginx:1.25"}, containerStatus{imageID, nginxImageID}), | ||
| wantDigests: map[string]string{"docker.io/library/nginx:1.25": nginxSha}, |
There was a problem hiding this comment.
Two cases that would pin behaviour the table currently leaves implicit.
1. A spec digest that differs from the ImageID digest. Every case here uses nginxDigest, whose digest is the same 644a… that nginxImageID carries, so nothing distinguishes "the name came from the spec tag" from "the name came from the same digest as the fingerprint". The divergent case is the one the digest-dropping decision was made for — an in-place image update, where the spec points at the new reference while the status still reports the old ImageID:
{
// the spec and the status disagree mid-update: the name comes from the
// spec's tag, the fingerprint from the status's image ID
name: "a spec digest that differs from the image ID does not reach the name",
pod: podWithSpec("pod", []string{"nginx:1.25@sha256:" + imageIDSha}, containerStatus{imageID, nginxImageID}),
wantDigests: map[string]string{"docker.io/library/nginx:1.25": nginxSha},
},That is the assertion the earlier review round asked for and it didn't land — worth having, since it's the one case where dropping the digest is load-bearing rather than cosmetic.
2. A registry with a port. normalizedTagName splits name from tag through reference, so registry.local:5000/app:v1 normalizes correctly, but nothing here would catch a future refactor to a naive cut at the last ::
{
name: "a registry port is not mistaken for a tag",
pod: podWithSpec("pod", []string{"registry.local:5000/app:v1"}, containerStatus{imageID, nginxImageID}),
wantDigests: map[string]string{"registry.local:5000/app:v1": nginxSha},
},Also worth considering, lower value: a multi-container pod whose spec order differs from the status order, so the container.Name == cs.Name match is doing real work rather than agreeing with position. podWithSpec pairs them positionally, so today the loop finds the right entry either way.
Closes #1203.
NewPodDatakeyed the digests map oncs.Image, the name the container runtime reports. containerd names an image record by the reference it was pulled with, so an image pulled by digest has no tagged name in the store and the runtime reports the image ID instead — and the artifact ends up named after a baresha256:, with nothing to say which image it is.The pod still carries readable references when that happens, so
artifactNamenow falls back, most informative first:docker.io/library/nginx:1.27docker.io/library/nginx@sha256:…Normalizing rather than taking the spec reference verbatim means one image gets one name: a digest-pinned pod on a node with a cold image cache and the same pod on a warm one now agree. The digest is dropped from the name because the fingerprint already records it, and the two are independent values that can disagree while an in-place image update is rolling.
internal/cloudrunresolves digest-pinned images to a tag-shaped name for the same reason.The fingerprint is untouched: it always came from
cs.ImageIDand still does. Since Kosli keys artifact identity, diffing and provenance on the fingerprint rather than the name, this cannot produce spurious replacements or split artifacts — see the second comment on #1203 for the experiment behind that.reference.Nameddoes the bare-digest test, so no new dependency. Thedocker-pullable://stripping moved out ofimageFingerprintintotrimRuntimePrefix, which both paths now use.Behaviour
Same pod, same moment, reported to two environments — a name-only change produces no new snapshot, so one environment would have shown nothing:
sha256:7791402a0bf5…6784fb0834aa…docker.io/library/nginx:1.276784fb0834aa…A tag-pulled
nginx:1.30in the same namespace reports asdocker.io/library/nginx:1.30, so the two are now the same shape — which is the point.What a user sees after upgrading
One environment across the upgrade boundary, with two digest-pinned workloads on a node that holds neither under a tag. Between
#1and#2only one of them changed image (1.27 → 1.24); the other kept running untouched.#1sha256:b005e885…started ·sha256:7791402a…started#2nginx:1.24started ·sha256:7791402a…stopped#3nginx:1.27started ·nginx:1.24stoppedunchanged(was=1 now=1): Kosli matches on fingerprint, so a rename causes no exit/start churn and no duplicate artifact.sha256:name on that exit event. Snapshot#2reads oddly for exactly that reason —nginx:1.24 startedsits directly abovesha256:7791402a… stopped, and those two lines are one workload's transition.sha256:7791402a…in#1and#2,nginx:1.27in#3, fingerprint6784fb0834aa…throughout. Anything keyed on the fingerprint is unaffected; name-based search across the boundary is not.unchangedproduces no event. The untouched workload appears once, in#1, under its sha, and never again — its corrected name is visible only in the snapshot itself.The UI renders the familiar short form (
nginx:1.27) while the stored name isdocker.io/library/nginx:1.27, so in the UI the fix lands as cleanly as it can. The full form is what API consumers and name-based search see, which is why the fallback normalizes to the runtime's exact form rather than to the spec string: a digest-pinned artifact and a tag-pulled one are then byte-identical there too, not merely similar on screen.Screenshots
Before — released v2.42.0 (

name-fix-before)After — this branch (
name-fix-after2)Verification
go vetandgolangci-lintclean oninternal/kubeinternal/kubeunit tests pass, including the existingTestNewPodDataandTestImageFingerprintTestNewPodDataArtifactName: runtime name wins when present, spec fallback keeps the tag, image-ID fallback, a spec image that is itself a bare digest, a spec container whose name does not match, an untagged spec reference falling through rather than defaulting to:latest, an empty container image name, and two digest-pinned containers keeping distinct map keys —cs.Imageis the map key as well as the name, so a fallback that collided would merge two containers' digestsNot run:
make test_integration, which needs the local server.Note for whoever verifies this after release
A name change alone does not create a snapshot, so upgrading the CLI changes nothing visible until the environment next takes a snapshot for some other reason — a new digest, a scale change. Forcing a change is the quickest way to see it. When that snapshot does happen, every running artifact is corrected at once, not just the changed one, as the timeline above shows. Background in #1203.
Checklist
charts/k8s-reporter/) — not needed, no chart-visible change