diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index 723c168..acc8942 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -8,7 +8,6 @@ import ( "context" "fmt" "slices" - "time" "github.com/blang/semver/v4" gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" @@ -111,20 +110,8 @@ type ImageUpdater struct { EnableCapabilities bool } -// resolveExpiration decides the expiration date to write for a source image. func (iu *ImageUpdater) resolveExpiration(src SourceImage, existing *metav1.Time) *metav1.Time { - isDeprecated := src.Classification != nil && *src.Classification == gardenerv1beta1.ClassificationDeprecated - if !isDeprecated { - return src.ExpirationDate - } - if existing != nil { - return existing - } - if src.ExpirationDate != nil { - return src.ExpirationDate - } - now := metav1.NewTime(time.Now()) - return &now + return cmp.Or(existing, src.ExpirationDate) } // mergeCapabilityFlavor appends the flavor from src to existing if not already present. @@ -245,8 +232,7 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou // Always write the full tag version (legacy path, safe for running Shoots). if idx, exists := existingVersions[sourceImage.Version]; exists { image.Versions[idx].Architectures = sourceImage.Architectures - image.Versions[idx].Classification = sourceImage.Classification //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate - // Stamp expiration once on the transition to deprecated; preserve it thereafter. + image.Versions[idx].Classification = sourceImage.Classification //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate image.Versions[idx].ExpirationDate = iu.resolveExpiration(sourceImage, image.Versions[idx].ExpirationDate) //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate image.Versions[idx].InPlaceUpdates = inPlaceUpdates(sourceImage.SupportInPlaceUpdate) } else { @@ -260,8 +246,8 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou image.Versions = append(image.Versions, gardenerv1beta1.MachineImageVersion{ ExpirableVersion: gardenerv1beta1.ExpirableVersion{ Version: sourceImage.Version, - Classification: sourceImage.Classification, //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate - ExpirationDate: iu.resolveExpiration(sourceImage, nil), //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + Classification: sourceImage.Classification, //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + ExpirationDate: sourceImage.ExpirationDate, //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate }, Architectures: sourceImage.Architectures, }) @@ -293,8 +279,8 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou v := gardenerv1beta1.MachineImageVersion{ ExpirableVersion: gardenerv1beta1.ExpirableVersion{ Version: sourceImage.CleanVersion, - Classification: sourceImage.Classification, //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate - ExpirationDate: iu.resolveExpiration(sourceImage, nil), //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + Classification: sourceImage.Classification, //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + ExpirationDate: sourceImage.ExpirationDate, //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate }, Architectures: slices.Clone(sourceImage.Architectures), CapabilityFlavors: mergeCapabilityFlavor(nil, sourceImage.Capabilities), diff --git a/cloudprofilesync/ossync/os_image_updater_test.go b/cloudprofilesync/ossync/os_image_updater_test.go index c8ff287..c846163 100644 --- a/cloudprofilesync/ossync/os_image_updater_test.go +++ b/cloudprofilesync/ossync/os_image_updater_test.go @@ -708,17 +708,6 @@ var _ = Describe("ImageUpdater", func() { Expect(cpSpec.MachineImages[0].Versions[0].ExpirationDate).To(Equal(&fromSource)) //nolint:staticcheck // legacy field; Lifecycle needs the VersionClassificationLifecycle feature gate }) - It("stamps an expiration date for a new deprecated version without one", func(ctx SpecContext) { - mockSource.images = []ossync.SourceImage{ - {Version: "1.0.0", Architectures: []string{"amd64"}, Classification: &deprecated}, - } - updater := newUpdater() - var cpSpec gardencorev1beta1.CloudProfileSpec - Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) - Expect(cpSpec.MachineImages[0].Versions).To(HaveLen(1)) - Expect(cpSpec.MachineImages[0].Versions[0].ExpirationDate).NotTo(BeNil()) //nolint:staticcheck // legacy field; Lifecycle needs the VersionClassificationLifecycle feature gate - }) - It("does not set an expiration date for a non-deprecated version", func(ctx SpecContext) { mockSource.images = []ossync.SourceImage{ {Version: "1.0.0", Architectures: []string{"amd64"}}, diff --git a/cloudprofilesync/ossync/provider/openstack/provider.go b/cloudprofilesync/ossync/provider/openstack/provider.go index 24253cf..82e02f0 100644 --- a/cloudprofilesync/ossync/provider/openstack/provider.go +++ b/cloudprofilesync/ossync/provider/openstack/provider.go @@ -1,9 +1,9 @@ -package openstack - // SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company // SPDX-License-Identifier: Apache-2.0 +package openstack import ( + "cmp" "encoding/json" "slices" @@ -68,6 +68,15 @@ func (p *OpenStackProvider) Configure(cpSpec *gardencorev1beta1.CloudProfileSpec // Update in place: a rebuilt image keeps the version but gets a new UUID. entry.Regions[existing].ID = r.ID } + // Sort regions by name so the marshaled ProviderConfig is stable across + // reconciles; the source does not guarantee a consistent region order, + // which would otherwise churn the CloudProfile and cause a reconcile loop. + slices.SortFunc(entry.Regions, func(a, b openstackv1alpha1.RegionIDMapping) int { + if c := cmp.Compare(a.Name, b.Name); c != 0 { + return c + } + return cmp.Compare(a.ID, b.ID) + }) } raw, err := json.Marshal(cfg) diff --git a/cloudprofilesync/ossync/source/glance/os_source.go b/cloudprofilesync/ossync/source/glance/os_source.go index 80fb283..06a7f45 100644 --- a/cloudprofilesync/ossync/source/glance/os_source.go +++ b/cloudprofilesync/ossync/source/glance/os_source.go @@ -15,6 +15,8 @@ import ( "github.com/blang/semver/v4" gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" "github.com/go-logr/logr" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/gophercloud/v2/openstack" "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" @@ -217,7 +219,12 @@ func (g *Glance) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) } if len(versions) > 0 { deprecated := gardenerv1beta1.ClassificationDeprecated - versions[len(versions)-1].Classification = &deprecated + last := &versions[len(versions)-1] + last.Classification = &deprecated + if last.ExpirationDate == nil { + now := metav1.NewTime(time.Now()) + last.ExpirationDate = &now + } } return versions, nil diff --git a/cloudprofilesync/ossync/source/glance/os_source_test.go b/cloudprofilesync/ossync/source/glance/os_source_test.go index 8002400..02bbceb 100644 --- a/cloudprofilesync/ossync/source/glance/os_source_test.go +++ b/cloudprofilesync/ossync/source/glance/os_source_test.go @@ -141,12 +141,39 @@ func TestPreferImageDeterministic(t *testing.T) { } } +// The oldest version is marked deprecated and gets an expiration date stamped; +// newer (supported) versions have none. +func TestGetVersionsStampsExpirationOnDeprecated(t *testing.T) { + imgs := []images.Image{ + {ID: "old-uuid", Name: "gardenlinux-openstack-gardener_prod-amd64-2150.8.0-40f62d58"}, + {ID: "new-uuid", Name: "gardenlinux-openstack-gardener_prod-amd64-2151.0.0-50f62d58"}, + } + g := newTestGlance(t, GlanceParams{Regions: []string{testRegion}}, map[string][]images.Image{testRegion: imgs}) + + versions, err := g.GetVersions(context.Background()) + if err != nil { + t.Fatalf("GetVersions: %v", err) + } + if len(versions) != 2 { + t.Fatalf("got %d versions, want 2: %+v", len(versions), versions) + } + // versions is sorted newest-first, so the last entry is the deprecated one. + newest, oldest := versions[0], versions[len(versions)-1] + if oldest.ExpirationDate == nil { + t.Error("deprecated version should have an expiration date stamped") + } + if newest.ExpirationDate != nil { + t.Errorf("supported version should not have an expiration date, got %v", newest.ExpirationDate) + } +} + // 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() if params.AuthURLFormat == "" { params.AuthURLFormat = "https://identity-3.%s.cloud.sap/v3" } + g, err := NewGlance(params, logr.Discard()) if err != nil { t.Fatalf("NewGlance: %v", err) diff --git a/controllers/cloud_profile.go b/controllers/cloud_profile.go index edbec9a..30be5b4 100644 --- a/controllers/cloud_profile.go +++ b/controllers/cloud_profile.go @@ -41,6 +41,7 @@ func (r *Reconciler) reconcileCloudProfile(ctx context.Context, log logr.Logger, if err := controllerutil.SetControllerReference(mcp, &cloudProfile, r.Scheme()); err != nil { return err } + storedExpirations := collectExpirationDates(cloudProfile.Spec.MachineImages) cloudProfile.Spec = CloudProfileSpecToGardener(&mcp.Spec.CloudProfile) errs := make([]error, 0) for _, updates := range mcp.Spec.MachineImageUpdates { @@ -49,6 +50,7 @@ func (r *Reconciler) reconcileCloudProfile(ctx context.Context, log logr.Logger, errs = append(errs, updateErr) } } + applyExpirationDates(cloudProfile.Spec.MachineImages, storedExpirations) if mcp.Spec.KubernetesUpdate != nil { log.V(1).Info("updating kubernetes versions", "cloudProfile", cloudProfile.Name) if updateErr := r.updateKubernetesVersions(ctx, *mcp.Spec.KubernetesUpdate, &cloudProfile.Spec); updateErr != nil { @@ -230,6 +232,37 @@ func (r *Reconciler) landscapeSetupSource(ctx context.Context, ls v1alpha1.Lands const maxConditionMessageLen = 32768 +func expirationDateKey(imageName, version string) string { + return imageName + "/" + version +} + +func collectExpirationDates(images []gardenerv1beta1.MachineImage) map[string]*metav1.Time { + out := make(map[string]*metav1.Time) + for _, img := range images { + for _, v := range img.Versions { + if v.ExpirationDate != nil { //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + out[expirationDateKey(img.Name, v.Version)] = v.ExpirationDate //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + } + } + } + return out +} + +func applyExpirationDates(images []gardenerv1beta1.MachineImage, stored map[string]*metav1.Time) { + for i := range images { + for j := range images[i].Versions { + v := &images[i].Versions[j] + isDeprecated := v.Classification != nil && *v.Classification == gardenerv1beta1.ClassificationDeprecated //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + if !isDeprecated { + continue + } + if exp, ok := stored[expirationDateKey(images[i].Name, v.Version)]; ok { + v.ExpirationDate = exp //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + } + } + } +} + func truncateConditionMessage(msg string) string { if len(msg) <= maxConditionMessageLen { return msg diff --git a/controllers/managedcloudprofile_controller.go b/controllers/managedcloudprofile_controller.go index 0e91ae0..0963cb7 100644 --- a/controllers/managedcloudprofile_controller.go +++ b/controllers/managedcloudprofile_controller.go @@ -10,9 +10,12 @@ import ( gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/api/equality" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/predicate" "github.com/cobaltcore-dev/cloud-profile-sync/api/v1alpha1" "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ocirepo" @@ -63,6 +66,9 @@ func (r *Reconciler) patchStatusAndCondition(ctx context.Context, mcp *v1alpha1. if cond.Type != "" { mcp.Status.Conditions = applyCondition(mcp.Status.Conditions, cond) } + if equality.Semantic.DeepEqual(original.Status, mcp.Status) { + return nil + } return r.Status().Patch(ctx, mcp, client.MergeFrom(original)) } @@ -116,7 +122,7 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { r.RegistryProviderFunc = r.getRegistryProvider } return ctrl.NewControllerManagedBy(mgr). - For(&v1alpha1.ManagedCloudProfile{}). + For(&v1alpha1.ManagedCloudProfile{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). Owns(&gardenerv1beta1.CloudProfile{}). Complete(r) }