Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
3de0fdc
feat: add OpenStack Glance source and provider for gardenlinux images
yahor-kurachkin Aug 11, 2026
7ef8a49
feat: add OpenStack Glance source and provider for gardenlinux images
yahor-kurachkin Aug 11, 2026
dc9da28
Merge remote-tracking branch 'origin/master' into feat/openstack-glance
yahor-kurachkin Aug 11, 2026
df8c369
fix: codeRabbitAI review
yahor-kurachkin Aug 20, 2026
6bc7a3f
fix: CopilotAI review
yahor-kurachkin Aug 20, 2026
d69732b
fix: Linter error
yahor-kurachkin Aug 21, 2026
f5417d2
Merge remote-tracking branch 'origin/master'
yahor-kurachkin Aug 31, 2026
30abb1a
Run go-makefile-maker
Aug 11, 2026
f21a464
feat: add OpenStack Glance source and provider for gardenlinux images
yahor-kurachkin Aug 11, 2026
17955c7
fix: codeRabbitAI review
yahor-kurachkin Aug 20, 2026
1e7da5b
fix: CopilotAI review
yahor-kurachkin Aug 20, 2026
6b560d7
fix: Linter error
yahor-kurachkin Aug 21, 2026
8fb0853
Run go-makefile-maker
Aug 20, 2026
5ee7420
feat: Automate gardenlinux image lifecycle management for OpenStack (…
yahor-kurachkin Aug 21, 2026
4e88a69
Populate `capabilityFlavors` to spec.machineImages (#46)
anton-paulovich Aug 26, 2026
90d3dfb
Run go-makefile-maker
Aug 26, 2026
d42478e
Run go-makefile-maker
Aug 27, 2026
75283ac
Improve logging and fix status patch issue (#47)
anton-paulovich Aug 28, 2026
b867582
fix: bug with reconcile loop every second
yahor-kurachkin Sep 1, 2026
16780d5
Merge branch 'master' into fix/reconcile-bug
yahor-kurachkin Sep 1, 2026
043d591
Merge remote-tracking branch 'origin/master'
yahor-kurachkin Sep 2, 2026
f8b370e
Merge branch 'master' into fix/reconcile-bug
yahor-kurachkin Sep 2, 2026
2f3f3e5
fix: move ExpirationDate from os_image_updater.go to source/glance/os…
yahor-kurachkin Sep 2, 2026
c53d427
fix: bug when existing version has ExpirationDate
yahor-kurachkin Sep 2, 2026
5e68720
fix: CopilotAI rewiev about test case
yahor-kurachkin Sep 2, 2026
8082f6c
fix: change logic of resolveExpiration
yahor-kurachkin Sep 2, 2026
5f3ad0f
test: remove stale-expiration test that no longer matches resolveExpi…
yahor-kurachkin Sep 2, 2026
f8ba935
fix: improve code
yahor-kurachkin Sep 2, 2026
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)
}
Comment thread
yahor-kurachkin marked this conversation as resolved.

// 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.
Comment thread
yahor-kurachkin marked this conversation as resolved.
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
27 changes: 27 additions & 0 deletions cloudprofilesync/ossync/source/glance/os_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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)
Comment thread
yahor-kurachkin marked this conversation as resolved.
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))
Comment thread
yahor-kurachkin marked this conversation as resolved.
}

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)
}