Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 6 additions & 20 deletions cloudprofilesync/ossync/os_image_updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"context"
"fmt"
"slices"
"time"

"github.com/blang/semver/v4"
gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
})
Expand Down Expand Up @@ -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),
Expand Down
11 changes: 0 additions & 11 deletions cloudprofilesync/ossync/os_image_updater_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
Expand Down
13 changes: 11 additions & 2 deletions cloudprofilesync/ossync/provider/openstack/provider.go
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion cloudprofilesync/ossync/source/glance/os_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions cloudprofilesync/ossync/source/glance/os_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,32 @@ 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()
Expand Down
33 changes: 33 additions & 0 deletions controllers/cloud_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion controllers/managedcloudprofile_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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)
}