Skip to content

Commit 63554ab

Browse files
authored
add bundle relatedimage image pullspec validation (#475)
Signed-off-by: grokspawn <jordan@nimblewidget.com>
1 parent 1b5c3ea commit 63554ab

File tree

4 files changed

+188
-0
lines changed

4 files changed

+188
-0
lines changed

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/sirupsen/logrus v1.9.4
1010
github.com/spf13/cobra v1.10.2
1111
github.com/stretchr/testify v1.11.1
12+
go.podman.io/image/v5 v5.39.1
1213
google.golang.org/genproto/googleapis/api v0.0.0-20260202165425-ce8ad4cf556b
1314
k8s.io/api v0.35.1
1415
k8s.io/apiextensions-apiserver v0.35.1
@@ -52,6 +53,7 @@ require (
5253
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
5354
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
5455
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
56+
github.com/opencontainers/go-digest v1.0.0 // indirect
5557
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
5658
github.com/prometheus/client_golang v1.23.2 // indirect
5759
github.com/prometheus/client_model v0.6.2 // indirect
@@ -68,6 +70,7 @@ require (
6870
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
6971
go.opentelemetry.io/otel/trace v1.40.0 // indirect
7072
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
73+
go.podman.io/storage v1.62.0 // indirect
7174
go.yaml.in/yaml/v2 v2.4.3 // indirect
7275
go.yaml.in/yaml/v3 v3.0.4 // indirect
7376
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns
118118
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
119119
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
120120
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
121+
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
122+
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
121123
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
122124
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
123125
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -175,6 +177,10 @@ go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZY
175177
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
176178
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
177179
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
180+
go.podman.io/image/v5 v5.39.1 h1:loIw4qHzZzBlUguYZau40u8HbR5MrTPQhwT4Hy6sCm0=
181+
go.podman.io/image/v5 v5.39.1/go.mod h1:SlaR6Pra1ATIx4BcuZ16oafb3QcCHISaKcJbtlN/G/0=
182+
go.podman.io/storage v1.62.0 h1:0QjX1XlzVmbiaulb+aR/CG6p9+pzaqwIeZPe3tEjHbY=
183+
go.podman.io/storage v1.62.0/go.mod h1:A3UBK0XypjNZ6pghRhuxg62+2NIm5lcUGv/7XyMhMUI=
178184
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
179185
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
180186
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=

pkg/validation/internal/bundle.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1"
99
"github.com/operator-framework/api/pkg/validation/errors"
1010
interfaces "github.com/operator-framework/api/pkg/validation/interfaces"
11+
"go.podman.io/image/v5/docker/reference"
1112
v1 "k8s.io/api/core/v1"
1213
"k8s.io/apimachinery/pkg/runtime"
1314
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -46,6 +47,10 @@ func validateBundle(bundle *manifests.Bundle) (result errors.ManifestResult) {
4647
if nameErrors != nil {
4748
result.Add(nameErrors...)
4849
}
50+
relatedImagesErrors := validateRelatedImages(bundle)
51+
if relatedImagesErrors != nil {
52+
result.Add(relatedImagesErrors...)
53+
}
4954
return result
5055
}
5156

@@ -62,6 +67,30 @@ func validateBundleName(bundle *manifests.Bundle) []errors.Error {
6267
return errs
6368
}
6469

70+
// validateRelatedImages checks that all relatedImages[].image pullspecs are valid
71+
// using github.com/distribution/reference.ParseNormalizedNamed
72+
func validateRelatedImages(bundle *manifests.Bundle) []errors.Error {
73+
var errs []errors.Error
74+
75+
for i, relatedImage := range bundle.CSV.Spec.RelatedImages {
76+
if relatedImage.Image == "" {
77+
errs = append(errs, errors.ErrInvalidBundle(
78+
fmt.Sprintf("relatedImages[%d] has an empty image field", i),
79+
fmt.Sprintf("spec.relatedImages[%d].image", i)))
80+
continue
81+
}
82+
83+
// Parse and validate the image reference
84+
if _, err := reference.ParseNormalizedNamed(relatedImage.Image); err != nil {
85+
errs = append(errs, errors.ErrInvalidBundle(
86+
fmt.Sprintf("relatedImages[%d] has an invalid image pullspec %q: %v", i, relatedImage.Image, err),
87+
fmt.Sprintf("spec.relatedImages[%d].image", i)))
88+
}
89+
}
90+
91+
return errs
92+
}
93+
6594
func validateServiceAccounts(bundle *manifests.Bundle) []errors.Error {
6695
// get service account names defined in the csv
6796
saNamesFromCSV := make(map[string]struct{}, 0)

pkg/validation/internal/bundle_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package internal
22

33
import (
44
"fmt"
5+
"strings"
56
"testing"
67

78
"github.com/stretchr/testify/require"
@@ -302,3 +303,152 @@ func Test_EnsureGetBundleSizeValue(t *testing.T) {
302303
})
303304
}
304305
}
306+
307+
func TestValidateRelatedImages(t *testing.T) {
308+
tests := []struct {
309+
name string
310+
relatedImages []v1alpha1.RelatedImage
311+
wantError bool
312+
errCount int
313+
errContains []string
314+
}{
315+
{
316+
name: "no related images should pass",
317+
relatedImages: []v1alpha1.RelatedImage{},
318+
wantError: false,
319+
},
320+
{
321+
name: "valid image with tag should pass",
322+
relatedImages: []v1alpha1.RelatedImage{
323+
{Name: "operator", Image: "quay.io/operator-framework/my-operator:v1.0.0"},
324+
},
325+
wantError: false,
326+
},
327+
{
328+
name: "valid image with digest should pass",
329+
relatedImages: []v1alpha1.RelatedImage{
330+
{Name: "operator", Image: "quay.io/operator-framework/my-operator@sha256:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"},
331+
},
332+
wantError: false,
333+
},
334+
{
335+
name: "valid image with tag and digest should pass",
336+
relatedImages: []v1alpha1.RelatedImage{
337+
{Name: "operator", Image: "quay.io/operator-framework/my-operator:v1.0.0@sha256:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"},
338+
},
339+
wantError: false,
340+
},
341+
{
342+
name: "valid image without tag (latest implied) should pass",
343+
relatedImages: []v1alpha1.RelatedImage{
344+
{Name: "operator", Image: "quay.io/operator-framework/my-operator"},
345+
},
346+
wantError: false,
347+
},
348+
{
349+
name: "multiple valid images should pass",
350+
relatedImages: []v1alpha1.RelatedImage{
351+
{Name: "operator", Image: "quay.io/operator-framework/my-operator:v1.0.0"},
352+
{Name: "operand", Image: "gcr.io/my-project/my-operand@sha256:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"},
353+
{Name: "init", Image: "docker.io/library/busybox:latest"},
354+
},
355+
wantError: false,
356+
},
357+
{
358+
name: "empty image field should error",
359+
relatedImages: []v1alpha1.RelatedImage{
360+
{Name: "operator", Image: ""},
361+
},
362+
wantError: true,
363+
errCount: 1,
364+
errContains: []string{"empty image field"},
365+
},
366+
{
367+
name: "invalid image with spaces should error",
368+
relatedImages: []v1alpha1.RelatedImage{
369+
{Name: "operator", Image: "invalid image name"},
370+
},
371+
wantError: true,
372+
errCount: 1,
373+
errContains: []string{"invalid image pullspec"},
374+
},
375+
{
376+
name: "invalid image with uppercase should error",
377+
relatedImages: []v1alpha1.RelatedImage{
378+
{Name: "operator", Image: "quay.io/Operator-Framework/my-operator:v1.0.0"},
379+
},
380+
wantError: true,
381+
errCount: 1,
382+
errContains: []string{"invalid image pullspec", "Operator-Framework"},
383+
},
384+
{
385+
name: "invalid image with special characters should error",
386+
relatedImages: []v1alpha1.RelatedImage{
387+
{Name: "operator", Image: "quay.io/operator-framework/my-operator:v1.0.0!"},
388+
},
389+
wantError: true,
390+
errCount: 1,
391+
errContains: []string{"invalid image pullspec"},
392+
},
393+
{
394+
name: "invalid digest algorithm should error",
395+
relatedImages: []v1alpha1.RelatedImage{
396+
{Name: "operator", Image: "quay.io/operator-framework/my-operator@ssha256:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"},
397+
},
398+
wantError: true,
399+
errCount: 1,
400+
errContains: []string{"invalid image pullspec"},
401+
},
402+
{
403+
name: "multiple errors should all be reported",
404+
relatedImages: []v1alpha1.RelatedImage{
405+
{Name: "operator", Image: ""},
406+
{Name: "operand", Image: "invalid image"},
407+
},
408+
wantError: true,
409+
errCount: 2,
410+
errContains: []string{"relatedImages[0]", "relatedImages[1]"},
411+
},
412+
{
413+
name: "mixed valid and invalid images should error",
414+
relatedImages: []v1alpha1.RelatedImage{
415+
{Name: "operator", Image: "quay.io/operator-framework/my-operator:v1.0.0"},
416+
{Name: "bad", Image: "invalid image"},
417+
},
418+
wantError: true,
419+
errCount: 1,
420+
errContains: []string{"relatedImages[1]", "invalid image pullspec"},
421+
},
422+
}
423+
424+
for _, tt := range tests {
425+
t.Run(tt.name, func(t *testing.T) {
426+
bundle := &manifests.Bundle{
427+
CSV: &v1alpha1.ClusterServiceVersion{
428+
Spec: v1alpha1.ClusterServiceVersionSpec{
429+
RelatedImages: tt.relatedImages,
430+
},
431+
},
432+
}
433+
434+
errs := validateRelatedImages(bundle)
435+
436+
if tt.wantError {
437+
require.Equal(t, tt.errCount, len(errs), "expected %d errors but got %d", tt.errCount, len(errs))
438+
// Check that each expected string appears in at least one error
439+
for _, expectedStr := range tt.errContains {
440+
found := false
441+
for _, err := range errs {
442+
if strings.Contains(err.Error(), expectedStr) {
443+
found = true
444+
break
445+
}
446+
}
447+
require.True(t, found, "expected to find %q in error messages", expectedStr)
448+
}
449+
} else {
450+
require.Equal(t, 0, len(errs), "expected no errors but got: %v", errs)
451+
}
452+
})
453+
}
454+
}

0 commit comments

Comments
 (0)