diff --git a/contrib/completions/bash/oc b/contrib/completions/bash/oc index b19721ed97..544f077062 100644 --- a/contrib/completions/bash/oc +++ b/contrib/completions/bash/oc @@ -16045,6 +16045,10 @@ _oc_image_info() two_word_flags+=("--filter-by-os") local_nonpersistent_flags+=("--filter-by-os") local_nonpersistent_flags+=("--filter-by-os=") + flags+=("--icsp-file=") + two_word_flags+=("--icsp-file") + local_nonpersistent_flags+=("--icsp-file") + local_nonpersistent_flags+=("--icsp-file=") flags+=("--insecure") local_nonpersistent_flags+=("--insecure") flags+=("--output=") diff --git a/pkg/cli/image/image.go b/pkg/cli/image/image.go index 48d9a8351b..be9bcc132a 100644 --- a/pkg/cli/image/image.go +++ b/pkg/cli/image/image.go @@ -35,7 +35,7 @@ func NewCmdImage(f kcmdutil.Factory, streams genericclioptions.IOStreams) *cobra { Message: "View or copy images:", Commands: []*cobra.Command{ - info.NewInfo(streams), + info.NewInfo(f, streams), mirror.NewCmdMirrorImage(streams), }, }, diff --git a/pkg/cli/image/info/info.go b/pkg/cli/image/info/info.go index 7aa9f3d201..2cba049260 100644 --- a/pkg/cli/image/info/info.go +++ b/pkg/cli/image/info/info.go @@ -29,6 +29,7 @@ import ( "github.com/openshift/library-go/pkg/image/registryclient" "github.com/openshift/oc/pkg/cli/image/imagesource" imagemanifest "github.com/openshift/oc/pkg/cli/image/manifest" + "github.com/openshift/oc/pkg/cli/image/strategy" "github.com/openshift/oc/pkg/cli/image/workqueue" ) @@ -38,7 +39,7 @@ func NewInfoOptions(streams genericclioptions.IOStreams) *InfoOptions { } } -func NewInfo(streams genericclioptions.IOStreams) *cobra.Command { +func NewInfo(f kcmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command { o := NewInfoOptions(streams) cmd := &cobra.Command{ Use: "info IMAGE [...]", @@ -68,8 +69,8 @@ func NewInfo(streams genericclioptions.IOStreams) *cobra.Command { `), Run: func(cmd *cobra.Command, args []string) { - kcmdutil.CheckErr(o.Complete(cmd, args)) - kcmdutil.CheckErr(o.Validate()) + kcmdutil.CheckErr(o.Complete(f, cmd, args)) + kcmdutil.CheckErr(o.Validate(cmd)) kcmdutil.CheckErr(o.Run()) }, } @@ -78,6 +79,8 @@ func NewInfo(streams genericclioptions.IOStreams) *cobra.Command { o.SecurityOptions.Bind(flags) flags.StringVarP(&o.Output, "output", "o", o.Output, "Print the image in an alternative format: json") flags.StringVar(&o.FileDir, "dir", o.FileDir, "The directory on disk that file:// images will be read from.") + flags.StringVar(&o.ICSPFile, "icsp-file", o.ICSPFile, "Path to an ImageContentSourcePolicy file. If set, data from this file will be used to find alternative locations for images.") + return cmd } @@ -87,35 +90,37 @@ type InfoOptions struct { SecurityOptions imagemanifest.SecurityOptions FilterOptions imagemanifest.FilterOptions - Images []string - - FileDir string - - Output string + Images []string + FileDir string + Output string + ICSPFile string } -func (o *InfoOptions) Complete(cmd *cobra.Command, args []string) error { +func (o *InfoOptions) Complete(f kcmdutil.Factory, cmd *cobra.Command, args []string) error { if len(args) < 1 { return fmt.Errorf("info expects at least one argument, an image pull spec") } o.Images = args - return nil -} -func (o *InfoOptions) Validate() error { - return o.FilterOptions.Validate() + return nil } -func (o *InfoOptions) Run() error { +func (o *InfoOptions) Validate(cmd *cobra.Command) error { if len(o.Images) == 0 { return fmt.Errorf("must specify one or more images as arguments") } + return o.FilterOptions.Validate() +} +func (o *InfoOptions) Run() error { // cache the context registryContext, err := o.SecurityOptions.Context() if err != nil { return err } + if len(o.ICSPFile) > 0 { + registryContext = registryContext.WithAlternateBlobSourceStrategy(strategy.NewICSPOnErrorStrategy(o.ICSPFile)) + } opts := &imagesource.Options{ FileDir: o.FileDir, Insecure: o.SecurityOptions.Insecure, @@ -123,6 +128,7 @@ func (o *InfoOptions) Run() error { } hadError := false + icspWarned := false for _, location := range o.Images { sources, err := imagesource.ParseSourceReference(location, opts.ExpandWildcard) if err != nil { @@ -132,6 +138,10 @@ func (o *InfoOptions) Run() error { if len(src.Ref.Tag) == 0 && len(src.Ref.ID) == 0 { return fmt.Errorf("--from must point to an image ID or image tag") } + if !icspWarned && len(o.ICSPFile) > 0 && len(src.Ref.Tag) > 0 { + fmt.Fprintf(o.ErrOut, "warning: --icsp-file only applies to images referenced by digest and will be ignored for tags\n") + icspWarned = true + } var image *Image retriever := &ImageRetriever{ diff --git a/pkg/cli/image/strategy/explicit.go b/pkg/cli/image/strategy/explicit.go new file mode 100644 index 0000000000..e309928e60 --- /dev/null +++ b/pkg/cli/image/strategy/explicit.go @@ -0,0 +1,80 @@ +package strategy + +import ( + "context" + "fmt" + "sync" + + "k8s.io/klog/v2" + + "github.com/openshift/library-go/pkg/image/reference" + "github.com/openshift/library-go/pkg/image/registryclient" +) + +type explicitStrategy struct { + lock sync.Mutex + + alternates map[reference.DockerImageReference][]reference.DockerImageReference + icspFile string + readICSPsFromFileFunc readICSPsFromFileFunc +} + +var _ registryclient.AlternateBlobSourceStrategy = &explicitStrategy{} + +// NewICSPExplicitStrategy returns ICSP alternate strategy which always reads +// alternate sources first rather than original requested. +func NewICSPExplicitStrategy(file string) registryclient.AlternateBlobSourceStrategy { + return &explicitStrategy{ + icspFile: file, + alternates: make(map[reference.DockerImageReference][]reference.DockerImageReference), + readICSPsFromFileFunc: readICSPsFromFile, + } +} + +func (s *explicitStrategy) FirstRequest(ctx context.Context, locator reference.DockerImageReference) (alternateRepositories []reference.DockerImageReference, err error) { + s.lock.Lock() + defer s.lock.Unlock() + if alternates, ok := s.alternates[locator]; ok { + return alternates, nil + } + alternates, err := s.resolve(ctx, locator) + if err != nil { + return nil, err + } + if len(alternates) == 0 { + return nil, fmt.Errorf("no alternative image references found for image: %s", locator.String()) + } + s.alternates[locator] = alternates + return s.alternates[locator], nil + +} + +func (s *explicitStrategy) OnFailure(ctx context.Context, locator reference.DockerImageReference) (alternateRepositories []reference.DockerImageReference, err error) { + s.lock.Lock() + defer s.lock.Unlock() + if len(s.alternates) == 0 { + return nil, fmt.Errorf("no alternative image references found for image: %s", locator.String()) + } + return s.alternates[locator], nil +} + +// resolve gathers possible image sources for a given image +// gathered from ImageContentSourcePolicy objects and user-passed image. +// Will lookup from cluster or from ImageContentSourcePolicy file passed from user. +// Image reference of user-given image may be different from original in case of mirrored images. +func (s *explicitStrategy) resolve(ctx context.Context, imageRef reference.DockerImageReference) ([]reference.DockerImageReference, error) { + if len(s.icspFile) == 0 { + return nil, fmt.Errorf("no ImageContentSourceFile specified") + } + klog.V(5).Infof("Reading ICSP from file %s", s.icspFile) + icspList, err := s.readICSPsFromFileFunc(s.icspFile) + if err != nil { + return nil, err + } + // always add the original as the last reference + imageRefList, err := alternativeImageSources(imageRef, icspList, true) + if err != nil { + return nil, err + } + return imageRefList, nil +} diff --git a/pkg/cli/image/strategy/explicit_test.go b/pkg/cli/image/strategy/explicit_test.go new file mode 100644 index 0000000000..ff7f21e069 --- /dev/null +++ b/pkg/cli/image/strategy/explicit_test.go @@ -0,0 +1,313 @@ +package strategy + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + operatorv1alpha1 "github.com/openshift/api/operator/v1alpha1" + "github.com/openshift/library-go/pkg/image/reference" +) + +func TestExplicitStrategy(t *testing.T) { + tests := []struct { + name string + icspList []operatorv1alpha1.ImageContentSourcePolicy + image string + imageSourcesExpected []string + }{ + { + name: "multiple ICSPs", + icspList: []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "quay.io/multiple/icsps", + Mirrors: []string{ + "someregistry/somerepo/release", + }, + }, + { + Source: "quay.io/ocp-test/another-release", + Mirrors: []string{ + "someregistry/repo/does-not-exist", + }, + }, + }, + }, + }, + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "quay.io/multiple/icsps", + Mirrors: []string{ + "anotherregistry/anotherrepo/release", + }, + }, + }, + }, + }, + }, + image: "quay.io/multiple/icsps:4.5", + imageSourcesExpected: []string{"someregistry/somerepo/release", "anotherregistry/anotherrepo/release", "quay.io/multiple/icsps"}, + }, + { + name: "multiple mirrors, single source match", + icspList: []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "docker.io/ocp-test/does-not-exist", + Mirrors: []string{ + "does.not.exist/match/image", + }, + }, + { + Source: "quay.io/ocp-test/does-not-exist", + Mirrors: []string{ + "exists/match/image", + }, + }, + }, + }, + }, + }, + image: "quay.io/ocp-test/does-not-exist:4.7", + imageSourcesExpected: []string{"exists/match/image", "quay.io/ocp-test/does-not-exist"}, + }, + { + name: "single mirror and match", + icspList: []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "quay.io/ocp-test/release", + Mirrors: []string{ + "someregistry/mirrors/match", + }, + }, + }, + }, + }, + }, + image: "quay.io/ocp-test/release:4.5", + imageSourcesExpected: []string{"someregistry/mirrors/match", "quay.io/ocp-test/release"}, + }, + { + name: "no source match", + icspList: []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "docker.io/ocp-test/does-not-exist", + Mirrors: []string{ + "does.not.exist/match/image", + }, + }, + { + Source: "quay.io/ocp-test/does-not-exist", + Mirrors: []string{ + "exists/match/image", + }, + }, + }, + }, + }, + }, + image: "quay.io/passed/image:4.5", + imageSourcesExpected: []string{"quay.io/passed/image"}, + }, + { + name: "multiple mirrors for single source match", + icspList: []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "quay.io/ocp-test/release", + Mirrors: []string{ + "someregistry/mirrors/match", + "quay.io/another/release", + "quay.io/andanother/release", + }, + }, + }, + }, + }, + }, + image: "quay.io/ocp-test/release:4.5", + imageSourcesExpected: []string{"someregistry/mirrors/match", "quay.io/another/release", "quay.io/andanother/release", "quay.io/ocp-test/release"}, + }, + { + name: "docker.io vs registry-1.docker.io", + icspList: []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "docker.io/ocp-test/release", + Mirrors: []string{ + "quay.io/ocp-test/release", + }, + }, + }, + }, + }, + }, + image: "registry-1.docker.io/ocp-test/release:4.5", + imageSourcesExpected: []string{"quay.io/ocp-test/release", "registry-1.docker.io/ocp-test/release"}, + }, + { + name: "docker.io and registry-1.docker.io as source", + icspList: []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "docker.io/ocp-test/release", + Mirrors: []string{ + "quay.io/ocp-test/release", + }, + }, + }, + }, + }, + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "registry-1.docker.io/ocp-test/release", + Mirrors: []string{ + "quay.io/ocp-test/release", + }, + }, + }, + }, + }, + }, + image: "registry-1.docker.io/ocp-test/release:4.5", + imageSourcesExpected: []string{"quay.io/ocp-test/release", "registry-1.docker.io/ocp-test/release"}, + }, + { + name: "no ICSP", + image: "quay.io/ocp-test/release:4.5", + imageSourcesExpected: []string{"quay.io/ocp-test/release"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expected := []reference.DockerImageReference{} + for _, e := range tt.imageSourcesExpected { + ref, _ := reference.Parse(e) + expected = append(expected, ref) + } + + alternates := NewICSPExplicitStrategy("name") + readCount := 0 + onErr := alternates.(*explicitStrategy) + onErr.readICSPsFromFileFunc = func(string) ([]operatorv1alpha1.ImageContentSourcePolicy, error) { + readCount++ + return tt.icspList, nil + } + imageRef, _ := reference.Parse(tt.image) + + actual, err := alternates.FirstRequest(context.Background(), imageRef) + if err != nil { + t.Errorf("Unexpected error %v", err) + return + } + if !reflect.DeepEqual(expected, actual) { + t.Errorf("Unexpected alternates got = %v, want %v", actual, expected) + } + + actual2, err := alternates.OnFailure(context.Background(), imageRef) + if err != nil { + t.Errorf("Unexpected error %v", err) + return + } + if !reflect.DeepEqual(actual2, actual) { + t.Errorf("Unexpected alternates got = %v, want %v", actual, expected) + } + if readCount > 1 { + t.Errorf("Unexpected number of ICSP reads, should be 1, got %d", readCount) + } + }) + } +} + +func TestExplicitStrategyErrors(t *testing.T) { + tests := []struct { + name string + readICSPFunc readICSPsFromFileFunc + image string + expectedErr string + }{ + { + name: "non-existent ICSP file", + image: "quay.io/ocp-test/release:4.5", + readICSPFunc: func(string) ([]operatorv1alpha1.ImageContentSourcePolicy, error) { + return nil, errors.New("no ImageContentSourceFile") + }, + expectedErr: "no ImageContentSourceFile", + }, + { + name: "invalid source locator", + image: "quay.io/ocp-test/release:4.5", + readICSPFunc: func(string) ([]operatorv1alpha1.ImageContentSourcePolicy, error) { + return []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: ".invalid-source-spec", + }, + }, + }, + }, + }, nil + }, + expectedErr: "invalid source", + }, + { + name: "invalid mirror locator", + image: "quay.io/ocp-test/release:4.5", + readICSPFunc: func(string) ([]operatorv1alpha1.ImageContentSourcePolicy, error) { + return []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "quay.io/ocp-test/release", + Mirrors: []string{ + ".invalid-mirror-spec", + }, + }, + }, + }, + }, + }, nil + }, + expectedErr: "invalid mirror", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + imageRef, _ := reference.Parse(tt.image) + alternates := NewICSPExplicitStrategy("name") + onErr := alternates.(*explicitStrategy) + onErr.readICSPsFromFileFunc = tt.readICSPFunc + _, err := alternates.FirstRequest(context.Background(), imageRef) + if err == nil || !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("Unexpected error, got %v, want %v", err, tt.expectedErr) + } + }) + } +} diff --git a/pkg/cli/image/strategy/onerror.go b/pkg/cli/image/strategy/onerror.go new file mode 100644 index 0000000000..a09c8a19a8 --- /dev/null +++ b/pkg/cli/image/strategy/onerror.go @@ -0,0 +1,148 @@ +package strategy + +import ( + "context" + "fmt" + "io/ioutil" + "sync" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/klog/v2" + + operatorv1alpha1 "github.com/openshift/api/operator/v1alpha1" + operatorv1alpha1scheme "github.com/openshift/client-go/operator/clientset/versioned/scheme" + "github.com/openshift/library-go/pkg/image/reference" + "github.com/openshift/library-go/pkg/image/registryclient" +) + +type onErrorStrategy struct { + lock sync.Mutex + + alternates map[reference.DockerImageReference][]reference.DockerImageReference + icspFile string + readICSPsFromFileFunc readICSPsFromFileFunc +} + +var _ registryclient.AlternateBlobSourceStrategy = &onErrorStrategy{} + +// NewICSPOnErrorStrategy returns ICSP alternate strategy which reads alternate +// sources only after getting an error from the original requested. +func NewICSPOnErrorStrategy(file string) registryclient.AlternateBlobSourceStrategy { + return &onErrorStrategy{ + icspFile: file, + alternates: make(map[reference.DockerImageReference][]reference.DockerImageReference), + readICSPsFromFileFunc: readICSPsFromFile, + } +} + +func (s *onErrorStrategy) FirstRequest(ctx context.Context, locator reference.DockerImageReference) (alternateRepositories []reference.DockerImageReference, err error) { + return nil, nil +} + +func (s *onErrorStrategy) OnFailure(ctx context.Context, locator reference.DockerImageReference) (alternateRepositories []reference.DockerImageReference, err error) { + s.lock.Lock() + defer s.lock.Unlock() + if alternates, ok := s.alternates[locator]; ok { + return alternates, nil + } + alternates, err := s.resolve(ctx, locator) + if err != nil { + return nil, err + } + if len(alternates) == 0 { + return nil, fmt.Errorf("no alternative image references found for image: %s", locator.String()) + } + s.alternates[locator] = alternates + return s.alternates[locator], nil +} + +// resolve gathers possible image sources for a given image +// gathered from ImageContentSourcePolicy file. +// Image reference of user-given image may be different from original in case of mirrored images. +func (s *onErrorStrategy) resolve(ctx context.Context, imageRef reference.DockerImageReference) ([]reference.DockerImageReference, error) { + if len(s.icspFile) == 0 { + return nil, fmt.Errorf("no ImageContentSourceFile specified") + } + klog.V(5).Infof("Reading ICSP from file %s", s.icspFile) + icspList, err := s.readICSPsFromFileFunc(s.icspFile) + if err != nil { + return nil, err + } + // always add the original as the first reference + imageRefList, err := alternativeImageSources(imageRef, icspList, false) + if err != nil { + return nil, err + } + return imageRefList, nil +} + +// alternativeImageSources returns unique list of DockerImageReference objects from list of ImageContentSourcePolicy objects +// addSourceAsLastAlternate decides whether the original imageRef is first or the last element in the result +func alternativeImageSources(imageRef reference.DockerImageReference, icspList []operatorv1alpha1.ImageContentSourcePolicy, addSourceAsLastAlternate bool) ([]reference.DockerImageReference, error) { + var imageSources []reference.DockerImageReference + klog.V(5).Infof("%v ImageReference added to potential ImageSourcePrefixes from ImageContentSourcePolicy", imageRef.AsRepository().AsV2()) + if !addSourceAsLastAlternate { + imageSources = append(imageSources, imageRef.AsRepository().AsV2()) + } + for _, icsp := range icspList { + repoDigestMirrors := icsp.Spec.RepositoryDigestMirrors + for _, rdm := range repoDigestMirrors { + var err error + rdmSourceRef, err := reference.Parse(rdm.Source) + if err != nil { + return nil, fmt.Errorf("invalid source %q: %w", rdm.Source, err) + } + // AsV2 in the right call is required to ensure we transform docker registry + // from docker.io to registry-1.docker.io + if imageRef.AsRepository().AsV2() != rdmSourceRef.AsRepository().AsV2() { + continue + } + klog.V(5).Infof("%v RepositoryDigestMirrors source matches given image", imageRef.AsRepository().AsV2()) + for _, m := range rdm.Mirrors { + mRef, err := reference.Parse(m) + if err != nil { + return nil, fmt.Errorf("invalid mirror %q: %w", m, err) + } + imageSources = append(imageSources, mRef) + klog.V(5).Infof("%v RepositoryDigestMirrors mirror added to potential ImageSourcePrefixes from ImageContentSourcePolicy", m) + } + } + } + if addSourceAsLastAlternate { + imageSources = append(imageSources, imageRef.AsRepository().AsV2()) + } + uniqueMirrors := make([]reference.DockerImageReference, 0, len(imageSources)) + uniqueMap := make(map[reference.DockerImageReference]bool) + for _, imageSourceMirror := range imageSources { + if _, ok := uniqueMap[imageSourceMirror]; !ok { + uniqueMap[imageSourceMirror] = true + uniqueMirrors = append(uniqueMirrors, imageSourceMirror) + } + } + klog.V(2).Infof("Found sources: %v for image: %v", uniqueMirrors, imageRef) + return uniqueMirrors, nil +} + +// readICSPsFromFileFunc is used for testing to be able to inject ICSP data +type readICSPsFromFileFunc func(string) ([]operatorv1alpha1.ImageContentSourcePolicy, error) + +// readICSPsFromFile appends to list of alternative image sources from ICSP file +// returns error if no icsp object decoded from file data +func readICSPsFromFile(icspFile string) ([]operatorv1alpha1.ImageContentSourcePolicy, error) { + icspData, err := ioutil.ReadFile(icspFile) + if err != nil { + return nil, fmt.Errorf("unable to read ImageContentSourceFile %s: %v", icspFile, err) + } + if len(icspData) == 0 { + return nil, fmt.Errorf("no data found in ImageContentSourceFile %s", icspFile) + } + icspObj, err := runtime.Decode(operatorv1alpha1scheme.Codecs.UniversalDeserializer(), icspData) + if err != nil { + return nil, fmt.Errorf("error decoding ImageContentSourcePolicy from %s: %v", icspFile, err) + } + icsp, ok := icspObj.(*operatorv1alpha1.ImageContentSourcePolicy) + if !ok { + return nil, fmt.Errorf("could not decode ImageContentSourcePolicy from %s", icspFile) + } + return []operatorv1alpha1.ImageContentSourcePolicy{*icsp}, nil +} diff --git a/pkg/cli/image/strategy/onerror_test.go b/pkg/cli/image/strategy/onerror_test.go new file mode 100644 index 0000000000..a738dc45f8 --- /dev/null +++ b/pkg/cli/image/strategy/onerror_test.go @@ -0,0 +1,309 @@ +package strategy + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + operatorv1alpha1 "github.com/openshift/api/operator/v1alpha1" + "github.com/openshift/library-go/pkg/image/reference" +) + +func TestOnErrorStrategy(t *testing.T) { + tests := []struct { + name string + icspList []operatorv1alpha1.ImageContentSourcePolicy + image string + imageSourcesExpected []string + }{ + { + name: "multiple ICSPs", + icspList: []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "quay.io/multiple/icsps", + Mirrors: []string{ + "someregistry/somerepo/release", + }, + }, + { + Source: "quay.io/ocp-test/another-release", + Mirrors: []string{ + "someregistry/repo/does-not-exist", + }, + }, + }, + }, + }, + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "quay.io/multiple/icsps", + Mirrors: []string{ + "anotherregistry/anotherrepo/release", + }, + }, + }, + }, + }, + }, + image: "quay.io/multiple/icsps:4.5", + imageSourcesExpected: []string{"quay.io/multiple/icsps", "someregistry/somerepo/release", "anotherregistry/anotherrepo/release"}, + }, + { + name: "multiple mirrors, single source match", + icspList: []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "docker.io/ocp-test/does-not-exist", + Mirrors: []string{ + "does.not.exist/match/image", + }, + }, + { + Source: "quay.io/ocp-test/does-not-exist", + Mirrors: []string{ + "exists/match/image", + }, + }, + }, + }, + }, + }, + image: "quay.io/ocp-test/does-not-exist:4.7", + imageSourcesExpected: []string{"quay.io/ocp-test/does-not-exist", "exists/match/image"}, + }, + { + name: "single mirror and match", + icspList: []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "quay.io/ocp-test/release", + Mirrors: []string{ + "someregistry/mirrors/match", + }, + }, + }, + }, + }, + }, + image: "quay.io/ocp-test/release:4.5", + imageSourcesExpected: []string{"quay.io/ocp-test/release", "someregistry/mirrors/match"}, + }, + { + name: "no source match", + icspList: []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "docker.io/ocp-test/does-not-exist", + Mirrors: []string{ + "does.not.exist/match/image", + }, + }, + { + Source: "quay.io/ocp-test/does-not-exist", + Mirrors: []string{ + "exists/match/image", + }, + }, + }, + }, + }, + }, + image: "quay.io/passed/image:4.5", + imageSourcesExpected: []string{"quay.io/passed/image"}, + }, + { + name: "multiple mirrors for single source match", + icspList: []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "quay.io/ocp-test/release", + Mirrors: []string{ + "someregistry/mirrors/match", + "quay.io/another/release", + "quay.io/andanother/release", + }, + }, + }, + }, + }, + }, + image: "quay.io/ocp-test/release:4.5", + imageSourcesExpected: []string{"quay.io/ocp-test/release", "someregistry/mirrors/match", "quay.io/another/release", "quay.io/andanother/release"}, + }, + { + name: "docker.io vs registry-1.docker.io", + icspList: []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "docker.io/ocp-test/release", + Mirrors: []string{ + "quay.io/ocp-test/release", + }, + }, + }, + }, + }, + }, + image: "registry-1.docker.io/ocp-test/release:4.5", + imageSourcesExpected: []string{"registry-1.docker.io/ocp-test/release", "quay.io/ocp-test/release"}, + }, + { + name: "docker.io and registry-1.docker.io as source", + icspList: []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "docker.io/ocp-test/release", + Mirrors: []string{ + "quay.io/ocp-test/release", + }, + }, + }, + }, + }, + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "registry-1.docker.io/ocp-test/release", + Mirrors: []string{ + "quay.io/ocp-test/release", + }, + }, + }, + }, + }, + }, + image: "registry-1.docker.io/ocp-test/release:4.5", + imageSourcesExpected: []string{"registry-1.docker.io/ocp-test/release", "quay.io/ocp-test/release"}, + }, + { + name: "no ICSP", + image: "quay.io/ocp-test/release:4.5", + imageSourcesExpected: []string{"quay.io/ocp-test/release"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expected := []reference.DockerImageReference{} + for _, e := range tt.imageSourcesExpected { + ref, _ := reference.Parse(e) + expected = append(expected, ref) + } + + alternates := NewICSPOnErrorStrategy("name") + readCount := 0 + onErr := alternates.(*onErrorStrategy) + onErr.readICSPsFromFileFunc = func(string) ([]operatorv1alpha1.ImageContentSourcePolicy, error) { + readCount++ + return tt.icspList, nil + } + imageRef, _ := reference.Parse(tt.image) + + actual, err := alternates.FirstRequest(context.Background(), imageRef) + if actual != nil || err != nil { + t.Errorf("Unexpected values returned from FirstRequest\nactual: %v\nerr: %v", actual, err) + } + + actual, err = alternates.OnFailure(context.Background(), imageRef) + if err != nil { + t.Errorf("Unexpected error %v", err) + return + } + if !reflect.DeepEqual(expected, actual) { + t.Errorf("Unexpected alternates got = %v, want %v", actual, expected) + } + if readCount > 1 { + t.Errorf("Unexpected number of ICSP reads, should be 1, got %d", readCount) + } + }) + } +} + +func TestOnErrorStrategyErrors(t *testing.T) { + tests := []struct { + name string + readICSPFunc readICSPsFromFileFunc + image string + expectedErr string + }{ + { + name: "non-existent ICSP file", + image: "quay.io/ocp-test/release:4.5", + readICSPFunc: func(string) ([]operatorv1alpha1.ImageContentSourcePolicy, error) { + return nil, errors.New("no ImageContentSourceFile") + }, + expectedErr: "no ImageContentSourceFile", + }, + { + name: "invalid source locator", + image: "quay.io/ocp-test/release:4.5", + readICSPFunc: func(string) ([]operatorv1alpha1.ImageContentSourcePolicy, error) { + return []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: ".invalid-source-spec", + }, + }, + }, + }, + }, nil + }, + expectedErr: "invalid source", + }, + { + name: "invalid mirror locator", + image: "quay.io/ocp-test/release:4.5", + readICSPFunc: func(string) ([]operatorv1alpha1.ImageContentSourcePolicy, error) { + return []operatorv1alpha1.ImageContentSourcePolicy{ + { + Spec: operatorv1alpha1.ImageContentSourcePolicySpec{ + RepositoryDigestMirrors: []operatorv1alpha1.RepositoryDigestMirrors{ + { + Source: "quay.io/ocp-test/release", + Mirrors: []string{ + ".invalid-mirror-spec", + }, + }, + }, + }, + }, + }, nil + }, + expectedErr: "invalid mirror", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + imageRef, _ := reference.Parse(tt.image) + alternates := NewICSPOnErrorStrategy("name") + onErr := alternates.(*onErrorStrategy) + onErr.readICSPsFromFileFunc = tt.readICSPFunc + _, err := alternates.OnFailure(context.Background(), imageRef) + if err == nil || !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("Unexpected error, got %v, want %v", err, tt.expectedErr) + } + }) + } +}