Skip to content

Commit ca0cc87

Browse files
authored
Merge branch 'main' into feat/network-operator-readiness-gate
2 parents 055b1f7 + 26eef38 commit ca0cc87

2 files changed

Lines changed: 184 additions & 25 deletions

File tree

pkg/bom/extract.go

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const (
3535
helmTemplatePlaceholder = "_aicr_helm_template_"
3636
imageRepositoryKey = "repository"
3737
imageTagKey = "tag"
38+
imageDigestKey = "digest"
3839
)
3940

4041
var helmTemplateRE = regexp.MustCompile(`\{\{[^{}]*\}\}`)
@@ -72,10 +73,12 @@ func stripHelmTemplates(data []byte) []byte {
7273
// mappings. Empty or null scalar `image:` values and values still containing an
7374
// unrendered Go template directive are skipped. A recognized structured
7475
// descriptor returns an invalid-request error when a present name or
75-
// repository field is null, empty, or non-scalar, when a tag field is
76-
// non-scalar, or when a registry or digest member is present (dropping either
77-
// would emit a wrong or un-pinned reference). A null or empty tag follows the
78-
// Helm appVersion idiom and is treated as absent.
76+
// repository field is null, empty, or non-scalar, when a tag or digest field
77+
// is non-scalar, or when a registry member is present (which cannot be folded
78+
// into the reference without losing information). A null or empty tag or digest
79+
// follows the Helm appVersion/unpinned idiom and is treated as absent. A
80+
// non-empty digest is validated as sha256:<64 lowercase hex chars> and folded
81+
// in as an @<digest> suffix.
7982
//
8083
// Helm template directives ({{ ... }}) are replaced with a placeholder before
8184
// parsing, so files mixing YAML with Helm templates (those under
@@ -206,31 +209,40 @@ func isStructuredImageKey(key string) bool {
206209
// repository: ghcr.io/kai-scheduler/kai-scheduler
207210
// tag: v0.14.1
208211
//
212+
// It also handles the digest-pinned form used by Helm charts that separate
213+
// repository, tag, and digest into sibling fields (e.g., Bitnami-style):
214+
//
215+
// image:
216+
// repository: docker.io/library/postgres
217+
// tag: "17.4"
218+
// digest: sha256:304ab813518754228f9f792f79d6da36359b82d8ecf418096c636725f8c930ad
219+
//
209220
// Some charts omit name because repository already carries the full image
210221
// path. In that form, repository becomes the image name before tag is
211222
// appended. A present name or repository must be a non-null, non-empty
212223
// scalar. A null or empty tag is the Helm idiom for "default to the chart
213-
// appVersion" and is treated like an absent tag. Only name, repository, and
214-
// tag are combined; a present registry or digest member is rejected because
215-
// dropping it would emit a reference that resolves to the wrong registry or
216-
// silently un-pins a digest. Other members (pullPolicy, pullSecrets, ...)
217-
// do not affect the reference identity and are ignored.
224+
// appVersion" and is treated like an absent tag. A present digest is appended
225+
// as @<digest> so the extracted reference is fully pinned. A present registry
226+
// member is rejected because dropping it would resolve to the wrong registry.
227+
// Other members (pullPolicy, pullSecrets, ...) do not affect reference
228+
// identity and are ignored.
218229
func imageReferenceFromMapping(n *yaml.Node) (string, error) {
219-
var name, repository, tag string
230+
var name, repository, tag, digest string
220231
var namePresent bool
232+
var digestNode *yaml.Node
221233
for i := 0; i+1 < len(n.Content); i += 2 {
222234
key, value := n.Content[i], n.Content[i+1]
223235
switch key.Value {
224-
case "name", imageRepositoryKey, imageTagKey:
225-
case "registry", "digest":
236+
case "name", imageRepositoryKey, imageTagKey, imageDigestKey:
237+
case "registry":
226238
return "", errors.Wrap(
227239
errors.ErrCodeInvalidRequest,
228240
"invalid image descriptor member",
229241
&invalidStructuredImageDescriptorError{
230242
field: key.Value,
231243
line: key.Line,
232244
column: key.Column,
233-
reason: "is not combined into the extracted reference; fold it into repository or tag",
245+
reason: "is not combined into the extracted reference; fold it into repository",
234246
},
235247
)
236248
default:
@@ -239,10 +251,11 @@ func imageReferenceFromMapping(n *yaml.Node) (string, error) {
239251

240252
scalar, ok := nonNullImageMappingScalar(value)
241253
if !ok {
242-
if key.Value == imageTagKey && isNullOrEmptyScalar(value) {
243-
// tag: "" / tag: null — the Helm "use appVersion"
244-
// idiom. Treat like an absent tag instead of failing
245-
// the whole survey.
254+
if (key.Value == imageTagKey || key.Value == imageDigestKey) && isNullOrEmptyScalar(value) {
255+
// tag: "" / tag: null — the Helm "use appVersion" idiom.
256+
// digest: "" / digest: null — unpinned default in charts
257+
// that optionally carry a digest pin.
258+
// Treat both as absent rather than failing the whole survey.
246259
continue
247260
}
248261
return "", errors.Wrap(
@@ -264,6 +277,9 @@ func imageReferenceFromMapping(n *yaml.Node) (string, error) {
264277
repository = scalar
265278
case imageTagKey:
266279
tag = scalar
280+
case imageDigestKey:
281+
digest = scalar
282+
digestNode = value
267283
}
268284
}
269285
if !namePresent {
@@ -276,7 +292,23 @@ func imageReferenceFromMapping(n *yaml.Node) (string, error) {
276292
if name == "" {
277293
return "", nil
278294
}
279-
return combineCRDTriplet(name, repository, tag), nil
295+
ref := combineCRDTriplet(name, repository, tag)
296+
if digest == "" || strings.Contains(ref, "@") {
297+
return ref, nil
298+
}
299+
if !containerSHARE.MatchString(digest) {
300+
return "", errors.Wrap(
301+
errors.ErrCodeInvalidRequest,
302+
"invalid image descriptor member",
303+
&invalidStructuredImageDescriptorError{
304+
field: imageDigestKey,
305+
line: digestNode.Line,
306+
column: digestNode.Column,
307+
reason: fmt.Sprintf("must match sha256:<64 lowercase hex chars>, got %q", digest),
308+
},
309+
)
310+
}
311+
return ref + "@" + digest, nil
280312
}
281313

282314
func nonNullImageMappingScalar(n *yaml.Node) (string, bool) {

pkg/bom/extract_test.go

Lines changed: 134 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,78 @@ spec:
350350
"ghcr.io/example/null-tag",
351351
},
352352
},
353+
{
354+
// Bitnami-style and Helm-chart patterns split the image reference
355+
// into separate repository, tag, and digest sibling fields. The
356+
// digest is appended as @<digest> so the extracted reference is
357+
// fully pinned and digest-pin tests see the correct form.
358+
name: "operator image mapping with separate digest field produces tag@digest ref",
359+
in: `spec:
360+
db:
361+
image:
362+
repository: docker.io/library/postgres
363+
tag: "17.4"
364+
digest: sha256:304ab813518754228f9f792f79d6da36359b82d8ecf418096c636725f8c930ad
365+
pullPolicy: IfNotPresent
366+
`,
367+
want: []string{
368+
"docker.io/library/postgres:17.4@sha256:304ab813518754228f9f792f79d6da36359b82d8ecf418096c636725f8c930ad",
369+
},
370+
},
371+
{
372+
// Charts that optionally carry a digest pin ship digest: "" or
373+
// digest: null as a "not pinned" default. Treat like an absent
374+
// digest rather than failing the whole survey.
375+
name: "operator image mapping treats empty and null digest as absent",
376+
in: `spec:
377+
empty:
378+
image:
379+
repository: ghcr.io/example/unpinned
380+
tag: v1
381+
digest: ""
382+
null:
383+
image:
384+
repository: ghcr.io/example/unpinned-null
385+
tag: v1
386+
digest:
387+
`,
388+
want: []string{
389+
"ghcr.io/example/unpinned-null:v1",
390+
"ghcr.io/example/unpinned:v1",
391+
},
392+
},
393+
{
394+
// The name-present branch combined with a sibling digest field
395+
// must produce a fully-pinned reference.
396+
name: "operator image mapping with name repository tag and digest produces pinned ref",
397+
in: `spec:
398+
binder:
399+
image:
400+
name: binder
401+
repository: ghcr.io/kai
402+
tag: v1
403+
digest: sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b
404+
`,
405+
want: []string{
406+
"ghcr.io/kai/binder:v1@sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b",
407+
},
408+
},
409+
{
410+
// When the repository already carries an inline @digest, the
411+
// sibling digest field must not re-append a different digest.
412+
// The inline digest is preserved and the sibling is ignored.
413+
name: "operator image mapping does not double-append digest when ref already has one",
414+
in: `spec:
415+
pinned:
416+
image:
417+
repository: ghcr.io/example/pinned@sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b
418+
tag: v1
419+
digest: sha256:304ab813518754228f9f792f79d6da36359b82d8ecf418096c636725f8c930ad
420+
`,
421+
want: []string{
422+
"ghcr.io/example/pinned@sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b",
423+
},
424+
},
353425
}
354426
for _, tt := range tests {
355427
t.Run(tt.name, func(t *testing.T) {
@@ -399,13 +471,6 @@ func TestExtractImagesFromYAML_InvalidStructuredImageDescriptor(t *testing.T) {
399471
tag: v1`,
400472
wantField: "registry",
401473
},
402-
{
403-
name: "digest member present",
404-
descriptor: `repository: ghcr.io/example/pinned
405-
tag: v1
406-
digest: sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b`,
407-
wantField: "digest",
408-
},
409474
{
410475
name: "empty name",
411476
descriptor: `name: ""
@@ -535,6 +600,68 @@ func TestExtractImagesFromYAML_InvalidContainerSHA(t *testing.T) {
535600
}
536601
}
537602

603+
// TestExtractImagesFromYAML_InvalidDigestInMapping exercises the fail-loud
604+
// guard for the structured image descriptor's `digest` field: any value that
605+
// does not match `^sha256:[a-f0-9]{64}$` must surface as an
606+
// invalidStructuredImageDescriptorError so mirror-discovery and BOM-generation
607+
// callers that gate on IsInvalidStructuredImageDescriptor fail closed rather
608+
// than warning-and-continuing with the image omitted.
609+
func TestExtractImagesFromYAML_InvalidDigestInMapping(t *testing.T) {
610+
tests := []struct {
611+
name string
612+
in string
613+
wantSub string
614+
}{
615+
{
616+
name: "non-sha256 prefix in digest field",
617+
in: `spec:
618+
db:
619+
image:
620+
repository: docker.io/library/postgres
621+
tag: "17.4"
622+
digest: notasha256
623+
`,
624+
wantSub: `field "digest"`,
625+
},
626+
{
627+
name: "sha256 prefix but truncated hex in digest field",
628+
in: `spec:
629+
db:
630+
image:
631+
repository: docker.io/library/postgres
632+
tag: "17.4"
633+
digest: sha256:abc123
634+
`,
635+
wantSub: `field "digest"`,
636+
},
637+
{
638+
name: "uppercase hex in digest field",
639+
in: `spec:
640+
db:
641+
image:
642+
repository: docker.io/library/postgres
643+
tag: "17.4"
644+
digest: sha256:304AB813518754228F9F792F79D6DA36359B82D8ECF418096C636725F8C930AD
645+
`,
646+
wantSub: `field "digest"`,
647+
},
648+
}
649+
for _, tt := range tests {
650+
t.Run(tt.name, func(t *testing.T) {
651+
_, err := ExtractImagesFromYAML([]byte(tt.in))
652+
if err == nil {
653+
t.Fatal("expected error for malformed digest field")
654+
}
655+
if !IsInvalidStructuredImageDescriptor(err) {
656+
t.Errorf("IsInvalidStructuredImageDescriptor(%v) = false, want true", err)
657+
}
658+
if !strings.Contains(err.Error(), tt.wantSub) {
659+
t.Errorf("error %q does not contain %q", err.Error(), tt.wantSub)
660+
}
661+
})
662+
}
663+
}
664+
538665
func TestParseImageRef(t *testing.T) {
539666
tests := []struct {
540667
in string

0 commit comments

Comments
 (0)