From 9dc75deea7ff869d36fcd2494a50c84c513ce450 Mon Sep 17 00:00:00 2001 From: Anton Paulovich Date: Tue, 16 Jun 2026 12:08:46 +0200 Subject: [PATCH 1/5] feature --- controllers/managedcloudprofile_controller.go | 97 ++++- .../managedcloudprofile_controller_test.go | 405 ++++++++++++++++++ go.mod | 2 +- 3 files changed, 488 insertions(+), 16 deletions(-) diff --git a/controllers/managedcloudprofile_controller.go b/controllers/managedcloudprofile_controller.go index 4c94d35..911ec90 100644 --- a/controllers/managedcloudprofile_controller.go +++ b/controllers/managedcloudprofile_controller.go @@ -209,15 +209,11 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image return err } - for i := range cp.Spec.MachineImages { - if cp.Spec.MachineImages[i].Name != imageName { - continue - } - cp.Spec.MachineImages[i].Versions = slices.DeleteFunc(cp.Spec.MachineImages[i].Versions, func(mv gardenerv1beta1.MachineImageVersion) bool { - _, exists := versionsToDelete[mv.Version] - return exists - }) - } + // Track which clean versions still have remaining capability flavors after deletion, + // so we can cascade-delete empty clean version entries from spec.machineImages. + // A version present in this map was a clean version entry; true means it still has flavors. + cleanVersionsWithFlavors := make(map[string]bool) + if cp.Spec.ProviderConfig != nil { var cfg providercfg.CloudProfileConfig if err := json.Unmarshal(cp.Spec.ProviderConfig.Raw, &cfg); err != nil { @@ -227,14 +223,36 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image if cfg.MachineImages[i].Name != imageName { continue } + for j := range cfg.MachineImages[i].Versions { + v := &cfg.MachineImages[i].Versions[j] + if len(v.CapabilityFlavors) == 0 { + continue + } + // Mark this as a clean version entry (key present = was a clean version). + cleanVersionsWithFlavors[v.Version] = true + v.CapabilityFlavors = slices.DeleteFunc(v.CapabilityFlavors, func(f providercfg.MachineImageFlavor) bool { + idx := strings.LastIndex(f.Image, ":") + if idx == -1 { + return false + } + _, exists := versionsToDelete[f.Image[idx+1:]] + return exists + }) + cleanVersionsWithFlavors[v.Version] = len(v.CapabilityFlavors) > 0 + } + // Remove version entries that have no legacy image ref and no remaining flavors. cfg.MachineImages[i].Versions = slices.DeleteFunc(cfg.MachineImages[i].Versions, func(mv providercfg.MachineImageVersion) bool { - idx := strings.LastIndex(mv.Image, ":") - if idx == -1 { - return false + if mv.Image != "" { + // Legacy flat entry — delete if its tag is in versionsToDelete. + idx := strings.LastIndex(mv.Image, ":") + if idx == -1 { + return false + } + _, exists := versionsToDelete[mv.Image[idx+1:]] + return exists } - version := mv.Image[idx+1:] - _, exists := versionsToDelete[version] - return exists + // Clean version entry — delete if all flavors were removed. + return !cleanVersionsWithFlavors[mv.Version] }) } raw, err := json.Marshal(cfg) @@ -243,6 +261,22 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image } cp.Spec.ProviderConfig.Raw = raw } + + for i := range cp.Spec.MachineImages { + if cp.Spec.MachineImages[i].Name != imageName { + continue + } + cp.Spec.MachineImages[i].Versions = slices.DeleteFunc(cp.Spec.MachineImages[i].Versions, func(mv gardenerv1beta1.MachineImageVersion) bool { + if _, exists := versionsToDelete[mv.Version]; exists { + return true + } + // Cascade-delete clean version entry if all its capability flavors were removed. + // Only entries tracked as clean versions (present in the map) are eligible. + hasRemainingFlavors, isCleanVersion := cleanVersionsWithFlavors[mv.Version] + return isCleanVersion && !hasRemainingFlavors + }) + } + if err := r.Update(ctx, &cp); err != nil { return err } @@ -271,6 +305,39 @@ func (r *Reconciler) getReferencedVersions(ctx context.Context, cloudProfileName } } + // For any clean version referenced by a Shoot, also protect the raw OCI tags + // that back it via capabilityFlavors — otherwise GC would delete the images + // that the clean version depends on. + if len(referenced) > 0 { + var cp gardenerv1beta1.CloudProfile + if err := r.Get(ctx, types.NamespacedName{Name: cloudProfileName}, &cp); err != nil { + return nil, fmt.Errorf("failed to get CloudProfile: %w", err) + } + if cp.Spec.ProviderConfig != nil { + var cfg providercfg.CloudProfileConfig + if err := json.Unmarshal(cp.Spec.ProviderConfig.Raw, &cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal ProviderConfig: %w", err) + } + for _, img := range cfg.MachineImages { + if img.Name != imageName { + continue + } + for _, v := range img.Versions { + if _, isReferenced := referenced[v.Version]; !isReferenced { + continue + } + for _, flavor := range v.CapabilityFlavors { + idx := strings.LastIndex(flavor.Image, ":") + if idx == -1 { + continue + } + referenced[flavor.Image[idx+1:]] = struct{}{} + } + } + } + } + } + return referenced, nil } diff --git a/controllers/managedcloudprofile_controller_test.go b/controllers/managedcloudprofile_controller_test.go index 42e5810..ca347a9 100644 --- a/controllers/managedcloudprofile_controller_test.go +++ b/controllers/managedcloudprofile_controller_test.go @@ -69,6 +69,14 @@ func (f *fakeRegistryClient) GetTags(ctx context.Context, registry, repository s }, nil } +type fakeRegistryClientWithTags struct { + tags map[string]time.Time +} + +func (f *fakeRegistryClientWithTags) GetTags(ctx context.Context, registry, repository string) (map[string]time.Time, error) { + return f.tags, nil +} + var _ = Describe("The ManagedCloudProfile reconciler", func() { amd64 := "amd64" @@ -898,4 +906,401 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Expect(k8sClient.Delete(ctx, &cloudProfile)).To(Succeed()) }) + It("preserves raw OCI tags backing a clean version referenced by a Shoot", func(ctx SpecContext) { + // Shoot references clean version "2254.0.0"; GC must not delete the raw tag + // "2254.0.0-baremetal-sci-usi-amd64" because it backs that clean version via capabilityFlavors. + rawTag := "2254.0.0-baremetal-sci-usi-amd64" + cleanVersion := "2254.0.0" + + cfg := providercfg.CloudProfileConfig{ + MachineImages: []providercfg.MachineImages{ + { + Name: "cap-image", + Versions: []providercfg.MachineImageVersion{ + { + Version: cleanVersion, + CapabilityFlavors: []providercfg.MachineImageFlavor{ + {Image: "repo/cap-image:" + rawTag}, + }, + }, + }, + }, + }, + } + raw, err := json.Marshal(cfg) + Expect(err).To(Succeed()) + + cp := &gardenerv1beta1.CloudProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "test-gc-protect-flavors"}, + Spec: gardenerv1beta1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "cap-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: rawTag}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64"}}, + }, + }, + }, + ProviderConfig: &runtime.RawExtension{Raw: raw}, + }, + } + Expect(k8sClient.Create(ctx, cp)).To(Succeed()) + + shoot := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{Name: "test-shoot-cap", 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: "cap-image", + Version: &cleanVersion, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, shoot)).To(Succeed()) + + mcp := &v1alpha1.ManagedCloudProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "test-gc-protect-flavors"}, + Spec: v1alpha1.ManagedCloudProfileSpec{ + CloudProfile: v1alpha1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + }, + MachineImageUpdates: []v1alpha1.MachineImageUpdate{ + { + ImageName: "cap-image", + Source: v1alpha1.MachineImageUpdateSource{ + OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + Registry: "keppel-fake", + Repository: "account/cap-repo", + Insecure: true, + }, + }, + }, + }, + GarbageCollection: &v1alpha1.GarbageCollectionConfig{ + Enabled: true, + MaxAge: metav1.Duration{Duration: 0}, + }, + }, + } + Expect(k8sClient.Create(ctx, mcp)).To(Succeed()) + + reconciler := &controllers.Reconciler{ + Client: k8sClient, + OCISourceFactory: &fakeFactory{}, + RegistryProviderFunc: func(registry string) (controllers.RegistryClient, error) { + return &fakeRegistryClientWithTags{tags: map[string]time.Time{ + rawTag: time.Now().Add(-48 * time.Hour), + }}, nil + }, + } + req := ctrl.Request{NamespacedName: client.ObjectKey{Name: mcp.Name}} + _, err = reconciler.Reconcile(ctx, req) + Expect(err).ToNot(HaveOccurred()) + + // Raw tag must still be present in spec.machineImages because the Shoot protects it. + Eventually(func(g Gomega) []string { + 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 == "cap-image" { + for _, v := range mi.Versions { + versions = append(versions, v.Version) + } + } + } + return versions + }).Should(ContainElement(rawTag)) + + // Flavor must still be present in providerConfig. + Eventually(func(g Gomega) []string { + updated := &gardenerv1beta1.CloudProfile{} + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cp), updated)).To(Succeed()) + g.Expect(updated.Spec.ProviderConfig).ToNot(BeNil()) + var updatedCfg providercfg.CloudProfileConfig + g.Expect(json.Unmarshal(updated.Spec.ProviderConfig.Raw, &updatedCfg)).To(Succeed()) + var flavors []string + for _, img := range updatedCfg.MachineImages { + if img.Name == "cap-image" { + for _, v := range img.Versions { + for _, f := range v.CapabilityFlavors { + flavors = append(flavors, f.Image) + } + } + } + } + return flavors + }).Should(ContainElement("repo/cap-image:" + rawTag)) + + Expect(k8sClient.Delete(ctx, mcp)).To(Succeed()) + Expect(k8sClient.Delete(ctx, cp)).To(Succeed()) + Expect(k8sClient.Delete(ctx, shoot)).To(Succeed()) + }) + + It("deletes only old flavors from a clean version entry, keeping new ones", func(ctx SpecContext) { + // Clean version "2254.0.0" has two flavors: one old (should be deleted), one recent (should stay). + oldTag := "2254.0.0-baremetal-sci-usi-amd64" + newTag := "2254.0.0-baremetal-sci-usi-arm64" + cleanVersion := "2254.0.0" + + cfg := providercfg.CloudProfileConfig{ + MachineImages: []providercfg.MachineImages{ + { + Name: "multi-flavor-image", + Versions: []providercfg.MachineImageVersion{ + { + Version: cleanVersion, + CapabilityFlavors: []providercfg.MachineImageFlavor{ + {Image: "repo/multi-flavor-image:" + oldTag}, + {Image: "repo/multi-flavor-image:" + newTag}, + }, + }, + }, + }, + }, + } + raw, err := json.Marshal(cfg) + Expect(err).To(Succeed()) + + cp := &gardenerv1beta1.CloudProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "test-gc-partial-flavor"}, + Spec: gardenerv1beta1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "multi-flavor-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: oldTag}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: newTag}, Architectures: []string{"arm64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64", "arm64"}}, + }, + }, + }, + ProviderConfig: &runtime.RawExtension{Raw: raw}, + }, + } + Expect(k8sClient.Create(ctx, cp)).To(Succeed()) + + mcp := &v1alpha1.ManagedCloudProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "test-gc-partial-flavor"}, + Spec: v1alpha1.ManagedCloudProfileSpec{ + CloudProfile: v1alpha1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + }, + MachineImageUpdates: []v1alpha1.MachineImageUpdate{ + { + ImageName: "multi-flavor-image", + Source: v1alpha1.MachineImageUpdateSource{ + OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + Registry: "keppel-fake", + Repository: "account/multi-flavor-repo", + Insecure: true, + }, + }, + }, + }, + GarbageCollection: &v1alpha1.GarbageCollectionConfig{ + Enabled: true, + MaxAge: metav1.Duration{Duration: 0}, + }, + }, + } + Expect(k8sClient.Create(ctx, mcp)).To(Succeed()) + + reconciler := &controllers.Reconciler{ + Client: k8sClient, + OCISourceFactory: &fakeFactory{}, + RegistryProviderFunc: func(registry string) (controllers.RegistryClient, error) { + return &fakeRegistryClientWithTags{tags: map[string]time.Time{ + oldTag: time.Now().Add(-48 * time.Hour), + newTag: time.Now().Add(-1 * time.Minute), + }}, nil + }, + } + req := ctrl.Request{NamespacedName: client.ObjectKey{Name: mcp.Name}} + _, err = reconciler.Reconcile(ctx, req) + Expect(err).ToNot(HaveOccurred()) + + // Old flavor must be gone; new flavor and clean version entry must remain. + Eventually(func(g Gomega) providercfg.CloudProfileConfig { + updated := &gardenerv1beta1.CloudProfile{} + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cp), updated)).To(Succeed()) + g.Expect(updated.Spec.ProviderConfig).ToNot(BeNil()) + var updatedCfg providercfg.CloudProfileConfig + g.Expect(json.Unmarshal(updated.Spec.ProviderConfig.Raw, &updatedCfg)).To(Succeed()) + return updatedCfg + }).Should(SatisfyAll( + WithTransform(func(c providercfg.CloudProfileConfig) []string { + var flavors []string + for _, img := range c.MachineImages { + if img.Name == "multi-flavor-image" { + for _, v := range img.Versions { + if v.Version == cleanVersion { + for _, f := range v.CapabilityFlavors { + flavors = append(flavors, f.Image) + } + } + } + } + } + return flavors + }, And( + Not(ContainElement("repo/multi-flavor-image:"+oldTag)), + ContainElement("repo/multi-flavor-image:"+newTag), + )), + )) + + // Clean version entry must still be present in spec.machineImages (has remaining flavor). + Eventually(func(g Gomega) []string { + 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 == "multi-flavor-image" { + for _, v := range mi.Versions { + versions = append(versions, v.Version) + } + } + } + return versions + }).Should(ContainElement(cleanVersion)) + + Expect(k8sClient.Delete(ctx, mcp)).To(Succeed()) + Expect(k8sClient.Delete(ctx, cp)).To(Succeed()) + }) + + It("cascade-deletes clean version entry when all its flavors are garbage collected", func(ctx SpecContext) { + // Clean version "2254.0.0" has one old flavor; after GC removes it, the clean version + // entry must be removed from both providerConfig and spec.machineImages. + oldTag := "2254.0.0-baremetal-sci-usi-amd64" + cleanVersion := "2254.0.0" + + cfg := providercfg.CloudProfileConfig{ + MachineImages: []providercfg.MachineImages{ + { + Name: "cascade-image", + Versions: []providercfg.MachineImageVersion{ + { + Version: cleanVersion, + CapabilityFlavors: []providercfg.MachineImageFlavor{ + {Image: "repo/cascade-image:" + oldTag}, + }, + }, + }, + }, + }, + } + raw, err := json.Marshal(cfg) + Expect(err).To(Succeed()) + + cp := &gardenerv1beta1.CloudProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "test-gc-cascade"}, + Spec: gardenerv1beta1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "cascade-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: oldTag}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64"}}, + }, + }, + }, + ProviderConfig: &runtime.RawExtension{Raw: raw}, + }, + } + Expect(k8sClient.Create(ctx, cp)).To(Succeed()) + + mcp := &v1alpha1.ManagedCloudProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "test-gc-cascade"}, + Spec: v1alpha1.ManagedCloudProfileSpec{ + CloudProfile: v1alpha1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + }, + MachineImageUpdates: []v1alpha1.MachineImageUpdate{ + { + ImageName: "cascade-image", + Source: v1alpha1.MachineImageUpdateSource{ + OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + Registry: "keppel-fake", + Repository: "account/cascade-repo", + Insecure: true, + }, + }, + }, + }, + GarbageCollection: &v1alpha1.GarbageCollectionConfig{ + Enabled: true, + MaxAge: metav1.Duration{Duration: 0}, + }, + }, + } + Expect(k8sClient.Create(ctx, mcp)).To(Succeed()) + + reconciler := &controllers.Reconciler{ + Client: k8sClient, + OCISourceFactory: &fakeFactory{}, + RegistryProviderFunc: func(registry string) (controllers.RegistryClient, error) { + return &fakeRegistryClientWithTags{tags: map[string]time.Time{ + oldTag: time.Now().Add(-48 * time.Hour), + }}, nil + }, + } + req := ctrl.Request{NamespacedName: client.ObjectKey{Name: mcp.Name}} + _, err = reconciler.Reconcile(ctx, req) + Expect(err).ToNot(HaveOccurred()) + + // Both raw tag and clean version must be removed from spec.machineImages. + Eventually(func(g Gomega) []string { + 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 == "cascade-image" { + for _, v := range mi.Versions { + versions = append(versions, v.Version) + } + } + } + return versions + }).Should(BeEmpty()) + + // Clean version entry must be gone from providerConfig as well. + Eventually(func(g Gomega) []providercfg.MachineImageVersion { + updated := &gardenerv1beta1.CloudProfile{} + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cp), updated)).To(Succeed()) + if updated.Spec.ProviderConfig == nil { + return nil + } + var updatedCfg providercfg.CloudProfileConfig + g.Expect(json.Unmarshal(updated.Spec.ProviderConfig.Raw, &updatedCfg)).To(Succeed()) + for _, img := range updatedCfg.MachineImages { + if img.Name == "cascade-image" { + return img.Versions + } + } + return nil + }).Should(BeEmpty()) + + Expect(k8sClient.Delete(ctx, mcp)).To(Succeed()) + Expect(k8sClient.Delete(ctx, cp)).To(Succeed()) + }) + }) diff --git a/go.mod b/go.mod index aafcb04..0bc82ee 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/cobaltcore-dev/cloud-profile-sync -go 1.26 +go 1.26.2 require ( github.com/blang/semver/v4 v4.0.0 From 0da9349a4828c02dc4f3284e855eb71cdadb9541 Mon Sep 17 00:00:00 2001 From: Anton Paulovich Date: Tue, 16 Jun 2026 12:16:58 +0200 Subject: [PATCH 2/5] fix lint --- controllers/managedcloudprofile_controller_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/controllers/managedcloudprofile_controller_test.go b/controllers/managedcloudprofile_controller_test.go index ca347a9..d5cadf3 100644 --- a/controllers/managedcloudprofile_controller_test.go +++ b/controllers/managedcloudprofile_controller_test.go @@ -997,7 +997,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { } Expect(k8sClient.Create(ctx, mcp)).To(Succeed()) - reconciler := &controllers.Reconciler{ + r := &controllers.Reconciler{ Client: k8sClient, OCISourceFactory: &fakeFactory{}, RegistryProviderFunc: func(registry string) (controllers.RegistryClient, error) { @@ -1007,7 +1007,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { }, } req := ctrl.Request{NamespacedName: client.ObjectKey{Name: mcp.Name}} - _, err = reconciler.Reconcile(ctx, req) + _, err = r.Reconcile(ctx, req) Expect(err).ToNot(HaveOccurred()) // Raw tag must still be present in spec.machineImages because the Shoot protects it. @@ -1122,7 +1122,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { } Expect(k8sClient.Create(ctx, mcp)).To(Succeed()) - reconciler := &controllers.Reconciler{ + r := &controllers.Reconciler{ Client: k8sClient, OCISourceFactory: &fakeFactory{}, RegistryProviderFunc: func(registry string) (controllers.RegistryClient, error) { @@ -1133,7 +1133,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { }, } req := ctrl.Request{NamespacedName: client.ObjectKey{Name: mcp.Name}} - _, err = reconciler.Reconcile(ctx, req) + _, err = r.Reconcile(ctx, req) Expect(err).ToNot(HaveOccurred()) // Old flavor must be gone; new flavor and clean version entry must remain. @@ -1254,7 +1254,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { } Expect(k8sClient.Create(ctx, mcp)).To(Succeed()) - reconciler := &controllers.Reconciler{ + r := &controllers.Reconciler{ Client: k8sClient, OCISourceFactory: &fakeFactory{}, RegistryProviderFunc: func(registry string) (controllers.RegistryClient, error) { @@ -1264,7 +1264,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { }, } req := ctrl.Request{NamespacedName: client.ObjectKey{Name: mcp.Name}} - _, err = reconciler.Reconcile(ctx, req) + _, err = r.Reconcile(ctx, req) Expect(err).ToNot(HaveOccurred()) // Both raw tag and clean version must be removed from spec.machineImages. From e194857086d92e487fc2d9632097d31ce4131bce Mon Sep 17 00:00:00 2001 From: Anton Paulovich Date: Tue, 16 Jun 2026 12:30:49 +0200 Subject: [PATCH 3/5] fix checks --- .golangci.yaml | 2 +- .typos.toml | 3 +++ go.mod | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index f14146f..98b6863 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -136,7 +136,7 @@ linters: # for github.com/sapcc/vpa_butler - k8s.io/client-go toolchain-forbidden: true - go-version-pattern: 1\.\d+(\.0)?$ + go-version-pattern: 1\.\d+(\.\d+)?$ # manually edited, as default rule does not allow go version with patch, but some deps require e.g. go 1.26.2 gosec: excludes: # gosec wants us to set a short ReadHeaderTimeout to avoid Slowloris attacks, but doing so would expose us to Keep-Alive race conditions (see https://iximiuz.com/en/posts/reverse-proxy-http-keep-alive-and-502s/ diff --git a/.typos.toml b/.typos.toml index 7dd0e8b..2ceb92e 100644 --- a/.typos.toml +++ b/.typos.toml @@ -4,6 +4,9 @@ [default.extend-words] +[default] +extend-ignore-identifiers-re = ["ANDed"] + [files] extend-exclude = [ "go.mod", diff --git a/go.mod b/go.mod index aafcb04..0bc82ee 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/cobaltcore-dev/cloud-profile-sync -go 1.26 +go 1.26.2 require ( github.com/blang/semver/v4 v4.0.0 From f8eedb85755cb6c92cd6dc61293cb9f2e58a12b8 Mon Sep 17 00:00:00 2001 From: Anton Paulovich Date: Tue, 16 Jun 2026 13:03:44 +0200 Subject: [PATCH 4/5] fix tests --- .../managedcloudprofile_controller_test.go | 266 ++++++++---------- 1 file changed, 113 insertions(+), 153 deletions(-) diff --git a/controllers/managedcloudprofile_controller_test.go b/controllers/managedcloudprofile_controller_test.go index d5cadf3..184457e 100644 --- a/controllers/managedcloudprofile_controller_test.go +++ b/controllers/managedcloudprofile_controller_test.go @@ -48,12 +48,24 @@ func (f *fakeOCISource) GetVersions(ctx context.Context) ([]cloudprofilesync.Sou }, nil } +type emptyOCISource struct{} + +func (f *emptyOCISource) GetVersions(ctx context.Context) ([]cloudprofilesync.SourceImage, error) { + return nil, nil +} + type fakeFactory struct{} func (f *fakeFactory) Create(params cloudprofilesync.OCIParams, insecure bool, _ logr.Logger) (cloudprofilesync.Source, error) { return &fakeOCISource{}, nil } +type emptyFactory struct{} + +func (f *emptyFactory) Create(params cloudprofilesync.OCIParams, insecure bool, _ logr.Logger) (cloudprofilesync.Source, error) { + return &emptyOCISource{}, nil +} + func (m *mockOCIFactory) Create(params cloudprofilesync.OCIParams, insecure bool, _ logr.Logger) (cloudprofilesync.Source, error) { return m.createFunc(params, insecure) } @@ -930,29 +942,10 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { raw, err := json.Marshal(cfg) Expect(err).To(Succeed()) - cp := &gardenerv1beta1.CloudProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "test-gc-protect-flavors"}, - Spec: gardenerv1beta1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - MachineImages: []gardenerv1beta1.MachineImage{ - { - Name: "cap-image", - Versions: []gardenerv1beta1.MachineImageVersion{ - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: rawTag}, Architectures: []string{"amd64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64"}}, - }, - }, - }, - ProviderConfig: &runtime.RawExtension{Raw: raw}, - }, - } - Expect(k8sClient.Create(ctx, cp)).To(Succeed()) - shoot := &gardenerv1beta1.Shoot{ ObjectMeta: metav1.ObjectMeta{Name: "test-shoot-cap", Namespace: metav1.NamespaceDefault}, Spec: gardenerv1beta1.ShootSpec{ - CloudProfile: &gardenerv1beta1.CloudProfileReference{Name: cp.Name}, + CloudProfile: &gardenerv1beta1.CloudProfileReference{Name: "test-gc-protect-flavors"}, Provider: gardenerv1beta1.Provider{ Workers: []gardenerv1beta1.Worker{ { @@ -976,6 +969,16 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { CloudProfile: v1alpha1.CloudProfileSpec{ Regions: []gardenerv1beta1.Region{{Name: "foo"}}, MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "cap-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: rawTag}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64"}}, + }, + }, + }, + ProviderConfig: &runtime.RawExtension{Raw: raw}, }, MachineImageUpdates: []v1alpha1.MachineImageUpdate{ { @@ -999,7 +1002,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { r := &controllers.Reconciler{ Client: k8sClient, - OCISourceFactory: &fakeFactory{}, + OCISourceFactory: &emptyFactory{}, RegistryProviderFunc: func(registry string) (controllers.RegistryClient, error) { return &fakeRegistryClientWithTags{tags: map[string]time.Time{ rawTag: time.Now().Add(-48 * time.Hour), @@ -1010,43 +1013,37 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { _, err = r.Reconcile(ctx, req) Expect(err).ToNot(HaveOccurred()) + cp := &gardenerv1beta1.CloudProfile{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: mcp.Name}, cp)).To(Succeed()) + // Raw tag must still be present in spec.machineImages because the Shoot protects it. - Eventually(func(g Gomega) []string { - 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 == "cap-image" { - for _, v := range mi.Versions { - versions = append(versions, v.Version) - } + var versions []string + for _, mi := range cp.Spec.MachineImages { + if mi.Name == "cap-image" { + for _, v := range mi.Versions { + versions = append(versions, v.Version) } } - return versions - }).Should(ContainElement(rawTag)) + } + Expect(versions).To(ContainElement(rawTag)) // Flavor must still be present in providerConfig. - Eventually(func(g Gomega) []string { - updated := &gardenerv1beta1.CloudProfile{} - g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cp), updated)).To(Succeed()) - g.Expect(updated.Spec.ProviderConfig).ToNot(BeNil()) - var updatedCfg providercfg.CloudProfileConfig - g.Expect(json.Unmarshal(updated.Spec.ProviderConfig.Raw, &updatedCfg)).To(Succeed()) - var flavors []string - for _, img := range updatedCfg.MachineImages { - if img.Name == "cap-image" { - for _, v := range img.Versions { - for _, f := range v.CapabilityFlavors { - flavors = append(flavors, f.Image) - } + Expect(cp.Spec.ProviderConfig).ToNot(BeNil()) + var updatedCfg providercfg.CloudProfileConfig + Expect(json.Unmarshal(cp.Spec.ProviderConfig.Raw, &updatedCfg)).To(Succeed()) + var flavors []string + for _, img := range updatedCfg.MachineImages { + if img.Name == "cap-image" { + for _, v := range img.Versions { + for _, f := range v.CapabilityFlavors { + flavors = append(flavors, f.Image) } } } - return flavors - }).Should(ContainElement("repo/cap-image:" + rawTag)) + } + Expect(flavors).To(ContainElement("repo/cap-image:" + rawTag)) Expect(k8sClient.Delete(ctx, mcp)).To(Succeed()) - Expect(k8sClient.Delete(ctx, cp)).To(Succeed()) Expect(k8sClient.Delete(ctx, shoot)).To(Succeed()) }) @@ -1075,32 +1072,23 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { raw, err := json.Marshal(cfg) Expect(err).To(Succeed()) - cp := &gardenerv1beta1.CloudProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "test-gc-partial-flavor"}, - Spec: gardenerv1beta1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - MachineImages: []gardenerv1beta1.MachineImage{ - { - Name: "multi-flavor-image", - Versions: []gardenerv1beta1.MachineImageVersion{ - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: oldTag}, Architectures: []string{"amd64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: newTag}, Architectures: []string{"arm64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64", "arm64"}}, - }, - }, - }, - ProviderConfig: &runtime.RawExtension{Raw: raw}, - }, - } - Expect(k8sClient.Create(ctx, cp)).To(Succeed()) - mcp := &v1alpha1.ManagedCloudProfile{ ObjectMeta: metav1.ObjectMeta{Name: "test-gc-partial-flavor"}, Spec: v1alpha1.ManagedCloudProfileSpec{ CloudProfile: v1alpha1.CloudProfileSpec{ Regions: []gardenerv1beta1.Region{{Name: "foo"}}, MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "multi-flavor-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: oldTag}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: newTag}, Architectures: []string{"arm64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64", "arm64"}}, + }, + }, + }, + ProviderConfig: &runtime.RawExtension{Raw: raw}, }, MachineImageUpdates: []v1alpha1.MachineImageUpdate{ { @@ -1116,7 +1104,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { }, GarbageCollection: &v1alpha1.GarbageCollectionConfig{ Enabled: true, - MaxAge: metav1.Duration{Duration: 0}, + MaxAge: metav1.Duration{Duration: 24 * time.Hour}, }, }, } @@ -1124,7 +1112,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { r := &controllers.Reconciler{ Client: k8sClient, - OCISourceFactory: &fakeFactory{}, + OCISourceFactory: &emptyFactory{}, RegistryProviderFunc: func(registry string) (controllers.RegistryClient, error) { return &fakeRegistryClientWithTags{tags: map[string]time.Time{ oldTag: time.Now().Add(-48 * time.Hour), @@ -1136,52 +1124,40 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { _, err = r.Reconcile(ctx, req) Expect(err).ToNot(HaveOccurred()) - // Old flavor must be gone; new flavor and clean version entry must remain. - Eventually(func(g Gomega) providercfg.CloudProfileConfig { - updated := &gardenerv1beta1.CloudProfile{} - g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cp), updated)).To(Succeed()) - g.Expect(updated.Spec.ProviderConfig).ToNot(BeNil()) - var updatedCfg providercfg.CloudProfileConfig - g.Expect(json.Unmarshal(updated.Spec.ProviderConfig.Raw, &updatedCfg)).To(Succeed()) - return updatedCfg - }).Should(SatisfyAll( - WithTransform(func(c providercfg.CloudProfileConfig) []string { - var flavors []string - for _, img := range c.MachineImages { - if img.Name == "multi-flavor-image" { - for _, v := range img.Versions { - if v.Version == cleanVersion { - for _, f := range v.CapabilityFlavors { - flavors = append(flavors, f.Image) - } - } + cp := &gardenerv1beta1.CloudProfile{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: mcp.Name}, cp)).To(Succeed()) + Expect(cp.Spec.ProviderConfig).ToNot(BeNil()) + var updatedCfg providercfg.CloudProfileConfig + Expect(json.Unmarshal(cp.Spec.ProviderConfig.Raw, &updatedCfg)).To(Succeed()) + + // Old flavor must be gone; new flavor must remain. + var flavors []string + for _, img := range updatedCfg.MachineImages { + if img.Name == "multi-flavor-image" { + for _, v := range img.Versions { + if v.Version == cleanVersion { + for _, f := range v.CapabilityFlavors { + flavors = append(flavors, f.Image) } } } - return flavors - }, And( - Not(ContainElement("repo/multi-flavor-image:"+oldTag)), - ContainElement("repo/multi-flavor-image:"+newTag), - )), - )) + } + } + Expect(flavors).ToNot(ContainElement("repo/multi-flavor-image:" + oldTag)) + Expect(flavors).To(ContainElement("repo/multi-flavor-image:" + newTag)) // Clean version entry must still be present in spec.machineImages (has remaining flavor). - Eventually(func(g Gomega) []string { - 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 == "multi-flavor-image" { - for _, v := range mi.Versions { - versions = append(versions, v.Version) - } + var machineVersions []string + for _, mi := range cp.Spec.MachineImages { + if mi.Name == "multi-flavor-image" { + for _, v := range mi.Versions { + machineVersions = append(machineVersions, v.Version) } } - return versions - }).Should(ContainElement(cleanVersion)) + } + Expect(machineVersions).To(ContainElement(cleanVersion)) Expect(k8sClient.Delete(ctx, mcp)).To(Succeed()) - Expect(k8sClient.Delete(ctx, cp)).To(Succeed()) }) It("cascade-deletes clean version entry when all its flavors are garbage collected", func(ctx SpecContext) { @@ -1208,31 +1184,22 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { raw, err := json.Marshal(cfg) Expect(err).To(Succeed()) - cp := &gardenerv1beta1.CloudProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "test-gc-cascade"}, - Spec: gardenerv1beta1.CloudProfileSpec{ - Regions: []gardenerv1beta1.Region{{Name: "foo"}}, - MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, - MachineImages: []gardenerv1beta1.MachineImage{ - { - Name: "cascade-image", - Versions: []gardenerv1beta1.MachineImageVersion{ - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: oldTag}, Architectures: []string{"amd64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64"}}, - }, - }, - }, - ProviderConfig: &runtime.RawExtension{Raw: raw}, - }, - } - Expect(k8sClient.Create(ctx, cp)).To(Succeed()) - mcp := &v1alpha1.ManagedCloudProfile{ ObjectMeta: metav1.ObjectMeta{Name: "test-gc-cascade"}, Spec: v1alpha1.ManagedCloudProfileSpec{ CloudProfile: v1alpha1.CloudProfileSpec{ Regions: []gardenerv1beta1.Region{{Name: "foo"}}, MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "cascade-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: oldTag}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64"}}, + }, + }, + }, + ProviderConfig: &runtime.RawExtension{Raw: raw}, }, MachineImageUpdates: []v1alpha1.MachineImageUpdate{ { @@ -1256,7 +1223,7 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { r := &controllers.Reconciler{ Client: k8sClient, - OCISourceFactory: &fakeFactory{}, + OCISourceFactory: &emptyFactory{}, RegistryProviderFunc: func(registry string) (controllers.RegistryClient, error) { return &fakeRegistryClientWithTags{tags: map[string]time.Time{ oldTag: time.Now().Add(-48 * time.Hour), @@ -1267,40 +1234,33 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { _, err = r.Reconcile(ctx, req) Expect(err).ToNot(HaveOccurred()) + cp := &gardenerv1beta1.CloudProfile{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: mcp.Name}, cp)).To(Succeed()) + // Both raw tag and clean version must be removed from spec.machineImages. - Eventually(func(g Gomega) []string { - 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 == "cascade-image" { - for _, v := range mi.Versions { - versions = append(versions, v.Version) - } + var machineVersions []string + for _, mi := range cp.Spec.MachineImages { + if mi.Name == "cascade-image" { + for _, v := range mi.Versions { + machineVersions = append(machineVersions, v.Version) } } - return versions - }).Should(BeEmpty()) + } + Expect(machineVersions).To(BeEmpty()) // Clean version entry must be gone from providerConfig as well. - Eventually(func(g Gomega) []providercfg.MachineImageVersion { - updated := &gardenerv1beta1.CloudProfile{} - g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cp), updated)).To(Succeed()) - if updated.Spec.ProviderConfig == nil { - return nil - } - var updatedCfg providercfg.CloudProfileConfig - g.Expect(json.Unmarshal(updated.Spec.ProviderConfig.Raw, &updatedCfg)).To(Succeed()) - for _, img := range updatedCfg.MachineImages { - if img.Name == "cascade-image" { - return img.Versions - } + Expect(cp.Spec.ProviderConfig).ToNot(BeNil()) + var updatedCfg providercfg.CloudProfileConfig + Expect(json.Unmarshal(cp.Spec.ProviderConfig.Raw, &updatedCfg)).To(Succeed()) + var providerVersions []providercfg.MachineImageVersion + for _, img := range updatedCfg.MachineImages { + if img.Name == "cascade-image" { + providerVersions = img.Versions } - return nil - }).Should(BeEmpty()) + } + Expect(providerVersions).To(BeEmpty()) Expect(k8sClient.Delete(ctx, mcp)).To(Succeed()) - Expect(k8sClient.Delete(ctx, cp)).To(Succeed()) }) }) From 1e68b3823d3892b39d9e7f45877bfe55ec7654f3 Mon Sep 17 00:00:00 2001 From: Anton Paulovich Date: Tue, 16 Jun 2026 14:16:14 +0200 Subject: [PATCH 5/5] fix copilot --- controllers/managedcloudprofile_controller.go | 18 ++-- .../managedcloudprofile_controller_test.go | 85 +++++++++++++++++++ 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/controllers/managedcloudprofile_controller.go b/controllers/managedcloudprofile_controller.go index 911ec90..84d0adb 100644 --- a/controllers/managedcloudprofile_controller.go +++ b/controllers/managedcloudprofile_controller.go @@ -190,13 +190,11 @@ func (r *Reconciler) reconcileGarbageCollection(ctx context.Context, mcp *v1alph } } - if len(versionsToDelete) > 0 { - if err := r.deleteVersions(ctx, mcp.Name, updates.ImageName, versionsToDelete); err != nil { - if apierrors.IsInvalid(err) { - continue - } - return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to delete image versions: %w", err)) + if err := r.deleteVersions(ctx, mcp.Name, updates.ImageName, versionsToDelete); err != nil { + if apierrors.IsInvalid(err) { + continue } + return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to delete image versions: %w", err)) } } @@ -225,11 +223,15 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image } for j := range cfg.MachineImages[i].Versions { v := &cfg.MachineImages[i].Versions[j] + if v.Image != "" { + // Legacy flat entry — not a clean version, skip. + continue + } + // Mark as a clean version entry; value indicates whether any flavors remain. + cleanVersionsWithFlavors[v.Version] = len(v.CapabilityFlavors) > 0 if len(v.CapabilityFlavors) == 0 { continue } - // Mark this as a clean version entry (key present = was a clean version). - cleanVersionsWithFlavors[v.Version] = true v.CapabilityFlavors = slices.DeleteFunc(v.CapabilityFlavors, func(f providercfg.MachineImageFlavor) bool { idx := strings.LastIndex(f.Image, ":") if idx == -1 { diff --git a/controllers/managedcloudprofile_controller_test.go b/controllers/managedcloudprofile_controller_test.go index 184457e..0f2fd1d 100644 --- a/controllers/managedcloudprofile_controller_test.go +++ b/controllers/managedcloudprofile_controller_test.go @@ -1263,4 +1263,89 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Expect(k8sClient.Delete(ctx, mcp)).To(Succeed()) }) + It("cascade-deletes clean version entry with zero flavors from spec.machineImages", func(ctx SpecContext) { + // Simulates a second GC run where the clean version entry already has no flavors + // (they were removed in a previous run), but the clean version still lingers in + // spec.machineImages. It must be removed. + cleanVersion := "2254.0.0" + + cfg := providercfg.CloudProfileConfig{ + MachineImages: []providercfg.MachineImages{ + { + Name: "stale-clean-image", + Versions: []providercfg.MachineImageVersion{ + // Clean version entry with no flavors — already emptied by a prior GC run. + {Version: cleanVersion}, + }, + }, + }, + } + raw, err := json.Marshal(cfg) + Expect(err).To(Succeed()) + + mcp := &v1alpha1.ManagedCloudProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "test-gc-stale-clean"}, + Spec: v1alpha1.ManagedCloudProfileSpec{ + CloudProfile: v1alpha1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "stale-clean-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64"}}, + }, + }, + }, + ProviderConfig: &runtime.RawExtension{Raw: raw}, + }, + MachineImageUpdates: []v1alpha1.MachineImageUpdate{ + { + ImageName: "stale-clean-image", + Source: v1alpha1.MachineImageUpdateSource{ + OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + Registry: "keppel-fake", + Repository: "account/stale-clean-repo", + Insecure: true, + }, + }, + }, + }, + GarbageCollection: &v1alpha1.GarbageCollectionConfig{ + Enabled: true, + MaxAge: metav1.Duration{Duration: 0}, + }, + }, + } + Expect(k8sClient.Create(ctx, mcp)).To(Succeed()) + + r := &controllers.Reconciler{ + Client: k8sClient, + OCISourceFactory: &emptyFactory{}, + RegistryProviderFunc: func(registry string) (controllers.RegistryClient, error) { + // Registry returns no tags — nothing to protect, triggers cascade cleanup. + return &fakeRegistryClientWithTags{tags: map[string]time.Time{}}, nil + }, + } + req := ctrl.Request{NamespacedName: client.ObjectKey{Name: mcp.Name}} + _, err = r.Reconcile(ctx, req) + Expect(err).ToNot(HaveOccurred()) + + cp := &gardenerv1beta1.CloudProfile{} + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: mcp.Name}, cp)).To(Succeed()) + + // Stale clean version entry must be gone from spec.machineImages. + var machineVersions []string + for _, mi := range cp.Spec.MachineImages { + if mi.Name == "stale-clean-image" { + for _, v := range mi.Versions { + machineVersions = append(machineVersions, v.Version) + } + } + } + Expect(machineVersions).To(BeEmpty()) + + Expect(k8sClient.Delete(ctx, mcp)).To(Succeed()) + }) + })