diff --git a/cloudprofilesync/source.go b/cloudprofilesync/source.go index 3599f10..15f6d1b 100644 --- a/cloudprofilesync/source.go +++ b/cloudprofilesync/source.go @@ -11,6 +11,7 @@ import ( "strings" gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + "github.com/go-logr/logr" "golang.org/x/sync/semaphore" "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" @@ -77,6 +78,7 @@ type Source interface { } type OCI struct { + log logr.Logger repo *remote.Repository sema *semaphore.Weighted } @@ -89,7 +91,7 @@ type OCIParams struct { Parallel int64 `json:"parallel"` } -func NewOCI(params OCIParams, insecure bool) (*OCI, error) { +func NewOCI(params OCIParams, insecure bool, log logr.Logger) (*OCI, error) { // Create a new OCI repository repo, err := remote.NewRepository(params.Registry + "/" + params.Repository) if err != nil { @@ -109,6 +111,7 @@ func NewOCI(params OCIParams, insecure bool) (*OCI, error) { repo.PlainHTTP = insecure return &OCI{ + log: log, repo: repo, sema: semaphore.NewWeighted(params.Parallel), }, nil @@ -134,7 +137,7 @@ func (o *OCI) GetVersions(ctx context.Context) ([]SourceImage, error) { defer o.sema.Release(1) _, reader, err := o.repo.FetchReference(ctx, tag) if err != nil { - out <- Result[SourceImage]{err: err} + out <- Result[SourceImage]{err: fmt.Errorf("tag %s: failed to fetch manifest: %w", tag, err)} return } defer reader.Close() @@ -143,12 +146,12 @@ func (o *OCI) GetVersions(ctx context.Context) ([]SourceImage, error) { }{} err = json.NewDecoder(reader).Decode(&manifest) if err != nil { - out <- Result[SourceImage]{err: err} + out <- Result[SourceImage]{err: fmt.Errorf("tag %s: failed to decode manifest: %w", tag, err)} return } arch, ok := manifest.Annotations["architecture"] if !ok { - out <- Result[SourceImage]{err: fmt.Errorf("architecture annotation not found in descriptor. tag: %s", tag)} + out <- Result[SourceImage]{err: fmt.Errorf("tag %s: architecture annotation not found", tag)} return } var capabilities gardencorev1beta1.Capabilities @@ -177,14 +180,25 @@ func (o *OCI) GetVersions(ctx context.Context) ([]SourceImage, error) { } images := []SourceImage{} - errs := []error{} + var skipped []error + var errs []error for range tags { result := <-out if result.err != nil { - errs = append(errs, result.err) + if errors.Is(result.err, context.Canceled) || errors.Is(result.err, context.DeadlineExceeded) { + errs = append(errs, result.err) + } else { + skipped = append(skipped, result.err) + } continue } images = append(images, result.value) } + if len(skipped) > 0 { + o.log.V(1).Info("skipped tags with errors", "count", len(skipped), "errors", errors.Join(skipped...)) + } + if len(errs) == 0 && len(images) == 0 && len(tags) > 0 { + return nil, fmt.Errorf("all %d tags were skipped; possible registry issue", len(tags)) + } return images, errors.Join(errs...) } diff --git a/cloudprofilesync/source_test.go b/cloudprofilesync/source_test.go index a49ee4e..42e8c02 100644 --- a/cloudprofilesync/source_test.go +++ b/cloudprofilesync/source_test.go @@ -9,6 +9,7 @@ import ( "strings" gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + "github.com/go-logr/logr" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/opencontainers/image-spec/specs-go" @@ -59,7 +60,7 @@ var _ = Describe("OCISource", func() { Registry: registryAddr, Repository: "repo", Parallel: 4, - }, true) + }, true, logr.Discard()) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) @@ -103,7 +104,7 @@ var _ = Describe("OCISource", func() { Registry: registryAddr, Repository: "repo-caps", Parallel: 4, - }, true) + }, true, logr.Discard()) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) @@ -148,7 +149,7 @@ var _ = Describe("OCISource", func() { Registry: registryAddr, Repository: "repo-legacy", Parallel: 4, - }, true) + }, true, logr.Discard()) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) @@ -156,6 +157,55 @@ var _ = Describe("OCISource", func() { Expect(versions[0].Capabilities).To(BeNil()) }) + It("skips tags without architecture annotation and returns remaining images", func(ctx SpecContext) { + repo, err := remote.NewRepository(registryAddr + "/repo-missing-arch") + Expect(err).To(Succeed()) + repo.PlainHTTP = true + + withArch := ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: 2}, + Manifests: []ocispec.Descriptor{ + {MediaType: ocispec.MediaTypeImageManifest, Size: 0, Digest: ocispec.DescriptorEmptyJSON.Digest}, + }, + Annotations: map[string]string{ + "architecture": "amd64", + }, + } + withArchBlob, err := json.Marshal(withArch) + Expect(err).To(Succeed()) + withArchDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageIndex, withArchBlob) + + noArch := ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: 2}, + Manifests: []ocispec.Descriptor{ + {MediaType: ocispec.MediaTypeImageManifest, Size: 0, Digest: ocispec.DescriptorEmptyJSON.Digest}, + }, + } + noArchBlob, err := json.Marshal(noArch) + Expect(err).To(Succeed()) + noArchDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageIndex, noArchBlob) + + err = repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}")) + Expect(err).To(Succeed()) + err = repo.PushReference(ctx, withArchDesc, bytes.NewReader(withArchBlob), "1.0.0") + Expect(err).To(Succeed()) + err = repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}")) + Expect(err).To(Succeed()) + err = repo.PushReference(ctx, noArchDesc, bytes.NewReader(noArchBlob), "1.0.1") + Expect(err).To(Succeed()) + + oci, err := cloudprofilesync.NewOCI(cloudprofilesync.OCIParams{ + Registry: registryAddr, + Repository: "repo-missing-arch", + Parallel: 4, + }, true, logr.Discard()) + Expect(err).To(Succeed()) + versions, err := oci.GetVersions(ctx) + Expect(err).To(Succeed()) + Expect(versions).To(HaveLen(1)) + Expect(versions[0].Version).To(Equal("1.0.0")) + }) + It("leaves Capabilities nil when feature_set contains no valid values", func(ctx SpecContext) { repo, err := remote.NewRepository(registryAddr + "/repo-no-valid-features") Expect(err).To(Succeed()) @@ -185,7 +235,7 @@ var _ = Describe("OCISource", func() { Registry: registryAddr, Repository: "repo-no-valid-features", Parallel: 4, - }, true) + }, true, logr.Discard()) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) diff --git a/controllers/managedcloudprofile_controller.go b/controllers/managedcloudprofile_controller.go index 85c1dc5..4c94d35 100644 --- a/controllers/managedcloudprofile_controller.go +++ b/controllers/managedcloudprofile_controller.go @@ -36,7 +36,7 @@ const ( // OCISourceFactory defines an interface for creating OCI sources. type OCISourceFactory interface { - Create(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) + Create(params cloudprofilesync.OCIParams, insecure bool, log logr.Logger) (cloudprofilesync.Source, error) } type RegistryClient interface { @@ -52,8 +52,8 @@ func (k *KeppelClient) GetTags(ctx context.Context, registry, repository string) // DefaultOCISourceFactory is the default implementation of OCISourceFactory. type DefaultOCISourceFactory struct{} -func (f *DefaultOCISourceFactory) Create(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) { - return cloudprofilesync.NewOCI(params, insecure) +func (f *DefaultOCISourceFactory) Create(params cloudprofilesync.OCIParams, insecure bool, log logr.Logger) (cloudprofilesync.Source, error) { + return cloudprofilesync.NewOCI(params, insecure, log) } type Reconciler struct { @@ -288,7 +288,7 @@ func (r *Reconciler) updateMachineImages(ctx context.Context, log logr.Logger, u Username: update.Source.OCI.Username, Password: string(password), Parallel: 1, - }, update.Source.OCI.Insecure) + }, update.Source.OCI.Insecure, log) if err != nil { return fmt.Errorf("failed to initialize OCI source: %w", err) } diff --git a/controllers/managedcloudprofile_controller_test.go b/controllers/managedcloudprofile_controller_test.go index b793fce..42e5810 100644 --- a/controllers/managedcloudprofile_controller_test.go +++ b/controllers/managedcloudprofile_controller_test.go @@ -17,6 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + "github.com/go-logr/logr" providercfg "github.com/ironcore-dev/gardener-extension-provider-ironcore-metal/pkg/apis/metal/v1alpha1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -49,11 +50,11 @@ func (f *fakeOCISource) GetVersions(ctx context.Context) ([]cloudprofilesync.Sou type fakeFactory struct{} -func (f *fakeFactory) Create(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) { +func (f *fakeFactory) Create(params cloudprofilesync.OCIParams, insecure bool, _ logr.Logger) (cloudprofilesync.Source, error) { return &fakeOCISource{}, nil } -func (m *mockOCIFactory) Create(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) { +func (m *mockOCIFactory) Create(params cloudprofilesync.OCIParams, insecure bool, _ logr.Logger) (cloudprofilesync.Source, error) { return m.createFunc(params, insecure) }