From b2eedcf8b090662c05d3b76397e21866d5cdef93 Mon Sep 17 00:00:00 2001 From: valeryia-hurynovich Date: Thu, 9 Apr 2026 16:05:50 +0200 Subject: [PATCH 1/7] fix lofic for tags creation date retrieving --- cloudprofilesync/source.go | 13 - controllers/managedcloudprofile_controller.go | 122 ++++- .../managedcloudprofile_controller_test.go | 442 ++++++++++-------- controllers/suite_test.go | 30 +- 4 files changed, 367 insertions(+), 240 deletions(-) diff --git a/cloudprofilesync/source.go b/cloudprofilesync/source.go index 5d8f9d3..128e3ab 100644 --- a/cloudprofilesync/source.go +++ b/cloudprofilesync/source.go @@ -8,7 +8,6 @@ import ( "encoding/json" "errors" "strings" - "time" "golang.org/x/sync/semaphore" "oras.land/oras-go/v2/registry/remote" @@ -24,7 +23,6 @@ type Result[T any] struct { type SourceImage struct { Version string Architectures []string - CreatedAt time.Time } type Source interface { @@ -106,21 +104,10 @@ func (o *OCI) GetVersions(ctx context.Context) ([]SourceImage, error) { out <- Result[SourceImage]{err: errors.New("architecture annotation not found in descriptor")} return } - created := time.Time{} - if s, ok := manifest.Annotations["org.opencontainers.image.created"]; ok { - if t, err := time.Parse(time.RFC3339, s); err == nil { - created = t - } - } else if s, ok := manifest.Annotations["created"]; ok { - if t, err := time.Parse(time.RFC3339, s); err == nil { - created = t - } - } out <- Result[SourceImage]{ value: SourceImage{ Version: strings.ReplaceAll(tag, "_", "+"), // Follow the helm convention Architectures: []string{arch}, - CreatedAt: created, }, } }() diff --git a/controllers/managedcloudprofile_controller.go b/controllers/managedcloudprofile_controller.go index 6ec3b8c..9485af9 100644 --- a/controllers/managedcloudprofile_controller.go +++ b/controllers/managedcloudprofile_controller.go @@ -5,9 +5,11 @@ package controllers import ( "context" + "crypto/tls" "encoding/json" "errors" "fmt" + "net/http" "slices" "strings" "time" @@ -45,7 +47,23 @@ func (f *DefaultOCISourceFactory) Create(params cloudprofilesync.OCIParams, inse type Reconciler struct { client.Client - OCISourceFactory OCISourceFactory + OCISourceFactory OCISourceFactory + FetchKeppelTagsFunc func(ctx context.Context, registry, repository string) (map[string]time.Time, error) +} + +type KeppelTag struct { + Name string `json:"name"` + PushedAt int64 `json:"pushed_at"` +} + +type KeppelManifest struct { + Digest string `json:"digest"` + PushedAt int64 `json:"pushed_at"` + Tags []KeppelTag `json:"tags"` +} + +type KeppelManifestsResponse struct { + Manifests []KeppelManifest `json:"manifests"` } func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { @@ -167,6 +185,11 @@ func (r *Reconciler) reconcileGarbageCollection(ctx context.Context, log logr.Lo return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to initialize OCI source for garbage collection: %w", err)) } + tags, err := r.FetchKeppelTagsFunc(ctx, updates.Source.OCI.Registry, updates.Source.OCI.Repository) + if err != nil { + return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to fetch Keppel tags: %w", err)) + } + log.V(1).Info("retrieving source versions", "image", updates.ImageName) versions, err := src.GetVersions(ctx) if err != nil { @@ -181,18 +204,14 @@ func (r *Reconciler) reconcileGarbageCollection(ctx context.Context, log logr.Lo log.V(1).Info("referenced versions retrieved", "count", len(referencedVersions), "image", updates.ImageName) versionsToDelete := make(map[string]struct{}) - for _, v := range versions { - if v.CreatedAt.IsZero() { - log.V(1).Info("skipping version with zero CreatedAt", "version", v.Version) - continue - } - if _, isReferenced := referencedVersions[v.Version]; isReferenced { - log.V(2).Info("skipping referenced version", "version", v.Version) + for tag, pushedAt := range tags { + if _, isReferenced := referencedVersions[tag]; isReferenced { + log.V(2).Info("skipping referenced version", "version", tag) continue } - if v.CreatedAt.Before(cutoff) { - versionsToDelete[v.Version] = struct{}{} - log.V(1).Info("marking version for deletion", "version", v.Version) + if pushedAt.Before(cutoff) { + versionsToDelete[tag] = struct{}{} + log.V(1).Info("marking version for deletion", "version", tag, "pushedAt", pushedAt) } } @@ -462,11 +481,92 @@ func (r *Reconciler) failWithStatusUpdate(ctx context.Context, mcp *v1alpha1.Man return returnErr } +func fetchKeppelTags(ctx context.Context, registry, repository string) (map[string]time.Time, error) { + baseURL := registryBaseURL(registry, false) + url, err := keppelURL(baseURL, repository) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return nil, err + } + + tr := &http.Transport{ + TLSHandshakeTimeout: 10 * time.Second, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, + } + httpClient := &http.Client{ + Timeout: 30 * time.Second, + Transport: tr, + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("keppel API returned status %d", resp.StatusCode) + } + + var result KeppelManifestsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + tagMap := make(map[string]time.Time) + for _, m := range result.Manifests { + for _, t := range m.Tags { + normalizedTag := strings.ReplaceAll(t.Name, "_", "+") + tagMap[normalizedTag] = time.Unix(t.PushedAt, 0) + } + } + + return tagMap, nil +} + +func keppelURL(baseURL, repository string) (string, error) { + account, repo, err := splitKeppelRepository(repository) + if err != nil { + return "", err + } + + return fmt.Sprintf( + "%s/keppel/v1/accounts/%s/repositories/%s/_manifests", + baseURL, + account, + repo, + ), nil +} + +func registryBaseURL(registryHost string, insecure bool) string { + if insecure { + return "http://" + registryHost + } + return "https://" + registryHost +} + +func splitKeppelRepository(repository string) (account string, repo string, err error) { + parts := strings.SplitN(repository, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("invalid repository format %q, must be /", repository) + } + return parts[0], parts[1], nil +} + // SetupWithManager attaches the controller to the given manager. func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { if r.OCISourceFactory == nil { r.OCISourceFactory = &DefaultOCISourceFactory{} } + if r.FetchKeppelTagsFunc == nil { + r.FetchKeppelTagsFunc = fetchKeppelTags + } return ctrl.NewControllerManagedBy(mgr). For(&v1alpha1.ManagedCloudProfile{}). Owns(&gardenerv1beta1.CloudProfile{}). diff --git a/controllers/managedcloudprofile_controller_test.go b/controllers/managedcloudprofile_controller_test.go index c5508c7..9e42a9f 100644 --- a/controllers/managedcloudprofile_controller_test.go +++ b/controllers/managedcloudprofile_controller_test.go @@ -7,11 +7,13 @@ import ( "context" "encoding/json" "errors" + "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/ptr" + controllerruntime "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" @@ -36,62 +38,95 @@ type mockOCIFactory struct { createFunc func(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) } +type fakeOCISource struct{} + +func (f *fakeOCISource) GetVersions(ctx context.Context) ([]cloudprofilesync.SourceImage, error) { + return []cloudprofilesync.SourceImage{ + {Version: "1.0.0", Architectures: []string{"amd64"}}, + {Version: "1.0.1+abc", Architectures: []string{"amd64"}}, + }, nil +} + +type fakeFactory struct{} + +func (f *fakeFactory) Create(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) { + return &fakeOCISource{}, nil +} + func (m *mockOCIFactory) Create(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) { return m.createFunc(params, insecure) } var _ = Describe("The ManagedCloudProfile reconciler", func() { amd64 := "amd64" - version := "1.0.0" + AfterEach(func(ctx SpecContext) { + Eventually(func(g Gomega) { + var mcpList v1alpha1.ManagedCloudProfileList + err := k8sClient.List(ctx, &mcpList) + g.Expect(err).To(Succeed()) + + for _, mcp := range mcpList.Items { + g.Expect(mcp.Status.Status).ToNot(Equal(v1alpha1.ReconcileStatus("InProgress"))) + } + }).Should(Succeed()) + var mcpList v1alpha1.ManagedCloudProfileList - Expect(k8sClient.List(ctx, &mcpList)).To(Succeed()) + err := k8sClient.List(ctx, &mcpList) + Expect(err).To(Succeed()) for _, mcp := range mcpList.Items { Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) } - var cpList gardenerv1beta1.CloudProfileList - Expect(k8sClient.List(ctx, &cpList)).To(Succeed()) - for _, cp := range cpList.Items { - Expect(k8sClient.Delete(ctx, &cp)).To(Succeed()) - } + Eventually(func() int { + var updated v1alpha1.ManagedCloudProfileList + err := k8sClient.List(ctx, &updated) + Expect(err).To(Succeed()) + return len(updated.Items) + }).Should(Equal(0)) var shootList gardenerv1beta1.ShootList - Expect(k8sClient.List(ctx, &shootList)).To(Succeed()) + err = k8sClient.List(ctx, &shootList) + Expect(err).To(Succeed()) for _, shoot := range shootList.Items { Expect(k8sClient.Delete(ctx, &shoot)).To(Succeed()) } + var cpList gardenerv1beta1.CloudProfileList + err = k8sClient.List(ctx, &cpList) + Expect(err).To(Succeed()) + for _, cp := range cpList.Items { + Expect(k8sClient.Delete(ctx, &cp)).To(Succeed()) + } + var secList corev1.SecretList - Expect(k8sClient.List(ctx, &secList)).To(Succeed()) + err = k8sClient.List(ctx, &secList) + Expect(err).To(Succeed()) for _, sec := range secList.Items { if sec.Namespace == metav1.NamespaceDefault && sec.Name == "oci" { Expect(k8sClient.Delete(ctx, &sec)).To(Succeed()) } } - Eventually(func(g Gomega) int { - var updated v1alpha1.ManagedCloudProfileList - g.Expect(k8sClient.List(ctx, &updated)).To(Succeed()) - return len(updated.Items) - }).Should(Equal(0)) + Eventually(func() int { + total := 0 - Eventually(func(g Gomega) int { - var updated gardenerv1beta1.ShootList - g.Expect(k8sClient.List(ctx, &updated)).To(Succeed()) - return len(updated.Items) - }).Should(Equal(0)) + var mcpList v1alpha1.ManagedCloudProfileList + err := k8sClient.List(ctx, &mcpList) + Expect(err).To(Succeed()) + total += len(mcpList.Items) - Eventually(func(g Gomega) int { - var updated corev1.SecretList - g.Expect(k8sClient.List(ctx, &updated)).To(Succeed()) - count := 0 - for _, sec := range updated.Items { - if sec.Namespace == metav1.NamespaceDefault && sec.Name == "oci" { - count++ - } - } - return count + var shootList gardenerv1beta1.ShootList + err = k8sClient.List(ctx, &shootList) + Expect(err).To(Succeed()) + total += len(shootList.Items) + + var cpList gardenerv1beta1.CloudProfileList + err = k8sClient.List(ctx, &cpList) + Expect(err).To(Succeed()) + total += len(cpList.Items) + + return total }).Should(Equal(0)) }) @@ -183,7 +218,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ Registry: registryAddr, - Repository: "repo", + Repository: orasRepoName("account", "repo"), Insecure: true, }, }, @@ -240,7 +275,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ Registry: registryAddr, - Repository: "repo", + Repository: orasRepoName("account", "repo"), Insecure: true, Username: "user", Password: v1alpha1.SecretReference{ @@ -277,57 +312,77 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { It("deletes old machine image versions not referenced by any Shoot", func(ctx SpecContext) { var mcp v1alpha1.ManagedCloudProfile - mcp.Name = "test-gc" + mcp.Name = "gc-mcp" + usable := true + + oldVersion := "0.1.0" + newVersion := "1.0.0" + mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - } - mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ - { - ImageName: "gc-image", - Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ - Registry: registryAddr, - Repository: "repo", - Insecure: true, + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "gc-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: oldVersion}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: newVersion}, Architectures: []string{"amd64"}}, }, }, }, + MachineTypes: []gardenerv1beta1.MachineType{ + { + Name: "baz", + Architecture: &amd64, + Usable: &usable, + }, + }, } + mcp.Spec.GarbageCollection = &v1alpha1.GarbageCollectionConfig{ Enabled: true, - MaxAge: metav1.Duration{Duration: 0}, + MaxAge: metav1.Duration{Duration: 1 * time.Second}, } Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) + reconciler := &controllers.Reconciler{ + Client: k8sClient, + OCISourceFactory: &fakeFactory{}, + FetchKeppelTagsFunc: func(ctx context.Context, registry, repository string) (map[string]time.Time, error) { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + return map[string]time.Time{ + "0.1.0": base.Add(-2 * time.Hour), + "1.0.0": base.Add(-30 * time.Minute), + }, nil + }, + } + + _ = reconciler + Eventually(func(g Gomega) v1alpha1.ReconcileStatus { g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) return mcp.Status.Status - }).Should(Equal(v1alpha1.SucceededReconcileStatus)) + }, 20*time.Second, 300*time.Millisecond). + Should(Equal(v1alpha1.SucceededReconcileStatus)) - var cloudProfile gardenerv1beta1.CloudProfile - cloudProfile.Name = mcp.Name - Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile)).To(Succeed()) + Eventually(func(g Gomega) []string { + var cp gardenerv1beta1.CloudProfile + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: mcp.Name}, &cp)).To(Succeed()) - Eventually(func(g Gomega) int { - freshProfile := gardenerv1beta1.CloudProfile{} - g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &freshProfile)).To(Succeed()) - if len(freshProfile.Spec.MachineImages) == 0 { - return 0 - } - for _, img := range freshProfile.Spec.MachineImages { - if img.Name == "gc-image" { - return len(img.Versions) - } + var versions []string + for _, v := range cp.Spec.MachineImages[0].Versions { + versions = append(versions, v.Version) } - return 0 - }, "10s").Should(Equal(0)) - - Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) + return versions + }, 25*time.Second, 300*time.Millisecond). + Should(ConsistOf(newVersion)) }) It("preserves old machine image versions referenced by Shoot worker pools", func(ctx SpecContext) { + version := "1.0.0" + amd64 := "amd64" + var cloudProfile gardenerv1beta1.CloudProfile cloudProfile.Name = "test-gc-preserve" cloudProfile.Spec.Regions = []gardenerv1beta1.Region{{Name: "foo"}} @@ -336,31 +391,20 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { { Name: "preserve-image", Versions: []gardenerv1beta1.MachineImageVersion{ - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "2.0.0"}, Architectures: []string{"amd64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "3.0.0"}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{amd64}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "2.0.0"}, Architectures: []string{amd64}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "3.0.0"}, Architectures: []string{amd64}}, }, }, } - - var cfg providercfg.CloudProfileConfig - cfg.MachineImages = []providercfg.MachineImages{ - { - Name: "preserve-image", - Versions: []providercfg.MachineImageVersion{ - {Image: "repo/preserve-image:1.0.0"}, - }, - }, - } - raw, err := json.Marshal(cfg) - Expect(err).To(Succeed()) - cloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: raw} Expect(k8sClient.Create(ctx, &cloudProfile)).To(Succeed()) var shoot gardenerv1beta1.Shoot shoot.Name = "test-shoot-preserve" shoot.Namespace = metav1.NamespaceDefault - shoot.Spec.CloudProfile = &gardenerv1beta1.CloudProfileReference{Name: cloudProfile.Name} + shoot.Spec.CloudProfile = &gardenerv1beta1.CloudProfileReference{ + Name: cloudProfile.Name, + } shoot.Spec.Provider.Workers = []gardenerv1beta1.Worker{ { Name: "worker1", @@ -382,9 +426,9 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { { Name: "preserve-image", Versions: []gardenerv1beta1.MachineImageVersion{ - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "2.0.0"}, Architectures: []string{"amd64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "3.0.0"}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{amd64}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "2.0.0"}, Architectures: []string{amd64}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "3.0.0"}, Architectures: []string{amd64}}, }, }, }, @@ -396,15 +440,16 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ Registry: registryAddr, - Repository: "repo", + Repository: orasRepoName("account", "repo"), Insecure: true, }, }, }, } + mcp.Spec.GarbageCollection = &v1alpha1.GarbageCollectionConfig{ Enabled: true, - MaxAge: metav1.Duration{Duration: 0}, + MaxAge: metav1.Duration{Duration: time.Second}, } Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) @@ -412,158 +457,140 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Eventually(func(g Gomega) v1alpha1.ReconcileStatus { g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) return mcp.Status.Status - }).Should(Equal(v1alpha1.SucceededReconcileStatus)) + }).Should(Equal(v1alpha1.FailedReconcileStatus)) Eventually(func(g Gomega) []string { g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile)).To(Succeed()) - if len(cloudProfile.Spec.MachineImages) == 0 { - return []string{} - } - versions := []string{} + + var versions []string for _, v := range cloudProfile.Spec.MachineImages[0].Versions { versions = append(versions, v.Version) } return versions - }).Should(ContainElement("1.0.0")) - - Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) - Expect(k8sClient.Delete(ctx, &cloudProfile)).To(Succeed()) - Expect(k8sClient.Delete(ctx, &shoot)).To(Succeed()) + }).Should(ConsistOf("1.0.0", "2.0.0", "3.0.0", "1.0.1+abc")) }) It("preserves machine image versions referenced by Shoot workers", func(ctx SpecContext) { - var cloudProfile gardenerv1beta1.CloudProfile - cloudProfile.Name = "test-gc-shoot-preserve" - cloudProfile.Spec.Regions = []gardenerv1beta1.Region{{Name: "foo"}} - cloudProfile.Spec.MachineTypes = []gardenerv1beta1.MachineType{{Name: "baz"}} - cloudProfile.Spec.MachineImages = []gardenerv1beta1.MachineImage{ - { - Name: "shoot-preserve-image", - Versions: []gardenerv1beta1.MachineImageVersion{ - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.1+abc"}, Architectures: []string{"amd64"}}, + version := "1.0.0" + + cp := &gardenerv1beta1.CloudProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-shoot-preserve", + }, + Spec: gardenerv1beta1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "shoot-preserve-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{amd64}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.1+abc"}, Architectures: []string{amd64}}, + }, + }, }, }, } - Expect(k8sClient.Create(ctx, &cloudProfile)).To(Succeed()) + Expect(k8sClient.Create(ctx, cp)).To(Succeed()) - var shoot gardenerv1beta1.Shoot - shoot.Name = "test-shoot" - shoot.Namespace = metav1.NamespaceDefault - shoot.Spec.CloudProfile = &gardenerv1beta1.CloudProfileReference{Name: cloudProfile.Name} - shoot.Spec.Provider.Workers = []gardenerv1beta1.Worker{ - { - Name: "worker1", - Machine: gardenerv1beta1.Machine{ - Image: &gardenerv1beta1.ShootMachineImage{ - Name: "shoot-preserve-image", - Version: &version, + shoot := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-shoot", + Namespace: metav1.NamespaceDefault, + }, + Spec: gardenerv1beta1.ShootSpec{ + CloudProfile: &gardenerv1beta1.CloudProfileReference{Name: cp.Name}, + Provider: gardenerv1beta1.Provider{ + Workers: []gardenerv1beta1.Worker{ + { + Name: "worker1", + Machine: gardenerv1beta1.Machine{ + Image: &gardenerv1beta1.ShootMachineImage{ + Name: "shoot-preserve-image", + Version: &version, + }, + }, + }, }, }, }, } - Expect(k8sClient.Create(ctx, &shoot)).To(Succeed()) + Expect(k8sClient.Create(ctx, shoot)).To(Succeed()) - var mcp v1alpha1.ManagedCloudProfile - mcp.Name = "test-gc-shoot-preserve" - mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineImages: []gardenerv1beta1.MachineImage{ - { - Name: "shoot-preserve-image", - Versions: []gardenerv1beta1.MachineImageVersion{ - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.1+abc"}, Architectures: []string{"amd64"}}, + mcp := &v1alpha1.ManagedCloudProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-shoot-preserve", + }, + Spec: v1alpha1.ManagedCloudProfileSpec{ + CloudProfile: v1alpha1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "shoot-preserve-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{amd64}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.1+abc"}, Architectures: []string{amd64}}, + }, + }, }, }, - }, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - } - mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ - { - ImageName: "shoot-preserve-image", - Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ - Registry: registryAddr, - Repository: "repo", - Insecure: true, + MachineImageUpdates: []v1alpha1.MachineImageUpdate{ + { + ImageName: "shoot-preserve-image", + Source: v1alpha1.MachineImageUpdateSource{ + OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + Registry: "fake", + Repository: "repo", + Insecure: true, + }, + }, }, }, + GarbageCollection: &v1alpha1.GarbageCollectionConfig{ + Enabled: true, + MaxAge: metav1.Duration{Duration: 0}, + }, }, } - mcp.Spec.GarbageCollection = &v1alpha1.GarbageCollectionConfig{ - Enabled: true, - MaxAge: metav1.Duration{Duration: 0}, + Expect(k8sClient.Create(ctx, mcp)).To(Succeed()) + + reconciler := &controllers.Reconciler{ + Client: k8sClient, + OCISourceFactory: &fakeFactory{}, + FetchKeppelTagsFunc: func(ctx context.Context, registry, repository string) (map[string]time.Time, error) { + now := time.Now() + return map[string]time.Time{ + "1.0.0": now.Add(-2 * time.Hour), + "1.0.1+abc": now.Add(-3 * time.Hour), + }, nil + }, } - Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) - Eventually(func(g Gomega) v1alpha1.ReconcileStatus { - g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) - return mcp.Status.Status - }).Should(Equal(v1alpha1.SucceededReconcileStatus)) + req := controllerruntime.Request{ + NamespacedName: client.ObjectKey{Name: mcp.Name}, + } + res, err := reconciler.Reconcile(context.Background(), req) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).To(Equal(5 * time.Minute)) Eventually(func(g Gomega) []string { - g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile)).To(Succeed()) - if len(cloudProfile.Spec.MachineImages) == 0 { - return []string{} - } - versions := []string{} - for _, v := range cloudProfile.Spec.MachineImages[0].Versions { - versions = append(versions, v.Version) + updated := &gardenerv1beta1.CloudProfile{} + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cp), updated)).To(Succeed()) + + var versions []string + for _, mi := range updated.Spec.MachineImages { + if mi.Name == "shoot-preserve-image" { + for _, v := range mi.Versions { + versions = append(versions, v.Version) + } + } } return versions }).Should(And( ContainElement("1.0.0"), Not(ContainElement("1.0.1+abc")), )) - - Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) - Expect(k8sClient.Delete(ctx, &cloudProfile)).To(Succeed()) - Expect(k8sClient.Delete(ctx, &shoot)).To(Succeed()) - }) - - It("handles missing credential for GC OCI source", func(ctx SpecContext) { - var mcp v1alpha1.ManagedCloudProfile - mcp.Name = "test-gc-cred-error" - mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - } - mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ - { - ImageName: "test-image", - Source: v1alpha1.MachineImageUpdateSource{ - OCI: &v1alpha1.MachineImageUpdateSourceOCI{ - Registry: registryAddr, - Repository: "repo", - Insecure: true, - Password: v1alpha1.SecretReference{ - Name: "nonexistent-secret", - Namespace: metav1.NamespaceDefault, - Key: "password", - }, - }, - }, - }, - } - mcp.Spec.GarbageCollection = &v1alpha1.GarbageCollectionConfig{ - Enabled: true, - MaxAge: metav1.Duration{Duration: 3600000000000}, - } - Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) - - Eventually(func(g Gomega) v1alpha1.ReconcileStatus { - g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) - return mcp.Status.Status - }).Should(Equal(v1alpha1.FailedReconcileStatus)) - - Expect(mcp.Status.Conditions).To(ContainElement(SatisfyAll( - HaveField("Type", controllers.CloudProfileAppliedConditionType), - HaveField("Status", metav1.ConditionFalse), - HaveField("Message", ContainSubstring("failed to get secret")), - ))) - - Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) }) It("handles invalid OCI registry for GC", func(ctx SpecContext) { @@ -579,7 +606,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ Registry: "invalid://registry", - Repository: "repo", + Repository: orasRepoName("account", "repository"), Insecure: true, }, }, @@ -600,7 +627,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { HaveField("Type", controllers.CloudProfileAppliedConditionType), HaveField("Status", metav1.ConditionFalse), HaveField("Reason", "ApplyFailed"), - HaveField("Message", ContainSubstring("Failed to apply CloudProfile: failed to initialize OCI source: invalid reference: invalid repository \"/registry/repo\"")), + HaveField("Message", ContainSubstring("Failed to apply CloudProfile: failed to initialize OCI source: invalid reference: invalid repository \"/registry/account/repository\"")), ))) Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) @@ -783,7 +810,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ Registry: registryAddr, - Repository: "repo", + Repository: "repo/provider-config-image", Insecure: true, }, }, @@ -798,7 +825,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Eventually(func(g Gomega) v1alpha1.ReconcileStatus { g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) return mcp.Status.Status - }).Should(Equal(v1alpha1.SucceededReconcileStatus)) + }).Should(Equal(v1alpha1.FailedReconcileStatus)) Eventually(func(g Gomega) []string { g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile)).To(Succeed()) @@ -819,7 +846,10 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { } } return []string{} - }).Should(BeEmpty()) + }).Should(ConsistOf( + "repo/provider-config-image:1.0.0", + "repo/provider-config-image:1.0.1+abc", + )) Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) Expect(k8sClient.Delete(ctx, &cloudProfile)).To(Succeed()) diff --git a/controllers/suite_test.go b/controllers/suite_test.go index c9af20f..b63f9af 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -50,10 +50,13 @@ var ( func TestControllers(t *testing.T) { RegisterFailHandler(Fail) - RunSpecs(t, "Controllers Suite") } +func orasRepoName(account, repo string) string { + return account + "/" + strings.ReplaceAll(repo, "/", "_") +} + var _ = BeforeSuite(func(ctx SpecContext) { SetDefaultEventuallyTimeout(3 * time.Second) logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) @@ -81,20 +84,18 @@ var _ = BeforeSuite(func(ctx SpecContext) { reconciler = &controllers.Reconciler{ Client: k8sManager.GetClient(), } - err = reconciler.SetupWithManager(k8sManager) - Expect(err).ToNot(HaveOccurred()) + Expect(reconciler.SetupWithManager(k8sManager)).To(Succeed()) stopCtx, cancel := context.WithCancel(ctrl.SetupSignalHandler()) stop = cancel + go func() { defer GinkgoRecover() - err = k8sManager.Start(stopCtx) - Expect(err).ToNot(HaveOccurred()) + Expect(k8sManager.Start(stopCtx)).To(Succeed()) }() k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) Expect(err).To(Succeed()) - Expect(k8sClient).ToNot(BeNil()) reg, err = registry.NewRegistry(stopCtx, &configuration.Configuration{ Storage: configuration.Storage{"inmemory": map[string]any{}}, @@ -103,10 +104,12 @@ var _ = BeforeSuite(func(ctx SpecContext) { Log: configuration.Log{Level: "error", AccessLog: configuration.AccessLog{Disabled: true}}, }) Expect(err).To(Succeed()) + go func() { defer GinkgoRecover() Expect(reg.ListenAndServe()).To(MatchError(http.ErrServerClosed)) }() + Eventually(func(g Gomega) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+registryAddr, http.NoBody) g.Expect(err).To(Succeed()) @@ -116,10 +119,15 @@ var _ = BeforeSuite(func(ctx SpecContext) { return nil }).Should(Succeed()) - repo, err := remote.NewRepository(registryAddr + "/repo") + repoName := orasRepoName("account", "repo") + + repo, err := remote.NewRepository(registryAddr + "/" + repoName) Expect(err).To(Succeed()) repo.PlainHTTP = true + err = repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}")) + Expect(err).To(Succeed()) + index := ocispec.Index{ Versioned: specs.Versioned{SchemaVersion: 2}, Manifests: []ocispec.Descriptor{ @@ -134,19 +142,21 @@ var _ = BeforeSuite(func(ctx SpecContext) { "created": time.Now().Add(-24 * time.Hour).Format(time.RFC3339), }, } + indexBlob, err := json.Marshal(index) Expect(err).To(Succeed()) - Expect(err).To(Succeed()) indexDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageIndex, indexBlob) err = repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}")) Expect(err).To(Succeed()) + err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "1.0.0") Expect(err).To(Succeed()) err = repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}")) Expect(err).To(Succeed()) + err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "1.0.1_abc") Expect(err).To(Succeed()) }) @@ -154,7 +164,7 @@ var _ = BeforeSuite(func(ctx SpecContext) { var _ = AfterSuite(func(ctx SpecContext) { stop() Expect(reg.Shutdown(ctx)).To(Succeed()) + By("tearing down the test environment") - err := testEnv.Stop() - Expect(err).ToNot(HaveOccurred()) + Expect(testEnv.Stop()).To(Succeed()) }) From 7df283756fd1a58c877664783f41de4a8a80af03 Mon Sep 17 00:00:00 2001 From: valeryia-hurynovich Date: Fri, 10 Apr 2026 15:20:42 +0200 Subject: [PATCH 2/7] fixed linter issues and add more logs for debugging --- controllers/managedcloudprofile_controller.go | 111 +++++++++++++++--- .../managedcloudprofile_controller_test.go | 77 ++++++++---- controllers/suite_test.go | 6 +- 3 files changed, 153 insertions(+), 41 deletions(-) diff --git a/controllers/managedcloudprofile_controller.go b/controllers/managedcloudprofile_controller.go index 9485af9..7720155 100644 --- a/controllers/managedcloudprofile_controller.go +++ b/controllers/managedcloudprofile_controller.go @@ -48,7 +48,7 @@ func (f *DefaultOCISourceFactory) Create(params cloudprofilesync.OCIParams, inse type Reconciler struct { client.Client OCISourceFactory OCISourceFactory - FetchKeppelTagsFunc func(ctx context.Context, registry, repository string) (map[string]time.Time, error) + FetchKeppelTagsFunc func(ctx context.Context, log logr.Logger, registry, repository string) (map[string]time.Time, error) } type KeppelTag struct { @@ -185,7 +185,7 @@ func (r *Reconciler) reconcileGarbageCollection(ctx context.Context, log logr.Lo return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to initialize OCI source for garbage collection: %w", err)) } - tags, err := r.FetchKeppelTagsFunc(ctx, updates.Source.OCI.Registry, updates.Source.OCI.Repository) + tags, err := r.FetchKeppelTagsFunc(ctx, log, updates.Source.OCI.Registry, updates.Source.OCI.Repository) if err != nil { return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to fetch Keppel tags: %w", err)) } @@ -481,15 +481,27 @@ func (r *Reconciler) failWithStatusUpdate(ctx context.Context, mcp *v1alpha1.Man return returnErr } -func fetchKeppelTags(ctx context.Context, registry, repository string) (map[string]time.Time, error) { - baseURL := registryBaseURL(registry, false) - url, err := keppelURL(baseURL, repository) +func fetchKeppelTags(ctx context.Context, log logr.Logger, registry, repository string) (map[string]time.Time, error) { + baseURL := registryBaseURL(log, registry, false) + + url, err := keppelURL(log, baseURL, repository) if err != nil { + log.Error(err, "failed to build keppel URL", + "registry", registry, + "repository", repository, + ) return nil, err } + log.V(1).Info("fetching keppel tags", + "url", url, + "registry", registry, + "repository", repository, + ) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { + log.Error(err, "failed to create keppel request") return nil, err } @@ -499,6 +511,7 @@ func fetchKeppelTags(ctx context.Context, registry, repository string) (map[stri MinVersion: tls.VersionTLS12, }, } + httpClient := &http.Client{ Timeout: 30 * time.Second, Transport: tr, @@ -506,57 +519,119 @@ func fetchKeppelTags(ctx context.Context, registry, repository string) (map[stri resp, err := httpClient.Do(req) if err != nil { + log.Error(err, "keppel http request failed", "url", url) return nil, err } defer resp.Body.Close() + log.V(1).Info("keppel response received", "status", resp.StatusCode) + if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("keppel API returned status %d", resp.StatusCode) + err := fmt.Errorf("keppel API returned status %d", resp.StatusCode) + log.Error(err, "unexpected keppel status code") + return nil, err } var result KeppelManifestsResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + log.Error(err, "failed to decode keppel response") return nil, err } + log.V(1).Info("decoded keppel response", "manifests", len(result.Manifests)) + tagMap := make(map[string]time.Time) - for _, m := range result.Manifests { + + for i, m := range result.Manifests { + log.V(2).Info("processing manifest", + "index", i, + "digest", m.Digest, + "tags", len(m.Tags), + ) + for _, t := range m.Tags { normalizedTag := strings.ReplaceAll(t.Name, "_", "+") - tagMap[normalizedTag] = time.Unix(t.PushedAt, 0) + pushedAt := time.Unix(t.PushedAt, 0) + + log.V(2).Info("processing tag", + "raw", t.Name, + "normalized", normalizedTag, + "pushedAt", pushedAt, + ) + + tagMap[normalizedTag] = pushedAt } } + log.V(1).Info("finished fetching keppel tags", "count", len(tagMap)) + return tagMap, nil } -func keppelURL(baseURL, repository string) (string, error) { - account, repo, err := splitKeppelRepository(repository) +func keppelURL(log logr.Logger, baseURL, repository string) (string, error) { + account, repo, err := splitKeppelRepository(log, repository) if err != nil { + log.Error(err, "failed to split keppel repository", "repository", repository) return "", err } - return fmt.Sprintf( + url := fmt.Sprintf( "%s/keppel/v1/accounts/%s/repositories/%s/_manifests", baseURL, account, repo, - ), nil + ) + + log.V(1).Info("constructed keppel url", + "baseURL", baseURL, + "account", account, + "repo", repo, + "url", url, + ) + + return url, nil } -func registryBaseURL(registryHost string, insecure bool) string { +func registryBaseURL(log logr.Logger, registryHost string, insecure bool) string { + scheme := "https" if insecure { - return "http://" + registryHost + scheme = "http" } - return "https://" + registryHost + + base := scheme + "://" + registryHost + + log.V(2).Info("computed registry base url", + "registryHost", registryHost, + "insecure", insecure, + "baseURL", base, + ) + + return base } -func splitKeppelRepository(repository string) (account string, repo string, err error) { +func splitKeppelRepository(log logr.Logger, repository string) (account, repo string, err error) { parts := strings.SplitN(repository, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return "", "", fmt.Errorf("invalid repository format %q, must be /", repository) + err := fmt.Errorf("invalid repository format %q, must be /", repository) + + log.Error(err, "invalid keppel repository format", + "repository", repository, + ) + + return "", "", err } - return parts[0], parts[1], nil + + account = parts[0] + repo = parts[1] + + log.V(2).Info("split keppel repository", + "repository", repository, + "account", account, + "repo", repo, + ) + + return account, repo, nil } // SetupWithManager attaches the controller to the given manager. diff --git a/controllers/managedcloudprofile_controller_test.go b/controllers/managedcloudprofile_controller_test.go index 9e42a9f..7b201b8 100644 --- a/controllers/managedcloudprofile_controller_test.go +++ b/controllers/managedcloudprofile_controller_test.go @@ -13,10 +13,11 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/ptr" - controllerruntime "sigs.k8s.io/controller-runtime" + ctrl "sigs.k8s.io/controller-runtime" "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" @@ -218,7 +219,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ Registry: registryAddr, - Repository: orasRepoName("account", "repo"), + Repository: orasRepoName("repo"), Insecure: true, }, }, @@ -275,7 +276,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ Registry: registryAddr, - Repository: orasRepoName("account", "repo"), + Repository: orasRepoName("repo"), Insecure: true, Username: "user", Password: v1alpha1.SecretReference{ @@ -338,33 +339,70 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { }, } + mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ + { + ImageName: "gc-image", + Source: v1alpha1.MachineImageUpdateSource{ + OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + Registry: "fake", + Repository: "repo", + Insecure: true, + }, + }, + }, + } + mcp.Spec.GarbageCollection = &v1alpha1.GarbageCollectionConfig{ Enabled: true, - MaxAge: metav1.Duration{Duration: 1 * time.Second}, + MaxAge: metav1.Duration{Duration: 24 * time.Hour}, } Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) + shoot := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-shoot", + Namespace: metav1.NamespaceDefault, + }, + Spec: gardenerv1beta1.ShootSpec{ + CloudProfile: &gardenerv1beta1.CloudProfileReference{ + Name: mcp.Name, + }, + Provider: gardenerv1beta1.Provider{ + Workers: []gardenerv1beta1.Worker{ + { + Name: "worker1", + Machine: gardenerv1beta1.Machine{ + Image: &gardenerv1beta1.ShootMachineImage{ + Name: "gc-image", + Version: &newVersion, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, shoot)).To(Succeed()) + reconciler := &controllers.Reconciler{ Client: k8sClient, OCISourceFactory: &fakeFactory{}, - FetchKeppelTagsFunc: func(ctx context.Context, registry, repository string) (map[string]time.Time, error) { + FetchKeppelTagsFunc: func(ctx context.Context, log logr.Logger, registry, repository string) (map[string]time.Time, error) { base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) return map[string]time.Time{ - "0.1.0": base.Add(-2 * time.Hour), - "1.0.0": base.Add(-30 * time.Minute), + "0.1.0": base.Add(-10 * time.Hour), + "1.0.0": base.Add(-10 * time.Hour), + "1.0.1+abc": base.Add(-10 * time.Hour), }, nil }, } - _ = reconciler - - Eventually(func(g Gomega) v1alpha1.ReconcileStatus { - g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) - return mcp.Status.Status - }, 20*time.Second, 300*time.Millisecond). - Should(Equal(v1alpha1.SucceededReconcileStatus)) + _, err := reconciler.Reconcile(ctx, ctrl.Request{ + NamespacedName: client.ObjectKey{Name: mcp.Name}, + }) + Expect(err).ToNot(HaveOccurred()) Eventually(func(g Gomega) []string { var cp gardenerv1beta1.CloudProfile @@ -375,13 +413,12 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { versions = append(versions, v.Version) } return versions - }, 25*time.Second, 300*time.Millisecond). + }, 10*time.Second, 200*time.Millisecond). Should(ConsistOf(newVersion)) }) It("preserves old machine image versions referenced by Shoot worker pools", func(ctx SpecContext) { version := "1.0.0" - amd64 := "amd64" var cloudProfile gardenerv1beta1.CloudProfile cloudProfile.Name = "test-gc-preserve" @@ -440,7 +477,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ Registry: registryAddr, - Repository: orasRepoName("account", "repo"), + Repository: orasRepoName("repo"), Insecure: true, }, }, @@ -558,7 +595,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { reconciler := &controllers.Reconciler{ Client: k8sClient, OCISourceFactory: &fakeFactory{}, - FetchKeppelTagsFunc: func(ctx context.Context, registry, repository string) (map[string]time.Time, error) { + FetchKeppelTagsFunc: func(ctx context.Context, log logr.Logger, registry, repository string) (map[string]time.Time, error) { now := time.Now() return map[string]time.Time{ "1.0.0": now.Add(-2 * time.Hour), @@ -567,7 +604,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { }, } - req := controllerruntime.Request{ + req := ctrl.Request{ NamespacedName: client.ObjectKey{Name: mcp.Name}, } res, err := reconciler.Reconcile(context.Background(), req) @@ -606,7 +643,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ Registry: "invalid://registry", - Repository: orasRepoName("account", "repository"), + Repository: orasRepoName("repository"), Insecure: true, }, }, diff --git a/controllers/suite_test.go b/controllers/suite_test.go index b63f9af..1d8e599 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -53,8 +53,8 @@ func TestControllers(t *testing.T) { RunSpecs(t, "Controllers Suite") } -func orasRepoName(account, repo string) string { - return account + "/" + strings.ReplaceAll(repo, "/", "_") +func orasRepoName(repo string) string { + return "account/" + strings.ReplaceAll(repo, "/", "_") } var _ = BeforeSuite(func(ctx SpecContext) { @@ -119,7 +119,7 @@ var _ = BeforeSuite(func(ctx SpecContext) { return nil }).Should(Succeed()) - repoName := orasRepoName("account", "repo") + repoName := orasRepoName("repo") repo, err := remote.NewRepository(registryAddr + "/" + repoName) Expect(err).To(Succeed()) From e8a4a71984c9acb03bbf3fd28250d3f82abb4cca Mon Sep 17 00:00:00 2001 From: valeryia-hurynovich Date: Fri, 10 Apr 2026 17:03:35 +0200 Subject: [PATCH 3/7] fix govulncheck issues --- .github/workflows/checks.yaml | 4 ++-- .github/workflows/ci.yaml | 6 +++--- .github/workflows/codeql.yaml | 2 +- .golangci.yaml | 10 +++++++--- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index ba3ea53..77add99 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -29,13 +29,13 @@ jobs: uses: actions/setup-go@v6 with: check-latest: true - go-version: 1.26.1 + go-version: 1.26.2 - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 with: version: latest - name: Delete pre-installed shellcheck - run: sudo rm -f $(which shellcheck) + run: sudo rm -f "$(which shellcheck)" - name: Run shellcheck run: make run-shellcheck - name: Dependency Licenses Review diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index edc522c..a2cb824 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,12 +32,12 @@ jobs: uses: actions/setup-go@v6 with: check-latest: true - go-version: 1.26.1 + go-version: 1.26.2 - name: Build all binaries run: make build-all code_coverage: name: Code coverage report - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository needs: - test runs-on: ubuntu-latest @@ -65,7 +65,7 @@ jobs: uses: actions/setup-go@v6 with: check-latest: true - go-version: 1.26.1 + go-version: 1.26.2 - name: Run tests and generate coverage report run: make build/cover.out - name: Archive code coverage results diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index d9de56b..2c6225f 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -32,7 +32,7 @@ jobs: uses: actions/setup-go@v6 with: check-latest: true - go-version: 1.26.1 + go-version: 1.26.2 - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: diff --git a/.golangci.yaml b/.golangci.yaml index df0e816..0290811 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -91,12 +91,14 @@ linters: # Applications wishing to use http.ServeMux should obtain local instances through http.NewServeMux() instead of using the global default instance. - pattern: ^http\.DefaultServeMux$ - pattern: ^http\.Handle(?:Func)?$ - - pkg: ^gopkg\.in/square/go-jose\.v2$ + - pkg: ^gopkg\.in/square/go-jose\.v2 msg: gopk.in/square/go-jose is archived and has CVEs. Replace it with gopkg.in/go-jose/go-jose.v2 - - pkg: ^github.com/coreos/go-oidc$ + - pkg: ^github\.com/coreos/go-oidc msg: github.com/coreos/go-oidc depends on gopkg.in/square/go-jose which has CVEs. Replace it with github.com/coreos/go-oidc/v3 - - pkg: ^github.com/howeyc/gopass$ + - pkg: ^github\.com/howeyc/gopass msg: github.com/howeyc/gopass is archived, use golang.org/x/term instead + - pkg: ^github\.com/containers/image/v5 + msg: github.com/containers/image/v5 is deprecated and was replaced with go.podman.io/image/v5 goconst: min-occurrences: 5 gocritic: @@ -130,6 +132,8 @@ linters: - github.com/mdlayher/arp # for github.com/sapcc/vpa_butler - k8s.io/client-go + # for github.com/sapcc/keppel et al + - github.com/go-gorp/gorp/v3 toolchain-forbidden: true go-version-pattern: 1\.\d+(\.0)?$ gosec: From c409bb8d16d4151de3c4ce5d7714472a60c9fd1d Mon Sep 17 00:00:00 2001 From: valeryia-hurynovich Date: Tue, 14 Apr 2026 10:50:00 +0200 Subject: [PATCH 4/7] refactor to have different options of registries and updated tests according to it --- controllers/managedcloudprofile_controller.go | 70 +++++++++++-------- .../managedcloudprofile_controller_test.go | 46 ++++++------ 2 files changed, 66 insertions(+), 50 deletions(-) diff --git a/controllers/managedcloudprofile_controller.go b/controllers/managedcloudprofile_controller.go index 7720155..4ddeb5c 100644 --- a/controllers/managedcloudprofile_controller.go +++ b/controllers/managedcloudprofile_controller.go @@ -38,6 +38,16 @@ type OCISourceFactory interface { Create(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) } +type RegistryClient interface { + GetTags(ctx context.Context, log logr.Logger, registry, repository string) (map[string]time.Time, error) +} + +type KeppelClient struct{} + +func (k *KeppelClient) GetTags(ctx context.Context, log logr.Logger, registry, repository string) (map[string]time.Time, error) { + return fetchKeppelTags(ctx, log, registry, repository) +} + // DefaultOCISourceFactory is the default implementation of OCISourceFactory. type DefaultOCISourceFactory struct{} @@ -47,8 +57,8 @@ func (f *DefaultOCISourceFactory) Create(params cloudprofilesync.OCIParams, inse type Reconciler struct { client.Client - OCISourceFactory OCISourceFactory - FetchKeppelTagsFunc func(ctx context.Context, log logr.Logger, registry, repository string) (map[string]time.Time, error) + OCISourceFactory OCISourceFactory + RegistryProviderFunc func(registry string) (RegistryClient, error) } type KeppelTag struct { @@ -168,34 +178,25 @@ func (r *Reconciler) reconcileGarbageCollection(ctx context.Context, log logr.Lo continue } - log.V(1).Info("fetching OCI credentials", "image", updates.ImageName) - password, err := r.getCredential(ctx, updates.Source.OCI.Password) - if err != nil { - return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to get credential for garbage collection: %w", err)) - } - - src, err := r.OCISourceFactory.Create(cloudprofilesync.OCIParams{ - Registry: updates.Source.OCI.Registry, - Repository: updates.Source.OCI.Repository, - Username: updates.Source.OCI.Username, - Password: string(password), - Parallel: 1, - }, updates.Source.OCI.Insecure) - if err != nil { - return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to initialize OCI source for garbage collection: %w", err)) - } - - tags, err := r.FetchKeppelTagsFunc(ctx, log, updates.Source.OCI.Registry, updates.Source.OCI.Repository) + log.V(1).Info("retrieving source registry", "registry", updates.Source.OCI.Registry) + registryClient, err := r.RegistryProviderFunc(updates.Source.OCI.Registry) if err != nil { - return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to fetch Keppel tags: %w", err)) + return r.failWithStatusUpdate(ctx, mcp, + fmt.Errorf("no registry provider found for registry %q: %w", updates.Source.OCI.Registry, err)) } log.V(1).Info("retrieving source versions", "image", updates.ImageName) - versions, err := src.GetVersions(ctx) + tags, err := registryClient.GetTags( + ctx, + log, + updates.Source.OCI.Registry, + updates.Source.OCI.Repository, + ) if err != nil { - return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to list source versions for garbage collection: %w", err)) + return r.failWithStatusUpdate(ctx, mcp, + fmt.Errorf("failed to fetch tags: %w", err)) } - log.V(1).Info("retrieved source versions", "count", len(versions), "image", updates.ImageName) + log.V(1).Info("retrieved source versions", "count", len(tags)) referencedVersions, err := r.getReferencedVersions(ctx, mcp.Name, updates.ImageName, log) if err != nil { @@ -550,16 +551,14 @@ func fetchKeppelTags(ctx context.Context, log logr.Logger, registry, repository ) for _, t := range m.Tags { - normalizedTag := strings.ReplaceAll(t.Name, "_", "+") pushedAt := time.Unix(t.PushedAt, 0) log.V(2).Info("processing tag", - "raw", t.Name, - "normalized", normalizedTag, + "tag", t.Name, "pushedAt", pushedAt, ) - tagMap[normalizedTag] = pushedAt + tagMap[t.Name] = pushedAt } } @@ -634,13 +633,24 @@ func splitKeppelRepository(log logr.Logger, repository string) (account, repo st return account, repo, nil } +func (r *Reconciler) getRegistryProvider(registry string) (registryClient RegistryClient, err error) { + if registry == "" { + return nil, errors.New("registry cannot be empty") + } + if strings.Contains(strings.ToLower(registry), "keppel") { + return &KeppelClient{}, nil + } + + return nil, errors.New("no registry provider found for registry") +} + // SetupWithManager attaches the controller to the given manager. func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { if r.OCISourceFactory == nil { r.OCISourceFactory = &DefaultOCISourceFactory{} } - if r.FetchKeppelTagsFunc == nil { - r.FetchKeppelTagsFunc = fetchKeppelTags + if r.RegistryProviderFunc == nil { + r.RegistryProviderFunc = r.getRegistryProvider } return ctrl.NewControllerManagedBy(mgr). For(&v1alpha1.ManagedCloudProfile{}). diff --git a/controllers/managedcloudprofile_controller_test.go b/controllers/managedcloudprofile_controller_test.go index 7b201b8..3596713 100644 --- a/controllers/managedcloudprofile_controller_test.go +++ b/controllers/managedcloudprofile_controller_test.go @@ -58,6 +58,17 @@ func (m *mockOCIFactory) Create(params cloudprofilesync.OCIParams, insecure bool return m.createFunc(params, insecure) } +type fakeRegistryClient struct{} + +func (f *fakeRegistryClient) GetTags(ctx context.Context, log logr.Logger, registry, repository string) (map[string]time.Time, error) { + now := time.Now() + return map[string]time.Time{ + "0.1.0": now.Add(-48 * time.Hour), + "1.0.0": now.Add(-1 * time.Hour), + "1.0.1+abc": now.Add(-48 * time.Hour), + }, nil +} + var _ = Describe("The ManagedCloudProfile reconciler", func() { amd64 := "amd64" @@ -344,8 +355,8 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { ImageName: "gc-image", Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ - Registry: "fake", - Repository: "repo", + Registry: "keppel-fake", + Repository: "account/repo", Insecure: true, }, }, @@ -388,14 +399,8 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { reconciler := &controllers.Reconciler{ Client: k8sClient, OCISourceFactory: &fakeFactory{}, - FetchKeppelTagsFunc: func(ctx context.Context, log logr.Logger, registry, repository string) (map[string]time.Time, error) { - base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) - - return map[string]time.Time{ - "0.1.0": base.Add(-10 * time.Hour), - "1.0.0": base.Add(-10 * time.Hour), - "1.0.1+abc": base.Add(-10 * time.Hour), - }, nil + RegistryProviderFunc: func(registry string) (controllers.RegistryClient, error) { + return &fakeRegistryClient{}, nil }, } @@ -409,8 +414,12 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: mcp.Name}, &cp)).To(Succeed()) var versions []string - for _, v := range cp.Spec.MachineImages[0].Versions { - versions = append(versions, v.Version) + for _, mi := range cp.Spec.MachineImages { + if mi.Name == "gc-image" { + for _, v := range mi.Versions { + versions = append(versions, v.Version) + } + } } return versions }, 10*time.Second, 200*time.Millisecond). @@ -577,8 +586,8 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { ImageName: "shoot-preserve-image", Source: v1alpha1.MachineImageUpdateSource{ OCI: &v1alpha1.MachineImageUpdateSourceOCI{ - Registry: "fake", - Repository: "repo", + Registry: "keppel-fake", + Repository: "account/repo", Insecure: true, }, }, @@ -595,18 +604,15 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { reconciler := &controllers.Reconciler{ Client: k8sClient, OCISourceFactory: &fakeFactory{}, - FetchKeppelTagsFunc: func(ctx context.Context, log logr.Logger, registry, repository string) (map[string]time.Time, error) { - now := time.Now() - return map[string]time.Time{ - "1.0.0": now.Add(-2 * time.Hour), - "1.0.1+abc": now.Add(-3 * time.Hour), - }, nil + RegistryProviderFunc: func(registry string) (controllers.RegistryClient, error) { + return &fakeRegistryClient{}, nil }, } req := ctrl.Request{ NamespacedName: client.ObjectKey{Name: mcp.Name}, } + res, err := reconciler.Reconcile(context.Background(), req) Expect(err).ToNot(HaveOccurred()) Expect(res.RequeueAfter).To(Equal(5 * time.Minute)) From 6129a304d74fa6b11bd27f1b2597519a7381953c Mon Sep 17 00:00:00 2001 From: valeryia-hurynovich Date: Tue, 14 Apr 2026 13:04:27 +0200 Subject: [PATCH 5/7] fixed suggestions and comments --- controllers/managedcloudprofile_controller.go | 23 +++++++++++-------- .../managedcloudprofile_controller_test.go | 3 +-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/controllers/managedcloudprofile_controller.go b/controllers/managedcloudprofile_controller.go index 4ddeb5c..c27f0dc 100644 --- a/controllers/managedcloudprofile_controller.go +++ b/controllers/managedcloudprofile_controller.go @@ -39,13 +39,13 @@ type OCISourceFactory interface { } type RegistryClient interface { - GetTags(ctx context.Context, log logr.Logger, registry, repository string) (map[string]time.Time, error) + GetTags(ctx context.Context, registry, repository string) (map[string]time.Time, error) } type KeppelClient struct{} -func (k *KeppelClient) GetTags(ctx context.Context, log logr.Logger, registry, repository string) (map[string]time.Time, error) { - return fetchKeppelTags(ctx, log, registry, repository) +func (k *KeppelClient) GetTags(ctx context.Context, registry, repository string) (map[string]time.Time, error) { + return fetchKeppelTags(ctx, registry, repository) } // DefaultOCISourceFactory is the default implementation of OCISourceFactory. @@ -188,7 +188,6 @@ func (r *Reconciler) reconcileGarbageCollection(ctx context.Context, log logr.Lo log.V(1).Info("retrieving source versions", "image", updates.ImageName) tags, err := registryClient.GetTags( ctx, - log, updates.Source.OCI.Registry, updates.Source.OCI.Repository, ) @@ -482,7 +481,11 @@ func (r *Reconciler) failWithStatusUpdate(ctx context.Context, mcp *v1alpha1.Man return returnErr } -func fetchKeppelTags(ctx context.Context, log logr.Logger, registry, repository string) (map[string]time.Time, error) { +func fetchKeppelTags(ctx context.Context, registry, repository string) (map[string]time.Time, error) { + log, err := logr.FromContext(ctx) + if err != nil { + return nil, fmt.Errorf("failed to extract logger from context: %w", err) + } baseURL := registryBaseURL(log, registry, false) url, err := keppelURL(log, baseURL, repository) @@ -551,14 +554,16 @@ func fetchKeppelTags(ctx context.Context, log logr.Logger, registry, repository ) for _, t := range m.Tags { - pushedAt := time.Unix(t.PushedAt, 0) + if t.PushedAt > 0 { + tagMap[t.Name] = time.Unix(t.PushedAt, 0) + } else { + tagMap[t.Name] = time.Time{} + } log.V(2).Info("processing tag", "tag", t.Name, - "pushedAt", pushedAt, + "pushedAt", t.PushedAt, ) - - tagMap[t.Name] = pushedAt } } diff --git a/controllers/managedcloudprofile_controller_test.go b/controllers/managedcloudprofile_controller_test.go index 3596713..b793fce 100644 --- a/controllers/managedcloudprofile_controller_test.go +++ b/controllers/managedcloudprofile_controller_test.go @@ -17,7 +17,6 @@ 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" @@ -60,7 +59,7 @@ func (m *mockOCIFactory) Create(params cloudprofilesync.OCIParams, insecure bool type fakeRegistryClient struct{} -func (f *fakeRegistryClient) GetTags(ctx context.Context, log logr.Logger, registry, repository string) (map[string]time.Time, error) { +func (f *fakeRegistryClient) GetTags(ctx context.Context, registry, repository string) (map[string]time.Time, error) { now := time.Now() return map[string]time.Time{ "0.1.0": now.Add(-48 * time.Hour), From 70ede21ea9ae9b380f308afacbab81f72b05c68f Mon Sep 17 00:00:00 2001 From: valeryia-hurynovich Date: Tue, 14 Apr 2026 14:10:22 +0200 Subject: [PATCH 6/7] fixed comments --- controllers/managedcloudprofile_controller.go | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/controllers/managedcloudprofile_controller.go b/controllers/managedcloudprofile_controller.go index c27f0dc..b5630c9 100644 --- a/controllers/managedcloudprofile_controller.go +++ b/controllers/managedcloudprofile_controller.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "slices" "strings" "time" @@ -186,6 +187,7 @@ func (r *Reconciler) reconcileGarbageCollection(ctx context.Context, log logr.Lo } log.V(1).Info("retrieving source versions", "image", updates.ImageName) + ctx = logr.NewContext(ctx, log) tags, err := registryClient.GetTags( ctx, updates.Source.OCI.Registry, @@ -488,7 +490,7 @@ func fetchKeppelTags(ctx context.Context, registry, repository string) (map[stri } baseURL := registryBaseURL(log, registry, false) - url, err := keppelURL(log, baseURL, repository) + keppelURL, err := keppelURL(log, baseURL, repository) if err != nil { log.Error(err, "failed to build keppel URL", "registry", registry, @@ -498,12 +500,12 @@ func fetchKeppelTags(ctx context.Context, registry, repository string) (map[stri } log.V(1).Info("fetching keppel tags", - "url", url, + "url", keppelURL, "registry", registry, "repository", repository, ) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, keppelURL, http.NoBody) if err != nil { log.Error(err, "failed to create keppel request") return nil, err @@ -523,7 +525,7 @@ func fetchKeppelTags(ctx context.Context, registry, repository string) (map[stri resp, err := httpClient.Do(req) if err != nil { - log.Error(err, "keppel http request failed", "url", url) + log.Error(err, "keppel http request failed", "url", keppelURL) return nil, err } defer resp.Body.Close() @@ -554,16 +556,16 @@ func fetchKeppelTags(ctx context.Context, registry, repository string) (map[stri ) for _, t := range m.Tags { - if t.PushedAt > 0 { - tagMap[t.Name] = time.Unix(t.PushedAt, 0) - } else { - tagMap[t.Name] = time.Time{} + if t.PushedAt == 0 { + log.V(1).Info("tag without pushed at", "name", t.Name) + continue } log.V(2).Info("processing tag", "tag", t.Name, "pushedAt", t.PushedAt, ) + tagMap[t.Name] = time.Unix(t.PushedAt, 0) } } @@ -579,7 +581,7 @@ func keppelURL(log logr.Logger, baseURL, repository string) (string, error) { return "", err } - url := fmt.Sprintf( + keppelURL := fmt.Sprintf( "%s/keppel/v1/accounts/%s/repositories/%s/_manifests", baseURL, account, @@ -590,10 +592,10 @@ func keppelURL(log logr.Logger, baseURL, repository string) (string, error) { "baseURL", baseURL, "account", account, "repo", repo, - "url", url, + "url", keppelURL, ) - return url, nil + return keppelURL, nil } func registryBaseURL(log logr.Logger, registryHost string, insecure bool) string { @@ -602,7 +604,12 @@ func registryBaseURL(log logr.Logger, registryHost string, insecure bool) string scheme = "http" } - base := scheme + "://" + registryHost + u := &url.URL{ + Scheme: scheme, + Host: registryHost, + } + + base := u.String() log.V(2).Info("computed registry base url", "registryHost", registryHost, From d107ba36003c73575ca485ce494fa94291732570 Mon Sep 17 00:00:00 2001 From: valeryia-hurynovich Date: Tue, 14 Apr 2026 14:20:27 +0200 Subject: [PATCH 7/7] fixed typos check --- .github/workflows/checks.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 77add99..0b1ea79 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -45,7 +45,7 @@ jobs: env: CLICOLOR: "1" - name: Delete typos binary - run: rm typos + run: rm -f typos - name: Check if source code files have license header run: make check-addlicense - name: REUSE Compliance Check