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
35 changes: 35 additions & 0 deletions api/v1alpha1/managedcloudprofile.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,36 @@ type MachineImageUpdateSource struct {
// OCI contains configuration for an OCI source.
// +optional
OCI *OCI `json:"oci,omitempty"`
// Glance contains configuration for an OpenStack Glance source.
// +optional
Glance *GlanceSource `json:"glance,omitempty"`
}

// GlanceSource configures discovery of gardenlinux images from OpenStack Glance.
type GlanceSource struct {
// AuthURLFormat is the Keystone endpoint format string with a single "%s" for the region.
AuthURLFormat string `json:"authURLFormat"`
// Regions is the list of OpenStack regions to query.
Regions []string `json:"regions"`
Comment thread
yahor-kurachkin marked this conversation as resolved.
// NamePrefix selects images by name prefix. Empty means the default.
// +optional
NamePrefix string `json:"namePrefix,omitempty"`
// KeepLatest limits results to the newest N versions.
// +optional
KeepLatest int `json:"keepLatest,omitempty"`
// Parallel bounds how many regions are queried concurrently.
// +optional
Parallel int64 `json:"parallel,omitempty"`
Comment thread
anton-paulovich marked this conversation as resolved.
// ProjectName scopes the token.
ProjectName string `json:"projectName"`
// ProjectDomainName scopes the token domain.
ProjectDomainName string `json:"projectDomainName"`
// Username for authentication.
Username string `json:"username"`
// UserDomainName is the domain of the authenticating user.
UserDomainName string `json:"userDomainName"`
// PasswordSecret is a reference to a secret containing the OpenStack password.
PasswordSecret SecretReference `json:"passwordSecret"`
}

type OCI struct {
Expand All @@ -193,8 +223,13 @@ type MachineImageUpdateProvider struct {
// Ironcore contains configuration to update provider.machineImages for ironcore-metal CloudProfiles
// +optional
IroncoreMetal *MachineImagesUpdateProviderIroncoreMetal `json:"ironcoreMetal,omitempty"`
// OpenStack contains configuration to update provider.machineImages for OpenStack CloudProfiles.
// +optional
OpenStack *MachineImagesUpdateProviderOpenStack `json:"openStack,omitempty"`
}

type MachineImagesUpdateProviderOpenStack struct{}

type MachineImagesUpdateProviderIroncoreMetal struct {
// Registry contains the hostname and port of the OCI registry
Registry string `json:"registry"`
Expand Down
46 changes: 46 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 54 additions & 7 deletions cloudprofilesync/ossync/os_image_updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import (
"context"
"fmt"
"slices"
"time"

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

type SourceImage struct {
Expand All @@ -27,6 +29,22 @@ type SourceImage struct {
Capabilities gardenerv1beta1.Capabilities
// SupportInPlaceUpdate hold value if image supports in place updates
SupportInPlaceUpdate bool
// Regions maps a region to the provider-specific image identifier (e.g. an
// OpenStack Glance image UUID) for this version. It is nil for sources whose
// images are not region-specific (e.g. OCI).
Regions []RegionImage
// Classification is the lifecycle state of the image version. Nil means unset (supported).
Classification *gardenerv1beta1.VersionClassification
// ExpirationDate is the date after which the version should no longer be used.
ExpirationDate *metav1.Time
}

// RegionImage is the image identifier for a single version in a single region.
type RegionImage struct {
// Region is the name of the region (e.g. "eu-de-1").
Region string
// ID is the image identifier in that region (e.g. a Glance image UUID).
ID string
}

// effectiveVersion returns CleanVersion when available, falling back to Version.
Expand Down Expand Up @@ -88,6 +106,29 @@ 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
Comment thread
yahor-kurachkin marked this conversation as resolved.
}

func inPlaceUpdates(supported bool) *gardenerv1beta1.InPlaceUpdates {
if !supported {
return nil
}
return &gardenerv1beta1.InPlaceUpdates{Supported: true}
}

func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.CloudProfileSpec) error {
sourceImages, err := iu.Source.GetVersions(ctx)
if err != nil {
Expand Down Expand Up @@ -120,6 +161,10 @@ 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].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 {
// Moving this check to filterImages() would break the core architectural goal of GEP-33
// as it intentionally decouples the OCI registry tag from the semantic OS version
Expand All @@ -130,7 +175,9 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou
} else {
image.Versions = append(image.Versions, gardenerv1beta1.MachineImageVersion{
ExpirableVersion: gardenerv1beta1.ExpirableVersion{
Version: sourceImage.Version,
Version: sourceImage.Version,
Classification: sourceImage.Classification,
ExpirationDate: iu.resolveExpiration(sourceImage, nil),
},
Architectures: sourceImage.Architectures,
})
Expand All @@ -152,15 +199,15 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou
existing.Architectures = append(existing.Architectures, arch)
}
}
if sourceImage.SupportInPlaceUpdate {
existing.InPlaceUpdates = &gardenerv1beta1.InPlaceUpdates{
Supported: sourceImage.SupportInPlaceUpdate,
}
}
existing.Classification = sourceImage.Classification //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate
existing.ExpirationDate = iu.resolveExpiration(sourceImage, existing.ExpirationDate) //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate
existing.InPlaceUpdates = inPlaceUpdates(sourceImage.SupportInPlaceUpdate)
} else {
image.Versions = append(image.Versions, gardenerv1beta1.MachineImageVersion{
ExpirableVersion: gardenerv1beta1.ExpirableVersion{
Version: sourceImage.CleanVersion,
Version: sourceImage.CleanVersion,
Classification: sourceImage.Classification,
ExpirationDate: iu.resolveExpiration(sourceImage, nil),
},
Architectures: slices.Clone(sourceImage.Architectures),
})
Expand Down
66 changes: 66 additions & 0 deletions cloudprofilesync/ossync/os_image_updater_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ package ossync_test

import (
"encoding/json"
"time"

gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1"
"github.com/go-logr/logr"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync"
)
Expand Down Expand Up @@ -364,4 +366,68 @@ var _ = Describe("ImageUpdater", func() {
Expect(cpSpec.MachineImages[0].Versions[1].InPlaceUpdates.Supported).To(BeTrue())
})
})

Describe("expiration", func() {
deprecated := gardencorev1beta1.ClassificationDeprecated

newUpdater := func() ossync.ImageUpdater {
return ossync.ImageUpdater{Log: GinkgoLogr, Source: &mockSource, ImageName: "test"}
}

It("keeps the existing expiration date for a deprecated version (never overwrites)", func(ctx SpecContext) {
existing := metav1.NewTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))
cpSpec := gardencorev1beta1.CloudProfileSpec{
MachineImages: []gardencorev1beta1.MachineImage{
{Name: "test", Versions: []gardencorev1beta1.MachineImageVersion{
{ExpirableVersion: gardencorev1beta1.ExpirableVersion{
Version: "1.0.0",
Classification: &deprecated,
ExpirationDate: &existing,
}, Architectures: []string{"amd64"}},
}},
},
}
mockSource.images = []ossync.SourceImage{
{Version: "1.0.0", Architectures: []string{"amd64"}, Classification: &deprecated},
}
updater := newUpdater()
Expect(updater.Update(ctx, &cpSpec)).To(Succeed())
Expect(cpSpec.MachineImages[0].Versions).To(HaveLen(1))
Expect(cpSpec.MachineImages[0].Versions[0].ExpirationDate).To(Equal(&existing)) //nolint:staticcheck // legacy field; Lifecycle needs the VersionClassificationLifecycle feature gate
})

It("uses the source's expiration date for a new deprecated version", func(ctx SpecContext) {
fromSource := metav1.NewTime(time.Date(2030, 6, 1, 0, 0, 0, 0, time.UTC))
mockSource.images = []ossync.SourceImage{
{Version: "1.0.0", Architectures: []string{"amd64"}, Classification: &deprecated, ExpirationDate: &fromSource},
}
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).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"}},
}
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).To(BeNil()) //nolint:staticcheck // legacy field; Lifecycle needs the VersionClassificationLifecycle feature gate
})
})
})
79 changes: 79 additions & 0 deletions cloudprofilesync/ossync/provider/openstack/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package openstack

// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company
// SPDX-License-Identifier: Apache-2.0

import (
"encoding/json"
"slices"

openstackv1alpha1 "github.com/gardener/gardener-extension-provider-openstack/pkg/apis/openstack/v1alpha1"
gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1"
"k8s.io/apimachinery/pkg/runtime"

"github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync"
)

type OpenStackProvider struct {
ImageName string
}

func (p *OpenStackProvider) Configure(cpSpec *gardencorev1beta1.CloudProfileSpec, versions []ossync.SourceImage) error {
var cfg openstackv1alpha1.CloudProfileConfig
if cpSpec.ProviderConfig != nil {
if err := json.Unmarshal(cpSpec.ProviderConfig.Raw, &cfg); err != nil {
return err
}
}

imageIndex := slices.IndexFunc(cfg.MachineImages, func(m openstackv1alpha1.MachineImages) bool {
return m.Name == p.ImageName
})
if imageIndex == -1 {
imageIndex = len(cfg.MachineImages)
cfg.MachineImages = append(cfg.MachineImages, openstackv1alpha1.MachineImages{
Name: p.ImageName,
Versions: []openstackv1alpha1.MachineImageVersion{},
})
}
image := &cfg.MachineImages[imageIndex]

existingVersions := make(map[string]int, len(image.Versions))
for i, v := range image.Versions {
existingVersions[v.Version] = i
}
Comment thread
anton-paulovich marked this conversation as resolved.

for _, src := range versions {
idx, exists := existingVersions[src.Version]
if !exists {
idx = len(image.Versions)
image.Versions = append(image.Versions, openstackv1alpha1.MachineImageVersion{
Version: src.Version,
})
existingVersions[src.Version] = idx
}
entry := &image.Versions[idx]

for _, r := range src.Regions {
existing := slices.IndexFunc(entry.Regions, func(m openstackv1alpha1.RegionIDMapping) bool {
return m.Name == r.Region
})
if existing == -1 {
entry.Regions = append(entry.Regions, openstackv1alpha1.RegionIDMapping{
Name: r.Region,
ID: r.ID,
})
continue
}
// Update in place: a rebuilt image keeps the version but gets a new UUID.
entry.Regions[existing].ID = r.ID
}
}

raw, err := json.Marshal(cfg)
if err != nil {
return err
}
cpSpec.ProviderConfig = &runtime.RawExtension{Raw: raw}
return nil
}
Loading