From 878c95ed4dbbc4d24d60f327cfc1d0542b2e4a77 Mon Sep 17 00:00:00 2001 From: C5421281 Date: Fri, 4 Sep 2026 15:20:16 +0200 Subject: [PATCH 1/2] feat: Implement OS image version offset and update pause for CloudProfile machine images Signed-off-by: C5421281 --- api/v1alpha1/managedcloudprofile.go | 4 ++++ .../ossync/source/glance/os_source.go | 17 +++++++++++++---- controllers/cloud_profile.go | 17 +++++++++++++++++ ...ync.cobaltcore.dev_managedcloudprofiles.yaml | 8 +++++++- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/api/v1alpha1/managedcloudprofile.go b/api/v1alpha1/managedcloudprofile.go index 0ff90ad..6309c74 100644 --- a/api/v1alpha1/managedcloudprofile.go +++ b/api/v1alpha1/managedcloudprofile.go @@ -100,6 +100,7 @@ type MachineImageUpdate struct { // ImagesName is the name of the image to maintain automatically ImageName string `json:"imageName"` + Paused bool `json:"paused,omitempty"` } type GarbageCollectionConfig struct { @@ -153,6 +154,9 @@ type GlanceSource struct { // KeepLatest limits results to the newest N versions. // +optional KeepLatest int `json:"keepLatest,omitempty"` + // VersionOffset control which slice of GardenLinux versions is published + // +optional + VersionOffset int `json:"versionOffset,omitempty"` // Parallel bounds how many regions are queried concurrently. // +optional Parallel int64 `json:"parallel,omitempty"` diff --git a/cloudprofilesync/ossync/source/glance/os_source.go b/cloudprofilesync/ossync/source/glance/os_source.go index 06a7f45..65c60b4 100644 --- a/cloudprofilesync/ossync/source/glance/os_source.go +++ b/cloudprofilesync/ossync/source/glance/os_source.go @@ -31,8 +31,9 @@ const ( DefaultGlanceKeepLatest = 3 // defaultGlanceParallel is the region query concurrency used when GlanceParams.Parallel // is not set. - defaultGlanceParallel = 8 - usiVariantMarker = "_usi" + defaultGlanceParallel = 8 + defaultGlanceVersionOffset = 0 + usiVariantMarker = "_usi" ) type Result[T any] struct { @@ -55,7 +56,8 @@ type GlanceParams struct { KeepLatest int // Parallel bounds how many regions are queried concurrently. - Parallel int64 + Parallel int64 + VersionOffset int // ProjectName / ProjectDomainName scope the token. ProjectName string @@ -73,6 +75,7 @@ type Glance struct { params GlanceParams namePrefix string keepLatest int + offset int sema *semaphore.Weighted authenticate func(ctx context.Context, authURL string, opts gophercloud.AuthOptions) (*gophercloud.ProviderClient, error) listImages func(ctx context.Context, provider *gophercloud.ProviderClient, region string) ([]images.Image, error) @@ -102,11 +105,17 @@ func NewGlance(params GlanceParams, log logr.Logger) (*Glance, error) { parallel = defaultGlanceParallel } + offset := params.VersionOffset + if offset <= 0 { + offset = defaultGlanceVersionOffset + } + return &Glance{ log: log, params: params, namePrefix: prefix, keepLatest: keepLatest, + offset: offset, sema: semaphore.NewWeighted(parallel), authenticate: defaultAuthenticate, listImages: defaultListImages, @@ -210,7 +219,7 @@ func (g *Glance) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) return compareSemverDesc(a.Version, b.Version) }) if g.keepLatest > 0 && len(versions) > g.keepLatest { - versions = versions[:g.keepLatest] + versions = versions[g.offset : g.offset+g.keepLatest] } supported := gardenerv1beta1.ClassificationSupported diff --git a/controllers/cloud_profile.go b/controllers/cloud_profile.go index 29549bb..a8fec33 100644 --- a/controllers/cloud_profile.go +++ b/controllers/cloud_profile.go @@ -42,9 +42,17 @@ func (r *Reconciler) reconcileCloudProfile(ctx context.Context, log logr.Logger, return err } storedExpirations := collectExpirationDates(cloudProfile.Spec.MachineImages) + storedImages := collectMachineImages(cloudProfile.Spec.MachineImages) cloudProfile.Spec = CloudProfileSpecToGardener(&mcp.Spec.CloudProfile) errs := make([]error, 0) for _, updates := range mcp.Spec.MachineImageUpdates { + if updates.Paused { + log.V(1).Info("machine image update paused, keeping existing images", "cloudProfile", cloudProfile.Name, "imageName", updates.ImageName) + if img, ok := storedImages[updates.ImageName]; ok { + cloudProfile.Spec.MachineImages = append(cloudProfile.Spec.MachineImages, img) + } + continue + } log.V(1).Info("updating machine images", "cloudProfile", cloudProfile.Name) if updateErr := r.updateMachineImages(ctx, log, updates, &cloudProfile.Spec); updateErr != nil { errs = append(errs, updateErr) @@ -127,6 +135,7 @@ func (r *Reconciler) updateMachineImages(ctx context.Context, log logr.Logger, u Regions: update.Source.Glance.Regions, NamePrefix: update.Source.Glance.NamePrefix, KeepLatest: update.Source.Glance.KeepLatest, + VersionOffset: update.Source.Glance.VersionOffset, Parallel: update.Source.Glance.Parallel, ProjectName: update.Source.Glance.ProjectName, ProjectDomainName: update.Source.Glance.ProjectDomainName, @@ -238,6 +247,14 @@ func expirationDateKey(imageName, version string) string { return imageName + "/" + version } +func collectMachineImages(images []gardenerv1beta1.MachineImage) map[string]gardenerv1beta1.MachineImage { + out := make(map[string]gardenerv1beta1.MachineImage, len(images)) + for _, img := range images { + out[img.Name] = *img.DeepCopy() + } + return out +} + func collectExpirationDates(images []gardenerv1beta1.MachineImage) map[string]*metav1.Time { out := make(map[string]*metav1.Time) for _, img := range images { diff --git a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml index 705317e..4faeb3a 100644 --- a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml +++ b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.21.0 + controller-gen.kubebuilder.io/version: v0.22.0 name: managedcloudprofiles.cloudprofilesync.cobaltcore.dev spec: group: cloudprofilesync.cobaltcore.dev @@ -672,6 +672,8 @@ spec: description: ImagesName is the name of the image to maintain automatically type: string + paused: + type: boolean provider: description: Provider contains configuration for a provider for machine images. @@ -760,6 +762,10 @@ spec: username: description: Username for authentication. type: string + versionOffset: + description: VersionOffset control which slice of GardenLinux + versions is published + type: integer required: - authURLFormat - passwordSecret From fe69f298286c2af9213d17fad49b9bc0ed632315 Mon Sep 17 00:00:00 2001 From: C5421281 Date: Fri, 4 Sep 2026 15:43:19 +0200 Subject: [PATCH 2/2] feat: Add test cases and fixs bugs Signed-off-by: C5421281 --- api/v1alpha1/managedcloudprofile.go | 8 +- .../ossync/source/glance/os_source.go | 13 ++- .../ossync/source/glance/os_source_test.go | 110 ++++++++++++++++++ controllers/cloud_profile.go | 6 + .../managedcloudprofile_controller_test.go | 51 ++++++++ ...c.cobaltcore.dev_managedcloudprofiles.yaml | 8 +- 6 files changed, 188 insertions(+), 8 deletions(-) diff --git a/api/v1alpha1/managedcloudprofile.go b/api/v1alpha1/managedcloudprofile.go index 6309c74..e9a44f5 100644 --- a/api/v1alpha1/managedcloudprofile.go +++ b/api/v1alpha1/managedcloudprofile.go @@ -98,9 +98,11 @@ type MachineImageUpdate struct { // Provider contains configuration for a provider for machine images. Provider MachineImageUpdateProvider `json:"provider"` - // ImagesName is the name of the image to maintain automatically + // ImageName is the name of the image to maintain automatically ImageName string `json:"imageName"` - Paused bool `json:"paused,omitempty"` + // Paused disables automatic updates for this image and keeps the existing CloudProfile machine images. + // +optional + Paused bool `json:"paused,omitempty"` } type GarbageCollectionConfig struct { @@ -154,7 +156,7 @@ type GlanceSource struct { // KeepLatest limits results to the newest N versions. // +optional KeepLatest int `json:"keepLatest,omitempty"` - // VersionOffset control which slice of GardenLinux versions is published + // VersionOffset controls how many newest GardenLinux versions to skip before applying KeepLatest. // +optional VersionOffset int `json:"versionOffset,omitempty"` // Parallel bounds how many regions are queried concurrently. diff --git a/cloudprofilesync/ossync/source/glance/os_source.go b/cloudprofilesync/ossync/source/glance/os_source.go index 65c60b4..2c83804 100644 --- a/cloudprofilesync/ossync/source/glance/os_source.go +++ b/cloudprofilesync/ossync/source/glance/os_source.go @@ -218,8 +218,17 @@ func (g *Glance) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) slices.SortFunc(versions, func(a, b ossync.SourceImage) int { return compareSemverDesc(a.Version, b.Version) }) - if g.keepLatest > 0 && len(versions) > g.keepLatest { - versions = versions[g.offset : g.offset+g.keepLatest] + if g.keepLatest > 0 || g.offset > 0 { + // Skip the newest `offset` versions, then keep the next `keepLatest`. + // Clamp both bounds so an offset that runs past the available versions + // never slices out of range. A keepLatest of 0 keeps everything after + // the offset. + lo := min(g.offset, len(versions)) + hi := len(versions) + if g.keepLatest > 0 { + hi = min(lo+g.keepLatest, len(versions)) + } + versions = versions[lo:hi] } supported := gardenerv1beta1.ClassificationSupported diff --git a/cloudprofilesync/ossync/source/glance/os_source_test.go b/cloudprofilesync/ossync/source/glance/os_source_test.go index 5c3eb9d..5d46811 100644 --- a/cloudprofilesync/ossync/source/glance/os_source_test.go +++ b/cloudprofilesync/ossync/source/glance/os_source_test.go @@ -5,12 +5,15 @@ package glance import ( "context" + "slices" "testing" "time" "github.com/go-logr/logr" "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" + + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" ) const ( @@ -167,6 +170,113 @@ func TestGetVersionsStampsExpirationOnDeprecated(t *testing.T) { } } +// imageName builds a standard gardenlinux Glance image name for a version. +func imageName(version string) string { + return defaultGlanceNamePrefix + version + "-deadbeef" +} + +// versionStrings extracts the ordered version strings from the result. +func versionStrings(vs []ossync.SourceImage) []string { + out := make([]string, len(vs)) + for i, v := range vs { + out[i] = v.Version + } + return out +} + +// VersionOffset selects a slice of the newest-first versions, skipping the first +// `offset` and keeping the next `keepLatest`. +func TestGetVersionsVersionOffset(t *testing.T) { + // Five versions; GetVersions sorts them newest-first: 5,4,3,2,1. + allVersions := []string{"1.0.0", "2.0.0", "3.0.0", "4.0.0", "5.0.0"} + imgs := make([]images.Image, 0, len(allVersions)) + for _, v := range allVersions { + imgs = append(imgs, images.Image{ID: v + "-uuid", Name: imageName(v)}) + } + + tests := []struct { + name string + keepLatest int + offset int + want []string + }{ + { + name: "offset 0 keeps newest N", + keepLatest: 3, + offset: 0, + want: []string{"5.0.0", "4.0.0", "3.0.0"}, + }, + { + name: "offset skips the newest versions", + keepLatest: 2, + offset: 2, + want: []string{"3.0.0", "2.0.0"}, + }, + { + name: "offset reaches the oldest window", + keepLatest: 2, + offset: 3, + want: []string{"2.0.0", "1.0.0"}, + }, + { + name: "offset applies even when keepLatest covers all versions", + keepLatest: 5, + offset: 2, + want: []string{"3.0.0", "2.0.0", "1.0.0"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := newTestGlance(t, GlanceParams{ + Regions: []string{testRegion}, + KeepLatest: tc.keepLatest, + VersionOffset: tc.offset, + }, map[string][]images.Image{testRegion: imgs}) + + versions, err := g.GetVersions(context.Background()) + if err != nil { + t.Fatalf("GetVersions: %v", err) + } + got := versionStrings(versions) + if !slices.Equal(got, tc.want) { + t.Errorf("versions = %v, want %v", got, tc.want) + } + }) + } +} + +// A VersionOffset whose window runs past the available versions must not panic +// and must not slice out of bounds. +func TestGetVersionsVersionOffsetOutOfBounds(t *testing.T) { + allVersions := []string{"1.0.0", "2.0.0", "3.0.0", "4.0.0"} + imgs := make([]images.Image, 0, len(allVersions)) + for _, v := range allVersions { + imgs = append(imgs, images.Image{ID: v + "-uuid", Name: imageName(v)}) + } + + // len=4, keepLatest=3 satisfies the len>keepLatest guard, but offset=2 makes + // the upper bound offset+keepLatest = 5, which is past len(versions). + g := newTestGlance(t, GlanceParams{ + Regions: []string{testRegion}, + KeepLatest: 3, + VersionOffset: 2, + }, map[string][]images.Image{testRegion: imgs}) + + versions, err := g.GetVersions(context.Background()) + if err != nil { + t.Fatalf("GetVersions: %v", err) + } + // Expect the window to be clamped to the available versions. Versions sorted + // newest-first are 4,3,2,1; skipping the 2 newest leaves 2.0.0, 1.0.0. The + // upper bound must clamp instead of panicking. + got := versionStrings(versions) + want := []string{"2.0.0", "1.0.0"} + if !slices.Equal(got, want) { + t.Errorf("versions = %v, want %v (window must clamp to available range)", got, want) + } +} + // newTestGlance builds a Glance source with auth/list stubbed so no real OpenStack is contacted. func newTestGlance(t *testing.T, params GlanceParams, imgsByRegion map[string][]images.Image) *Glance { t.Helper() diff --git a/controllers/cloud_profile.go b/controllers/cloud_profile.go index a8fec33..dd028b4 100644 --- a/controllers/cloud_profile.go +++ b/controllers/cloud_profile.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "slices" gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" "github.com/go-logr/logr" @@ -49,6 +50,11 @@ func (r *Reconciler) reconcileCloudProfile(ctx context.Context, log logr.Logger, if updates.Paused { log.V(1).Info("machine image update paused, keeping existing images", "cloudProfile", cloudProfile.Name, "imageName", updates.ImageName) if img, ok := storedImages[updates.ImageName]; ok { + // Replace any entry the MCP spec contributed for this image so the + // stored (previously reconciled) versions are kept without duplicating. + cloudProfile.Spec.MachineImages = slices.DeleteFunc(cloudProfile.Spec.MachineImages, func(m gardenerv1beta1.MachineImage) bool { + return m.Name == updates.ImageName + }) cloudProfile.Spec.MachineImages = append(cloudProfile.Spec.MachineImages, img) } continue diff --git a/controllers/managedcloudprofile_controller_test.go b/controllers/managedcloudprofile_controller_test.go index 4a14c95..ec8a899 100644 --- a/controllers/managedcloudprofile_controller_test.go +++ b/controllers/managedcloudprofile_controller_test.go @@ -328,6 +328,57 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Expect(k8sClient.Delete(ctx, cloudProfile)).To(Succeed()) }) + It("keeps existing images when the update is paused", func(ctx SpecContext) { + keptVersion := "4242.0.0" + + var mcp v1alpha1.ManagedCloudProfile + mcp.Name = "test-paused" + mcp.Spec.CloudProfile = baseCloudProfileSpec( + gardenerv1beta1.MachineImage{ + Name: "the-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {Version: keptVersion, Architectures: []string{"amd64"}}, + }, + }, + ) + mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ + { + Source: v1alpha1.MachineImageUpdateSource{ + OCI: &v1alpha1.OCI{ + Registry: registryAddr, + Repository: orasRepoName("repo"), + Insecure: true, + }, + }, + Provider: v1alpha1.MachineImageUpdateProvider{ + IroncoreMetal: &v1alpha1.MachineImagesUpdateProviderIroncoreMetal{ + Registry: registryAddr, + Repository: orasRepoName("repo"), + }, + }, + ImageName: "the-image", + Paused: true, + }, + } + Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) + + expectReconcileStatus(ctx, &mcp, v1alpha1.SucceededReconcileStatus) + expectAppliedCondition(&mcp, metav1.ConditionTrue) + + cloudProfile := getCloudProfile(ctx, mcp.Name) + mi := cloudProfile.Spec.MachineImages + Expect(mi).To(HaveLen(1)) + Expect(mi[0].Name).To(Equal("the-image")) + // The updater was skipped, so the pre-existing version is kept and the OCI + // source versions (1.0.0, 1.0.1+abc) were never fetched. + vers := mi[0].Versions + Expect(vers).To(HaveLen(1)) + Expect(vers[0].Version).To(Equal(keptVersion)) + + Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) + Expect(k8sClient.Delete(ctx, cloudProfile)).To(Succeed()) + }) + It("fetches a secret for the OCI source", func(ctx SpecContext) { var secret corev1.Secret secret.Name = "oci" diff --git a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml index 4faeb3a..c6c774f 100644 --- a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml +++ b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml @@ -669,10 +669,12 @@ spec: items: properties: imageName: - description: ImagesName is the name of the image to maintain + description: ImageName is the name of the image to maintain automatically type: string paused: + description: Paused disables automatic updates for this image + and keeps the existing CloudProfile machine images. type: boolean provider: description: Provider contains configuration for a provider @@ -763,8 +765,8 @@ spec: description: Username for authentication. type: string versionOffset: - description: VersionOffset control which slice of GardenLinux - versions is published + description: VersionOffset controls how many newest + GardenLinux versions to skip before applying KeepLatest. type: integer required: - authURLFormat