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/controllers/managedcloudprofile_controller.go b/controllers/managedcloudprofile_controller.go index 4c94d35..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)) } } @@ -209,15 +207,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 +221,40 @@ 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 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 + } + 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 +263,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 +307,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..0f2fd1d 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) } @@ -69,6 +81,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 +918,434 @@ 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()) + + shoot := &gardenerv1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{Name: "test-shoot-cap", Namespace: metav1.NamespaceDefault}, + Spec: gardenerv1beta1.ShootSpec{ + CloudProfile: &gardenerv1beta1.CloudProfileReference{Name: "test-gc-protect-flavors"}, + 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"}}, + 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{ + { + 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()) + + r := &controllers.Reconciler{ + Client: k8sClient, + OCISourceFactory: &emptyFactory{}, + 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 = 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. + var versions []string + for _, mi := range cp.Spec.MachineImages { + if mi.Name == "cap-image" { + for _, v := range mi.Versions { + versions = append(versions, v.Version) + } + } + } + Expect(versions).To(ContainElement(rawTag)) + + // Flavor must still be present in providerConfig. + 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) + } + } + } + } + Expect(flavors).To(ContainElement("repo/cap-image:" + rawTag)) + + Expect(k8sClient.Delete(ctx, mcp)).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()) + + 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{ + { + 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: 24 * time.Hour}, + }, + }, + } + Expect(k8sClient.Create(ctx, mcp)).To(Succeed()) + + r := &controllers.Reconciler{ + Client: k8sClient, + OCISourceFactory: &emptyFactory{}, + 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 = r.Reconcile(ctx, req) + Expect(err).ToNot(HaveOccurred()) + + 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) + } + } + } + } + } + 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). + 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) + } + } + } + Expect(machineVersions).To(ContainElement(cleanVersion)) + + Expect(k8sClient.Delete(ctx, mcp)).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()) + + 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{ + { + 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()) + + r := &controllers.Reconciler{ + Client: k8sClient, + OCISourceFactory: &emptyFactory{}, + 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 = 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. + var machineVersions []string + for _, mi := range cp.Spec.MachineImages { + if mi.Name == "cascade-image" { + for _, v := range mi.Versions { + machineVersions = append(machineVersions, v.Version) + } + } + } + Expect(machineVersions).To(BeEmpty()) + + // Clean version entry must be gone from providerConfig as well. + 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 + } + } + Expect(providerVersions).To(BeEmpty()) + + 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()) + }) + }) 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