From 3de0fdc99b83a71e24cc36f08857aa4b955c9020 Mon Sep 17 00:00:00 2001 From: C5421281 Date: Tue, 11 Aug 2026 09:44:40 +0200 Subject: [PATCH 01/23] feat: add OpenStack Glance source and provider for gardenlinux images Add machine-image discovery for OpenStack CloudProfiles: - Glance source: discover public gardenlinux images across regions, parse versions from image names, keep the newest N (default 3), skip _usi variants. - OpenStackProvider: write per-region image UUIDs into the gardener-extension-provider-openstack providerConfig. - Lifecycle: mark the oldest kept version deprecated and stamp its expirationDate once on the transition, preserving it thereafter (ImageUpdater.resolveExpiration). - Wire GlanceSource into the ManagedCloudProfile API and controller; regenerate CRD and deepcopy. - Unit tests for expiration, usi skipping, and provider config. --- api/v1alpha1/managedcloudprofile.go | 35 +++ api/v1alpha1/zz_generated.deepcopy.go | 46 +++ cloudprofilesync/ossync/os_image_updater.go | 41 ++- .../ossync/os_image_updater_test.go | 66 ++++ .../ossync/provider/openstack/provider.go | 73 +++++ .../provider/openstack/provider_test.go | 226 ++++++++++++++ .../ossync/source/glance/os_source.go | 291 ++++++++++++++++++ .../ossync/source/glance/os_source_test.go | 112 +++++++ controllers/cloud_profile.go | 28 ++ ...c.cobaltcore.dev_managedcloudprofiles.yaml | 72 +++++ go.mod | 12 +- go.sum | 12 + 12 files changed, 1008 insertions(+), 6 deletions(-) create mode 100644 cloudprofilesync/ossync/provider/openstack/provider.go create mode 100644 cloudprofilesync/ossync/provider/openstack/provider_test.go create mode 100644 cloudprofilesync/ossync/source/glance/os_source.go create mode 100644 cloudprofilesync/ossync/source/glance/os_source_test.go diff --git a/api/v1alpha1/managedcloudprofile.go b/api/v1alpha1/managedcloudprofile.go index 0ab9c05..553577c 100644 --- a/api/v1alpha1/managedcloudprofile.go +++ b/api/v1alpha1/managedcloudprofile.go @@ -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"` + // 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"` + // 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 { @@ -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"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index aaa62fc..7864a41 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -118,6 +118,27 @@ func (in *GithubAppAuth) DeepCopy() *GithubAppAuth { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GlanceSource) DeepCopyInto(out *GlanceSource) { + *out = *in + if in.Regions != nil { + in, out := &in.Regions, &out.Regions + *out = make([]string, len(*in)) + copy(*out, *in) + } + out.PasswordSecret = in.PasswordSecret +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GlanceSource. +func (in *GlanceSource) DeepCopy() *GlanceSource { + if in == nil { + return nil + } + out := new(GlanceSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KubernetesVersionSourceGithub) DeepCopyInto(out *KubernetesVersionSourceGithub) { *out = *in @@ -206,6 +227,11 @@ func (in *MachineImageUpdateProvider) DeepCopyInto(out *MachineImageUpdateProvid *out = new(MachineImagesUpdateProviderIroncoreMetal) **out = **in } + if in.OpenStack != nil { + in, out := &in.OpenStack, &out.OpenStack + *out = new(MachineImagesUpdateProviderOpenStack) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineImageUpdateProvider. @@ -226,6 +252,11 @@ func (in *MachineImageUpdateSource) DeepCopyInto(out *MachineImageUpdateSource) *out = new(OCI) **out = **in } + if in.Glance != nil { + in, out := &in.Glance, &out.Glance + *out = new(GlanceSource) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineImageUpdateSource. @@ -253,6 +284,21 @@ func (in *MachineImagesUpdateProviderIroncoreMetal) DeepCopy() *MachineImagesUpd return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MachineImagesUpdateProviderOpenStack) DeepCopyInto(out *MachineImagesUpdateProviderOpenStack) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineImagesUpdateProviderOpenStack. +func (in *MachineImagesUpdateProviderOpenStack) DeepCopy() *MachineImagesUpdateProviderOpenStack { + if in == nil { + return nil + } + out := new(MachineImagesUpdateProviderOpenStack) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ManagedCloudProfile) DeepCopyInto(out *ManagedCloudProfile) { *out = *in diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index 7ea4b9f..3abdd66 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -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 { @@ -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. @@ -88,6 +106,22 @@ 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 +} + func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.CloudProfileSpec) error { sourceImages, err := iu.Source.GetVersions(ctx) if err != nil { @@ -120,6 +154,9 @@ 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 + // Stamp expiration once on the transition to deprecated; preserve it thereafter. + image.Versions[idx].ExpirationDate = iu.resolveExpiration(sourceImage, image.Versions[idx].ExpirationDate) } 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 @@ -130,7 +167,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, }) diff --git a/cloudprofilesync/ossync/os_image_updater_test.go b/cloudprofilesync/ossync/os_image_updater_test.go index 53134e2..e85d238 100644 --- a/cloudprofilesync/ossync/os_image_updater_test.go +++ b/cloudprofilesync/ossync/os_image_updater_test.go @@ -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" ) @@ -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)) + }) + + 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)) + }) + + 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()) + }) + + 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()) + }) + }) }) diff --git a/cloudprofilesync/ossync/provider/openstack/provider.go b/cloudprofilesync/ossync/provider/openstack/provider.go new file mode 100644 index 0000000..189ca6d --- /dev/null +++ b/cloudprofilesync/ossync/provider/openstack/provider.go @@ -0,0 +1,73 @@ +package openstack + +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 + } + + 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 { + alreadyPresent := slices.ContainsFunc(entry.Regions, func(m openstackv1alpha1.RegionIDMapping) bool { + return m.Name == r.Region + }) + if !alreadyPresent { + entry.Regions = append(entry.Regions, openstackv1alpha1.RegionIDMapping{ + Name: r.Region, + ID: r.ID, + }) + } + } + } + + raw, err := json.Marshal(cfg) + if err != nil { + return err + } + cpSpec.ProviderConfig = &runtime.RawExtension{Raw: raw} + return nil +} diff --git a/cloudprofilesync/ossync/provider/openstack/provider_test.go b/cloudprofilesync/ossync/provider/openstack/provider_test.go new file mode 100644 index 0000000..1bcfb80 --- /dev/null +++ b/cloudprofilesync/ossync/provider/openstack/provider_test.go @@ -0,0 +1,226 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package openstack + +import ( + "encoding/json" + "testing" + + 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" +) + +const ( + imageName = "gardenlinux" + testVersion = "2150.8.0" + regionDE = "eu-de-1" + regionNL = "eu-nl-1" +) + +// Configure creates the image, version, and regions from an empty config. +func TestConfigureCreatesEntryFromEmpty(t *testing.T) { + p := &OpenStackProvider{ImageName: imageName} + spec := specWithConfig(t, nil) + + err := p.Configure(spec, []ossync.SourceImage{ + { + Version: testVersion, + Regions: []ossync.RegionImage{ + {Region: regionDE, ID: "uuid-de-1"}, + {Region: regionNL, ID: "uuid-nl-1"}, + }, + }, + }) + if err != nil { + t.Fatalf("Configure: %v", err) + } + + cfg := parseConfig(t, spec) + img := findImage(cfg, imageName) + if img == nil { + t.Fatalf("machineImages entry %q not created: %+v", imageName, cfg.MachineImages) + } + v := findVersion(img, testVersion) + if v == nil { + t.Fatalf("version %s not created: %+v", testVersion, img.Versions) + } + if len(v.Regions) != 2 { + t.Fatalf("got %d regions, want 2: %+v", len(v.Regions), v.Regions) + } +} + +// Configure merges into the existing image without dropping other versions. +func TestConfigureMergesIntoExistingImage(t *testing.T) { + p := &OpenStackProvider{ImageName: imageName} + spec := specWithConfig(t, &openstackv1alpha1.CloudProfileConfig{ + MachineImages: []openstackv1alpha1.MachineImages{ + { + Name: imageName, + Versions: []openstackv1alpha1.MachineImageVersion{ + { + Version: "2000.0.0", + Regions: []openstackv1alpha1.RegionIDMapping{{Name: regionDE, ID: "old-uuid"}}, + }, + }, + }, + }, + }) + + err := p.Configure(spec, []ossync.SourceImage{ + {Version: testVersion, Regions: []ossync.RegionImage{{Region: regionDE, ID: "new-uuid"}}}, + }) + if err != nil { + t.Fatalf("Configure: %v", err) + } + + cfg := parseConfig(t, spec) + if len(cfg.MachineImages) != 1 { + t.Fatalf("got %d machineImages, want 1 (no duplicate entry): %+v", len(cfg.MachineImages), cfg.MachineImages) + } + img := findImage(cfg, imageName) + if findVersion(img, "2000.0.0") == nil { + t.Error("pre-existing version 2000.0.0 was dropped") + } + if findVersion(img, testVersion) == nil { + t.Errorf("new version %s was not added", testVersion) + } +} + +// Configure does not duplicate an existing region or overwrite its ID. +func TestConfigureIsIdempotentOnRegions(t *testing.T) { + p := &OpenStackProvider{ImageName: imageName} + spec := specWithConfig(t, &openstackv1alpha1.CloudProfileConfig{ + MachineImages: []openstackv1alpha1.MachineImages{ + { + Name: imageName, + Versions: []openstackv1alpha1.MachineImageVersion{ + { + Version: testVersion, + Regions: []openstackv1alpha1.RegionIDMapping{{Name: regionDE, ID: "existing-uuid"}}, + }, + }, + }, + }, + }) + + // Re-apply the same region (with a different ID) plus a new one. + err := p.Configure(spec, []ossync.SourceImage{ + { + Version: testVersion, + Regions: []ossync.RegionImage{ + {Region: regionDE, ID: "would-be-new-uuid"}, + {Region: regionNL, ID: "uuid-nl-1"}, + }, + }, + }) + if err != nil { + t.Fatalf("Configure: %v", err) + } + + cfg := parseConfig(t, spec) + v := findVersion(findImage(cfg, imageName), testVersion) + if v == nil { + t.Fatalf("version %s missing", testVersion) + } + if len(v.Regions) != 2 { + t.Fatalf("got %d regions, want 2 (%s must not be duplicated): %+v", len(v.Regions), regionDE, v.Regions) + } + for _, r := range v.Regions { + if r.Name == regionDE && r.ID != "existing-uuid" { + t.Errorf("%s ID = %q, want existing-uuid (existing region must not be overwritten)", regionDE, r.ID) + } + } +} + +// Configure only touches the image matching p.ImageName. +func TestConfigureLeavesOtherImagesUntouched(t *testing.T) { + p := &OpenStackProvider{ImageName: imageName} + spec := specWithConfig(t, &openstackv1alpha1.CloudProfileConfig{ + MachineImages: []openstackv1alpha1.MachineImages{ + { + Name: "coreos", + Versions: []openstackv1alpha1.MachineImageVersion{{Version: "1.0.0"}}, + }, + }, + }) + + err := p.Configure(spec, []ossync.SourceImage{ + {Version: testVersion, Regions: []ossync.RegionImage{{Region: regionDE, ID: "uuid"}}}, + }) + if err != nil { + t.Fatalf("Configure: %v", err) + } + + cfg := parseConfig(t, spec) + if len(cfg.MachineImages) != 2 { + t.Fatalf("got %d machineImages, want 2 (coreos + gardenlinux): %+v", len(cfg.MachineImages), cfg.MachineImages) + } + coreos := findImage(cfg, "coreos") + if coreos == nil || findVersion(coreos, "1.0.0") == nil { + t.Error("unrelated image coreos was modified or dropped") + } +} + +// Configure returns an error for a malformed ProviderConfig. +func TestConfigureReturnsErrorOnInvalidConfig(t *testing.T) { + p := &OpenStackProvider{ImageName: imageName} + spec := &gardencorev1beta1.CloudProfileSpec{ + ProviderConfig: &runtime.RawExtension{Raw: []byte("{not json")}, + } + + if err := p.Configure(spec, nil); err == nil { + t.Fatal("Configure returned nil error for malformed ProviderConfig, want an error") + } +} + +// specWithConfig builds a CloudProfileSpec from cfg (nil yields no ProviderConfig). +func specWithConfig(t *testing.T, cfg *openstackv1alpha1.CloudProfileConfig) *gardencorev1beta1.CloudProfileSpec { + t.Helper() + spec := &gardencorev1beta1.CloudProfileSpec{} + if cfg == nil { + return spec + } + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + spec.ProviderConfig = &runtime.RawExtension{Raw: raw} + return spec +} + +// parseConfig unmarshals the ProviderConfig written back onto the spec. +func parseConfig(t *testing.T, spec *gardencorev1beta1.CloudProfileSpec) openstackv1alpha1.CloudProfileConfig { + t.Helper() + if spec.ProviderConfig == nil { + t.Fatal("ProviderConfig is nil, want it to be set") + } + var cfg openstackv1alpha1.CloudProfileConfig + if err := json.Unmarshal(spec.ProviderConfig.Raw, &cfg); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + return cfg +} + +// findImage returns the machineImages entry with the given name, or nil. +func findImage(cfg openstackv1alpha1.CloudProfileConfig, name string) *openstackv1alpha1.MachineImages { + for i := range cfg.MachineImages { + if cfg.MachineImages[i].Name == name { + return &cfg.MachineImages[i] + } + } + return nil +} + +// findVersion returns the version entry with the given version string, or nil. +func findVersion(img *openstackv1alpha1.MachineImages, version string) *openstackv1alpha1.MachineImageVersion { + for i := range img.Versions { + if img.Versions[i].Version == version { + return &img.Versions[i] + } + } + return nil +} diff --git a/cloudprofilesync/ossync/source/glance/os_source.go b/cloudprofilesync/ossync/source/glance/os_source.go new file mode 100644 index 0000000..8e38a62 --- /dev/null +++ b/cloudprofilesync/ossync/source/glance/os_source.go @@ -0,0 +1,291 @@ +package glance + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/blang/semver/v4" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" + gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + "github.com/go-logr/logr" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" + "golang.org/x/sync/semaphore" + _ "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + defaultGlanceNamePrefix = "gardenlinux-openstack-gardener_prod-amd64-" + glanceRequestTimeout = 60 * time.Second + DefaultGlanceKeepLatest = 3 + // defaultGlanceParallel is the region query concurrency used when GlanceParams.Parallel + // is not set. + defaultGlanceParallel = 8 + usiVariantMarker = "_usi" +) + +type Result[T any] struct { + value T + err error +} + +// GlanceParams configures discovery of public gardenlinux images from OpenStack Glance. +type GlanceParams struct { + // AuthURLFormat is the Keystone endpoint format string with a single "%s" verb for + // the region, e.g. "https://identity-3.%s.cloud.sap/v3". + AuthURLFormat string + + Regions []string + + // NamePrefix selects gardenlinux images by exact prefix. Empty means the default. + NamePrefix string + + // KeepLatest limits the result to the newest N versions. + KeepLatest int + + // Parallel bounds how many regions are queried concurrently. + Parallel int64 + + // ProjectName / ProjectDomainName scope the token. + ProjectName string + ProjectDomainName string + + // Username / UserDomainName / Password authenticate the user. + Username string + UserDomainName string + Password string +} + +// Glance discovers public gardenlinux images from OpenStack Glance across regions. +type Glance struct { + log logr.Logger + params GlanceParams + namePrefix string + keepLatest 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) +} + +// NewGlance constructs a Glance source using the real gophercloud client. +func NewGlance(params GlanceParams, log logr.Logger) (*Glance, error) { + if params.AuthURLFormat == "" { + return nil, errors.New("glance: authURLFormat is required") + } + if len(params.Regions) == 0 { + return nil, errors.New("glance: at least one region is required") + } + + prefix := params.NamePrefix + if prefix == "" { + prefix = defaultGlanceNamePrefix + } + + keepLatest := params.KeepLatest + if keepLatest == 0 { + keepLatest = DefaultGlanceKeepLatest + } + + parallel := params.Parallel + if parallel <= 0 { + parallel = defaultGlanceParallel + } + + return &Glance{ + log: log, + params: params, + namePrefix: prefix, + keepLatest: keepLatest, + sema: semaphore.NewWeighted(parallel), + authenticate: defaultAuthenticate, + listImages: defaultListImages, + }, nil +} + +func defaultAuthenticate(ctx context.Context, authURL string, opts gophercloud.AuthOptions) (*gophercloud.ProviderClient, error) { + opts.IdentityEndpoint = authURL + provider, err := openstack.NewClient(authURL) + if err != nil { + return nil, err + } + + provider.HTTPClient = http.Client{Timeout: glanceRequestTimeout} + if err := openstack.Authenticate(ctx, provider, opts); err != nil { + return nil, err + } + return provider, nil +} + +func defaultListImages(ctx context.Context, provider *gophercloud.ProviderClient, region string) ([]images.Image, error) { + client, err := openstack.NewImageV2(provider, gophercloud.EndpointOpts{Region: region}) + if err != nil { + return nil, err + } + + pages, err := images.List(client, images.ListOpts{ + Visibility: images.ImageVisibilityPublic, + Limit: 1000, + }).AllPages(ctx) + if err != nil { + return nil, err + } + return images.ExtractImages(pages) +} + +func (g *Glance) authOptions() gophercloud.AuthOptions { + return gophercloud.AuthOptions{ + Username: g.params.Username, + Password: g.params.Password, + DomainName: g.params.UserDomainName, + AllowReauth: true, + Scope: &gophercloud.AuthScope{ + ProjectName: g.params.ProjectName, + DomainName: g.params.ProjectDomainName, + }, + } +} + +func (g *Glance) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) { + out := make(chan Result[[]ossync.SourceImage]) + for _, region := range g.params.Regions { + go func() { + if err := g.sema.Acquire(ctx, 1); err != nil { + out <- Result[[]ossync.SourceImage]{err: err} + return + } + defer g.sema.Release(1) + found, err := g.discoverRegion(ctx, region) + out <- Result[[]ossync.SourceImage]{value: found, err: err} + }() + } + + imagesByVersion := map[string]*ossync.SourceImage{} + var skipped []error + for range g.params.Regions { + result := <-out + if result.err != nil { + if errors.Is(result.err, context.Canceled) || errors.Is(result.err, context.DeadlineExceeded) { + return nil, result.err + } + skipped = append(skipped, result.err) + continue + } + for _, img := range result.value { + entry, exists := imagesByVersion[img.Version] + if !exists { + entry = &ossync.SourceImage{ + Version: img.Version, + Architectures: img.Architectures, + } + imagesByVersion[img.Version] = entry + } + entry.Regions = append(entry.Regions, img.Regions...) + } + } + + if len(skipped) > 0 { + g.log.V(1).Info("skipped regions with errors", "count", len(skipped), "errors", errors.Join(skipped...)) + } + if len(imagesByVersion) == 0 && len(skipped) == len(g.params.Regions) { + return nil, fmt.Errorf("all %d regions failed: %w", len(g.params.Regions), errors.Join(skipped...)) + } + + versions := make([]ossync.SourceImage, 0, len(imagesByVersion)) + for _, img := range imagesByVersion { + versions = append(versions, *img) + } + + 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.keepLatest] + } + + supported := gardenerv1beta1.ClassificationSupported + for i := range versions { + versions[i].Classification = &supported + } + if len(versions) > 0 { + deprecated := gardenerv1beta1.ClassificationDeprecated + versions[len(versions)-1].Classification = &deprecated + } + + return versions, nil +} + +// discoverRegion returns a region's public images. +func (g *Glance) discoverRegion(ctx context.Context, region string) ([]ossync.SourceImage, error) { + authURL := fmt.Sprintf(g.params.AuthURLFormat, region) + provider, err := g.authenticate(ctx, authURL, g.authOptions()) + if err != nil { + return nil, fmt.Errorf("region %s: authenticate: %w", region, err) + } + + imgs, err := g.listImages(ctx, provider, region) + if err != nil { + return nil, fmt.Errorf("region %s: list images: %w", region, err) + } + + var found []ossync.SourceImage + for _, img := range imgs { + version, ok := g.parseVersion(img.Name) + if !ok { + continue + } + found = append(found, ossync.SourceImage{ + Version: version, + Architectures: []string{"amd64"}, + Regions: []ossync.RegionImage{{Region: region, ID: img.ID}}, + }) + } + return found, nil +} + +// compareSemverDesc orders two versions newest-first. Unparsable versions sort last. +func compareSemverDesc(a, b string) int { + av, aerr := semver.ParseTolerant(a) + bv, berr := semver.ParseTolerant(b) + switch { + case aerr != nil && berr != nil: + return strings.Compare(a, b) + case aerr != nil: + return 1 + case berr != nil: + return -1 + } + return bv.Compare(av) +} + +// parseVersion extracts the semver version from a matching image name. +func (g *Glance) parseVersion(name string) (string, bool) { + if strings.Contains(name, usiVariantMarker) { + g.log.V(1).Info("skipping usi image variant", "name", name) + return "", false + } + + rest, ok := strings.CutPrefix(name, g.namePrefix) + if !ok { + return "", false + } + + // rest is "-"; the hash is the final dash-separated segment. + idx := strings.LastIndex(rest, "-") + if idx <= 0 { + return "", false + } + + raw := rest[:idx] + parsed, err := semver.ParseTolerant(raw) + if err != nil { + g.log.V(1).Info("skipping image with unparsable version", "name", name, "raw", raw) + return "", false + } + return parsed.String(), true +} diff --git a/cloudprofilesync/ossync/source/glance/os_source_test.go b/cloudprofilesync/ossync/source/glance/os_source_test.go new file mode 100644 index 0000000..b3be0e9 --- /dev/null +++ b/cloudprofilesync/ossync/source/glance/os_source_test.go @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package glance + +import ( + "context" + "testing" + + "github.com/go-logr/logr" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" +) + +const ( + testRegion = "eu-de-1" + testVersion = "2150.8.0" + // stdImage and usiImage are the standard and _usi variants of the same version. + stdImage = "gardenlinux-openstack-gardener_prod-amd64-2150.8.0-40f62d58" + usiImage = "gardenlinux-openstack-gardener_prod_usi-amd64-2150.8.0-40f62d58" +) + +func TestParseVersionSkipsUsiVariant(t *testing.T) { + g := newTestGlance(t, GlanceParams{Regions: []string{testRegion}}, nil) + + tests := []struct { + name string + imgName string + wantVer string + wantKeep bool + }{ + { + name: "standard image is parsed", + imgName: stdImage, + wantVer: testVersion, + wantKeep: true, + }, + { + name: "usi variant is skipped", + imgName: usiImage, + wantKeep: false, + }, + { + name: "usi variant with two-part version is skipped", + imgName: "gardenlinux-openstack-gardener_prod_usi-amd64-1877.13-81e502e7", + wantKeep: false, + }, + { + name: "unrelated image is skipped", + imgName: "some-other-image-1.2.3-deadbeef", + wantKeep: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, keep := g.parseVersion(tc.imgName) + if keep != tc.wantKeep { + t.Fatalf("parseVersion(%q) keep = %v, want %v", tc.imgName, keep, tc.wantKeep) + } + if keep && got != tc.wantVer { + t.Errorf("parseVersion(%q) = %q, want %q", tc.imgName, got, tc.wantVer) + } + }) + } +} + +// When a version has both a standard and a usi image, only the standard one survives. +func TestGetVersionsUsiDoesNotCollide(t *testing.T) { + imgs := []images.Image{ + {ID: "standard-uuid", Name: stdImage}, + {ID: "usi-uuid", Name: usiImage}, + } + 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) != 1 { + t.Fatalf("got %d versions, want 1 (usi must not create a second entry): %+v", len(versions), versions) + } + v := versions[0] + if v.Version != testVersion { + t.Errorf("version = %q, want %s", v.Version, testVersion) + } + if len(v.Regions) != 1 { + t.Fatalf("got %d region entries, want 1 (usi must not duplicate the region)", len(v.Regions)) + } + if v.Regions[0].ID != "standard-uuid" { + t.Errorf("region ID = %q, want standard-uuid (usi UUID must not win)", v.Regions[0].ID) + } +} + +// 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) + } + g.authenticate = func(ctx context.Context, authURL string, opts gophercloud.AuthOptions) (*gophercloud.ProviderClient, error) { + return &gophercloud.ProviderClient{}, nil + } + g.listImages = func(ctx context.Context, provider *gophercloud.ProviderClient, region string) ([]images.Image, error) { + return imgsByRegion[region], nil + } + return g +} diff --git a/controllers/cloud_profile.go b/controllers/cloud_profile.go index c957f0c..2e5a1e3 100644 --- a/controllers/cloud_profile.go +++ b/controllers/cloud_profile.go @@ -22,6 +22,8 @@ import ( "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ocirepo" "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/provider/ironcore" + osprovider "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/provider/openstack" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/source/glance" "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/source/oci" ) @@ -106,6 +108,28 @@ func (r *Reconciler) updateMachineImages(ctx context.Context, log logr.Logger, u } source = src + case update.Source.Glance != nil: + password, err := r.getCredential(ctx, update.Source.Glance.PasswordSecret) + if err != nil { + return err + } + src, err := glance.NewGlance(glance.GlanceParams{ + AuthURLFormat: update.Source.Glance.AuthURLFormat, + Regions: update.Source.Glance.Regions, + NamePrefix: update.Source.Glance.NamePrefix, + KeepLatest: update.Source.Glance.KeepLatest, + Parallel: update.Source.Glance.Parallel, + ProjectName: update.Source.Glance.ProjectName, + ProjectDomainName: update.Source.Glance.ProjectDomainName, + Username: update.Source.Glance.Username, + UserDomainName: update.Source.Glance.UserDomainName, + Password: string(password), + }, log) + if err != nil { + return fmt.Errorf("failed to initialize Glance source: %w", err) + } + source = src + default: return errors.New("no machine images source configured") } @@ -119,6 +143,10 @@ func (r *Reconciler) updateMachineImages(ctx context.Context, log logr.Logger, u ImageName: update.ImageName, EnableCapabilities: r.EnableCapabilities, } + case update.Provider.OpenStack != nil: + provider = &osprovider.OpenStackProvider{ + ImageName: update.ImageName, + } default: return errors.New("no known provider configured") } diff --git a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml index a067c32..b1aadc1 100644 --- a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml +++ b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml @@ -774,11 +774,83 @@ spec: - registry - repository type: object + openStack: + description: OpenStack contains configuration to update + provider.machineImages for OpenStack CloudProfiles. + type: object type: object source: description: Source contains configuration for a source for machine images. properties: + glance: + description: Glance contains configuration for an OpenStack + Glance source. + properties: + authURLFormat: + description: AuthURLFormat is the Keystone endpoint + format string with a single "%s" for the region. + type: string + keepLatest: + description: KeepLatest limits results to the newest + N versions. + type: integer + namePrefix: + description: NamePrefix selects images by name prefix. + Empty means the default. + type: string + parallel: + description: Parallel bounds how many regions are queried + concurrently. + format: int64 + type: integer + passwordSecret: + description: PasswordSecret is a reference to a secret + containing the OpenStack password. + properties: + key: + description: Key within the Secret to use for required + data. + type: string + name: + description: Name of a Secret. + type: string + namespace: + description: Namespace of a Secret. + type: string + required: + - key + - name + - namespace + type: object + projectDomainName: + description: ProjectDomainName scopes the token domain. + type: string + projectName: + description: ProjectName scopes the token. + type: string + regions: + description: Regions is the list of OpenStack regions + to query. + items: + type: string + type: array + userDomainName: + description: UserDomainName is the domain of the authenticating + user. + type: string + username: + description: Username for authentication. + type: string + required: + - authURLFormat + - passwordSecret + - projectDomainName + - projectName + - regions + - userDomainName + - username + type: object oci: description: OCI contains configuration for an OCI source. properties: diff --git a/go.mod b/go.mod index d101a65..78c2e03 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/cobaltcore-dev/cloud-profile-sync -go 1.26.0 +go 1.26.2 require ( github.com/blang/semver/v4 v4.0.0 github.com/distribution/distribution/v3 v3.1.1 - github.com/gardener/gardener/pkg/apis v1.144.0 + github.com/gardener/gardener/pkg/apis v1.145.0 github.com/go-logr/logr v1.4.3 github.com/ironcore-dev/gardener-extension-provider-ironcore-metal v0.1.1-0.20260624151759-9166baa81e86 github.com/onsi/ginkgo/v2 v2.32.0 @@ -42,6 +42,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/gardener/gardener-extension-provider-openstack v1.57.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect @@ -63,6 +64,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gophercloud/gophercloud/v2 v2.13.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect @@ -70,13 +72,13 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect - github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_golang v1.23.3-0.20260602051030-3537b20ac86b // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.68.1 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect @@ -120,7 +122,7 @@ require ( golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.46.0 // indirect + golang.org/x/tools v0.47.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260608224507-4308a22a1bab // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab // indirect diff --git a/go.sum b/go.sum index 777c174..69a40d0 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,12 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gardener/gardener-extension-provider-openstack v1.57.0 h1:QQXVSOC+9c94r21BbcLW1gQfxJ95zmo0e18Aoy32BhQ= +github.com/gardener/gardener-extension-provider-openstack v1.57.0/go.mod h1:N397cSwvKwFl/as2g39eeXyFj3gON83KQb7TliQX9nI= github.com/gardener/gardener/pkg/apis v1.144.0 h1:xlnGMbliM5TjlK5o2oCxzyFSmtxgGerP4SDwEXdHL2E= github.com/gardener/gardener/pkg/apis v1.144.0/go.mod h1:we6hJ8r80nL1rkXzVnOQwey4q77pQXHN3pvoBgeak8g= +github.com/gardener/gardener/pkg/apis v1.145.0 h1:E9mnDYOKOoOEJnpGCPpFuS0OX32uoOnv27a03b/nlP0= +github.com/gardener/gardener/pkg/apis v1.145.0/go.mod h1:LsjZw5/3awWSMDnKg4bgftM1kW9Dkeor/ged5DNHVPI= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -129,6 +133,8 @@ github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVp github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gophercloud/gophercloud/v2 v2.13.0 h1:yEyJG+kABd8x2ttTqLsomihU6Kg2YheJSZhvP/QSx+8= +github.com/gophercloud/gophercloud/v2 v2.13.0/go.mod h1:KZRLVs6gcoy/pEFdkZqFjdYqnS0emMHv66UqdM5lMjU= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -152,6 +158,8 @@ github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -195,6 +203,8 @@ github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5Fsn github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_golang v1.23.3-0.20260602051030-3537b20ac86b h1:QNV54DNcRqdeECNdEXiOqTmI75w2rlZtOq5rt8RKhVo= +github.com/prometheus/client_golang v1.23.3-0.20260602051030-3537b20ac86b/go.mod h1:kPaff19KETV3GKIZJehgPmlA2Di3jNeWdgKA9RpObuU= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -344,6 +354,8 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= From 7ef8a495fc6406f07e79ade2530e6696e0be526c Mon Sep 17 00:00:00 2001 From: C5421281 Date: Tue, 11 Aug 2026 09:44:40 +0200 Subject: [PATCH 02/23] feat: add OpenStack Glance source and provider for gardenlinux images Add machine-image discovery for OpenStack CloudProfiles: - Glance source: discover public gardenlinux images across regions, parse versions from image names, keep the newest N (default 3), skip _usi variants. - OpenStackProvider: write per-region image UUIDs into the gardener-extension-provider-openstack providerConfig. - Lifecycle: mark the oldest kept version deprecated and stamp its expirationDate once on the transition, preserving it thereafter (ImageUpdater.resolveExpiration). - Wire GlanceSource into the ManagedCloudProfile API and controller; regenerate CRD and deepcopy. - Unit tests for expiration, usi skipping, and provider config. --- api/v1alpha1/managedcloudprofile.go | 35 +++ api/v1alpha1/zz_generated.deepcopy.go | 46 +++ cloudprofilesync/ossync/os_image_updater.go | 41 ++- .../ossync/os_image_updater_test.go | 66 ++++ .../ossync/provider/openstack/provider.go | 73 +++++ .../provider/openstack/provider_test.go | 226 ++++++++++++++ .../ossync/source/glance/os_source.go | 291 ++++++++++++++++++ .../ossync/source/glance/os_source_test.go | 113 +++++++ controllers/cloud_profile.go | 28 ++ ...c.cobaltcore.dev_managedcloudprofiles.yaml | 72 +++++ go.mod | 12 +- go.sum | 12 + 12 files changed, 1009 insertions(+), 6 deletions(-) create mode 100644 cloudprofilesync/ossync/provider/openstack/provider.go create mode 100644 cloudprofilesync/ossync/provider/openstack/provider_test.go create mode 100644 cloudprofilesync/ossync/source/glance/os_source.go create mode 100644 cloudprofilesync/ossync/source/glance/os_source_test.go diff --git a/api/v1alpha1/managedcloudprofile.go b/api/v1alpha1/managedcloudprofile.go index 0ab9c05..553577c 100644 --- a/api/v1alpha1/managedcloudprofile.go +++ b/api/v1alpha1/managedcloudprofile.go @@ -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"` + // 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"` + // 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 { @@ -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"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index aaa62fc..7864a41 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -118,6 +118,27 @@ func (in *GithubAppAuth) DeepCopy() *GithubAppAuth { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GlanceSource) DeepCopyInto(out *GlanceSource) { + *out = *in + if in.Regions != nil { + in, out := &in.Regions, &out.Regions + *out = make([]string, len(*in)) + copy(*out, *in) + } + out.PasswordSecret = in.PasswordSecret +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GlanceSource. +func (in *GlanceSource) DeepCopy() *GlanceSource { + if in == nil { + return nil + } + out := new(GlanceSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KubernetesVersionSourceGithub) DeepCopyInto(out *KubernetesVersionSourceGithub) { *out = *in @@ -206,6 +227,11 @@ func (in *MachineImageUpdateProvider) DeepCopyInto(out *MachineImageUpdateProvid *out = new(MachineImagesUpdateProviderIroncoreMetal) **out = **in } + if in.OpenStack != nil { + in, out := &in.OpenStack, &out.OpenStack + *out = new(MachineImagesUpdateProviderOpenStack) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineImageUpdateProvider. @@ -226,6 +252,11 @@ func (in *MachineImageUpdateSource) DeepCopyInto(out *MachineImageUpdateSource) *out = new(OCI) **out = **in } + if in.Glance != nil { + in, out := &in.Glance, &out.Glance + *out = new(GlanceSource) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineImageUpdateSource. @@ -253,6 +284,21 @@ func (in *MachineImagesUpdateProviderIroncoreMetal) DeepCopy() *MachineImagesUpd return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MachineImagesUpdateProviderOpenStack) DeepCopyInto(out *MachineImagesUpdateProviderOpenStack) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineImagesUpdateProviderOpenStack. +func (in *MachineImagesUpdateProviderOpenStack) DeepCopy() *MachineImagesUpdateProviderOpenStack { + if in == nil { + return nil + } + out := new(MachineImagesUpdateProviderOpenStack) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ManagedCloudProfile) DeepCopyInto(out *ManagedCloudProfile) { *out = *in diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index 7ea4b9f..3abdd66 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -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 { @@ -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. @@ -88,6 +106,22 @@ 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 +} + func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.CloudProfileSpec) error { sourceImages, err := iu.Source.GetVersions(ctx) if err != nil { @@ -120,6 +154,9 @@ 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 + // Stamp expiration once on the transition to deprecated; preserve it thereafter. + image.Versions[idx].ExpirationDate = iu.resolveExpiration(sourceImage, image.Versions[idx].ExpirationDate) } 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 @@ -130,7 +167,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, }) diff --git a/cloudprofilesync/ossync/os_image_updater_test.go b/cloudprofilesync/ossync/os_image_updater_test.go index 53134e2..e85d238 100644 --- a/cloudprofilesync/ossync/os_image_updater_test.go +++ b/cloudprofilesync/ossync/os_image_updater_test.go @@ -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" ) @@ -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)) + }) + + 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)) + }) + + 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()) + }) + + 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()) + }) + }) }) diff --git a/cloudprofilesync/ossync/provider/openstack/provider.go b/cloudprofilesync/ossync/provider/openstack/provider.go new file mode 100644 index 0000000..189ca6d --- /dev/null +++ b/cloudprofilesync/ossync/provider/openstack/provider.go @@ -0,0 +1,73 @@ +package openstack + +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 + } + + 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 { + alreadyPresent := slices.ContainsFunc(entry.Regions, func(m openstackv1alpha1.RegionIDMapping) bool { + return m.Name == r.Region + }) + if !alreadyPresent { + entry.Regions = append(entry.Regions, openstackv1alpha1.RegionIDMapping{ + Name: r.Region, + ID: r.ID, + }) + } + } + } + + raw, err := json.Marshal(cfg) + if err != nil { + return err + } + cpSpec.ProviderConfig = &runtime.RawExtension{Raw: raw} + return nil +} diff --git a/cloudprofilesync/ossync/provider/openstack/provider_test.go b/cloudprofilesync/ossync/provider/openstack/provider_test.go new file mode 100644 index 0000000..1bcfb80 --- /dev/null +++ b/cloudprofilesync/ossync/provider/openstack/provider_test.go @@ -0,0 +1,226 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package openstack + +import ( + "encoding/json" + "testing" + + 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" +) + +const ( + imageName = "gardenlinux" + testVersion = "2150.8.0" + regionDE = "eu-de-1" + regionNL = "eu-nl-1" +) + +// Configure creates the image, version, and regions from an empty config. +func TestConfigureCreatesEntryFromEmpty(t *testing.T) { + p := &OpenStackProvider{ImageName: imageName} + spec := specWithConfig(t, nil) + + err := p.Configure(spec, []ossync.SourceImage{ + { + Version: testVersion, + Regions: []ossync.RegionImage{ + {Region: regionDE, ID: "uuid-de-1"}, + {Region: regionNL, ID: "uuid-nl-1"}, + }, + }, + }) + if err != nil { + t.Fatalf("Configure: %v", err) + } + + cfg := parseConfig(t, spec) + img := findImage(cfg, imageName) + if img == nil { + t.Fatalf("machineImages entry %q not created: %+v", imageName, cfg.MachineImages) + } + v := findVersion(img, testVersion) + if v == nil { + t.Fatalf("version %s not created: %+v", testVersion, img.Versions) + } + if len(v.Regions) != 2 { + t.Fatalf("got %d regions, want 2: %+v", len(v.Regions), v.Regions) + } +} + +// Configure merges into the existing image without dropping other versions. +func TestConfigureMergesIntoExistingImage(t *testing.T) { + p := &OpenStackProvider{ImageName: imageName} + spec := specWithConfig(t, &openstackv1alpha1.CloudProfileConfig{ + MachineImages: []openstackv1alpha1.MachineImages{ + { + Name: imageName, + Versions: []openstackv1alpha1.MachineImageVersion{ + { + Version: "2000.0.0", + Regions: []openstackv1alpha1.RegionIDMapping{{Name: regionDE, ID: "old-uuid"}}, + }, + }, + }, + }, + }) + + err := p.Configure(spec, []ossync.SourceImage{ + {Version: testVersion, Regions: []ossync.RegionImage{{Region: regionDE, ID: "new-uuid"}}}, + }) + if err != nil { + t.Fatalf("Configure: %v", err) + } + + cfg := parseConfig(t, spec) + if len(cfg.MachineImages) != 1 { + t.Fatalf("got %d machineImages, want 1 (no duplicate entry): %+v", len(cfg.MachineImages), cfg.MachineImages) + } + img := findImage(cfg, imageName) + if findVersion(img, "2000.0.0") == nil { + t.Error("pre-existing version 2000.0.0 was dropped") + } + if findVersion(img, testVersion) == nil { + t.Errorf("new version %s was not added", testVersion) + } +} + +// Configure does not duplicate an existing region or overwrite its ID. +func TestConfigureIsIdempotentOnRegions(t *testing.T) { + p := &OpenStackProvider{ImageName: imageName} + spec := specWithConfig(t, &openstackv1alpha1.CloudProfileConfig{ + MachineImages: []openstackv1alpha1.MachineImages{ + { + Name: imageName, + Versions: []openstackv1alpha1.MachineImageVersion{ + { + Version: testVersion, + Regions: []openstackv1alpha1.RegionIDMapping{{Name: regionDE, ID: "existing-uuid"}}, + }, + }, + }, + }, + }) + + // Re-apply the same region (with a different ID) plus a new one. + err := p.Configure(spec, []ossync.SourceImage{ + { + Version: testVersion, + Regions: []ossync.RegionImage{ + {Region: regionDE, ID: "would-be-new-uuid"}, + {Region: regionNL, ID: "uuid-nl-1"}, + }, + }, + }) + if err != nil { + t.Fatalf("Configure: %v", err) + } + + cfg := parseConfig(t, spec) + v := findVersion(findImage(cfg, imageName), testVersion) + if v == nil { + t.Fatalf("version %s missing", testVersion) + } + if len(v.Regions) != 2 { + t.Fatalf("got %d regions, want 2 (%s must not be duplicated): %+v", len(v.Regions), regionDE, v.Regions) + } + for _, r := range v.Regions { + if r.Name == regionDE && r.ID != "existing-uuid" { + t.Errorf("%s ID = %q, want existing-uuid (existing region must not be overwritten)", regionDE, r.ID) + } + } +} + +// Configure only touches the image matching p.ImageName. +func TestConfigureLeavesOtherImagesUntouched(t *testing.T) { + p := &OpenStackProvider{ImageName: imageName} + spec := specWithConfig(t, &openstackv1alpha1.CloudProfileConfig{ + MachineImages: []openstackv1alpha1.MachineImages{ + { + Name: "coreos", + Versions: []openstackv1alpha1.MachineImageVersion{{Version: "1.0.0"}}, + }, + }, + }) + + err := p.Configure(spec, []ossync.SourceImage{ + {Version: testVersion, Regions: []ossync.RegionImage{{Region: regionDE, ID: "uuid"}}}, + }) + if err != nil { + t.Fatalf("Configure: %v", err) + } + + cfg := parseConfig(t, spec) + if len(cfg.MachineImages) != 2 { + t.Fatalf("got %d machineImages, want 2 (coreos + gardenlinux): %+v", len(cfg.MachineImages), cfg.MachineImages) + } + coreos := findImage(cfg, "coreos") + if coreos == nil || findVersion(coreos, "1.0.0") == nil { + t.Error("unrelated image coreos was modified or dropped") + } +} + +// Configure returns an error for a malformed ProviderConfig. +func TestConfigureReturnsErrorOnInvalidConfig(t *testing.T) { + p := &OpenStackProvider{ImageName: imageName} + spec := &gardencorev1beta1.CloudProfileSpec{ + ProviderConfig: &runtime.RawExtension{Raw: []byte("{not json")}, + } + + if err := p.Configure(spec, nil); err == nil { + t.Fatal("Configure returned nil error for malformed ProviderConfig, want an error") + } +} + +// specWithConfig builds a CloudProfileSpec from cfg (nil yields no ProviderConfig). +func specWithConfig(t *testing.T, cfg *openstackv1alpha1.CloudProfileConfig) *gardencorev1beta1.CloudProfileSpec { + t.Helper() + spec := &gardencorev1beta1.CloudProfileSpec{} + if cfg == nil { + return spec + } + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + spec.ProviderConfig = &runtime.RawExtension{Raw: raw} + return spec +} + +// parseConfig unmarshals the ProviderConfig written back onto the spec. +func parseConfig(t *testing.T, spec *gardencorev1beta1.CloudProfileSpec) openstackv1alpha1.CloudProfileConfig { + t.Helper() + if spec.ProviderConfig == nil { + t.Fatal("ProviderConfig is nil, want it to be set") + } + var cfg openstackv1alpha1.CloudProfileConfig + if err := json.Unmarshal(spec.ProviderConfig.Raw, &cfg); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + return cfg +} + +// findImage returns the machineImages entry with the given name, or nil. +func findImage(cfg openstackv1alpha1.CloudProfileConfig, name string) *openstackv1alpha1.MachineImages { + for i := range cfg.MachineImages { + if cfg.MachineImages[i].Name == name { + return &cfg.MachineImages[i] + } + } + return nil +} + +// findVersion returns the version entry with the given version string, or nil. +func findVersion(img *openstackv1alpha1.MachineImages, version string) *openstackv1alpha1.MachineImageVersion { + for i := range img.Versions { + if img.Versions[i].Version == version { + return &img.Versions[i] + } + } + return nil +} diff --git a/cloudprofilesync/ossync/source/glance/os_source.go b/cloudprofilesync/ossync/source/glance/os_source.go new file mode 100644 index 0000000..8e38a62 --- /dev/null +++ b/cloudprofilesync/ossync/source/glance/os_source.go @@ -0,0 +1,291 @@ +package glance + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/blang/semver/v4" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" + gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + "github.com/go-logr/logr" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" + "golang.org/x/sync/semaphore" + _ "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + defaultGlanceNamePrefix = "gardenlinux-openstack-gardener_prod-amd64-" + glanceRequestTimeout = 60 * time.Second + DefaultGlanceKeepLatest = 3 + // defaultGlanceParallel is the region query concurrency used when GlanceParams.Parallel + // is not set. + defaultGlanceParallel = 8 + usiVariantMarker = "_usi" +) + +type Result[T any] struct { + value T + err error +} + +// GlanceParams configures discovery of public gardenlinux images from OpenStack Glance. +type GlanceParams struct { + // AuthURLFormat is the Keystone endpoint format string with a single "%s" verb for + // the region, e.g. "https://identity-3.%s.cloud.sap/v3". + AuthURLFormat string + + Regions []string + + // NamePrefix selects gardenlinux images by exact prefix. Empty means the default. + NamePrefix string + + // KeepLatest limits the result to the newest N versions. + KeepLatest int + + // Parallel bounds how many regions are queried concurrently. + Parallel int64 + + // ProjectName / ProjectDomainName scope the token. + ProjectName string + ProjectDomainName string + + // Username / UserDomainName / Password authenticate the user. + Username string + UserDomainName string + Password string +} + +// Glance discovers public gardenlinux images from OpenStack Glance across regions. +type Glance struct { + log logr.Logger + params GlanceParams + namePrefix string + keepLatest 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) +} + +// NewGlance constructs a Glance source using the real gophercloud client. +func NewGlance(params GlanceParams, log logr.Logger) (*Glance, error) { + if params.AuthURLFormat == "" { + return nil, errors.New("glance: authURLFormat is required") + } + if len(params.Regions) == 0 { + return nil, errors.New("glance: at least one region is required") + } + + prefix := params.NamePrefix + if prefix == "" { + prefix = defaultGlanceNamePrefix + } + + keepLatest := params.KeepLatest + if keepLatest == 0 { + keepLatest = DefaultGlanceKeepLatest + } + + parallel := params.Parallel + if parallel <= 0 { + parallel = defaultGlanceParallel + } + + return &Glance{ + log: log, + params: params, + namePrefix: prefix, + keepLatest: keepLatest, + sema: semaphore.NewWeighted(parallel), + authenticate: defaultAuthenticate, + listImages: defaultListImages, + }, nil +} + +func defaultAuthenticate(ctx context.Context, authURL string, opts gophercloud.AuthOptions) (*gophercloud.ProviderClient, error) { + opts.IdentityEndpoint = authURL + provider, err := openstack.NewClient(authURL) + if err != nil { + return nil, err + } + + provider.HTTPClient = http.Client{Timeout: glanceRequestTimeout} + if err := openstack.Authenticate(ctx, provider, opts); err != nil { + return nil, err + } + return provider, nil +} + +func defaultListImages(ctx context.Context, provider *gophercloud.ProviderClient, region string) ([]images.Image, error) { + client, err := openstack.NewImageV2(provider, gophercloud.EndpointOpts{Region: region}) + if err != nil { + return nil, err + } + + pages, err := images.List(client, images.ListOpts{ + Visibility: images.ImageVisibilityPublic, + Limit: 1000, + }).AllPages(ctx) + if err != nil { + return nil, err + } + return images.ExtractImages(pages) +} + +func (g *Glance) authOptions() gophercloud.AuthOptions { + return gophercloud.AuthOptions{ + Username: g.params.Username, + Password: g.params.Password, + DomainName: g.params.UserDomainName, + AllowReauth: true, + Scope: &gophercloud.AuthScope{ + ProjectName: g.params.ProjectName, + DomainName: g.params.ProjectDomainName, + }, + } +} + +func (g *Glance) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) { + out := make(chan Result[[]ossync.SourceImage]) + for _, region := range g.params.Regions { + go func() { + if err := g.sema.Acquire(ctx, 1); err != nil { + out <- Result[[]ossync.SourceImage]{err: err} + return + } + defer g.sema.Release(1) + found, err := g.discoverRegion(ctx, region) + out <- Result[[]ossync.SourceImage]{value: found, err: err} + }() + } + + imagesByVersion := map[string]*ossync.SourceImage{} + var skipped []error + for range g.params.Regions { + result := <-out + if result.err != nil { + if errors.Is(result.err, context.Canceled) || errors.Is(result.err, context.DeadlineExceeded) { + return nil, result.err + } + skipped = append(skipped, result.err) + continue + } + for _, img := range result.value { + entry, exists := imagesByVersion[img.Version] + if !exists { + entry = &ossync.SourceImage{ + Version: img.Version, + Architectures: img.Architectures, + } + imagesByVersion[img.Version] = entry + } + entry.Regions = append(entry.Regions, img.Regions...) + } + } + + if len(skipped) > 0 { + g.log.V(1).Info("skipped regions with errors", "count", len(skipped), "errors", errors.Join(skipped...)) + } + if len(imagesByVersion) == 0 && len(skipped) == len(g.params.Regions) { + return nil, fmt.Errorf("all %d regions failed: %w", len(g.params.Regions), errors.Join(skipped...)) + } + + versions := make([]ossync.SourceImage, 0, len(imagesByVersion)) + for _, img := range imagesByVersion { + versions = append(versions, *img) + } + + 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.keepLatest] + } + + supported := gardenerv1beta1.ClassificationSupported + for i := range versions { + versions[i].Classification = &supported + } + if len(versions) > 0 { + deprecated := gardenerv1beta1.ClassificationDeprecated + versions[len(versions)-1].Classification = &deprecated + } + + return versions, nil +} + +// discoverRegion returns a region's public images. +func (g *Glance) discoverRegion(ctx context.Context, region string) ([]ossync.SourceImage, error) { + authURL := fmt.Sprintf(g.params.AuthURLFormat, region) + provider, err := g.authenticate(ctx, authURL, g.authOptions()) + if err != nil { + return nil, fmt.Errorf("region %s: authenticate: %w", region, err) + } + + imgs, err := g.listImages(ctx, provider, region) + if err != nil { + return nil, fmt.Errorf("region %s: list images: %w", region, err) + } + + var found []ossync.SourceImage + for _, img := range imgs { + version, ok := g.parseVersion(img.Name) + if !ok { + continue + } + found = append(found, ossync.SourceImage{ + Version: version, + Architectures: []string{"amd64"}, + Regions: []ossync.RegionImage{{Region: region, ID: img.ID}}, + }) + } + return found, nil +} + +// compareSemverDesc orders two versions newest-first. Unparsable versions sort last. +func compareSemverDesc(a, b string) int { + av, aerr := semver.ParseTolerant(a) + bv, berr := semver.ParseTolerant(b) + switch { + case aerr != nil && berr != nil: + return strings.Compare(a, b) + case aerr != nil: + return 1 + case berr != nil: + return -1 + } + return bv.Compare(av) +} + +// parseVersion extracts the semver version from a matching image name. +func (g *Glance) parseVersion(name string) (string, bool) { + if strings.Contains(name, usiVariantMarker) { + g.log.V(1).Info("skipping usi image variant", "name", name) + return "", false + } + + rest, ok := strings.CutPrefix(name, g.namePrefix) + if !ok { + return "", false + } + + // rest is "-"; the hash is the final dash-separated segment. + idx := strings.LastIndex(rest, "-") + if idx <= 0 { + return "", false + } + + raw := rest[:idx] + parsed, err := semver.ParseTolerant(raw) + if err != nil { + g.log.V(1).Info("skipping image with unparsable version", "name", name, "raw", raw) + return "", false + } + return parsed.String(), true +} diff --git a/cloudprofilesync/ossync/source/glance/os_source_test.go b/cloudprofilesync/ossync/source/glance/os_source_test.go new file mode 100644 index 0000000..630b9ee --- /dev/null +++ b/cloudprofilesync/ossync/source/glance/os_source_test.go @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package glance + +import ( + "context" + "testing" + + "github.com/go-logr/logr" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" +) + +const ( + testRegion = "eu-de-1" + testVersion = "2150.8.0" + // stdImage and usiImage are the standard and _usi variants of the same version. + stdImage = "gardenlinux-openstack-gardener_prod-amd64-2150.8.0-40f62d58" + usiImage = "gardenlinux-openstack-gardener_prod_usi-amd64-2150.8.0-40f62d58" +) + +func TestParseVersionSkipsUsiVariant(t *testing.T) { + g := newTestGlance(t, GlanceParams{Regions: []string{testRegion}}, nil) + + tests := []struct { + name string + imgName string + wantVer string + wantKeep bool + }{ + { + name: "standard image is parsed", + imgName: stdImage, + wantVer: testVersion, + wantKeep: true, + }, + { + name: "usi variant is skipped", + imgName: usiImage, + wantKeep: false, + }, + { + name: "usi variant with two-part version is skipped", + imgName: "gardenlinux-openstack-gardener_prod_usi-amd64-1877.13-81e502e7", + wantKeep: false, + }, + { + name: "unrelated image is skipped", + imgName: "some-other-image-1.2.3-deadbeef", + wantKeep: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, keep := g.parseVersion(tc.imgName) + if keep != tc.wantKeep { + t.Fatalf("parseVersion(%q) keep = %v, want %v", tc.imgName, keep, tc.wantKeep) + } + if keep && got != tc.wantVer { + t.Errorf("parseVersion(%q) = %q, want %q", tc.imgName, got, tc.wantVer) + } + }) + } +} + +// When a version has both a standard and a usi image, only the standard one survives. +func TestGetVersionsUsiDoesNotCollide(t *testing.T) { + imgs := []images.Image{ + {ID: "standard-uuid", Name: stdImage}, + {ID: "usi-uuid", Name: usiImage}, + } + 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) != 1 { + t.Fatalf("got %d versions, want 1 (usi must not create a second entry): %+v", len(versions), versions) + } + v := versions[0] + if v.Version != testVersion { + t.Errorf("version = %q, want %s", v.Version, testVersion) + } + if len(v.Regions) != 1 { + t.Fatalf("got %d region entries, want 1 (usi must not duplicate the region)", len(v.Regions)) + } + if v.Regions[0].ID != "standard-uuid" { + t.Errorf("region ID = %q, want standard-uuid (usi UUID must not win)", v.Regions[0].ID) + } +} + +// 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) + } + g.authenticate = func(ctx context.Context, authURL string, opts gophercloud.AuthOptions) (*gophercloud.ProviderClient, error) { + return &gophercloud.ProviderClient{}, nil + } + g.listImages = func(ctx context.Context, provider *gophercloud.ProviderClient, region string) ([]images.Image, error) { + return imgsByRegion[region], nil + } + return g +} diff --git a/controllers/cloud_profile.go b/controllers/cloud_profile.go index c957f0c..2e5a1e3 100644 --- a/controllers/cloud_profile.go +++ b/controllers/cloud_profile.go @@ -22,6 +22,8 @@ import ( "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ocirepo" "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/provider/ironcore" + osprovider "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/provider/openstack" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/source/glance" "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/source/oci" ) @@ -106,6 +108,28 @@ func (r *Reconciler) updateMachineImages(ctx context.Context, log logr.Logger, u } source = src + case update.Source.Glance != nil: + password, err := r.getCredential(ctx, update.Source.Glance.PasswordSecret) + if err != nil { + return err + } + src, err := glance.NewGlance(glance.GlanceParams{ + AuthURLFormat: update.Source.Glance.AuthURLFormat, + Regions: update.Source.Glance.Regions, + NamePrefix: update.Source.Glance.NamePrefix, + KeepLatest: update.Source.Glance.KeepLatest, + Parallel: update.Source.Glance.Parallel, + ProjectName: update.Source.Glance.ProjectName, + ProjectDomainName: update.Source.Glance.ProjectDomainName, + Username: update.Source.Glance.Username, + UserDomainName: update.Source.Glance.UserDomainName, + Password: string(password), + }, log) + if err != nil { + return fmt.Errorf("failed to initialize Glance source: %w", err) + } + source = src + default: return errors.New("no machine images source configured") } @@ -119,6 +143,10 @@ func (r *Reconciler) updateMachineImages(ctx context.Context, log logr.Logger, u ImageName: update.ImageName, EnableCapabilities: r.EnableCapabilities, } + case update.Provider.OpenStack != nil: + provider = &osprovider.OpenStackProvider{ + ImageName: update.ImageName, + } default: return errors.New("no known provider configured") } diff --git a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml index a067c32..b1aadc1 100644 --- a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml +++ b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml @@ -774,11 +774,83 @@ spec: - registry - repository type: object + openStack: + description: OpenStack contains configuration to update + provider.machineImages for OpenStack CloudProfiles. + type: object type: object source: description: Source contains configuration for a source for machine images. properties: + glance: + description: Glance contains configuration for an OpenStack + Glance source. + properties: + authURLFormat: + description: AuthURLFormat is the Keystone endpoint + format string with a single "%s" for the region. + type: string + keepLatest: + description: KeepLatest limits results to the newest + N versions. + type: integer + namePrefix: + description: NamePrefix selects images by name prefix. + Empty means the default. + type: string + parallel: + description: Parallel bounds how many regions are queried + concurrently. + format: int64 + type: integer + passwordSecret: + description: PasswordSecret is a reference to a secret + containing the OpenStack password. + properties: + key: + description: Key within the Secret to use for required + data. + type: string + name: + description: Name of a Secret. + type: string + namespace: + description: Namespace of a Secret. + type: string + required: + - key + - name + - namespace + type: object + projectDomainName: + description: ProjectDomainName scopes the token domain. + type: string + projectName: + description: ProjectName scopes the token. + type: string + regions: + description: Regions is the list of OpenStack regions + to query. + items: + type: string + type: array + userDomainName: + description: UserDomainName is the domain of the authenticating + user. + type: string + username: + description: Username for authentication. + type: string + required: + - authURLFormat + - passwordSecret + - projectDomainName + - projectName + - regions + - userDomainName + - username + type: object oci: description: OCI contains configuration for an OCI source. properties: diff --git a/go.mod b/go.mod index d101a65..78c2e03 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/cobaltcore-dev/cloud-profile-sync -go 1.26.0 +go 1.26.2 require ( github.com/blang/semver/v4 v4.0.0 github.com/distribution/distribution/v3 v3.1.1 - github.com/gardener/gardener/pkg/apis v1.144.0 + github.com/gardener/gardener/pkg/apis v1.145.0 github.com/go-logr/logr v1.4.3 github.com/ironcore-dev/gardener-extension-provider-ironcore-metal v0.1.1-0.20260624151759-9166baa81e86 github.com/onsi/ginkgo/v2 v2.32.0 @@ -42,6 +42,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/gardener/gardener-extension-provider-openstack v1.57.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect @@ -63,6 +64,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gophercloud/gophercloud/v2 v2.13.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect @@ -70,13 +72,13 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect - github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_golang v1.23.3-0.20260602051030-3537b20ac86b // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.68.1 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect @@ -120,7 +122,7 @@ require ( golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.46.0 // indirect + golang.org/x/tools v0.47.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260608224507-4308a22a1bab // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab // indirect diff --git a/go.sum b/go.sum index 777c174..69a40d0 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,12 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gardener/gardener-extension-provider-openstack v1.57.0 h1:QQXVSOC+9c94r21BbcLW1gQfxJ95zmo0e18Aoy32BhQ= +github.com/gardener/gardener-extension-provider-openstack v1.57.0/go.mod h1:N397cSwvKwFl/as2g39eeXyFj3gON83KQb7TliQX9nI= github.com/gardener/gardener/pkg/apis v1.144.0 h1:xlnGMbliM5TjlK5o2oCxzyFSmtxgGerP4SDwEXdHL2E= github.com/gardener/gardener/pkg/apis v1.144.0/go.mod h1:we6hJ8r80nL1rkXzVnOQwey4q77pQXHN3pvoBgeak8g= +github.com/gardener/gardener/pkg/apis v1.145.0 h1:E9mnDYOKOoOEJnpGCPpFuS0OX32uoOnv27a03b/nlP0= +github.com/gardener/gardener/pkg/apis v1.145.0/go.mod h1:LsjZw5/3awWSMDnKg4bgftM1kW9Dkeor/ged5DNHVPI= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -129,6 +133,8 @@ github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVp github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gophercloud/gophercloud/v2 v2.13.0 h1:yEyJG+kABd8x2ttTqLsomihU6Kg2YheJSZhvP/QSx+8= +github.com/gophercloud/gophercloud/v2 v2.13.0/go.mod h1:KZRLVs6gcoy/pEFdkZqFjdYqnS0emMHv66UqdM5lMjU= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -152,6 +158,8 @@ github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -195,6 +203,8 @@ github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5Fsn github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_golang v1.23.3-0.20260602051030-3537b20ac86b h1:QNV54DNcRqdeECNdEXiOqTmI75w2rlZtOq5rt8RKhVo= +github.com/prometheus/client_golang v1.23.3-0.20260602051030-3537b20ac86b/go.mod h1:kPaff19KETV3GKIZJehgPmlA2Di3jNeWdgKA9RpObuU= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -344,6 +354,8 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= From df8c36983e09b194f933664f9af9f71e649a8768 Mon Sep 17 00:00:00 2001 From: C5421281 Date: Thu, 20 Aug 2026 13:14:37 +0200 Subject: [PATCH 03/23] fix: codeRabbitAI review --- cloudprofilesync/ossync/os_image_updater.go | 14 +++++++++----- cloudprofilesync/ossync/source/glance/os_source.go | 3 +-- go.mod | 6 +++--- go.sum | 8 -------- 4 files changed, 13 insertions(+), 18 deletions(-) diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index 3abdd66..a63e780 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -122,6 +122,13 @@ func (iu *ImageUpdater) resolveExpiration(src SourceImage, existing *metav1.Time return &now } +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 { @@ -157,6 +164,7 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou image.Versions[idx].Classification = sourceImage.Classification // Stamp expiration once on the transition to deprecated; preserve it thereafter. image.Versions[idx].ExpirationDate = iu.resolveExpiration(sourceImage, image.Versions[idx].ExpirationDate) + 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 @@ -191,11 +199,7 @@ 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.InPlaceUpdates = inPlaceUpdates(sourceImage.SupportInPlaceUpdate) } else { image.Versions = append(image.Versions, gardenerv1beta1.MachineImageVersion{ ExpirableVersion: gardenerv1beta1.ExpirableVersion{ diff --git a/cloudprofilesync/ossync/source/glance/os_source.go b/cloudprofilesync/ossync/source/glance/os_source.go index 8e38a62..d09fa64 100644 --- a/cloudprofilesync/ossync/source/glance/os_source.go +++ b/cloudprofilesync/ossync/source/glance/os_source.go @@ -17,7 +17,6 @@ import ( "github.com/gophercloud/gophercloud/v2/openstack" "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" "golang.org/x/sync/semaphore" - _ "k8s.io/apimachinery/pkg/apis/meta/v1" ) const ( @@ -152,7 +151,7 @@ func (g *Glance) authOptions() gophercloud.AuthOptions { } func (g *Glance) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) { - out := make(chan Result[[]ossync.SourceImage]) + out := make(chan Result[[]ossync.SourceImage], len(g.params.Regions)) for _, region := range g.params.Regions { go func() { if err := g.sema.Acquire(ctx, 1); err != nil { diff --git a/go.mod b/go.mod index 78c2e03..6e9b4bb 100644 --- a/go.mod +++ b/go.mod @@ -5,15 +5,16 @@ go 1.26.2 require ( github.com/blang/semver/v4 v4.0.0 github.com/distribution/distribution/v3 v3.1.1 + github.com/gardener/gardener-extension-provider-openstack v1.57.0 github.com/gardener/gardener/pkg/apis v1.145.0 github.com/go-logr/logr v1.4.3 + github.com/gophercloud/gophercloud/v2 v2.13.0 github.com/ironcore-dev/gardener-extension-provider-ironcore-metal v0.1.1-0.20260624151759-9166baa81e86 github.com/onsi/ginkgo/v2 v2.32.0 github.com/onsi/gomega v1.42.1 github.com/opencontainers/image-spec v1.1.1 go.uber.org/zap v1.28.0 go.yaml.in/yaml/v3 v3.0.4 - golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.21.0 k8s.io/api v0.36.0 k8s.io/apiextensions-apiserver v0.36.0 @@ -42,7 +43,6 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect - github.com/gardener/gardener-extension-provider-openstack v1.57.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect @@ -64,7 +64,6 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gophercloud/gophercloud/v2 v2.13.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect @@ -118,6 +117,7 @@ require ( golang.org/x/crypto v0.53.0 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect diff --git a/go.sum b/go.sum index 69a40d0..73d0c7e 100644 --- a/go.sum +++ b/go.sum @@ -56,8 +56,6 @@ github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gardener/gardener-extension-provider-openstack v1.57.0 h1:QQXVSOC+9c94r21BbcLW1gQfxJ95zmo0e18Aoy32BhQ= github.com/gardener/gardener-extension-provider-openstack v1.57.0/go.mod h1:N397cSwvKwFl/as2g39eeXyFj3gON83KQb7TliQX9nI= -github.com/gardener/gardener/pkg/apis v1.144.0 h1:xlnGMbliM5TjlK5o2oCxzyFSmtxgGerP4SDwEXdHL2E= -github.com/gardener/gardener/pkg/apis v1.144.0/go.mod h1:we6hJ8r80nL1rkXzVnOQwey4q77pQXHN3pvoBgeak8g= github.com/gardener/gardener/pkg/apis v1.145.0 h1:E9mnDYOKOoOEJnpGCPpFuS0OX32uoOnv27a03b/nlP0= github.com/gardener/gardener/pkg/apis v1.145.0/go.mod h1:LsjZw5/3awWSMDnKg4bgftM1kW9Dkeor/ged5DNHVPI= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -156,8 +154,6 @@ github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -201,8 +197,6 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.23.3-0.20260602051030-3537b20ac86b h1:QNV54DNcRqdeECNdEXiOqTmI75w2rlZtOq5rt8RKhVo= github.com/prometheus/client_golang v1.23.3-0.20260602051030-3537b20ac86b/go.mod h1:kPaff19KETV3GKIZJehgPmlA2Di3jNeWdgKA9RpObuU= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= @@ -352,8 +346,6 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= -golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= From 6bc7a3f8d920091540ef9093d68299692ea76f68 Mon Sep 17 00:00:00 2001 From: C5421281 Date: Thu, 20 Aug 2026 16:44:13 +0200 Subject: [PATCH 04/23] fix: CopilotAI review --- cloudprofilesync/ossync/os_image_updater.go | 6 ++- .../ossync/provider/openstack/provider.go | 7 ++- .../provider/openstack/provider_test.go | 15 +++--- .../ossync/source/glance/os_source.go | 23 ++++++++- .../ossync/source/glance/os_source_test.go | 49 +++++++++++++++++++ 5 files changed, 89 insertions(+), 11 deletions(-) diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index a63e780..1268638 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -199,11 +199,15 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou existing.Architectures = append(existing.Architectures, arch) } } + existing.Classification = sourceImage.Classification + existing.ExpirationDate = iu.resolveExpiration(sourceImage, existing.ExpirationDate) 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), }) diff --git a/cloudprofilesync/ossync/provider/openstack/provider.go b/cloudprofilesync/ossync/provider/openstack/provider.go index 189ca6d..77287e7 100644 --- a/cloudprofilesync/ossync/provider/openstack/provider.go +++ b/cloudprofilesync/ossync/provider/openstack/provider.go @@ -52,15 +52,18 @@ func (p *OpenStackProvider) Configure(cpSpec *gardencorev1beta1.CloudProfileSpec entry := &image.Versions[idx] for _, r := range src.Regions { - alreadyPresent := slices.ContainsFunc(entry.Regions, func(m openstackv1alpha1.RegionIDMapping) bool { + existing := slices.IndexFunc(entry.Regions, func(m openstackv1alpha1.RegionIDMapping) bool { return m.Name == r.Region }) - if !alreadyPresent { + 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 } } diff --git a/cloudprofilesync/ossync/provider/openstack/provider_test.go b/cloudprofilesync/ossync/provider/openstack/provider_test.go index 1bcfb80..e9a2236 100644 --- a/cloudprofilesync/ossync/provider/openstack/provider_test.go +++ b/cloudprofilesync/ossync/provider/openstack/provider_test.go @@ -90,8 +90,9 @@ func TestConfigureMergesIntoExistingImage(t *testing.T) { } } -// Configure does not duplicate an existing region or overwrite its ID. -func TestConfigureIsIdempotentOnRegions(t *testing.T) { +// Configure does not duplicate an existing region but updates its ID so a +// rebuilt image (same version, new UUID) replaces the stale mapping. +func TestConfigureUpdatesExistingRegionID(t *testing.T) { p := &OpenStackProvider{ImageName: imageName} spec := specWithConfig(t, &openstackv1alpha1.CloudProfileConfig{ MachineImages: []openstackv1alpha1.MachineImages{ @@ -100,19 +101,19 @@ func TestConfigureIsIdempotentOnRegions(t *testing.T) { Versions: []openstackv1alpha1.MachineImageVersion{ { Version: testVersion, - Regions: []openstackv1alpha1.RegionIDMapping{{Name: regionDE, ID: "existing-uuid"}}, + Regions: []openstackv1alpha1.RegionIDMapping{{Name: regionDE, ID: "stale-uuid"}}, }, }, }, }, }) - // Re-apply the same region (with a different ID) plus a new one. + // Re-apply the same region (with a new ID) plus a new one. err := p.Configure(spec, []ossync.SourceImage{ { Version: testVersion, Regions: []ossync.RegionImage{ - {Region: regionDE, ID: "would-be-new-uuid"}, + {Region: regionDE, ID: "rebuilt-uuid"}, {Region: regionNL, ID: "uuid-nl-1"}, }, }, @@ -130,8 +131,8 @@ func TestConfigureIsIdempotentOnRegions(t *testing.T) { t.Fatalf("got %d regions, want 2 (%s must not be duplicated): %+v", len(v.Regions), regionDE, v.Regions) } for _, r := range v.Regions { - if r.Name == regionDE && r.ID != "existing-uuid" { - t.Errorf("%s ID = %q, want existing-uuid (existing region must not be overwritten)", regionDE, r.ID) + if r.Name == regionDE && r.ID != "rebuilt-uuid" { + t.Errorf("%s ID = %q, want rebuilt-uuid (stale mapping must be updated)", regionDE, r.ID) } } } diff --git a/cloudprofilesync/ossync/source/glance/os_source.go b/cloudprofilesync/ossync/source/glance/os_source.go index d09fa64..88e3fc6 100644 --- a/cloudprofilesync/ossync/source/glance/os_source.go +++ b/cloudprofilesync/ossync/source/glance/os_source.go @@ -232,12 +232,20 @@ func (g *Glance) discoverRegion(ctx context.Context, region string) ([]ossync.So return nil, fmt.Errorf("region %s: list images: %w", region, err) } - var found []ossync.SourceImage + // Pick one canonical image per version (a rebuilt image reuses the version with a new UUID). + canonical := map[string]images.Image{} for _, img := range imgs { version, ok := g.parseVersion(img.Name) if !ok { continue } + if cur, exists := canonical[version]; !exists || preferImage(img, cur) { + canonical[version] = img + } + } + + found := make([]ossync.SourceImage, 0, len(canonical)) + for version, img := range canonical { found = append(found, ossync.SourceImage{ Version: version, Architectures: []string{"amd64"}, @@ -247,6 +255,19 @@ func (g *Glance) discoverRegion(ctx context.Context, region string) ([]ossync.So return found, nil } +// preferImage reports whether candidate should replace current: active wins over non-active, then newer CreatedAt, then larger UUID (deterministic tiebreak). +func preferImage(candidate, current images.Image) bool { + candActive := candidate.Status == images.ImageStatusActive + curActive := current.Status == images.ImageStatusActive + if candActive != curActive { + return candActive + } + if !candidate.CreatedAt.Equal(current.CreatedAt) { + return candidate.CreatedAt.After(current.CreatedAt) + } + return candidate.ID > current.ID +} + // compareSemverDesc orders two versions newest-first. Unparsable versions sort last. func compareSemverDesc(a, b string) int { av, aerr := semver.ParseTolerant(a) diff --git a/cloudprofilesync/ossync/source/glance/os_source_test.go b/cloudprofilesync/ossync/source/glance/os_source_test.go index b3be0e9..8002400 100644 --- a/cloudprofilesync/ossync/source/glance/os_source_test.go +++ b/cloudprofilesync/ossync/source/glance/os_source_test.go @@ -6,6 +6,7 @@ package glance import ( "context" "testing" + "time" "github.com/go-logr/logr" "github.com/gophercloud/gophercloud/v2" @@ -92,6 +93,54 @@ func TestGetVersionsUsiDoesNotCollide(t *testing.T) { } } +// When a region has two images for the same version (a rebuilt image with a new +// UUID), the newest active image is chosen deterministically. +func TestGetVersionsPicksCanonicalImage(t *testing.T) { + older := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + newer := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC) + imgs := []images.Image{ + {ID: "old-active", Name: stdImage, Status: images.ImageStatusActive, CreatedAt: older}, + {ID: "new-active", Name: stdImage, Status: images.ImageStatusActive, CreatedAt: newer}, + {ID: "new-queued", Name: stdImage, Status: images.ImageStatusQueued, CreatedAt: newer}, + } + 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) != 1 { + t.Fatalf("got %d versions, want 1: %+v", len(versions), versions) + } + v := versions[0] + if len(v.Regions) != 1 { + t.Fatalf("got %d region entries, want 1 (rebuilt image must not duplicate the region): %+v", len(v.Regions), v.Regions) + } + if v.Regions[0].ID != "new-active" { + t.Errorf("region ID = %q, want new-active (newest active image must win)", v.Regions[0].ID) + } +} + +// preferImage selection is stable regardless of listing order. +func TestPreferImageDeterministic(t *testing.T) { + base := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + active := images.Image{ID: "a", Status: images.ImageStatusActive, CreatedAt: base} + queued := images.Image{ID: "z", Status: images.ImageStatusQueued, CreatedAt: base.Add(time.Hour)} + if !preferImage(active, queued) { + t.Error("active image should be preferred over a newer queued image") + } + if preferImage(queued, active) { + t.Error("newer queued image should not be preferred over an active image") + } + + // Same status and CreatedAt: larger UUID wins, both directions. + lo := images.Image{ID: "aaa", Status: images.ImageStatusActive, CreatedAt: base} + hi := images.Image{ID: "bbb", Status: images.ImageStatusActive, CreatedAt: base} + if !preferImage(hi, lo) || preferImage(lo, hi) { + t.Error("UUID tie-break is not deterministic") + } +} + // 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() From d69732b9aedbf2110ae1bc585886463b0a7c6b07 Mon Sep 17 00:00:00 2001 From: C5421281 Date: Fri, 21 Aug 2026 11:57:28 +0200 Subject: [PATCH 05/23] fix: Linter error --- cloudprofilesync/ossync/os_image_updater.go | 8 ++++---- cloudprofilesync/ossync/os_image_updater_test.go | 8 ++++---- cloudprofilesync/ossync/source/glance/os_source.go | 3 ++- go.mod | 4 ++-- go.sum | 4 ++-- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index 1268638..907db24 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -161,9 +161,9 @@ 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 + 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) + 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 @@ -199,8 +199,8 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou existing.Architectures = append(existing.Architectures, arch) } } - existing.Classification = sourceImage.Classification - existing.ExpirationDate = iu.resolveExpiration(sourceImage, existing.ExpirationDate) + 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{ diff --git a/cloudprofilesync/ossync/os_image_updater_test.go b/cloudprofilesync/ossync/os_image_updater_test.go index e85d238..f72d96f 100644 --- a/cloudprofilesync/ossync/os_image_updater_test.go +++ b/cloudprofilesync/ossync/os_image_updater_test.go @@ -393,7 +393,7 @@ var _ = Describe("ImageUpdater", func() { 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)) + 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) { @@ -405,7 +405,7 @@ var _ = Describe("ImageUpdater", func() { 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)) + 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) { @@ -416,7 +416,7 @@ var _ = Describe("ImageUpdater", func() { 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()) + 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) { @@ -427,7 +427,7 @@ var _ = Describe("ImageUpdater", func() { 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()) + Expect(cpSpec.MachineImages[0].Versions[0].ExpirationDate).To(BeNil()) //nolint:staticcheck // legacy field; Lifecycle needs the VersionClassificationLifecycle feature gate }) }) }) diff --git a/cloudprofilesync/ossync/source/glance/os_source.go b/cloudprofilesync/ossync/source/glance/os_source.go index 88e3fc6..0eb1b71 100644 --- a/cloudprofilesync/ossync/source/glance/os_source.go +++ b/cloudprofilesync/ossync/source/glance/os_source.go @@ -10,13 +10,14 @@ import ( "time" "github.com/blang/semver/v4" - "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" "github.com/go-logr/logr" "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/gophercloud/v2/openstack" "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" "golang.org/x/sync/semaphore" + + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" ) const ( diff --git a/go.mod b/go.mod index 6e9b4bb..2c99806 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,10 @@ module github.com/cobaltcore-dev/cloud-profile-sync -go 1.26.2 +go 1.26.0 require ( github.com/blang/semver/v4 v4.0.0 github.com/distribution/distribution/v3 v3.1.1 - github.com/gardener/gardener-extension-provider-openstack v1.57.0 github.com/gardener/gardener/pkg/apis v1.145.0 github.com/go-logr/logr v1.4.3 github.com/gophercloud/gophercloud/v2 v2.13.0 @@ -43,6 +42,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/gardener/gardener-extension-provider-openstack v1.54.0 github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect diff --git a/go.sum b/go.sum index 73d0c7e..59f0119 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,8 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gardener/gardener-extension-provider-openstack v1.57.0 h1:QQXVSOC+9c94r21BbcLW1gQfxJ95zmo0e18Aoy32BhQ= -github.com/gardener/gardener-extension-provider-openstack v1.57.0/go.mod h1:N397cSwvKwFl/as2g39eeXyFj3gON83KQb7TliQX9nI= +github.com/gardener/gardener-extension-provider-openstack v1.54.0 h1:Bg/5rk1zMdMxtsLPKhOKfo3e6O2n+JwETzAixPDSJ3o= +github.com/gardener/gardener-extension-provider-openstack v1.54.0/go.mod h1:mQue5NBj8udoTdD4ArXMuJ1wNMemTl2Am9astQ0SZvc= github.com/gardener/gardener/pkg/apis v1.145.0 h1:E9mnDYOKOoOEJnpGCPpFuS0OX32uoOnv27a03b/nlP0= github.com/gardener/gardener/pkg/apis v1.145.0/go.mod h1:LsjZw5/3awWSMDnKg4bgftM1kW9Dkeor/ged5DNHVPI= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= From 30abb1a457e645a48f4ed13e7f6fc0138e04e136 Mon Sep 17 00:00:00 2001 From: sapcc-bot Date: Tue, 11 Aug 2026 13:22:24 +0000 Subject: [PATCH 06/23] Run go-makefile-maker Signed-off-by: C5421281 --- .github/workflows/checks.yaml | 2 +- .github/workflows/codeql.yaml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index e6bc442..38beec8 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -43,7 +43,7 @@ jobs: - name: Dependency Licenses Review run: make check-dependency-licenses - name: Check for spelling errors - uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1 + uses: crate-ci/typos@8a48f81b6c64dcfea44b3633223084c4be58ac5f # v1 env: CLICOLOR: "1" - name: Check if source code files have license header diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index a9d0304..5375fc9 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -36,11 +36,11 @@ jobs: check-latest: true go-version: 1.26.5 - name: Initialize CodeQL - uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 with: languages: go queries: security-extended - name: Autobuild - uses: github/codeql-action/autobuild@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4 + uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 From f21a4642d34513cb4da327632e7fd54e31e347e6 Mon Sep 17 00:00:00 2001 From: C5421281 Date: Tue, 11 Aug 2026 09:44:40 +0200 Subject: [PATCH 07/23] feat: add OpenStack Glance source and provider for gardenlinux images Add machine-image discovery for OpenStack CloudProfiles: - Glance source: discover public gardenlinux images across regions, parse versions from image names, keep the newest N (default 3), skip _usi variants. - OpenStackProvider: write per-region image UUIDs into the gardener-extension-provider-openstack providerConfig. - Lifecycle: mark the oldest kept version deprecated and stamp its expirationDate once on the transition, preserving it thereafter (ImageUpdater.resolveExpiration). - Wire GlanceSource into the ManagedCloudProfile API and controller; regenerate CRD and deepcopy. - Unit tests for expiration, usi skipping, and provider config. Signed-off-by: C5421281 --- api/v1alpha1/managedcloudprofile.go | 35 +++ api/v1alpha1/zz_generated.deepcopy.go | 46 +++ cloudprofilesync/ossync/os_image_updater.go | 41 ++- .../ossync/os_image_updater_test.go | 66 ++++ .../ossync/provider/openstack/provider.go | 73 +++++ .../provider/openstack/provider_test.go | 226 ++++++++++++++ .../ossync/source/glance/os_source.go | 291 ++++++++++++++++++ .../ossync/source/glance/os_source_test.go | 113 +++++++ controllers/cloud_profile.go | 28 ++ ...c.cobaltcore.dev_managedcloudprofiles.yaml | 72 +++++ go.mod | 12 +- go.sum | 12 + 12 files changed, 1009 insertions(+), 6 deletions(-) create mode 100644 cloudprofilesync/ossync/provider/openstack/provider.go create mode 100644 cloudprofilesync/ossync/provider/openstack/provider_test.go create mode 100644 cloudprofilesync/ossync/source/glance/os_source.go create mode 100644 cloudprofilesync/ossync/source/glance/os_source_test.go diff --git a/api/v1alpha1/managedcloudprofile.go b/api/v1alpha1/managedcloudprofile.go index 0ab9c05..553577c 100644 --- a/api/v1alpha1/managedcloudprofile.go +++ b/api/v1alpha1/managedcloudprofile.go @@ -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"` + // 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"` + // 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 { @@ -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"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index aaa62fc..7864a41 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -118,6 +118,27 @@ func (in *GithubAppAuth) DeepCopy() *GithubAppAuth { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GlanceSource) DeepCopyInto(out *GlanceSource) { + *out = *in + if in.Regions != nil { + in, out := &in.Regions, &out.Regions + *out = make([]string, len(*in)) + copy(*out, *in) + } + out.PasswordSecret = in.PasswordSecret +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GlanceSource. +func (in *GlanceSource) DeepCopy() *GlanceSource { + if in == nil { + return nil + } + out := new(GlanceSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KubernetesVersionSourceGithub) DeepCopyInto(out *KubernetesVersionSourceGithub) { *out = *in @@ -206,6 +227,11 @@ func (in *MachineImageUpdateProvider) DeepCopyInto(out *MachineImageUpdateProvid *out = new(MachineImagesUpdateProviderIroncoreMetal) **out = **in } + if in.OpenStack != nil { + in, out := &in.OpenStack, &out.OpenStack + *out = new(MachineImagesUpdateProviderOpenStack) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineImageUpdateProvider. @@ -226,6 +252,11 @@ func (in *MachineImageUpdateSource) DeepCopyInto(out *MachineImageUpdateSource) *out = new(OCI) **out = **in } + if in.Glance != nil { + in, out := &in.Glance, &out.Glance + *out = new(GlanceSource) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineImageUpdateSource. @@ -253,6 +284,21 @@ func (in *MachineImagesUpdateProviderIroncoreMetal) DeepCopy() *MachineImagesUpd return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MachineImagesUpdateProviderOpenStack) DeepCopyInto(out *MachineImagesUpdateProviderOpenStack) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineImagesUpdateProviderOpenStack. +func (in *MachineImagesUpdateProviderOpenStack) DeepCopy() *MachineImagesUpdateProviderOpenStack { + if in == nil { + return nil + } + out := new(MachineImagesUpdateProviderOpenStack) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ManagedCloudProfile) DeepCopyInto(out *ManagedCloudProfile) { *out = *in diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index 7ea4b9f..3abdd66 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -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 { @@ -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. @@ -88,6 +106,22 @@ 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 +} + func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.CloudProfileSpec) error { sourceImages, err := iu.Source.GetVersions(ctx) if err != nil { @@ -120,6 +154,9 @@ 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 + // Stamp expiration once on the transition to deprecated; preserve it thereafter. + image.Versions[idx].ExpirationDate = iu.resolveExpiration(sourceImage, image.Versions[idx].ExpirationDate) } 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 @@ -130,7 +167,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, }) diff --git a/cloudprofilesync/ossync/os_image_updater_test.go b/cloudprofilesync/ossync/os_image_updater_test.go index 53134e2..e85d238 100644 --- a/cloudprofilesync/ossync/os_image_updater_test.go +++ b/cloudprofilesync/ossync/os_image_updater_test.go @@ -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" ) @@ -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)) + }) + + 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)) + }) + + 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()) + }) + + 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()) + }) + }) }) diff --git a/cloudprofilesync/ossync/provider/openstack/provider.go b/cloudprofilesync/ossync/provider/openstack/provider.go new file mode 100644 index 0000000..189ca6d --- /dev/null +++ b/cloudprofilesync/ossync/provider/openstack/provider.go @@ -0,0 +1,73 @@ +package openstack + +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 + } + + 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 { + alreadyPresent := slices.ContainsFunc(entry.Regions, func(m openstackv1alpha1.RegionIDMapping) bool { + return m.Name == r.Region + }) + if !alreadyPresent { + entry.Regions = append(entry.Regions, openstackv1alpha1.RegionIDMapping{ + Name: r.Region, + ID: r.ID, + }) + } + } + } + + raw, err := json.Marshal(cfg) + if err != nil { + return err + } + cpSpec.ProviderConfig = &runtime.RawExtension{Raw: raw} + return nil +} diff --git a/cloudprofilesync/ossync/provider/openstack/provider_test.go b/cloudprofilesync/ossync/provider/openstack/provider_test.go new file mode 100644 index 0000000..1bcfb80 --- /dev/null +++ b/cloudprofilesync/ossync/provider/openstack/provider_test.go @@ -0,0 +1,226 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package openstack + +import ( + "encoding/json" + "testing" + + 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" +) + +const ( + imageName = "gardenlinux" + testVersion = "2150.8.0" + regionDE = "eu-de-1" + regionNL = "eu-nl-1" +) + +// Configure creates the image, version, and regions from an empty config. +func TestConfigureCreatesEntryFromEmpty(t *testing.T) { + p := &OpenStackProvider{ImageName: imageName} + spec := specWithConfig(t, nil) + + err := p.Configure(spec, []ossync.SourceImage{ + { + Version: testVersion, + Regions: []ossync.RegionImage{ + {Region: regionDE, ID: "uuid-de-1"}, + {Region: regionNL, ID: "uuid-nl-1"}, + }, + }, + }) + if err != nil { + t.Fatalf("Configure: %v", err) + } + + cfg := parseConfig(t, spec) + img := findImage(cfg, imageName) + if img == nil { + t.Fatalf("machineImages entry %q not created: %+v", imageName, cfg.MachineImages) + } + v := findVersion(img, testVersion) + if v == nil { + t.Fatalf("version %s not created: %+v", testVersion, img.Versions) + } + if len(v.Regions) != 2 { + t.Fatalf("got %d regions, want 2: %+v", len(v.Regions), v.Regions) + } +} + +// Configure merges into the existing image without dropping other versions. +func TestConfigureMergesIntoExistingImage(t *testing.T) { + p := &OpenStackProvider{ImageName: imageName} + spec := specWithConfig(t, &openstackv1alpha1.CloudProfileConfig{ + MachineImages: []openstackv1alpha1.MachineImages{ + { + Name: imageName, + Versions: []openstackv1alpha1.MachineImageVersion{ + { + Version: "2000.0.0", + Regions: []openstackv1alpha1.RegionIDMapping{{Name: regionDE, ID: "old-uuid"}}, + }, + }, + }, + }, + }) + + err := p.Configure(spec, []ossync.SourceImage{ + {Version: testVersion, Regions: []ossync.RegionImage{{Region: regionDE, ID: "new-uuid"}}}, + }) + if err != nil { + t.Fatalf("Configure: %v", err) + } + + cfg := parseConfig(t, spec) + if len(cfg.MachineImages) != 1 { + t.Fatalf("got %d machineImages, want 1 (no duplicate entry): %+v", len(cfg.MachineImages), cfg.MachineImages) + } + img := findImage(cfg, imageName) + if findVersion(img, "2000.0.0") == nil { + t.Error("pre-existing version 2000.0.0 was dropped") + } + if findVersion(img, testVersion) == nil { + t.Errorf("new version %s was not added", testVersion) + } +} + +// Configure does not duplicate an existing region or overwrite its ID. +func TestConfigureIsIdempotentOnRegions(t *testing.T) { + p := &OpenStackProvider{ImageName: imageName} + spec := specWithConfig(t, &openstackv1alpha1.CloudProfileConfig{ + MachineImages: []openstackv1alpha1.MachineImages{ + { + Name: imageName, + Versions: []openstackv1alpha1.MachineImageVersion{ + { + Version: testVersion, + Regions: []openstackv1alpha1.RegionIDMapping{{Name: regionDE, ID: "existing-uuid"}}, + }, + }, + }, + }, + }) + + // Re-apply the same region (with a different ID) plus a new one. + err := p.Configure(spec, []ossync.SourceImage{ + { + Version: testVersion, + Regions: []ossync.RegionImage{ + {Region: regionDE, ID: "would-be-new-uuid"}, + {Region: regionNL, ID: "uuid-nl-1"}, + }, + }, + }) + if err != nil { + t.Fatalf("Configure: %v", err) + } + + cfg := parseConfig(t, spec) + v := findVersion(findImage(cfg, imageName), testVersion) + if v == nil { + t.Fatalf("version %s missing", testVersion) + } + if len(v.Regions) != 2 { + t.Fatalf("got %d regions, want 2 (%s must not be duplicated): %+v", len(v.Regions), regionDE, v.Regions) + } + for _, r := range v.Regions { + if r.Name == regionDE && r.ID != "existing-uuid" { + t.Errorf("%s ID = %q, want existing-uuid (existing region must not be overwritten)", regionDE, r.ID) + } + } +} + +// Configure only touches the image matching p.ImageName. +func TestConfigureLeavesOtherImagesUntouched(t *testing.T) { + p := &OpenStackProvider{ImageName: imageName} + spec := specWithConfig(t, &openstackv1alpha1.CloudProfileConfig{ + MachineImages: []openstackv1alpha1.MachineImages{ + { + Name: "coreos", + Versions: []openstackv1alpha1.MachineImageVersion{{Version: "1.0.0"}}, + }, + }, + }) + + err := p.Configure(spec, []ossync.SourceImage{ + {Version: testVersion, Regions: []ossync.RegionImage{{Region: regionDE, ID: "uuid"}}}, + }) + if err != nil { + t.Fatalf("Configure: %v", err) + } + + cfg := parseConfig(t, spec) + if len(cfg.MachineImages) != 2 { + t.Fatalf("got %d machineImages, want 2 (coreos + gardenlinux): %+v", len(cfg.MachineImages), cfg.MachineImages) + } + coreos := findImage(cfg, "coreos") + if coreos == nil || findVersion(coreos, "1.0.0") == nil { + t.Error("unrelated image coreos was modified or dropped") + } +} + +// Configure returns an error for a malformed ProviderConfig. +func TestConfigureReturnsErrorOnInvalidConfig(t *testing.T) { + p := &OpenStackProvider{ImageName: imageName} + spec := &gardencorev1beta1.CloudProfileSpec{ + ProviderConfig: &runtime.RawExtension{Raw: []byte("{not json")}, + } + + if err := p.Configure(spec, nil); err == nil { + t.Fatal("Configure returned nil error for malformed ProviderConfig, want an error") + } +} + +// specWithConfig builds a CloudProfileSpec from cfg (nil yields no ProviderConfig). +func specWithConfig(t *testing.T, cfg *openstackv1alpha1.CloudProfileConfig) *gardencorev1beta1.CloudProfileSpec { + t.Helper() + spec := &gardencorev1beta1.CloudProfileSpec{} + if cfg == nil { + return spec + } + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + spec.ProviderConfig = &runtime.RawExtension{Raw: raw} + return spec +} + +// parseConfig unmarshals the ProviderConfig written back onto the spec. +func parseConfig(t *testing.T, spec *gardencorev1beta1.CloudProfileSpec) openstackv1alpha1.CloudProfileConfig { + t.Helper() + if spec.ProviderConfig == nil { + t.Fatal("ProviderConfig is nil, want it to be set") + } + var cfg openstackv1alpha1.CloudProfileConfig + if err := json.Unmarshal(spec.ProviderConfig.Raw, &cfg); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + return cfg +} + +// findImage returns the machineImages entry with the given name, or nil. +func findImage(cfg openstackv1alpha1.CloudProfileConfig, name string) *openstackv1alpha1.MachineImages { + for i := range cfg.MachineImages { + if cfg.MachineImages[i].Name == name { + return &cfg.MachineImages[i] + } + } + return nil +} + +// findVersion returns the version entry with the given version string, or nil. +func findVersion(img *openstackv1alpha1.MachineImages, version string) *openstackv1alpha1.MachineImageVersion { + for i := range img.Versions { + if img.Versions[i].Version == version { + return &img.Versions[i] + } + } + return nil +} diff --git a/cloudprofilesync/ossync/source/glance/os_source.go b/cloudprofilesync/ossync/source/glance/os_source.go new file mode 100644 index 0000000..8e38a62 --- /dev/null +++ b/cloudprofilesync/ossync/source/glance/os_source.go @@ -0,0 +1,291 @@ +package glance + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/blang/semver/v4" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" + gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + "github.com/go-logr/logr" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" + "golang.org/x/sync/semaphore" + _ "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + defaultGlanceNamePrefix = "gardenlinux-openstack-gardener_prod-amd64-" + glanceRequestTimeout = 60 * time.Second + DefaultGlanceKeepLatest = 3 + // defaultGlanceParallel is the region query concurrency used when GlanceParams.Parallel + // is not set. + defaultGlanceParallel = 8 + usiVariantMarker = "_usi" +) + +type Result[T any] struct { + value T + err error +} + +// GlanceParams configures discovery of public gardenlinux images from OpenStack Glance. +type GlanceParams struct { + // AuthURLFormat is the Keystone endpoint format string with a single "%s" verb for + // the region, e.g. "https://identity-3.%s.cloud.sap/v3". + AuthURLFormat string + + Regions []string + + // NamePrefix selects gardenlinux images by exact prefix. Empty means the default. + NamePrefix string + + // KeepLatest limits the result to the newest N versions. + KeepLatest int + + // Parallel bounds how many regions are queried concurrently. + Parallel int64 + + // ProjectName / ProjectDomainName scope the token. + ProjectName string + ProjectDomainName string + + // Username / UserDomainName / Password authenticate the user. + Username string + UserDomainName string + Password string +} + +// Glance discovers public gardenlinux images from OpenStack Glance across regions. +type Glance struct { + log logr.Logger + params GlanceParams + namePrefix string + keepLatest 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) +} + +// NewGlance constructs a Glance source using the real gophercloud client. +func NewGlance(params GlanceParams, log logr.Logger) (*Glance, error) { + if params.AuthURLFormat == "" { + return nil, errors.New("glance: authURLFormat is required") + } + if len(params.Regions) == 0 { + return nil, errors.New("glance: at least one region is required") + } + + prefix := params.NamePrefix + if prefix == "" { + prefix = defaultGlanceNamePrefix + } + + keepLatest := params.KeepLatest + if keepLatest == 0 { + keepLatest = DefaultGlanceKeepLatest + } + + parallel := params.Parallel + if parallel <= 0 { + parallel = defaultGlanceParallel + } + + return &Glance{ + log: log, + params: params, + namePrefix: prefix, + keepLatest: keepLatest, + sema: semaphore.NewWeighted(parallel), + authenticate: defaultAuthenticate, + listImages: defaultListImages, + }, nil +} + +func defaultAuthenticate(ctx context.Context, authURL string, opts gophercloud.AuthOptions) (*gophercloud.ProviderClient, error) { + opts.IdentityEndpoint = authURL + provider, err := openstack.NewClient(authURL) + if err != nil { + return nil, err + } + + provider.HTTPClient = http.Client{Timeout: glanceRequestTimeout} + if err := openstack.Authenticate(ctx, provider, opts); err != nil { + return nil, err + } + return provider, nil +} + +func defaultListImages(ctx context.Context, provider *gophercloud.ProviderClient, region string) ([]images.Image, error) { + client, err := openstack.NewImageV2(provider, gophercloud.EndpointOpts{Region: region}) + if err != nil { + return nil, err + } + + pages, err := images.List(client, images.ListOpts{ + Visibility: images.ImageVisibilityPublic, + Limit: 1000, + }).AllPages(ctx) + if err != nil { + return nil, err + } + return images.ExtractImages(pages) +} + +func (g *Glance) authOptions() gophercloud.AuthOptions { + return gophercloud.AuthOptions{ + Username: g.params.Username, + Password: g.params.Password, + DomainName: g.params.UserDomainName, + AllowReauth: true, + Scope: &gophercloud.AuthScope{ + ProjectName: g.params.ProjectName, + DomainName: g.params.ProjectDomainName, + }, + } +} + +func (g *Glance) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) { + out := make(chan Result[[]ossync.SourceImage]) + for _, region := range g.params.Regions { + go func() { + if err := g.sema.Acquire(ctx, 1); err != nil { + out <- Result[[]ossync.SourceImage]{err: err} + return + } + defer g.sema.Release(1) + found, err := g.discoverRegion(ctx, region) + out <- Result[[]ossync.SourceImage]{value: found, err: err} + }() + } + + imagesByVersion := map[string]*ossync.SourceImage{} + var skipped []error + for range g.params.Regions { + result := <-out + if result.err != nil { + if errors.Is(result.err, context.Canceled) || errors.Is(result.err, context.DeadlineExceeded) { + return nil, result.err + } + skipped = append(skipped, result.err) + continue + } + for _, img := range result.value { + entry, exists := imagesByVersion[img.Version] + if !exists { + entry = &ossync.SourceImage{ + Version: img.Version, + Architectures: img.Architectures, + } + imagesByVersion[img.Version] = entry + } + entry.Regions = append(entry.Regions, img.Regions...) + } + } + + if len(skipped) > 0 { + g.log.V(1).Info("skipped regions with errors", "count", len(skipped), "errors", errors.Join(skipped...)) + } + if len(imagesByVersion) == 0 && len(skipped) == len(g.params.Regions) { + return nil, fmt.Errorf("all %d regions failed: %w", len(g.params.Regions), errors.Join(skipped...)) + } + + versions := make([]ossync.SourceImage, 0, len(imagesByVersion)) + for _, img := range imagesByVersion { + versions = append(versions, *img) + } + + 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.keepLatest] + } + + supported := gardenerv1beta1.ClassificationSupported + for i := range versions { + versions[i].Classification = &supported + } + if len(versions) > 0 { + deprecated := gardenerv1beta1.ClassificationDeprecated + versions[len(versions)-1].Classification = &deprecated + } + + return versions, nil +} + +// discoverRegion returns a region's public images. +func (g *Glance) discoverRegion(ctx context.Context, region string) ([]ossync.SourceImage, error) { + authURL := fmt.Sprintf(g.params.AuthURLFormat, region) + provider, err := g.authenticate(ctx, authURL, g.authOptions()) + if err != nil { + return nil, fmt.Errorf("region %s: authenticate: %w", region, err) + } + + imgs, err := g.listImages(ctx, provider, region) + if err != nil { + return nil, fmt.Errorf("region %s: list images: %w", region, err) + } + + var found []ossync.SourceImage + for _, img := range imgs { + version, ok := g.parseVersion(img.Name) + if !ok { + continue + } + found = append(found, ossync.SourceImage{ + Version: version, + Architectures: []string{"amd64"}, + Regions: []ossync.RegionImage{{Region: region, ID: img.ID}}, + }) + } + return found, nil +} + +// compareSemverDesc orders two versions newest-first. Unparsable versions sort last. +func compareSemverDesc(a, b string) int { + av, aerr := semver.ParseTolerant(a) + bv, berr := semver.ParseTolerant(b) + switch { + case aerr != nil && berr != nil: + return strings.Compare(a, b) + case aerr != nil: + return 1 + case berr != nil: + return -1 + } + return bv.Compare(av) +} + +// parseVersion extracts the semver version from a matching image name. +func (g *Glance) parseVersion(name string) (string, bool) { + if strings.Contains(name, usiVariantMarker) { + g.log.V(1).Info("skipping usi image variant", "name", name) + return "", false + } + + rest, ok := strings.CutPrefix(name, g.namePrefix) + if !ok { + return "", false + } + + // rest is "-"; the hash is the final dash-separated segment. + idx := strings.LastIndex(rest, "-") + if idx <= 0 { + return "", false + } + + raw := rest[:idx] + parsed, err := semver.ParseTolerant(raw) + if err != nil { + g.log.V(1).Info("skipping image with unparsable version", "name", name, "raw", raw) + return "", false + } + return parsed.String(), true +} diff --git a/cloudprofilesync/ossync/source/glance/os_source_test.go b/cloudprofilesync/ossync/source/glance/os_source_test.go new file mode 100644 index 0000000..630b9ee --- /dev/null +++ b/cloudprofilesync/ossync/source/glance/os_source_test.go @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package glance + +import ( + "context" + "testing" + + "github.com/go-logr/logr" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" +) + +const ( + testRegion = "eu-de-1" + testVersion = "2150.8.0" + // stdImage and usiImage are the standard and _usi variants of the same version. + stdImage = "gardenlinux-openstack-gardener_prod-amd64-2150.8.0-40f62d58" + usiImage = "gardenlinux-openstack-gardener_prod_usi-amd64-2150.8.0-40f62d58" +) + +func TestParseVersionSkipsUsiVariant(t *testing.T) { + g := newTestGlance(t, GlanceParams{Regions: []string{testRegion}}, nil) + + tests := []struct { + name string + imgName string + wantVer string + wantKeep bool + }{ + { + name: "standard image is parsed", + imgName: stdImage, + wantVer: testVersion, + wantKeep: true, + }, + { + name: "usi variant is skipped", + imgName: usiImage, + wantKeep: false, + }, + { + name: "usi variant with two-part version is skipped", + imgName: "gardenlinux-openstack-gardener_prod_usi-amd64-1877.13-81e502e7", + wantKeep: false, + }, + { + name: "unrelated image is skipped", + imgName: "some-other-image-1.2.3-deadbeef", + wantKeep: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, keep := g.parseVersion(tc.imgName) + if keep != tc.wantKeep { + t.Fatalf("parseVersion(%q) keep = %v, want %v", tc.imgName, keep, tc.wantKeep) + } + if keep && got != tc.wantVer { + t.Errorf("parseVersion(%q) = %q, want %q", tc.imgName, got, tc.wantVer) + } + }) + } +} + +// When a version has both a standard and a usi image, only the standard one survives. +func TestGetVersionsUsiDoesNotCollide(t *testing.T) { + imgs := []images.Image{ + {ID: "standard-uuid", Name: stdImage}, + {ID: "usi-uuid", Name: usiImage}, + } + 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) != 1 { + t.Fatalf("got %d versions, want 1 (usi must not create a second entry): %+v", len(versions), versions) + } + v := versions[0] + if v.Version != testVersion { + t.Errorf("version = %q, want %s", v.Version, testVersion) + } + if len(v.Regions) != 1 { + t.Fatalf("got %d region entries, want 1 (usi must not duplicate the region)", len(v.Regions)) + } + if v.Regions[0].ID != "standard-uuid" { + t.Errorf("region ID = %q, want standard-uuid (usi UUID must not win)", v.Regions[0].ID) + } +} + +// 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) + } + g.authenticate = func(ctx context.Context, authURL string, opts gophercloud.AuthOptions) (*gophercloud.ProviderClient, error) { + return &gophercloud.ProviderClient{}, nil + } + g.listImages = func(ctx context.Context, provider *gophercloud.ProviderClient, region string) ([]images.Image, error) { + return imgsByRegion[region], nil + } + return g +} diff --git a/controllers/cloud_profile.go b/controllers/cloud_profile.go index c957f0c..2e5a1e3 100644 --- a/controllers/cloud_profile.go +++ b/controllers/cloud_profile.go @@ -22,6 +22,8 @@ import ( "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ocirepo" "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/provider/ironcore" + osprovider "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/provider/openstack" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/source/glance" "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/source/oci" ) @@ -106,6 +108,28 @@ func (r *Reconciler) updateMachineImages(ctx context.Context, log logr.Logger, u } source = src + case update.Source.Glance != nil: + password, err := r.getCredential(ctx, update.Source.Glance.PasswordSecret) + if err != nil { + return err + } + src, err := glance.NewGlance(glance.GlanceParams{ + AuthURLFormat: update.Source.Glance.AuthURLFormat, + Regions: update.Source.Glance.Regions, + NamePrefix: update.Source.Glance.NamePrefix, + KeepLatest: update.Source.Glance.KeepLatest, + Parallel: update.Source.Glance.Parallel, + ProjectName: update.Source.Glance.ProjectName, + ProjectDomainName: update.Source.Glance.ProjectDomainName, + Username: update.Source.Glance.Username, + UserDomainName: update.Source.Glance.UserDomainName, + Password: string(password), + }, log) + if err != nil { + return fmt.Errorf("failed to initialize Glance source: %w", err) + } + source = src + default: return errors.New("no machine images source configured") } @@ -119,6 +143,10 @@ func (r *Reconciler) updateMachineImages(ctx context.Context, log logr.Logger, u ImageName: update.ImageName, EnableCapabilities: r.EnableCapabilities, } + case update.Provider.OpenStack != nil: + provider = &osprovider.OpenStackProvider{ + ImageName: update.ImageName, + } default: return errors.New("no known provider configured") } diff --git a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml index a067c32..b1aadc1 100644 --- a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml +++ b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml @@ -774,11 +774,83 @@ spec: - registry - repository type: object + openStack: + description: OpenStack contains configuration to update + provider.machineImages for OpenStack CloudProfiles. + type: object type: object source: description: Source contains configuration for a source for machine images. properties: + glance: + description: Glance contains configuration for an OpenStack + Glance source. + properties: + authURLFormat: + description: AuthURLFormat is the Keystone endpoint + format string with a single "%s" for the region. + type: string + keepLatest: + description: KeepLatest limits results to the newest + N versions. + type: integer + namePrefix: + description: NamePrefix selects images by name prefix. + Empty means the default. + type: string + parallel: + description: Parallel bounds how many regions are queried + concurrently. + format: int64 + type: integer + passwordSecret: + description: PasswordSecret is a reference to a secret + containing the OpenStack password. + properties: + key: + description: Key within the Secret to use for required + data. + type: string + name: + description: Name of a Secret. + type: string + namespace: + description: Namespace of a Secret. + type: string + required: + - key + - name + - namespace + type: object + projectDomainName: + description: ProjectDomainName scopes the token domain. + type: string + projectName: + description: ProjectName scopes the token. + type: string + regions: + description: Regions is the list of OpenStack regions + to query. + items: + type: string + type: array + userDomainName: + description: UserDomainName is the domain of the authenticating + user. + type: string + username: + description: Username for authentication. + type: string + required: + - authURLFormat + - passwordSecret + - projectDomainName + - projectName + - regions + - userDomainName + - username + type: object oci: description: OCI contains configuration for an OCI source. properties: diff --git a/go.mod b/go.mod index d101a65..78c2e03 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/cobaltcore-dev/cloud-profile-sync -go 1.26.0 +go 1.26.2 require ( github.com/blang/semver/v4 v4.0.0 github.com/distribution/distribution/v3 v3.1.1 - github.com/gardener/gardener/pkg/apis v1.144.0 + github.com/gardener/gardener/pkg/apis v1.145.0 github.com/go-logr/logr v1.4.3 github.com/ironcore-dev/gardener-extension-provider-ironcore-metal v0.1.1-0.20260624151759-9166baa81e86 github.com/onsi/ginkgo/v2 v2.32.0 @@ -42,6 +42,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/gardener/gardener-extension-provider-openstack v1.57.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect @@ -63,6 +64,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gophercloud/gophercloud/v2 v2.13.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect @@ -70,13 +72,13 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect - github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_golang v1.23.3-0.20260602051030-3537b20ac86b // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.68.1 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect @@ -120,7 +122,7 @@ require ( golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.46.0 // indirect + golang.org/x/tools v0.47.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260608224507-4308a22a1bab // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab // indirect diff --git a/go.sum b/go.sum index 777c174..69a40d0 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,12 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gardener/gardener-extension-provider-openstack v1.57.0 h1:QQXVSOC+9c94r21BbcLW1gQfxJ95zmo0e18Aoy32BhQ= +github.com/gardener/gardener-extension-provider-openstack v1.57.0/go.mod h1:N397cSwvKwFl/as2g39eeXyFj3gON83KQb7TliQX9nI= github.com/gardener/gardener/pkg/apis v1.144.0 h1:xlnGMbliM5TjlK5o2oCxzyFSmtxgGerP4SDwEXdHL2E= github.com/gardener/gardener/pkg/apis v1.144.0/go.mod h1:we6hJ8r80nL1rkXzVnOQwey4q77pQXHN3pvoBgeak8g= +github.com/gardener/gardener/pkg/apis v1.145.0 h1:E9mnDYOKOoOEJnpGCPpFuS0OX32uoOnv27a03b/nlP0= +github.com/gardener/gardener/pkg/apis v1.145.0/go.mod h1:LsjZw5/3awWSMDnKg4bgftM1kW9Dkeor/ged5DNHVPI= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -129,6 +133,8 @@ github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVp github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gophercloud/gophercloud/v2 v2.13.0 h1:yEyJG+kABd8x2ttTqLsomihU6Kg2YheJSZhvP/QSx+8= +github.com/gophercloud/gophercloud/v2 v2.13.0/go.mod h1:KZRLVs6gcoy/pEFdkZqFjdYqnS0emMHv66UqdM5lMjU= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -152,6 +158,8 @@ github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -195,6 +203,8 @@ github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5Fsn github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_golang v1.23.3-0.20260602051030-3537b20ac86b h1:QNV54DNcRqdeECNdEXiOqTmI75w2rlZtOq5rt8RKhVo= +github.com/prometheus/client_golang v1.23.3-0.20260602051030-3537b20ac86b/go.mod h1:kPaff19KETV3GKIZJehgPmlA2Di3jNeWdgKA9RpObuU= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -344,6 +354,8 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= From 17955c74e4f9b1e68d49f88927166dbcc368b213 Mon Sep 17 00:00:00 2001 From: C5421281 Date: Thu, 20 Aug 2026 13:14:37 +0200 Subject: [PATCH 08/23] fix: codeRabbitAI review Signed-off-by: C5421281 --- cloudprofilesync/ossync/os_image_updater.go | 14 +++++++++----- cloudprofilesync/ossync/source/glance/os_source.go | 3 +-- go.mod | 6 +++--- go.sum | 8 -------- 4 files changed, 13 insertions(+), 18 deletions(-) diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index 3abdd66..a63e780 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -122,6 +122,13 @@ func (iu *ImageUpdater) resolveExpiration(src SourceImage, existing *metav1.Time return &now } +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 { @@ -157,6 +164,7 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou image.Versions[idx].Classification = sourceImage.Classification // Stamp expiration once on the transition to deprecated; preserve it thereafter. image.Versions[idx].ExpirationDate = iu.resolveExpiration(sourceImage, image.Versions[idx].ExpirationDate) + 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 @@ -191,11 +199,7 @@ 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.InPlaceUpdates = inPlaceUpdates(sourceImage.SupportInPlaceUpdate) } else { image.Versions = append(image.Versions, gardenerv1beta1.MachineImageVersion{ ExpirableVersion: gardenerv1beta1.ExpirableVersion{ diff --git a/cloudprofilesync/ossync/source/glance/os_source.go b/cloudprofilesync/ossync/source/glance/os_source.go index 8e38a62..d09fa64 100644 --- a/cloudprofilesync/ossync/source/glance/os_source.go +++ b/cloudprofilesync/ossync/source/glance/os_source.go @@ -17,7 +17,6 @@ import ( "github.com/gophercloud/gophercloud/v2/openstack" "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" "golang.org/x/sync/semaphore" - _ "k8s.io/apimachinery/pkg/apis/meta/v1" ) const ( @@ -152,7 +151,7 @@ func (g *Glance) authOptions() gophercloud.AuthOptions { } func (g *Glance) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) { - out := make(chan Result[[]ossync.SourceImage]) + out := make(chan Result[[]ossync.SourceImage], len(g.params.Regions)) for _, region := range g.params.Regions { go func() { if err := g.sema.Acquire(ctx, 1); err != nil { diff --git a/go.mod b/go.mod index 78c2e03..6e9b4bb 100644 --- a/go.mod +++ b/go.mod @@ -5,15 +5,16 @@ go 1.26.2 require ( github.com/blang/semver/v4 v4.0.0 github.com/distribution/distribution/v3 v3.1.1 + github.com/gardener/gardener-extension-provider-openstack v1.57.0 github.com/gardener/gardener/pkg/apis v1.145.0 github.com/go-logr/logr v1.4.3 + github.com/gophercloud/gophercloud/v2 v2.13.0 github.com/ironcore-dev/gardener-extension-provider-ironcore-metal v0.1.1-0.20260624151759-9166baa81e86 github.com/onsi/ginkgo/v2 v2.32.0 github.com/onsi/gomega v1.42.1 github.com/opencontainers/image-spec v1.1.1 go.uber.org/zap v1.28.0 go.yaml.in/yaml/v3 v3.0.4 - golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.21.0 k8s.io/api v0.36.0 k8s.io/apiextensions-apiserver v0.36.0 @@ -42,7 +43,6 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect - github.com/gardener/gardener-extension-provider-openstack v1.57.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect @@ -64,7 +64,6 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gophercloud/gophercloud/v2 v2.13.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect @@ -118,6 +117,7 @@ require ( golang.org/x/crypto v0.53.0 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect diff --git a/go.sum b/go.sum index 69a40d0..73d0c7e 100644 --- a/go.sum +++ b/go.sum @@ -56,8 +56,6 @@ github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gardener/gardener-extension-provider-openstack v1.57.0 h1:QQXVSOC+9c94r21BbcLW1gQfxJ95zmo0e18Aoy32BhQ= github.com/gardener/gardener-extension-provider-openstack v1.57.0/go.mod h1:N397cSwvKwFl/as2g39eeXyFj3gON83KQb7TliQX9nI= -github.com/gardener/gardener/pkg/apis v1.144.0 h1:xlnGMbliM5TjlK5o2oCxzyFSmtxgGerP4SDwEXdHL2E= -github.com/gardener/gardener/pkg/apis v1.144.0/go.mod h1:we6hJ8r80nL1rkXzVnOQwey4q77pQXHN3pvoBgeak8g= github.com/gardener/gardener/pkg/apis v1.145.0 h1:E9mnDYOKOoOEJnpGCPpFuS0OX32uoOnv27a03b/nlP0= github.com/gardener/gardener/pkg/apis v1.145.0/go.mod h1:LsjZw5/3awWSMDnKg4bgftM1kW9Dkeor/ged5DNHVPI= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -156,8 +154,6 @@ github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -201,8 +197,6 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.23.3-0.20260602051030-3537b20ac86b h1:QNV54DNcRqdeECNdEXiOqTmI75w2rlZtOq5rt8RKhVo= github.com/prometheus/client_golang v1.23.3-0.20260602051030-3537b20ac86b/go.mod h1:kPaff19KETV3GKIZJehgPmlA2Di3jNeWdgKA9RpObuU= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= @@ -352,8 +346,6 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= -golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= From 1e7da5b1c4468f1dafd1bf336f6e3a0f40115332 Mon Sep 17 00:00:00 2001 From: C5421281 Date: Thu, 20 Aug 2026 16:44:13 +0200 Subject: [PATCH 09/23] fix: CopilotAI review Signed-off-by: C5421281 --- cloudprofilesync/ossync/os_image_updater.go | 6 ++- .../ossync/provider/openstack/provider.go | 7 ++- .../provider/openstack/provider_test.go | 15 +++--- .../ossync/source/glance/os_source.go | 23 ++++++++- .../ossync/source/glance/os_source_test.go | 49 +++++++++++++++++++ 5 files changed, 89 insertions(+), 11 deletions(-) diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index a63e780..1268638 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -199,11 +199,15 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou existing.Architectures = append(existing.Architectures, arch) } } + existing.Classification = sourceImage.Classification + existing.ExpirationDate = iu.resolveExpiration(sourceImage, existing.ExpirationDate) 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), }) diff --git a/cloudprofilesync/ossync/provider/openstack/provider.go b/cloudprofilesync/ossync/provider/openstack/provider.go index 189ca6d..77287e7 100644 --- a/cloudprofilesync/ossync/provider/openstack/provider.go +++ b/cloudprofilesync/ossync/provider/openstack/provider.go @@ -52,15 +52,18 @@ func (p *OpenStackProvider) Configure(cpSpec *gardencorev1beta1.CloudProfileSpec entry := &image.Versions[idx] for _, r := range src.Regions { - alreadyPresent := slices.ContainsFunc(entry.Regions, func(m openstackv1alpha1.RegionIDMapping) bool { + existing := slices.IndexFunc(entry.Regions, func(m openstackv1alpha1.RegionIDMapping) bool { return m.Name == r.Region }) - if !alreadyPresent { + 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 } } diff --git a/cloudprofilesync/ossync/provider/openstack/provider_test.go b/cloudprofilesync/ossync/provider/openstack/provider_test.go index 1bcfb80..e9a2236 100644 --- a/cloudprofilesync/ossync/provider/openstack/provider_test.go +++ b/cloudprofilesync/ossync/provider/openstack/provider_test.go @@ -90,8 +90,9 @@ func TestConfigureMergesIntoExistingImage(t *testing.T) { } } -// Configure does not duplicate an existing region or overwrite its ID. -func TestConfigureIsIdempotentOnRegions(t *testing.T) { +// Configure does not duplicate an existing region but updates its ID so a +// rebuilt image (same version, new UUID) replaces the stale mapping. +func TestConfigureUpdatesExistingRegionID(t *testing.T) { p := &OpenStackProvider{ImageName: imageName} spec := specWithConfig(t, &openstackv1alpha1.CloudProfileConfig{ MachineImages: []openstackv1alpha1.MachineImages{ @@ -100,19 +101,19 @@ func TestConfigureIsIdempotentOnRegions(t *testing.T) { Versions: []openstackv1alpha1.MachineImageVersion{ { Version: testVersion, - Regions: []openstackv1alpha1.RegionIDMapping{{Name: regionDE, ID: "existing-uuid"}}, + Regions: []openstackv1alpha1.RegionIDMapping{{Name: regionDE, ID: "stale-uuid"}}, }, }, }, }, }) - // Re-apply the same region (with a different ID) plus a new one. + // Re-apply the same region (with a new ID) plus a new one. err := p.Configure(spec, []ossync.SourceImage{ { Version: testVersion, Regions: []ossync.RegionImage{ - {Region: regionDE, ID: "would-be-new-uuid"}, + {Region: regionDE, ID: "rebuilt-uuid"}, {Region: regionNL, ID: "uuid-nl-1"}, }, }, @@ -130,8 +131,8 @@ func TestConfigureIsIdempotentOnRegions(t *testing.T) { t.Fatalf("got %d regions, want 2 (%s must not be duplicated): %+v", len(v.Regions), regionDE, v.Regions) } for _, r := range v.Regions { - if r.Name == regionDE && r.ID != "existing-uuid" { - t.Errorf("%s ID = %q, want existing-uuid (existing region must not be overwritten)", regionDE, r.ID) + if r.Name == regionDE && r.ID != "rebuilt-uuid" { + t.Errorf("%s ID = %q, want rebuilt-uuid (stale mapping must be updated)", regionDE, r.ID) } } } diff --git a/cloudprofilesync/ossync/source/glance/os_source.go b/cloudprofilesync/ossync/source/glance/os_source.go index d09fa64..88e3fc6 100644 --- a/cloudprofilesync/ossync/source/glance/os_source.go +++ b/cloudprofilesync/ossync/source/glance/os_source.go @@ -232,12 +232,20 @@ func (g *Glance) discoverRegion(ctx context.Context, region string) ([]ossync.So return nil, fmt.Errorf("region %s: list images: %w", region, err) } - var found []ossync.SourceImage + // Pick one canonical image per version (a rebuilt image reuses the version with a new UUID). + canonical := map[string]images.Image{} for _, img := range imgs { version, ok := g.parseVersion(img.Name) if !ok { continue } + if cur, exists := canonical[version]; !exists || preferImage(img, cur) { + canonical[version] = img + } + } + + found := make([]ossync.SourceImage, 0, len(canonical)) + for version, img := range canonical { found = append(found, ossync.SourceImage{ Version: version, Architectures: []string{"amd64"}, @@ -247,6 +255,19 @@ func (g *Glance) discoverRegion(ctx context.Context, region string) ([]ossync.So return found, nil } +// preferImage reports whether candidate should replace current: active wins over non-active, then newer CreatedAt, then larger UUID (deterministic tiebreak). +func preferImage(candidate, current images.Image) bool { + candActive := candidate.Status == images.ImageStatusActive + curActive := current.Status == images.ImageStatusActive + if candActive != curActive { + return candActive + } + if !candidate.CreatedAt.Equal(current.CreatedAt) { + return candidate.CreatedAt.After(current.CreatedAt) + } + return candidate.ID > current.ID +} + // compareSemverDesc orders two versions newest-first. Unparsable versions sort last. func compareSemverDesc(a, b string) int { av, aerr := semver.ParseTolerant(a) diff --git a/cloudprofilesync/ossync/source/glance/os_source_test.go b/cloudprofilesync/ossync/source/glance/os_source_test.go index 630b9ee..75f82f5 100644 --- a/cloudprofilesync/ossync/source/glance/os_source_test.go +++ b/cloudprofilesync/ossync/source/glance/os_source_test.go @@ -6,6 +6,7 @@ package glance import ( "context" "testing" + "time" "github.com/go-logr/logr" "github.com/gophercloud/gophercloud/v2" @@ -92,6 +93,54 @@ func TestGetVersionsUsiDoesNotCollide(t *testing.T) { } } +// When a region has two images for the same version (a rebuilt image with a new +// UUID), the newest active image is chosen deterministically. +func TestGetVersionsPicksCanonicalImage(t *testing.T) { + older := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + newer := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC) + imgs := []images.Image{ + {ID: "old-active", Name: stdImage, Status: images.ImageStatusActive, CreatedAt: older}, + {ID: "new-active", Name: stdImage, Status: images.ImageStatusActive, CreatedAt: newer}, + {ID: "new-queued", Name: stdImage, Status: images.ImageStatusQueued, CreatedAt: newer}, + } + 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) != 1 { + t.Fatalf("got %d versions, want 1: %+v", len(versions), versions) + } + v := versions[0] + if len(v.Regions) != 1 { + t.Fatalf("got %d region entries, want 1 (rebuilt image must not duplicate the region): %+v", len(v.Regions), v.Regions) + } + if v.Regions[0].ID != "new-active" { + t.Errorf("region ID = %q, want new-active (newest active image must win)", v.Regions[0].ID) + } +} + +// preferImage selection is stable regardless of listing order. +func TestPreferImageDeterministic(t *testing.T) { + base := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + active := images.Image{ID: "a", Status: images.ImageStatusActive, CreatedAt: base} + queued := images.Image{ID: "z", Status: images.ImageStatusQueued, CreatedAt: base.Add(time.Hour)} + if !preferImage(active, queued) { + t.Error("active image should be preferred over a newer queued image") + } + if preferImage(queued, active) { + t.Error("newer queued image should not be preferred over an active image") + } + + // Same status and CreatedAt: larger UUID wins, both directions. + lo := images.Image{ID: "aaa", Status: images.ImageStatusActive, CreatedAt: base} + hi := images.Image{ID: "bbb", Status: images.ImageStatusActive, CreatedAt: base} + if !preferImage(hi, lo) || preferImage(lo, hi) { + t.Error("UUID tie-break is not deterministic") + } +} + // 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() From 6b560d755a3ca9acd09beaaf5764add6d2899e08 Mon Sep 17 00:00:00 2001 From: C5421281 Date: Fri, 21 Aug 2026 11:57:28 +0200 Subject: [PATCH 10/23] fix: Linter error Signed-off-by: C5421281 --- cloudprofilesync/ossync/os_image_updater.go | 8 ++++---- cloudprofilesync/ossync/os_image_updater_test.go | 8 ++++---- cloudprofilesync/ossync/source/glance/os_source.go | 3 ++- go.mod | 4 ++-- go.sum | 4 ++-- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index 1268638..907db24 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -161,9 +161,9 @@ 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 + 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) + 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 @@ -199,8 +199,8 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou existing.Architectures = append(existing.Architectures, arch) } } - existing.Classification = sourceImage.Classification - existing.ExpirationDate = iu.resolveExpiration(sourceImage, existing.ExpirationDate) + 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{ diff --git a/cloudprofilesync/ossync/os_image_updater_test.go b/cloudprofilesync/ossync/os_image_updater_test.go index e85d238..f72d96f 100644 --- a/cloudprofilesync/ossync/os_image_updater_test.go +++ b/cloudprofilesync/ossync/os_image_updater_test.go @@ -393,7 +393,7 @@ var _ = Describe("ImageUpdater", func() { 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)) + 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) { @@ -405,7 +405,7 @@ var _ = Describe("ImageUpdater", func() { 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)) + 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) { @@ -416,7 +416,7 @@ var _ = Describe("ImageUpdater", func() { 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()) + 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) { @@ -427,7 +427,7 @@ var _ = Describe("ImageUpdater", func() { 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()) + Expect(cpSpec.MachineImages[0].Versions[0].ExpirationDate).To(BeNil()) //nolint:staticcheck // legacy field; Lifecycle needs the VersionClassificationLifecycle feature gate }) }) }) diff --git a/cloudprofilesync/ossync/source/glance/os_source.go b/cloudprofilesync/ossync/source/glance/os_source.go index 88e3fc6..0eb1b71 100644 --- a/cloudprofilesync/ossync/source/glance/os_source.go +++ b/cloudprofilesync/ossync/source/glance/os_source.go @@ -10,13 +10,14 @@ import ( "time" "github.com/blang/semver/v4" - "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" "github.com/go-logr/logr" "github.com/gophercloud/gophercloud/v2" "github.com/gophercloud/gophercloud/v2/openstack" "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" "golang.org/x/sync/semaphore" + + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" ) const ( diff --git a/go.mod b/go.mod index 6e9b4bb..2c99806 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,10 @@ module github.com/cobaltcore-dev/cloud-profile-sync -go 1.26.2 +go 1.26.0 require ( github.com/blang/semver/v4 v4.0.0 github.com/distribution/distribution/v3 v3.1.1 - github.com/gardener/gardener-extension-provider-openstack v1.57.0 github.com/gardener/gardener/pkg/apis v1.145.0 github.com/go-logr/logr v1.4.3 github.com/gophercloud/gophercloud/v2 v2.13.0 @@ -43,6 +42,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/gardener/gardener-extension-provider-openstack v1.54.0 github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect diff --git a/go.sum b/go.sum index 73d0c7e..59f0119 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,8 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gardener/gardener-extension-provider-openstack v1.57.0 h1:QQXVSOC+9c94r21BbcLW1gQfxJ95zmo0e18Aoy32BhQ= -github.com/gardener/gardener-extension-provider-openstack v1.57.0/go.mod h1:N397cSwvKwFl/as2g39eeXyFj3gON83KQb7TliQX9nI= +github.com/gardener/gardener-extension-provider-openstack v1.54.0 h1:Bg/5rk1zMdMxtsLPKhOKfo3e6O2n+JwETzAixPDSJ3o= +github.com/gardener/gardener-extension-provider-openstack v1.54.0/go.mod h1:mQue5NBj8udoTdD4ArXMuJ1wNMemTl2Am9astQ0SZvc= github.com/gardener/gardener/pkg/apis v1.145.0 h1:E9mnDYOKOoOEJnpGCPpFuS0OX32uoOnv27a03b/nlP0= github.com/gardener/gardener/pkg/apis v1.145.0/go.mod h1:LsjZw5/3awWSMDnKg4bgftM1kW9Dkeor/ged5DNHVPI= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= From 8fb0853e5ef909fd2578157c866e1a39afebb6d8 Mon Sep 17 00:00:00 2001 From: sapcc-bot Date: Thu, 20 Aug 2026 19:03:28 +0000 Subject: [PATCH 11/23] Run go-makefile-maker Signed-off-by: C5421281 --- .github/workflows/checks.yaml | 2 +- .github/workflows/ci.yaml | 4 ++-- .github/workflows/codeql.yaml | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 38beec8..5a38a7c 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -31,7 +31,7 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: check-latest: true - go-version: 1.26.5 + go-version: 1.26.6 - name: Run golangci-lint uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 with: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 919c47b..9bd17f5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,7 +34,7 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: check-latest: true - go-version: 1.26.5 + go-version: 1.26.6 - name: Build all binaries run: make build-all code_coverage: @@ -72,7 +72,7 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: check-latest: true - go-version: 1.26.5 + go-version: 1.26.6 - name: Run tests and generate coverage report run: make build/cover.out - name: Archive code coverage results diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 5375fc9..17caa4a 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -34,13 +34,13 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: check-latest: true - go-version: 1.26.5 + go-version: 1.26.6 - name: Initialize CodeQL - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 with: languages: go queries: security-extended - name: Autobuild - uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 From 5ee742017324b8f1601ab6debaed301a9db5fb66 Mon Sep 17 00:00:00 2001 From: yahor-kurachkin Date: Fri, 21 Aug 2026 12:30:55 +0200 Subject: [PATCH 12/23] feat: Automate gardenlinux image lifecycle management for OpenStack (#44) * Run go-makefile-maker Signed-off-by: C5421281 * feat: add OpenStack Glance source and provider for gardenlinux images Add machine-image discovery for OpenStack CloudProfiles: - Glance source: discover public gardenlinux images across regions, parse versions from image names, keep the newest N (default 3), skip _usi variants. - OpenStackProvider: write per-region image UUIDs into the gardener-extension-provider-openstack providerConfig. - Lifecycle: mark the oldest kept version deprecated and stamp its expirationDate once on the transition, preserving it thereafter (ImageUpdater.resolveExpiration). - Wire GlanceSource into the ManagedCloudProfile API and controller; regenerate CRD and deepcopy. - Unit tests for expiration, usi skipping, and provider config. Signed-off-by: C5421281 * feat: add OpenStack Glance source and provider for gardenlinux images Add machine-image discovery for OpenStack CloudProfiles: - Glance source: discover public gardenlinux images across regions, parse versions from image names, keep the newest N (default 3), skip _usi variants. - OpenStackProvider: write per-region image UUIDs into the gardener-extension-provider-openstack providerConfig. - Lifecycle: mark the oldest kept version deprecated and stamp its expirationDate once on the transition, preserving it thereafter (ImageUpdater.resolveExpiration). - Wire GlanceSource into the ManagedCloudProfile API and controller; regenerate CRD and deepcopy. - Unit tests for expiration, usi skipping, and provider config. Signed-off-by: C5421281 # Conflicts: # cloudprofilesync/ossync/source/glance/os_source_test.go * fix: codeRabbitAI review Signed-off-by: C5421281 * fix: CopilotAI review Signed-off-by: C5421281 * fix: Linter error Signed-off-by: C5421281 * fix: add license Signed-off-by: C5421281 --------- Signed-off-by: C5421281 Signed-off-by: yahor-kurachkin Co-authored-by: sapcc-bot Signed-off-by: C5421281 --- cloudprofilesync/ossync/provider/openstack/provider.go | 3 +++ cloudprofilesync/ossync/source/glance/os_source.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/cloudprofilesync/ossync/provider/openstack/provider.go b/cloudprofilesync/ossync/provider/openstack/provider.go index 77287e7..24253cf 100644 --- a/cloudprofilesync/ossync/provider/openstack/provider.go +++ b/cloudprofilesync/ossync/provider/openstack/provider.go @@ -1,5 +1,8 @@ package openstack +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + import ( "encoding/json" "slices" diff --git a/cloudprofilesync/ossync/source/glance/os_source.go b/cloudprofilesync/ossync/source/glance/os_source.go index 0eb1b71..f7d8bd2 100644 --- a/cloudprofilesync/ossync/source/glance/os_source.go +++ b/cloudprofilesync/ossync/source/glance/os_source.go @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + package glance import ( From 4e88a6943dd221d8a9c34da472c71606874ec47e Mon Sep 17 00:00:00 2001 From: Anton Paulovich Date: Wed, 26 Aug 2026 13:48:22 +0200 Subject: [PATCH 13/23] Populate `capabilityFlavors` to spec.machineImages (#46) * Populate capabilities Signed-off-by: Anton Paulovich * Changes motivated by AI code review Signed-off-by: Anton Paulovich --------- Signed-off-by: Anton Paulovich Signed-off-by: C5421281 --- cloudprofilesync/ossync/os_image_updater.go | 48 +- .../ossync/os_image_updater_test.go | 164 + controllers/garbage_collection.go | 41 +- .../managedcloudprofile_controller_test.go | 185 +- crd/README.md | 24 + crd/core.gardener.cloud_cloudprofiles.yaml | 1011 ++--- crd/core.gardener.cloud_shoots.yaml | 3319 ++++++++++++++++- 7 files changed, 4257 insertions(+), 535 deletions(-) create mode 100644 crd/README.md diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index 907db24..498f979 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -122,6 +122,35 @@ func (iu *ImageUpdater) resolveExpiration(src SourceImage, existing *metav1.Time return &now } +// mergeCapabilityFlavor appends the flavor from src to existing if not already present. +func mergeCapabilityFlavor(existing []gardenerv1beta1.MachineImageFlavor, caps gardenerv1beta1.Capabilities) []gardenerv1beta1.MachineImageFlavor { + if len(caps) == 0 { + return existing + } + for _, f := range existing { + if capabilitiesEqual(f.Capabilities, caps) { + return existing + } + } + return append(existing, gardenerv1beta1.MachineImageFlavor{Capabilities: caps}) +} + +func capabilitiesEqual(a, b gardenerv1beta1.Capabilities) bool { + if len(a) != len(b) { + return false + } + for k, aVals := range a { + bVals, ok := b[k] + if !ok { + return false + } + if !slices.Equal(aVals, bVals) { + return false + } + } + return true +} + func inPlaceUpdates(supported bool) *gardenerv1beta1.InPlaceUpdates { if !supported { return nil @@ -190,8 +219,10 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou } } - // When capabilities are enabled, also write the clean version entry. - if iu.EnableCapabilities && sourceImage.CleanVersion != "" && sourceImage.CleanVersion != sourceImage.Version { + // When capabilities are enabled, also write/update the clean version entry. + // When CleanVersion == Version the entry already exists from the legacy path above; + // the existing-entry branch merges the flavor onto it without re-writing other fields. + if iu.EnableCapabilities && sourceImage.CleanVersion != "" { if idx, exists := existingVersions[sourceImage.CleanVersion]; exists { existing := &image.Versions[idx] for _, arch := range sourceImage.Architectures { @@ -202,20 +233,21 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou 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) + existing.CapabilityFlavors = mergeCapabilityFlavor(existing.CapabilityFlavors, sourceImage.Capabilities) } else { - image.Versions = append(image.Versions, gardenerv1beta1.MachineImageVersion{ + v := gardenerv1beta1.MachineImageVersion{ ExpirableVersion: gardenerv1beta1.ExpirableVersion{ Version: sourceImage.CleanVersion, Classification: sourceImage.Classification, ExpirationDate: iu.resolveExpiration(sourceImage, nil), }, - Architectures: slices.Clone(sourceImage.Architectures), - }) + Architectures: slices.Clone(sourceImage.Architectures), + CapabilityFlavors: mergeCapabilityFlavor(nil, sourceImage.Capabilities), + } if sourceImage.SupportInPlaceUpdate { - image.Versions[len(image.Versions)-1].InPlaceUpdates = &gardenerv1beta1.InPlaceUpdates{ - Supported: sourceImage.SupportInPlaceUpdate, - } + v.InPlaceUpdates = &gardenerv1beta1.InPlaceUpdates{Supported: true} } + image.Versions = append(image.Versions, v) existingVersions[sourceImage.CleanVersion] = len(image.Versions) - 1 } } diff --git a/cloudprofilesync/ossync/os_image_updater_test.go b/cloudprofilesync/ossync/os_image_updater_test.go index f72d96f..3db6be3 100644 --- a/cloudprofilesync/ossync/os_image_updater_test.go +++ b/cloudprofilesync/ossync/os_image_updater_test.go @@ -248,6 +248,170 @@ var _ = Describe("ImageUpdater", func() { }) Describe("flag ON (dual-write clean version)", func() { + It("sets CapabilityFlavors when CleanVersion equals Version (semver tag with matching annotation)", func(ctx SpecContext) { + mockSource.images = []ossync.SourceImage{ + { + Version: "2254.0.0", + CleanVersion: "2254.0.0", + Architectures: []string{"amd64"}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + }, + } + updater := ossync.ImageUpdater{ + Log: GinkgoLogr, + Source: &mockSource, + ImageName: "test", + EnableCapabilities: true, + } + var cpSpec gardencorev1beta1.CloudProfileSpec + Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) + + Expect(cpSpec.MachineImages[0].Versions).To(HaveLen(1)) + v := cpSpec.MachineImages[0].Versions[0] + Expect(v.Version).To(Equal("2254.0.0")) + Expect(v.CapabilityFlavors).To(HaveLen(1)) + Expect(v.CapabilityFlavors[0].Capabilities).To(Equal( + gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + )) + }) + + It("sets CapabilityFlavors on the clean version entry", func(ctx SpecContext) { + mockSource.images = []ossync.SourceImage{ + { + Version: "2254.0.0-baremetal-sci-usi-amd64", + CleanVersion: "2254.0.0", + Architectures: []string{"amd64"}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + }, + } + updater := ossync.ImageUpdater{ + Log: GinkgoLogr, + Source: &mockSource, + ImageName: "test", + EnableCapabilities: true, + } + var cpSpec gardencorev1beta1.CloudProfileSpec + Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) + + versions := cpSpec.MachineImages[0].Versions + var cleanEntry *gardencorev1beta1.MachineImageVersion + for i := range versions { + if versions[i].Version == "2254.0.0" { + cleanEntry = &versions[i] + break + } + } + Expect(cleanEntry).NotTo(BeNil()) + Expect(cleanEntry.CapabilityFlavors).To(HaveLen(1)) + Expect(cleanEntry.CapabilityFlavors[0].Capabilities).To(Equal( + gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + )) + }) + + It("accumulates multiple flavors under the same clean version entry", func(ctx SpecContext) { + mockSource.images = []ossync.SourceImage{ + { + Version: "2254.0.0-baremetal-sci-usi-amd64", + CleanVersion: "2254.0.0", + Architectures: []string{"amd64"}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + }, + { + Version: "2254.0.0-baremetal-sci-pxe-amd64", + CleanVersion: "2254.0.0", + Architectures: []string{"amd64"}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_pxe"}}, + }, + } + updater := ossync.ImageUpdater{ + Log: GinkgoLogr, + Source: &mockSource, + ImageName: "test", + EnableCapabilities: true, + } + var cpSpec gardencorev1beta1.CloudProfileSpec + Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) + + versions := cpSpec.MachineImages[0].Versions + var cleanEntry *gardencorev1beta1.MachineImageVersion + for i := range versions { + if versions[i].Version == "2254.0.0" { + cleanEntry = &versions[i] + break + } + } + Expect(cleanEntry).NotTo(BeNil()) + Expect(cleanEntry.CapabilityFlavors).To(HaveLen(2)) + flavors := []gardencorev1beta1.Capabilities{ + cleanEntry.CapabilityFlavors[0].Capabilities, + cleanEntry.CapabilityFlavors[1].Capabilities, + } + Expect(flavors).To(ConsistOf( + gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_pxe"}}, + )) + }) + + It("does not append duplicate flavors on re-reconcile", func(ctx SpecContext) { + mockSource.images = []ossync.SourceImage{ + { + Version: "2254.0.0-baremetal-sci-usi-amd64", + CleanVersion: "2254.0.0", + Architectures: []string{"amd64"}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + }, + } + updater := ossync.ImageUpdater{ + Log: GinkgoLogr, + Source: &mockSource, + ImageName: "test", + EnableCapabilities: true, + } + var cpSpec gardencorev1beta1.CloudProfileSpec + Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) + Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) + + versions := cpSpec.MachineImages[0].Versions + var cleanEntry *gardencorev1beta1.MachineImageVersion + for i := range versions { + if versions[i].Version == "2254.0.0" { + cleanEntry = &versions[i] + break + } + } + Expect(cleanEntry).NotTo(BeNil()) + Expect(cleanEntry.CapabilityFlavors).To(HaveLen(1)) + }) + + It("does not set CapabilityFlavors when Capabilities is nil", func(ctx SpecContext) { + mockSource.images = []ossync.SourceImage{ + { + Version: "2254.0.0-baremetal-amd64", + CleanVersion: "2254.0.0", + Architectures: []string{"amd64"}, + }, + } + updater := ossync.ImageUpdater{ + Log: GinkgoLogr, + Source: &mockSource, + ImageName: "test", + EnableCapabilities: true, + } + var cpSpec gardencorev1beta1.CloudProfileSpec + Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) + + versions := cpSpec.MachineImages[0].Versions + var cleanEntry *gardencorev1beta1.MachineImageVersion + for i := range versions { + if versions[i].Version == "2254.0.0" { + cleanEntry = &versions[i] + break + } + } + Expect(cleanEntry).NotTo(BeNil()) + Expect(cleanEntry.CapabilityFlavors).To(BeEmpty()) + }) + It("writes both full tag and clean version entries when CleanVersion differs", func(ctx SpecContext) { mockSource.images = []ossync.SourceImage{ { diff --git a/controllers/garbage_collection.go b/controllers/garbage_collection.go index 4ccb0e5..c827bc3 100644 --- a/controllers/garbage_collection.go +++ b/controllers/garbage_collection.go @@ -126,10 +126,10 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image return err } - // Track which clean versions still have remaining capability flavors after deletion, - // so we can cascade-delete empty clean version entries from spec.machineImages. - // A version present in this map was a clean version entry; true means it still has flavors. - cleanVersionsWithFlavors := make(map[string]bool) + // Track surviving capability flavors per clean version so the spec.machineImages + // entry can be kept in sync. Nil value means the version was not a clean version entry. + // Non-nil (possibly empty) slice means it was, and holds the remaining capabilities. + survivingFlavors := make(map[string][]gardenerv1beta1.Capabilities) if cp.Spec.ProviderConfig != nil { var cfg providercfg.CloudProfileConfig @@ -146,11 +146,7 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image // Legacy flat entry — not a clean version, skip. continue } - // Mark as a clean version entry; value indicates whether any flavors remain. - cleanVersionsWithFlavors[v.Version] = len(v.CapabilityFlavors) > 0 - if len(v.CapabilityFlavors) == 0 { - continue - } + // Prune stale flavors. v.CapabilityFlavors = slices.DeleteFunc(v.CapabilityFlavors, func(f providercfg.MachineImageFlavor) bool { idx := strings.LastIndex(f.Image, ":") if idx == -1 { @@ -159,7 +155,12 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image _, exists := versionsToDelete[f.Image[idx+1:]] return exists }) - cleanVersionsWithFlavors[v.Version] = len(v.CapabilityFlavors) > 0 + // Record surviving capabilities for this clean version. + caps := make([]gardenerv1beta1.Capabilities, 0, len(v.CapabilityFlavors)) + for _, f := range v.CapabilityFlavors { + caps = append(caps, f.Capabilities) + } + survivingFlavors[v.Version] = caps } // Remove version entries that have no legacy image ref and no remaining flavors. cfg.MachineImages[i].Versions = slices.DeleteFunc(cfg.MachineImages[i].Versions, func(mv providercfg.MachineImageVersion) bool { @@ -173,7 +174,7 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image return exists } // Clean version entry — delete if all flavors were removed. - return !cleanVersionsWithFlavors[mv.Version] + return len(survivingFlavors[mv.Version]) == 0 }) } raw, err := json.Marshal(cfg) @@ -193,9 +194,23 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image } // Cascade-delete clean version entry if all its capability flavors were removed. // Only entries tracked as clean versions (present in the map) are eligible. - hasRemainingFlavors, isCleanVersion := cleanVersionsWithFlavors[mv.Version] - return isCleanVersion && !hasRemainingFlavors + remaining, isCleanVersion := survivingFlavors[mv.Version] + return isCleanVersion && len(remaining) == 0 }) + // Rebuild CapabilityFlavors on surviving clean version entries to match + // what remains in providerConfig after pruning. + for j := range cp.Spec.MachineImages[i].Versions { + mv := &cp.Spec.MachineImages[i].Versions[j] + remaining, isCleanVersion := survivingFlavors[mv.Version] + if !isCleanVersion { + continue + } + flavors := make([]gardenerv1beta1.MachineImageFlavor, 0, len(remaining)) + for _, caps := range remaining { + flavors = append(flavors, gardenerv1beta1.MachineImageFlavor{Capabilities: caps}) + } + mv.CapabilityFlavors = flavors + } } if err := r.Update(ctx, &cp); err != nil { diff --git a/controllers/managedcloudprofile_controller_test.go b/controllers/managedcloudprofile_controller_test.go index ea88b00..57572c4 100644 --- a/controllers/managedcloudprofile_controller_test.go +++ b/controllers/managedcloudprofile_controller_test.go @@ -1045,11 +1045,129 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Expect(k8sClient.Delete(ctx, shoot)).To(Succeed()) }) + It("removes the capability flavor from spec.machineImages when its backing tag is garbage collected", func(ctx SpecContext) { + oldFactory := reconciler.OCISourceFactory + defer func() { reconciler.OCISourceFactory = oldFactory }() + reconciler.OCISourceFactory = &emptyFactory{} + + oldTag := "2254.0.0-baremetal-sci-usi-amd64" + newTag := "2254.0.0-baremetal-sci-pxe-amd64" + cleanVersion := "2254.0.0" + oldCaps := gardenerv1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}} + newCaps := gardenerv1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_pxe"}} + + provCfg := providercfg.CloudProfileConfig{ + MachineImages: []providercfg.MachineImages{{ + Name: "gc-flavor-image", + Versions: []providercfg.MachineImageVersion{{ + Version: cleanVersion, + CapabilityFlavors: []providercfg.MachineImageFlavor{ + {Image: "repo/gc-flavor-image:" + oldTag, Capabilities: oldCaps}, + {Image: "repo/gc-flavor-image:" + newTag, Capabilities: newCaps}, + }, + }}, + }}, + } + raw, err := json.Marshal(provCfg) + Expect(err).To(Succeed()) + + mcpSpec := baseCloudProfileSpec(gardenerv1beta1.MachineImage{ + Name: "gc-flavor-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: oldTag}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: newTag}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64"}}, + }, + }) + mcpSpec.ProviderConfig = &runtime.RawExtension{Raw: raw} + + mcp := &v1alpha1.ManagedCloudProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "test-gc-flavor-removal"}, + Spec: v1alpha1.ManagedCloudProfileSpec{ + CloudProfile: mcpSpec, + MachineImageUpdates: []v1alpha1.MachineImageUpdate{{ + ImageName: "gc-flavor-image", + Source: v1alpha1.MachineImageUpdateSource{ + OCI: &v1alpha1.OCI{Registry: "keppel-fake", Repository: "account/gc-flavor-repo", Insecure: true}, + }, + Provider: v1alpha1.MachineImageUpdateProvider{ + IroncoreMetal: &v1alpha1.MachineImagesUpdateProviderIroncoreMetal{ + Registry: "keppel-fake", Repository: "account/gc-flavor-repo", + }, + }, + }}, + GarbageCollection: &v1alpha1.GarbageCollectionConfig{ + Enabled: true, + MaxAge: metav1.Duration{Duration: 24 * time.Hour}, + }, + }, + } + Expect(k8sClient.Create(ctx, mcp)).To(Succeed()) + + // Wait for the background manager to create the CloudProfile, then patch in capabilityFlavors. + // This avoids a race where the background manager overwrites the CP after we pre-create it. + cp := &gardenerv1beta1.CloudProfile{} + Eventually(func() error { + return k8sClient.Get(ctx, client.ObjectKey{Name: mcp.Name}, cp) + }).Should(Succeed()) + for i, mi := range cp.Spec.MachineImages { + if mi.Name != "gc-flavor-image" { + continue + } + for j, v := range mi.Versions { + if v.Version == cleanVersion { + cp.Spec.MachineImages[i].Versions[j].CapabilityFlavors = []gardenerv1beta1.MachineImageFlavor{ + {Capabilities: oldCaps}, {Capabilities: newCaps}, + } + } + } + } + Expect(k8sClient.Update(ctx, cp)).To(Succeed()) + + r := &controllers.Reconciler{ + Client: k8sClient, + OCISourceFactory: &emptyFactory{}, + RegistryProviderFunc: func(registry string) (controllers.RegistryClient, error) { + return &fakeRegistryClientWithTags{tags: map[string]time.Time{ + oldTag: time.Now().Add(-48 * time.Hour), + newTag: time.Now().Add(-1 * time.Minute), + }}, nil + }, + } + _, err = r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKey{Name: mcp.Name}}) + Expect(err).ToNot(HaveOccurred()) + + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: mcp.Name}, cp)).To(Succeed()) + + var specFlavors []gardenerv1beta1.MachineImageFlavor + for _, mi := range cp.Spec.MachineImages { + if mi.Name == "gc-flavor-image" { + for _, v := range mi.Versions { + if v.Version == cleanVersion { + specFlavors = v.CapabilityFlavors + } + } + } + } + Expect(specFlavors).To(HaveLen(1)) + Expect(specFlavors[0].Capabilities).To(Equal(newCaps)) + + Expect(k8sClient.Delete(ctx, mcp)).To(Succeed()) + Expect(k8sClient.Delete(ctx, cp)).To(Succeed()) + }) + It("deletes only old flavors from a clean version entry, keeping new ones", func(ctx SpecContext) { + oldFactory := reconciler.OCISourceFactory + defer func() { reconciler.OCISourceFactory = oldFactory }() + reconciler.OCISourceFactory = &emptyFactory{} + // Clean version "2254.0.0" has two flavors: one old (should be deleted), one recent (should stay). + // After GC the spec.machineImages entry must reflect only the surviving flavor's capabilities. oldTag := "2254.0.0-baremetal-sci-usi-amd64" newTag := "2254.0.0-baremetal-sci-usi-arm64" cleanVersion := "2254.0.0" + oldCaps := gardenerv1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}} + newCaps := gardenerv1beta1.Capabilities{"architecture": {"arm64"}, "feature": {"sci", "_usi"}} cfg := providercfg.CloudProfileConfig{ MachineImages: []providercfg.MachineImages{ @@ -1059,8 +1177,8 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { { Version: cleanVersion, CapabilityFlavors: []providercfg.MachineImageFlavor{ - {Image: "repo/multi-flavor-image:" + oldTag}, - {Image: "repo/multi-flavor-image:" + newTag}, + {Image: "repo/multi-flavor-image:" + oldTag, Capabilities: oldCaps}, + {Image: "repo/multi-flavor-image:" + newTag, Capabilities: newCaps}, }, }, }, @@ -1070,23 +1188,20 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { raw, err := json.Marshal(cfg) Expect(err).To(Succeed()) + mcpSpec := baseCloudProfileSpec(gardenerv1beta1.MachineImage{ + Name: "multi-flavor-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: oldTag}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: newTag}, Architectures: []string{"arm64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64", "arm64"}}, + }, + }) + mcpSpec.ProviderConfig = &runtime.RawExtension{Raw: raw} + mcp := &v1alpha1.ManagedCloudProfile{ ObjectMeta: metav1.ObjectMeta{Name: "test-gc-partial-flavor"}, Spec: v1alpha1.ManagedCloudProfileSpec{ - CloudProfile: func() v1alpha1.CloudProfileSpec { - cp := baseCloudProfileSpec( - gardenerv1beta1.MachineImage{ - Name: "multi-flavor-image", - Versions: []gardenerv1beta1.MachineImageVersion{ - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: oldTag}, Architectures: []string{"amd64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: newTag}, Architectures: []string{"arm64"}}, - {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: cleanVersion}, Architectures: []string{"amd64", "arm64"}}, - }, - }, - ) - cp.ProviderConfig = &runtime.RawExtension{Raw: raw} - return cp - }(), + CloudProfile: mcpSpec, MachineImageUpdates: []v1alpha1.MachineImageUpdate{ { ImageName: "multi-flavor-image", @@ -1113,6 +1228,26 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { } Expect(k8sClient.Create(ctx, mcp)).To(Succeed()) + // Wait for the background manager to create the CloudProfile, then patch in capabilityFlavors. + // This avoids a race where the background manager overwrites the CP after we pre-create it. + cp := &gardenerv1beta1.CloudProfile{} + Eventually(func() error { + return k8sClient.Get(ctx, client.ObjectKey{Name: mcp.Name}, cp) + }).Should(Succeed()) + for i, mi := range cp.Spec.MachineImages { + if mi.Name != "multi-flavor-image" { + continue + } + for j, v := range mi.Versions { + if v.Version == cleanVersion { + cp.Spec.MachineImages[i].Versions[j].CapabilityFlavors = []gardenerv1beta1.MachineImageFlavor{ + {Capabilities: oldCaps}, {Capabilities: newCaps}, + } + } + } + } + Expect(k8sClient.Update(ctx, cp)).To(Succeed()) + r := &controllers.Reconciler{ Client: k8sClient, OCISourceFactory: &emptyFactory{}, @@ -1127,13 +1262,12 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { _, err = r.Reconcile(ctx, req) Expect(err).ToNot(HaveOccurred()) - cp := &gardenerv1beta1.CloudProfile{} Expect(k8sClient.Get(ctx, client.ObjectKey{Name: mcp.Name}, cp)).To(Succeed()) Expect(cp.Spec.ProviderConfig).ToNot(BeNil()) var updatedCfg providercfg.CloudProfileConfig Expect(json.Unmarshal(cp.Spec.ProviderConfig.Raw, &updatedCfg)).To(Succeed()) - // Old flavor must be gone; new flavor must remain. + // Old flavor must be gone from providerConfig; new flavor must remain. var flavors []string for _, img := range updatedCfg.MachineImages { if img.Name == "multi-flavor-image" { @@ -1152,7 +1286,22 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { // Clean version entry must still be present in spec.machineImages (has remaining flavor). Expect(versionsByMachineImage(cp, "multi-flavor-image")).To(ContainElement(cleanVersion)) + // spec.machineImages clean version entry must have only the surviving flavor's capabilities. + var specFlavors []gardenerv1beta1.MachineImageFlavor + for _, mi := range cp.Spec.MachineImages { + if mi.Name == "multi-flavor-image" { + for _, v := range mi.Versions { + if v.Version == cleanVersion { + specFlavors = v.CapabilityFlavors + } + } + } + } + Expect(specFlavors).To(HaveLen(1)) + Expect(specFlavors[0].Capabilities).To(Equal(newCaps)) + Expect(k8sClient.Delete(ctx, mcp)).To(Succeed()) + Expect(k8sClient.Delete(ctx, cp)).To(Succeed()) }) It("cascade-deletes clean version entry when all its flavors are garbage collected", func(ctx SpecContext) { diff --git a/crd/README.md b/crd/README.md new file mode 100644 index 0000000..2bf50f6 --- /dev/null +++ b/crd/README.md @@ -0,0 +1,24 @@ +`core.gardener.cloud_cloudprofiles.yaml` and `core.gardener.cloud_shoots.yaml` files are required for the tests setup (these CRDs should be installed) + +Since these resources are not actual CRDs from the k8s api perspective and their spec cant be fetched via `kubectl` + +Gardener currently does not provide these CRDs in `yaml` so we need to generate it ourselves as a temporary solution + +1. Clone gardener repo +2. Add `kubebuilder` markers to `gardener/pkg/apis/core/v1beta1/types_cloudprofile.go` +```go +// CloudProfile represents certain properties about a provider environment. +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +type CloudProfile struct { +``` +3. Generate +```shell +go run sigs.k8s.io/controller-tools/cmd/controller-gen@latest \ + crd:allowDangerousTypes=true \ + paths=./pkg/apis/core/v1beta1/... \ + output:crd:artifacts:config=crd-out +``` +and copy + +Probably this thingy should be revisited at some point to reduce overhead. Maybe using different testing approach diff --git a/crd/core.gardener.cloud_cloudprofiles.yaml b/crd/core.gardener.cloud_cloudprofiles.yaml index c68e8c6..4edfaf9 100644 --- a/crd/core.gardener.cloud_cloudprofiles.yaml +++ b/crd/core.gardener.cloud_cloudprofiles.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.18.0 + controller-gen.kubebuilder.io/version: v0.21.0 name: cloudprofiles.core.gardener.cloud spec: group: core.gardener.cloud @@ -14,493 +14,638 @@ spec: singular: cloudprofile scope: Cluster versions: - - name: v1beta1 - schema: - openAPIV3Schema: - description: CloudProfile represents certain properties about a provider environment. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the provider environment properties. - properties: - bastion: - description: Bastion contains the machine and image properties - properties: - machineImage: - description: MachineImage contains the bastions machine image - properties - properties: - name: - description: Name of the machine image - type: string - version: - description: Version of the machine image - type: string - required: - - name - type: object - machineType: - description: MachineType contains the bastions machine type properties - properties: - name: - description: Name of the machine type - type: string - required: - - name - type: object - type: object - caBundle: - description: CABundle is a certificate bundle which will be installed - onto every host machine of shoot cluster targeting this profile. - type: string - capabilities: - description: |- - Capabilities contains the definition of all possible capabilities in the CloudProfile. - Only capabilities and values defined here can be used to describe MachineImages and MachineTypes. - The order of values for a given capability is relevant. The most important value is listed first. - During maintenance upgrades, the image that matches most capabilities will be selected. - items: - description: CapabilityDefinition contains the Name and Values of - a capability. + - name: v1beta1 + schema: + openAPIV3Schema: + description: CloudProfile represents certain properties about a provider environment. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Spec defines the provider environment properties. + properties: + bastion: + description: Bastion contains the machine and image properties properties: - name: - type: string - values: - description: |- - CapabilityValues contains capability values. - This is a workaround as the Protobuf generator can't handle a map with slice values. - items: - type: string - type: array - required: - - name - - values - type: object - type: array - kubernetes: - description: Kubernetes contains constraints regarding allowed values - of the 'kubernetes' block in the Shoot specification. - properties: - versions: - description: Versions is the list of allowed Kubernetes versions - with optional expiration dates for Shoot clusters. - items: - description: ExpirableVersion contains a version and an expiration - date. + machineImage: + description: MachineImage contains the bastions machine image + properties properties: - classification: - description: Classification defines the state of a version - (preview, supported, deprecated) - type: string - expirationDate: - description: ExpirationDate defines the time at which this - version expires. - format: date-time + name: + description: Name of the machine image type: string version: - description: Version is the version identifier. + description: Version of the machine image type: string required: - - version + - name + type: object + machineType: + description: MachineType contains the bastions machine type properties + properties: + name: + description: Name of the machine type + type: string + required: + - name type: object - type: array - type: object - limits: - description: |- - Limits configures operational limits for Shoot clusters using this CloudProfile. - See https://github.com/gardener/gardener/blob/master/docs/usage/shoot/shoot_limits.md. - properties: - maxNodesTotal: - description: MaxNodesTotal configures the maximum node count a - Shoot cluster can have during runtime. - format: int32 - type: integer - type: object - machineImages: - description: MachineImages contains constraints regarding allowed - values for machine images in the Shoot specification. - items: - description: MachineImage defines the name and multiple versions - of the machine image in any environment. + type: object + caBundle: + description: CABundle is a certificate bundle which will be installed + onto every host machine of shoot cluster targeting this profile. + type: string + controlPlane: + description: |- + ControlPlane holds settings that control what control-plane-related features shoots + using this CloudProfile may configure. properties: - name: - description: Name is the name of the image. - type: string - updateStrategy: + allowZonePinning: description: |- - UpdateStrategy is the update strategy to use for the machine image. Possible values are: - - patch: update to the latest patch version of the current minor version. - - minor: update to the latest minor and patch version. - - major: always update to the overall latest version (default). - type: string + AllowZonePinning enables shoots to set spec.controlPlane.zones to explicitly pin + their control plane to specific seed zones. Only set to true for providers where + zone names are globally consistent across all users of the provider. + type: boolean + type: object + kubernetes: + description: Kubernetes contains constraints regarding allowed values + of the 'kubernetes' block in the Shoot specification. + properties: versions: - description: Versions contains versions, expiration dates and - container runtimes of the machine image + description: Versions is the list of allowed Kubernetes versions + with optional expiration dates for Shoot clusters. items: - description: MachineImageVersion is an expirable version with - list of supported container runtimes and interfaces + description: ExpirableVersion contains a version with associated + lifecycle information. properties: - architectures: - description: Architectures is the list of CPU architectures - of the machine image in this version. - items: - type: string - type: array - capabilitySets: - description: |- - CapabilitySets is an array of capability sets. Each entry represents a combination of capabilities that is provided by - the machine image version. - items: - description: |- - CapabilitySet is a wrapper for Capabilities. - This is a workaround as the Protobuf generator can't handle a slice of maps. - type: object - type: array classification: - description: Classification defines the state of a version - (preview, supported, deprecated) + description: |- + Classification defines the state of a version (preview, supported, deprecated). + + Deprecated: Is replaced by Lifecycle. mutually exclusive with it. + type: string + expirationDate: + description: |- + ExpirationDate defines the time at which this version expires. + + Deprecated: Is replaced by Lifecycle; mutually exclusive with it. + format: date-time type: string - cri: - description: CRI list of supported container runtime and - interfaces supported by this version + lifecycle: + description: |- + Lifecycle defines the lifecycle stages for this version. + Mutually exclusive with Classification and ExpirationDate. + This can only be used when the VersionClassificationLifecycle feature gate is enabled. items: - description: CRI contains information about the Container - Runtimes. + description: |- + LifecycleStage describes a stage in the versions lifecycle. + Each stage defines the classification of the version (e.g. unavailable, preview, supported, deprecated, expired) + and the time at which this classification becomes effective. properties: - containerRuntimes: - description: ContainerRuntimes is the list of the - required container runtimes supported for a worker - pool. - items: - description: ContainerRuntime contains information - about worker's available container runtime - properties: - providerConfig: - description: ProviderConfig is the configuration - passed to container runtime resource. - type: object - x-kubernetes-preserve-unknown-fields: true - type: - description: Type is the type of the Container - Runtime. - type: string - required: - - type - type: object - type: array - name: - description: The name of the CRI library. Supported - values are `containerd`. + classification: + description: Classification is the category of this + lifecycle stage (unavailable, preview, supported, + deprecated, expired). + type: string + startTime: + description: |- + StartTime defines when this lifecycle stage becomes active. + StartTime can be omitted for the first lifecycle stage, implying a start time in the past. + format: date-time type: string required: - - name + - classification type: object type: array - expirationDate: - description: ExpirationDate defines the time at which - this version expires. - format: date-time - type: string - inPlaceUpdates: - description: InPlaceUpdates contains the configuration - for in-place updates for this machine image version. - properties: - minVersionForUpdate: - description: MinVersionForInPlaceUpdate specifies - the minimum supported version from which an in-place - update to this machine image version can be performed. - type: string - supported: - description: Supported indicates whether in-place - updates are supported for this machine image version. - type: boolean - required: - - supported - type: object - kubeletVersionConstraint: - description: |- - KubeletVersionConstraint is a constraint describing the supported kubelet versions by the machine image in this version. - If the field is not specified, it is assumed that the machine image in this version supports all kubelet versions. - Examples: - - '>= 1.26' - supports only kubelet versions greater than or equal to 1.26 - - '< 1.26' - supports only kubelet versions less than 1.26 - type: string version: description: Version is the version identifier. type: string required: - - version + - version type: object type: array - required: - - name - - versions type: object - type: array - machineTypes: - description: MachineTypes contains constraints regarding allowed values - for machine types in the 'workers' block in the Shoot specification. - items: - description: MachineType contains certain properties of a machine - type. + limits: + description: |- + Limits configures operational limits for Shoot clusters using this CloudProfile. + See https://github.com/gardener/gardener/blob/master/docs/usage/shoot/shoot_limits.md. properties: - architecture: - description: Architecture is the CPU architecture of this machine - type. - type: string - capabilities: - additionalProperties: + maxNodesTotal: + description: MaxNodesTotal configures the maximum node count a + Shoot cluster can have during runtime. + format: int32 + type: integer + type: object + machineCapabilities: + description: |- + MachineCapabilities contains the definition of all possible capabilities in the CloudProfile. + Only capabilities and values defined here can be used to describe MachineImages and MachineTypes. + The order of values for a given capability is relevant. The most important value is listed first. + During maintenance upgrades, the image that matches most capabilities will be selected. + items: + description: CapabilityDefinition contains the Name and Values of + a capability. + properties: + name: + type: string + values: description: |- CapabilityValues contains capability values. This is a workaround as the Protobuf generator can't handle a map with slice values. items: type: string type: array - description: Capabilities contains the machine type capabilities. - type: object - cpu: - anyOf: - - type: integer - - type: string - description: CPU is the number of CPUs for this machine type. - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - gpu: - anyOf: - - type: integer - - type: string - description: GPU is the number of GPUs for this machine type. - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - memory: - anyOf: - - type: integer - - type: string - description: Memory is the amount of memory for this machine - type. - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - name: - description: Name is the name of the machine type. - type: string - storage: - description: Storage is the amount of storage associated with - the root volume of this machine type. - properties: - class: - description: Class is the class of the storage type. - type: string - minSize: - anyOf: + required: + - name + - values + type: object + type: array + machineImages: + description: MachineImages contains constraints regarding allowed + values for machine images in the Shoot specification. + items: + description: MachineImage defines the name and multiple versions + of the machine image in any environment. + properties: + name: + description: Name is the name of the image. + type: string + updateStrategy: + description: |- + UpdateStrategy is the update strategy to use for the machine image. Possible values are: + - patch: update to the latest patch version of the current minor version. + - minor: update to the latest minor and patch version. + - major: always update to the overall latest version (default). + type: string + versions: + description: Versions contains versions, expiration dates and + container runtimes of the machine image + items: + description: MachineImageVersion is an expirable version with + list of supported container runtimes and interfaces + properties: + architectures: + description: Architectures is the list of CPU architectures + of the machine image in this version. + items: + type: string + type: array + capabilityFlavors: + description: |- + CapabilityFlavors is an array of MachineImageFlavor. Each entry represents a combination of capabilities that is provided by + the machine image version. + items: + description: |- + MachineImageFlavor is a wrapper for Capabilities. + This is a workaround as the Protobuf generator can't handle a slice of maps. + type: object + x-kubernetes-preserve-unknown-fields: true + type: array + classification: + description: |- + Classification defines the state of a version (preview, supported, deprecated). + + Deprecated: Is replaced by Lifecycle. mutually exclusive with it. + type: string + cri: + description: CRI list of supported container runtime and + interfaces supported by this version + items: + description: CRI contains information about the Container + Runtimes. + properties: + containerRuntimes: + description: ContainerRuntimes is the list of the + required container runtimes supported for a worker + pool. + items: + description: ContainerRuntime contains information + about worker's available container runtime + properties: + providerConfig: + description: ProviderConfig is the configuration + passed to container runtime resource. + type: object + x-kubernetes-preserve-unknown-fields: true + type: + description: Type is the type of the Container + Runtime. + type: string + required: + - type + type: object + type: array + name: + description: The name of the CRI library. Supported + values are `containerd`. + type: string + required: + - name + type: object + type: array + expirationDate: + description: |- + ExpirationDate defines the time at which this version expires. + + Deprecated: Is replaced by Lifecycle; mutually exclusive with it. + format: date-time + type: string + inPlaceUpdates: + description: InPlaceUpdates contains the configuration + for in-place updates for this machine image version. + properties: + minVersionForUpdate: + description: MinVersionForInPlaceUpdate specifies + the minimum supported version from which an in-place + update to this machine image version can be performed. + type: string + supported: + description: Supported indicates whether in-place + updates are supported for this machine image version. + type: boolean + required: + - supported + type: object + kubeletVersionConstraint: + description: |- + KubeletVersionConstraint is a constraint describing the supported kubelet versions by the machine image in this version. + If the field is not specified, it is assumed that the machine image in this version supports all kubelet versions. + Examples: + - '>= 1.26' - supports only kubelet versions greater than or equal to 1.26 + - '< 1.26' - supports only kubelet versions less than 1.26 + type: string + lifecycle: + description: |- + Lifecycle defines the lifecycle stages for this version. + Mutually exclusive with Classification and ExpirationDate. + This can only be used when the VersionClassificationLifecycle feature gate is enabled. + items: + description: |- + LifecycleStage describes a stage in the versions lifecycle. + Each stage defines the classification of the version (e.g. unavailable, preview, supported, deprecated, expired) + and the time at which this classification becomes effective. + properties: + classification: + description: Classification is the category of this + lifecycle stage (unavailable, preview, supported, + deprecated, expired). + type: string + startTime: + description: |- + StartTime defines when this lifecycle stage becomes active. + StartTime can be omitted for the first lifecycle stage, implying a start time in the past. + format: date-time + type: string + required: + - classification + type: object + type: array + version: + description: Version is the version identifier. + type: string + required: + - version + type: object + type: array + required: + - name + - versions + type: object + type: array + machineTypes: + description: MachineTypes contains constraints regarding allowed values + for machine types in the 'workers' block in the Shoot specification. + items: + description: MachineType contains certain properties of a machine + type. + properties: + architecture: + description: Architecture is the CPU architecture of this machine + type. + type: string + capabilities: + additionalProperties: + description: |- + CapabilityValues contains capability values. + This is a workaround as the Protobuf generator can't handle a map with slice values. + items: + type: string + type: array + description: Capabilities contains the machine type capabilities. + type: object + cpu: + anyOf: - type: integer - type: string - description: |- - MinSize is the minimal supported storage size. - This overrides any other common minimum size configuration from `spec.volumeTypes[*].minSize`. - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - size: - anyOf: + description: CPU is the number of CPUs for this machine type. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + gpu: + anyOf: - type: integer - type: string - description: StorageSize is the storage size. - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - type: - description: Type is the type of the storage. - type: string - required: - - class - - type - type: object - usable: - description: Usable defines if the machine type can be used - for shoot clusters. - type: boolean - required: - - cpu - - gpu - - memory - - name + description: GPU is the number of GPUs for this machine type. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + machineControllerManager: + description: MachineControllerManagerSettings contains a subset + of the MachineControllerManagerSettings which can be defaulted + for a machine type in a CloudProfile. + properties: + machineCreationTimeout: + description: MachineCreationTimeout is the period after + which creation of a machine of this machine type is declared + failed. + type: string + type: object + memory: + anyOf: + - type: integer + - type: string + description: Memory is the amount of memory for this machine + type. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + name: + description: Name is the name of the machine type. + type: string + storage: + description: Storage is the amount of storage associated with + the root volume of this machine type. + properties: + class: + description: Class is the class of the storage type. + type: string + minSize: + anyOf: + - type: integer + - type: string + description: |- + MinSize is the minimal supported storage size. + This overrides any other common minimum size configuration from `spec.volumeTypes[*].minSize`. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + size: + anyOf: + - type: integer + - type: string + description: StorageSize is the storage size. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: + description: Type is the type of the storage. + type: string + required: + - class + - type + type: object + usable: + description: Usable defines if the machine type can be used + for shoot clusters. + type: boolean + required: + - cpu + - gpu + - memory + - name + type: object + type: array + providerConfig: + description: ProviderConfig contains provider-specific configuration + for the profile. type: object - type: array - providerConfig: - description: ProviderConfig contains provider-specific configuration - for the profile. - type: object - x-kubernetes-preserve-unknown-fields: true - regions: - description: Regions contains constraints regarding allowed values - for regions and zones. - items: - description: Region contains certain properties of a region. + x-kubernetes-preserve-unknown-fields: true + regions: + description: Regions contains constraints regarding allowed values + for regions and zones. + items: + description: Region contains certain properties of a region. + properties: + accessRestrictions: + description: AccessRestrictions describe a list of access restrictions + that can be used for Shoots using this region. + items: + description: AccessRestriction describes an access restriction + for a Kubernetes cluster (e.g., EU access-only). + properties: + name: + description: Name is the name of the restriction. + type: string + required: + - name + type: object + type: array + labels: + additionalProperties: + type: string + description: |- + Labels is an optional set of key-value pairs that contain certain administrator-controlled labels for this region. + It can be used by Gardener administrators/operators to provide additional information about a region, e.g. wrt + quality, reliability, etc. + type: object + name: + description: Name is a region name. + type: string + zones: + description: Zones is a list of availability zones in this region. + items: + description: AvailabilityZone is an availability zone. + properties: + name: + description: Name is an availability zone name. + type: string + unavailableMachineTypes: + description: UnavailableMachineTypes is a list of machine + type names that are not availability in this zone. + items: + type: string + type: array + unavailableVolumeTypes: + description: UnavailableVolumeTypes is a list of volume + type names that are not availability in this zone. + items: + type: string + type: array + required: + - name + type: object + type: array + required: + - name + type: object + type: array + seedSelector: + description: |- + SeedSelector contains an optional list of labels on `Seed` resources that marks those seeds whose shoots may use this provider profile. + An empty list means that all seeds of the same provider type are supported. + This is useful for environments that are of the same type (like openstack) but may have different "instances"/landscapes. + Optionally a list of possible providers can be added to enable cross-provider scheduling. By default, the provider + type of the seed must match the shoot's provider. properties: - accessRestrictions: - description: AccessRestrictions describe a list of access restrictions - that can be used for Shoots using this region. + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. items: - description: AccessRestriction describes an access restriction - for a Kubernetes cluster (e.g., EU access-only). + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. properties: - name: - description: Name is the name of the restriction. + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic required: - - name + - key + - operator type: object type: array - labels: + x-kubernetes-list-type: atomic + matchLabels: additionalProperties: type: string description: |- - Labels is an optional set of key-value pairs that contain certain administrator-controlled labels for this region. - It can be used by Gardener administrators/operators to provide additional information about a region, e.g. wrt - quality, reliability, etc. + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. type: object - name: - description: Name is a region name. - type: string - zones: - description: Zones is a list of availability zones in this region. + providerTypes: + description: Providers is optional and can be used by restricting + seeds by their provider type. '*' can be used to enable seeds + regardless of their provider type. items: - description: AvailabilityZone is an availability zone. + type: string + type: array + type: object + x-kubernetes-map-type: atomic + type: + description: Type is the name of the provider. + type: string + volumeTypes: + description: VolumeTypes contains constraints regarding allowed values + for volume types in the 'workers' block in the Shoot specification. + items: + description: VolumeType contains certain properties of a volume + type. + properties: + class: + description: Class is the class of the volume type. + type: string + minSize: + anyOf: + - type: integer + - type: string + description: MinSize is the minimal supported storage size. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + name: + description: Name is the name of the volume type. + type: string + usable: + description: Usable defines if the volume type can be used for + shoot clusters. + type: boolean + required: + - class + - name + type: object + type: array + required: + - kubernetes + - machineImages + - machineTypes + - regions + - type + type: object + status: + description: Status contains the current status of the cloud profile. + properties: + kubernetes: + description: Kubernetes contains the status information for kubernetes. + properties: + versions: + description: Versions contains the statuses of the kubernetes + versions. + items: + description: ExpirableVersionStatus defines the current status + of an expirable version. properties: - name: - description: Name is an availability zone name. + classification: + description: Classification reflects the current state in + the classification lifecycle. + type: string + version: + description: Version is the version identifier. type: string - unavailableMachineTypes: - description: UnavailableMachineTypes is a list of machine - type names that are not availability in this zone. - items: - type: string - type: array - unavailableVolumeTypes: - description: UnavailableVolumeTypes is a list of volume - type names that are not availability in this zone. - items: - type: string - type: array required: - - name + - classification + - version type: object type: array - required: - - name type: object - type: array - seedSelector: - description: |- - SeedSelector contains an optional list of labels on `Seed` resources that marks those seeds whose shoots may use this provider profile. - An empty list means that all seeds of the same provider type are supported. - This is useful for environments that are of the same type (like openstack) but may have different "instances"/landscapes. - Optionally a list of possible providers can be added to enable cross-provider scheduling. By default, the provider - type of the seed must match the shoot's provider. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. - The requirements are ANDead. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector applies - to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDead. + machineImages: + description: MachineImages contains the statuses of the machine image + versions. + items: + description: MachineImageStatus contains the status of a machine + image and its version classifications. + properties: + name: + description: Name matches the name of the MachineImage the status + is represented of. + type: string + versions: + description: Versions contains the statuses of the machine image + versions. + items: + description: ExpirableVersionStatus defines the current status + of an expirable version. + properties: + classification: + description: Classification reflects the current state + in the classification lifecycle. + type: string + version: + description: Version is the version identifier. + type: string + required: + - classification + - version + type: object + type: array + required: + - name type: object - providerTypes: - description: Providers is optional and can be used by restricting - seeds by their provider type. '*' can be used to enable seeds - regardless of their provider type. - items: - type: string - type: array - type: object - x-kubernetes-map-type: atomic - type: - description: Type is the name of the provider. - type: string - volumeTypes: - description: VolumeTypes contains constraints regarding allowed values - for volume types in the 'workers' block in the Shoot specification. - items: - description: VolumeType contains certain properties of a volume - type. - properties: - class: - description: Class is the class of the volume type. - type: string - minSize: - anyOf: - - type: integer - - type: string - description: MinSize is the minimal supported storage size. - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - name: - description: Name is the name of the volume type. - type: string - usable: - description: Usable defines if the volume type can be used for - shoot clusters. - type: boolean - required: - - class - - name - type: object - type: array - required: - - kubernetes - - machineImages - - machineTypes - - regions - - type - type: object - type: object - served: true - storage: true + type: array + type: object + type: object + served: true + storage: true diff --git a/crd/core.gardener.cloud_shoots.yaml b/crd/core.gardener.cloud_shoots.yaml index da58e2b..8629016 100644 --- a/crd/core.gardener.cloud_shoots.yaml +++ b/crd/core.gardener.cloud_shoots.yaml @@ -1,75 +1,3268 @@ +--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 name: shoots.core.gardener.cloud spec: group: core.gardener.cloud + names: + kind: Shoot + listKind: ShootList + plural: shoots + singular: shoot + scope: Namespaced versions: - - name: v1beta1 - served: true - storage: true - schema: - openAPIV3Schema: - description: Shoot is the schema for the shoots API. - type: object - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: Spec defines the desired state of the Shoot. - type: object - properties: - cloudProfile: - description: Reference to the CloudProfile used by the Shoot. - type: object - properties: - name: - description: Name of the CloudProfile. - type: string - provider: - description: Provider-specific configuration. - type: object - properties: - workers: - description: Worker pools for the Shoot. - type: array - items: - type: object - properties: - machine: - description: Machine configuration for a worker pool. - type: object - properties: - image: - description: Image configuration for worker nodes. + - name: v1beta1 + schema: + openAPIV3Schema: + description: Shoot represents a Shoot cluster created and managed by Gardener. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + Specification of the Shoot cluster. + If the object's deletion timestamp is set, this field is immutable. + properties: + accessRestrictions: + description: AccessRestrictions describe a list of access restrictions + for this shoot cluster. + items: + description: |- + AccessRestrictionWithOptions describes an access restriction for a Kubernetes cluster (e.g., EU access-only) and + allows to specify additional options. + properties: + name: + description: Name is the name of the restriction. + type: string + options: + additionalProperties: + type: string + description: Options is a map of additional options for the + access restriction. + type: object + required: + - name + type: object + type: array + addons: + description: |- + Addons contains information about enabled/disabled addons and their configuration. + + Deprecated: This field is deprecated. Enabling addons will be forbidden starting from Kubernetes 1.35. + properties: + kubernetesDashboard: + description: KubernetesDashboard holds configuration settings + for the kubernetes dashboard addon. + properties: + authenticationMode: + description: AuthenticationMode defines the authentication + mode for the kubernetes-dashboard. + type: string + enabled: + description: Enabled indicates whether the addon is enabled + or not. + type: boolean + required: + - enabled + type: object + nginxIngress: + description: NginxIngress holds configuration settings for the + nginx-ingress addon. + properties: + config: + additionalProperties: + type: string + description: |- + Config contains custom configuration for the nginx-ingress-controller configuration. + See https://github.com/kubernetes/ingress-nginx/blob/master/docs/user-guide/nginx-configuration/configmap.md#configuration-options + type: object + enabled: + description: Enabled indicates whether the addon is enabled + or not. + type: boolean + externalTrafficPolicy: + description: |- + ExternalTrafficPolicy controls the `.spec.externalTrafficPolicy` value of the load balancer `Service` + exposing the nginx-ingress. Defaults to `Cluster`. + type: string + loadBalancerSourceRanges: + description: LoadBalancerSourceRanges is list of allowed IP + sources for NginxIngress + items: + type: string + type: array + required: + - enabled + type: object + type: object + cloudProfile: + description: CloudProfile contains a reference to a CloudProfile or + a NamespacedCloudProfile. + properties: + kind: + description: Kind contains a CloudProfile kind. + type: string + name: + description: Name contains the name of the referenced CloudProfile. + type: string + required: + - kind + - name + type: object + cloudProfileName: + description: |- + CloudProfileName is a name of a CloudProfile object. + + Deprecated: This field will be removed in a future version of Gardener. Use `CloudProfile` instead. + Until Kubernetes v1.33, this field is synced with the `CloudProfile` field. + Starting with Kubernetes v1.34, this field is set to empty string and must not be provided anymore. + type: string + controlPlane: + description: ControlPlane contains general settings for the control + plane of the shoot. + properties: + highAvailability: + description: |- + HighAvailability holds the configuration settings for high availability of the + control plane of a shoot. + properties: + failureTolerance: + description: FailureTolerance holds information about failure + tolerance level of a highly available resource. + properties: + type: + description: Type specifies the type of failure that the + highly available resource can tolerate + type: string + required: + - type + type: object + required: + - failureTolerance + type: object + zones: + description: |- + Zones is a list of availability zones in which the control plane components should be placed. + Requires the referenced CloudProfile to have spec.controlPlane.allowZonePinning set to true. + This field is immutable once set. + items: + type: string + type: array + type: object + credentialsBindingName: + description: |- + CredentialsBindingName is the name of a CredentialsBinding that has a reference to the provider credentials. + The credentials will be used to create the shoot in the respective account. The field is mutually exclusive with SecretBindingName. + type: string + dns: + description: DNS contains information about the DNS settings of the + Shoot. + properties: + domain: + description: |- + Domain is the external available domain of the Shoot cluster. This domain will be written into the + kubeconfig that is handed out to end-users. This field is immutable. + type: string + providers: + description: |- + Providers is a list of DNS providers that shall be enabled for this shoot cluster. Only relevant if + not a default domain is used. + + Deprecated: Configuring multiple DNS providers is deprecated and will be forbidden in a future release. + Please use the DNS extension provider config (e.g. shoot-dns-service) for additional providers. + items: + description: DNSProvider contains information about a DNS provider. + properties: + credentialsRef: + description: |- + CredentialsRef is a reference to a resource providing credentials for the DNS provider. + Supported resources are Secret and WorkloadIdentity. + properties: + apiVersion: + description: apiVersion is the API version of the referent + type: string + kind: + description: 'kind is the kind of the referent; More + info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'name is the name of the referent; More + info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + required: + - kind + - name + type: object + x-kubernetes-map-type: atomic + domains: + description: |- + Domains contains information about which domains shall be included/excluded for this provider. + + Deprecated: This field is deprecated and will be removed in a future release. + Please use the DNS extension provider config (e.g. shoot-dns-service) for additional configuration. + properties: + exclude: + description: Exclude is a list of domains that shall + be excluded. + items: + type: string + type: array + include: + description: Include is a list of domains that shall + be included. + items: + type: string + type: array + type: object + primary: + description: |- + Primary indicates that this DNSProvider is used for shoot related domains. + + Deprecated: This field is deprecated and will be removed in a future release. + Please use the DNS extension provider config (e.g. shoot-dns-service) for additional and non-primary providers. + type: boolean + secretName: + description: |- + SecretName is a name of a secret containing credentials for the stated domain and the + provider. When not specified, the Gardener will use the cloud provider credentials referenced + by the Shoot and try to find respective credentials there (primary provider only). Specifying this field may override + this behavior, i.e. forcing the Gardener to only look into the given secret. + + Deprecated: This field is deprecated and will be forbidden starting from Kubernetes 1.35. Please use `CredentialsRef` instead. + Until removed, this field is synced with the `CredentialsRef` field when it refers to a secret. + type: string + type: + description: Type is the DNS provider type. + type: string + zones: + description: |- + Zones contains information about which hosted zones shall be included/excluded for this provider. + + Deprecated: This field is deprecated and will be removed in a future release. + Please use the DNS extension provider config (e.g. shoot-dns-service) for additional configuration. + properties: + exclude: + description: Exclude is a list of domains that shall + be excluded. + items: + type: string + type: array + include: + description: Include is a list of domains that shall + be included. + items: + type: string + type: array + type: object + type: object + type: array + type: object + exposureClassName: + description: ExposureClassName is the optional name of an exposure + class to apply a control plane endpoint exposure strategy. + type: string + extensions: + description: Extensions contain type and provider information for + Shoot extensions. + items: + description: Extension contains type and provider information for + extensions. + properties: + disabled: + description: Disabled allows to disable extensions that were + marked as 'automatically enabled' by Gardener administrators. + type: boolean + providerConfig: + description: ProviderConfig is the configuration passed to extension + resource. + type: object + x-kubernetes-preserve-unknown-fields: true + type: + description: Type is the type of the extension resource. + type: string + required: + - type + type: object + type: array + hibernation: + description: Hibernation contains information whether the Shoot is + suspended or not. + properties: + enabled: + description: |- + Enabled specifies whether the Shoot needs to be hibernated or not. If it is true, the Shoot's desired state is to be hibernated. + If it is false or nil, the Shoot's desired state is to be awakened. + type: boolean + schedules: + description: Schedules determine the hibernation schedules. + items: + description: |- + HibernationSchedule determines the hibernation schedule of a Shoot. + A Shoot will be regularly hibernated at each start time and will be woken up at each end time. + Start or End can be omitted, though at least one of each has to be specified. + properties: + end: + description: End is a Cron spec at which time a Shoot will + be woken up. + type: string + location: + description: Location is the time location in which both + start and shall be evaluated. + type: string + start: + description: Start is a Cron spec at which time a Shoot + will be hibernated. + type: string + type: object + type: array + type: object + kubernetes: + description: Kubernetes contains the version and configuration settings + of the control plane components. + properties: + clusterAutoscaler: + description: ClusterAutoscaler contains the configuration flags + for the Kubernetes cluster autoscaler. + properties: + autoscaling: + description: Autoscaling contains auto-scaling configuration + options for the cluster-autoscaler. + properties: + minAllowed: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + MinAllowed configures the minimum allowed resource requests for vertical pod autoscaling.. + Configuration of minAllowed resources is an advanced feature that can help clusters to overcome scale-up delays. + Default values are not applied to this field. type: object + required: + - minAllowed + type: object + emitPerNodeGroupMetrics: + description: 'EmitPerNodeGroupMetrics emits additional per + node group metrics (default: false).' + type: boolean + expander: + description: |- + Expander defines the algorithm to use during scale up (default: least-waste). + See: https://github.com/gardener/autoscaler/blob/machine-controller-manager-provider/cluster-autoscaler/FAQ.md#what-are-expanders. + type: string + ignoreDaemonsetsUtilization: + description: 'IgnoreDaemonsetsUtilization allows CA to ignore + DaemonSet pods when calculating resource utilization for + scaling down (default: false).' + type: boolean + ignoreTaints: + description: |- + IgnoreTaints specifies a list of taint keys to ignore in node templates when considering to scale a node group. + + Deprecated: Ignore taints are deprecated and treated as startup taints + items: + type: string + type: array + initialNodeGroupBackoffDuration: + description: 'InitialNodeGroupBackoffDuration is the duration + of first backoff after a new node failed to start (default: + 5m).' + type: string + maxBinpackingTime: + description: |- + MaxBinpackingTime is the maximum time spent on binpacking for a single scale-up. + If binpacking is limited by this, scale-up continues with the already calculated scale-up options (default: 5m). + type: string + maxDrainParallelism: + description: |- + MaxDrainParallelism specifies the maximum number of nodes needing drain, that can be drained and deleted in parallel. + Default: 1 + format: int32 + type: integer + maxEmptyBulkDelete: + description: |- + MaxEmptyBulkDelete specifies the maximum number of empty nodes that can be deleted at the same time (default: MaxScaleDownParallelism when that is set). + + Deprecated: This field is deprecated. Setting this field will be forbidden starting from Kubernetes 1.33 and will be removed once gardener drops support for kubernetes v1.32. + This cluster-autoscaler field is deprecated upstream, use --max-scale-down-parallelism instead. + format: int32 + type: integer + maxGracefulTerminationSeconds: + description: 'MaxGracefulTerminationSeconds is the number + of seconds CA waits for pod termination when trying to scale + down a node (default: 600).' + format: int32 + type: integer + maxNodeGroupBackoffDuration: + description: 'MaxNodeGroupBackoffDuration is the maximum backoff + duration for a NodeGroup after new nodes failed to start + (default: 30m).' + type: string + maxNodeProvisionTime: + description: 'MaxNodeProvisionTime defines how long CA waits + for node to be provisioned (default: 20 mins).' + type: string + maxScaleDownParallelism: + description: |- + MaxScaleDownParallelism specifies the maximum number of nodes (both empty and needing drain) that can be deleted in parallel. + Default: 10 or MaxEmptyBulkDelete when that is set + format: int32 + type: integer + newPodScaleUpDelay: + description: 'NewPodScaleUpDelay specifies how long CA should + ignore newly created pods before they have to be considered + for scale-up (default: 0s).' + type: string + nodeGroupBackoffResetTimeout: + description: 'NodeGroupBackoffResetTimeout is the time after + last failed scale-up when the backoff duration is reset + (default: 3h).' + type: string + scaleDownDelayAfterAdd: + description: 'ScaleDownDelayAfterAdd defines how long after + scale up that scale down evaluation resumes (default: 1 + hour).' + type: string + scaleDownDelayAfterDelete: + description: 'ScaleDownDelayAfterDelete how long after node + deletion that scale down evaluation resumes, defaults to + scanInterval (default: 0 secs).' + type: string + scaleDownDelayAfterFailure: + description: 'ScaleDownDelayAfterFailure how long after scale + down failure that scale down evaluation resumes (default: + 3 mins).' + type: string + scaleDownUnneededTime: + description: 'ScaleDownUnneededTime defines how long a node + should be unneeded before it is eligible for scale down + (default: 30 mins).' + type: string + scaleDownUtilizationThreshold: + description: 'ScaleDownUtilizationThreshold defines the threshold + in fraction (0.0 - 1.0) under which a node is being removed + (default: 0.5).' + type: number + scanInterval: + description: 'ScanInterval how often cluster is reevaluated + for scale up or down (default: 10 secs).' + type: string + startupTaints: + description: |- + StartupTaints specifies a list of taint keys to ignore in node templates when considering to scale a node group. + Cluster Autoscaler treats nodes tainted with startup taints as unready, but taken into account during scale up logic, assuming they will become ready shortly. + items: + type: string + type: array + statusTaints: + description: |- + StatusTaints specifies a list of taint keys to ignore in node templates when considering to scale a node group. + Cluster Autoscaler internally treats nodes tainted with status taints as ready, but filtered out during scale up logic. + items: + type: string + type: array + verbosity: + description: 'Verbosity allows CA to modify its log level + (default: 2).' + format: int32 + type: integer + type: object + etcd: + description: ETCD contains configuration for etcds of the shoot + cluster. + properties: + events: + description: Events contains configuration for the events + etcd. + properties: + autoscaling: + description: Autoscaling contains auto-scaling configuration + options for etcd. + properties: + minAllowed: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + MinAllowed configures the minimum allowed resource requests for vertical pod autoscaling.. + Configuration of minAllowed resources is an advanced feature that can help clusters to overcome scale-up delays. + Default values are not applied to this field. + type: object + required: + - minAllowed + type: object + type: object + main: + description: Main contains configuration for the main etcd. + properties: + autoscaling: + description: Autoscaling contains auto-scaling configuration + options for etcd. + properties: + minAllowed: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + MinAllowed configures the minimum allowed resource requests for vertical pod autoscaling.. + Configuration of minAllowed resources is an advanced feature that can help clusters to overcome scale-up delays. + Default values are not applied to this field. + type: object + required: + - minAllowed + type: object + type: object + type: object + kubeAPIServer: + description: KubeAPIServer contains configuration settings for + the kube-apiserver. + properties: + admissionPlugins: + description: |- + AdmissionPlugins contains the list of user-defined admission plugins (additional to those managed by Gardener), and, if desired, the corresponding + configuration. + items: + description: AdmissionPlugin contains information about + a specific admission plugin and its corresponding configuration. + properties: + config: + description: Config is the configuration of the plugin. + type: object + x-kubernetes-preserve-unknown-fields: true + disabled: + description: Disabled specifies whether this plugin + should be disabled. + type: boolean + kubeconfigSecretName: + description: KubeconfigSecretName specifies the name + of a secret containing the kubeconfig for this admission + plugin. + type: string + name: + description: Name is the name of the plugin. + type: string + required: + - name + type: object + type: array + apiAudiences: + description: |- + APIAudiences are the identifiers of the API. The service account token authenticator will + validate that tokens used against the API are bound to at least one of these audiences. + Defaults to ["kubernetes"]. + items: + type: string + type: array + auditConfig: + description: AuditConfig contains configuration settings for + the audit of the kube-apiserver. + properties: + auditPolicy: + description: AuditPolicy contains configuration settings + for audit policy of the kube-apiserver. + properties: + configMapRef: + description: |- + ConfigMapRef is a reference to a ConfigMap object in the same namespace, + which contains the audit policy for the kube-apiserver. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: |- + If referring to a piece of an object instead of an entire object, this string + should contain a valid JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within a pod, this would take on a value like: + "spec.containers{name}" (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" (container with + index 2 in this pod). This syntax is chosen only to have some well-defined way of + referencing a part of an object. + type: string + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + namespace: + description: |- + Namespace of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ + type: string + resourceVersion: + description: |- + Specific resourceVersion to which this reference is made, if any. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + type: string + uid: + description: |- + UID of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids + type: string + type: object + x-kubernetes-map-type: atomic + type: object + type: object + autoscaling: + description: Autoscaling contains auto-scaling configuration + options for the kube-apiserver. + properties: + minAllowed: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + MinAllowed configures the minimum allowed resource requests for vertical pod autoscaling.. + Configuration of minAllowed resources is an advanced feature that can help clusters to overcome scale-up delays. + Default values are not applied to this field. + type: object + required: + - minAllowed + type: object + defaultNotReadyTolerationSeconds: + description: |- + DefaultNotReadyTolerationSeconds indicates the tolerationSeconds of the toleration for notReady:NoExecute + that is added by default to every pod that does not already have such a toleration (flag `--default-not-ready-toleration-seconds`). + The field has effect only when the `DefaultTolerationSeconds` admission plugin is enabled. + Defaults to 300. + format: int64 + type: integer + defaultUnreachableTolerationSeconds: + description: |- + DefaultUnreachableTolerationSeconds indicates the tolerationSeconds of the toleration for unreachable:NoExecute + that is added by default to every pod that does not already have such a toleration (flag `--default-unreachable-toleration-seconds`). + The field has effect only when the `DefaultTolerationSeconds` admission plugin is enabled. + Defaults to 300. + format: int64 + type: integer + enableAnonymousAuthentication: + description: |- + EnableAnonymousAuthentication defines whether anonymous requests to the secure port + of the API server should be allowed (flag `--anonymous-auth`). + See: https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/ + + Deprecated: This field is deprecated and will be removed after support for Kubernetes v1.34 is dropped. + This field is forbidden for clusters with Kubernetes version >= 1.35. + Please use anonymous authentication configuration instead. + type: boolean + encryptionConfig: + description: EncryptionConfig contains customizable encryption + configuration of the Kube API server. + properties: + provider: + description: Provider contains information about the encryption + provider. properties: + type: + description: |- + Type contains the type of the encryption provider. + + Supported types: + - "aescbc" + - "aesgcm" + - "secretbox" + Defaults to aescbc. + type: string + type: object + resources: + description: |- + Resources contains the list of resources that shall be encrypted in addition to secrets. + Each item is a Kubernetes resource name in plural (resource or resource.group) that should be encrypted. + Wildcards are not supported for now. + See https://github.com/gardener/gardener/blob/master/docs/usage/security/etcd_encryption_config.md for more details. + items: + type: string + type: array + required: + - provider + type: object + eventTTL: + description: |- + EventTTL controls the amount of time to retain events. + Defaults to 1h. + type: string + featureGates: + additionalProperties: + type: boolean + description: FeatureGates contains information about enabled + feature gates. + type: object + logging: + description: Logging contains configuration for the log level + and HTTP access logs. + properties: + httpAccessVerbosity: + description: HTTPAccessVerbosity is the kube-apiserver + access logs level + format: int32 + type: integer + verbosity: + description: |- + Verbosity is the kube-apiserver log verbosity level + Defaults to 2. + format: int32 + type: integer + type: object + requests: + description: Requests contains configuration for request-specific + settings for the kube-apiserver. + properties: + maxMutatingInflight: + description: |- + MaxMutatingInflight is the maximum number of mutating requests in flight at a given time. When the server + exceeds this, it rejects requests. + format: int32 + type: integer + maxNonMutatingInflight: + description: |- + MaxNonMutatingInflight is the maximum number of non-mutating requests in flight at a given time. When the server + exceeds this, it rejects requests. + format: int32 + type: integer + type: object + runtimeConfig: + additionalProperties: + type: boolean + description: RuntimeConfig contains information about enabled + or disabled APIs. + type: object + serviceAccountConfig: + description: |- + ServiceAccountConfig contains configuration settings for the service account handling + of the kube-apiserver. + properties: + acceptedIssuers: + description: |- + AcceptedIssuers is an additional set of issuers that are used to determine which service account tokens are accepted. + These values are not used to generate new service account tokens. Only useful when service account tokens are also + issued by another external system or a change of the current issuer that is used for generating tokens is being performed. + items: + type: string + type: array + extendTokenExpiration: + description: |- + ExtendTokenExpiration turns on projected service account expiration extension during token generation, which + helps safe transition from legacy token to bound service account token feature. If this flag is enabled, + admission injected tokens would be extended up to 1 year to prevent unexpected failure during transition, + ignoring value of service-account-max-token-expiration. + type: boolean + issuer: + description: |- + Issuer is the identifier of the service account token issuer. The issuer will assert this + identifier in "iss" claim of issued tokens. This value is used to generate new service account tokens. + This value is a string or URI. Defaults to URI of the API server. + type: string + maxTokenExpiration: + description: |- + MaxTokenExpiration is the maximum validity duration of a token created by the service account token issuer. If an + otherwise valid TokenRequest with a validity duration larger than this value is requested, a token will be issued + with a validity duration of this value. + This field must be within [30d,90d]. + type: string + type: object + structuredAuthentication: + description: StructuredAuthentication contains configuration + settings for structured authentication for the kube-apiserver. + properties: + configMapName: + description: |- + ConfigMapName is the name of the ConfigMap in the project namespace which contains AuthenticationConfiguration + for the kube-apiserver. + type: string + required: + - configMapName + type: object + structuredAuthorization: + description: StructuredAuthorization contains configuration + settings for structured authorization for the kube-apiserver. + properties: + configMapName: + description: |- + ConfigMapName is the name of the ConfigMap in the project namespace which contains AuthorizationConfiguration for + the kube-apiserver. + type: string + kubeconfigs: + description: Kubeconfigs is a list of references for kubeconfigs + for the authorization webhooks. + items: + description: AuthorizerKubeconfigReference is a reference + for a kubeconfig for a authorization webhook. + properties: + authorizerName: + description: AuthorizerName is the name of a webhook + authorizer. + type: string + secretName: + description: SecretName is the name of a secret + containing the kubeconfig. + type: string + required: + - authorizerName + - secretName + type: object + type: array + required: + - configMapName + - kubeconfigs + type: object + tlsMinVersion: + description: |- + TLSMinVersion is the minimum TLS version accepted by the kube-apiserver. + Supported values: VersionTLS12, VersionTLS13. + type: string + watchCacheSizes: + description: |- + WatchCacheSizes contains configuration of the API server's watch cache sizes. + Configuring these flags might be useful for large-scale Shoot clusters with a lot of parallel update requests + and a lot of watching controllers (e.g. large ManagedSeed clusters). When the API server's watch cache's + capacity is too small to cope with the amount of update requests and watchers for a particular resource, it + might happen that controller watches are permanently stopped with `too old resource version` errors. + Starting from kubernetes v1.19, the API server's watch cache size is adapted dynamically and setting the watch + cache size flags will have no effect, except when setting it to 0 (which disables the watch cache). + properties: + default: + description: |- + Default is not respected anymore by kube-apiserver. + The cache is sized automatically. + + Deprecated: This field is deprecated. Setting the default cache size will be forbidden starting from Kubernetes 1.35. + format: int32 + type: integer + resources: + description: |- + Resources configures the watch cache size of the kube-apiserver per resource + (flag `--watch-cache-sizes`). + See: https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/ + items: + description: ResourceWatchCacheSize contains configuration + of the API server's watch cache size for one specific + resource. + properties: + apiGroup: + description: |- + APIGroup is the API group of the resource for which the watch cache size should be configured. + An unset value is used to specify the legacy core API (e.g. for `secrets`). + type: string + resource: + description: |- + Resource is the name of the resource for which the watch cache size should be configured + (in lowercase plural form, e.g. `secrets`). + type: string + size: + description: CacheSize specifies the watch cache + size that should be configured for the specified + resource. + format: int32 + type: integer + required: + - resource + - size + type: object + type: array + type: object + type: object + kubeControllerManager: + description: KubeControllerManager contains configuration settings + for the kube-controller-manager. + properties: + featureGates: + additionalProperties: + type: boolean + description: FeatureGates contains information about enabled + feature gates. + type: object + horizontalPodAutoscaler: + description: HorizontalPodAutoscalerConfig contains horizontal + pod autoscaler configuration settings for the kube-controller-manager. + properties: + cpuInitializationPeriod: + description: The period after which a ready pod transition + is considered to be the first. + type: string + downscaleStabilization: + description: The configurable window at which the controller + will choose the highest recommendation for autoscaling. + type: string + initialReadinessDelay: + description: The configurable period at which the horizontal + pod autoscaler considers a Pod “not yet ready” given + that it’s unready and it has transitioned to unready + during that time. + type: string + syncPeriod: + description: The period for syncing the number of pods + in horizontal pod autoscaler. + type: string + tolerance: + description: The minimum change (from 1.0) in the desired-to-actual + metrics ratio for the horizontal pod autoscaler to consider + scaling. + type: number + type: object + nodeCIDRMaskSize: + description: NodeCIDRMaskSize defines the mask size for node + cidr in cluster (default is 24). This field is immutable. + format: int32 + type: integer + nodeCIDRMaskSizeIPv6: + description: NodeCIDRMaskSizeIPv6 defines the mask size for + node cidr in cluster (default is 64). This field is immutable. + format: int32 + type: integer + nodeMonitorGracePeriod: + description: NodeMonitorGracePeriod defines the grace period + before an unresponsive node is marked unhealthy. + type: string + podEvictionTimeout: + description: |- + PodEvictionTimeout defines the grace period for deleting pods on failed nodes. Defaults to 2m. + + Deprecated: The corresponding kube-controller-manager flag `--pod-eviction-timeout` is deprecated + in favor of the kube-apiserver flags `--default-not-ready-toleration-seconds` and `--default-unreachable-toleration-seconds`. + The `--pod-eviction-timeout` flag does not have effect when the taint based eviction is enabled. The taint + based eviction is beta (enabled by default) since Kubernetes 1.13 and GA since Kubernetes 1.18. Hence, + instead of setting this field, set the `spec.kubernetes.kubeAPIServer.defaultNotReadyTolerationSeconds` and + `spec.kubernetes.kubeAPIServer.defaultUnreachableTolerationSeconds`. Setting this field is forbidden starting + from Kubernetes 1.33. + type: string + type: object + kubeProxy: + description: KubeProxy contains configuration settings for the + kube-proxy. + properties: + enabled: + description: |- + Enabled indicates whether kube-proxy should be deployed or not. + Depending on the networking extensions switching kube-proxy off might be rejected. Consulting the respective documentation of the used networking extension is recommended before using this field. + defaults to true if not specified. + type: boolean + featureGates: + additionalProperties: + type: boolean + description: FeatureGates contains information about enabled + feature gates. + type: object + mode: + description: |- + Mode specifies which proxy mode to use. + defaults to IPTables. + type: string + type: object + kubeScheduler: + description: KubeScheduler contains configuration settings for + the kube-scheduler. + properties: + featureGates: + additionalProperties: + type: boolean + description: FeatureGates contains information about enabled + feature gates. + type: object + kubeMaxPDVols: + description: |- + KubeMaxPDVols is not respected anymore by kube-scheduler. + The maximum number of attached volumes is configured by the CSI driver. + More information can be found at https://kubernetes.io/docs/concepts/storage/storage-limits/#custom-limits. + + Deprecated: This field is deprecated. Using this field will be forbidden starting from Kubernetes 1.35. + type: string + profile: + description: |- + Profile configures the scheduling profile for the cluster. + If not specified, the used profile is "balanced" (provides the default kube-scheduler behavior). + type: string + type: object + kubelet: + description: Kubelet contains configuration settings for the kubelet. + properties: + containerLogMaxFiles: + description: Maximum number of container log files that can + be present for a container. + format: int32 + type: integer + containerLogMaxSize: + anyOf: + - type: integer + - type: string + description: |- + A quantity defines the maximum size of the container log file before it is rotated. For example: "5Mi" or "256Ki". + Default: 100Mi + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + cpuCFSQuota: + description: CPUCFSQuota allows you to disable/enable CPU + throttling for Pods. + type: boolean + cpuManagerPolicy: + description: 'CPUManagerPolicy allows to set alternative CPU + management policies (default: none).' + type: string + evictionHard: + description: |- + EvictionHard describes a set of eviction thresholds (e.g. memory.available<1Gi) that if met would trigger a Pod eviction. + Default: + memory.available: "100Mi/1Gi/5%" + nodefs.available: "5%" + nodefs.inodesFree: "5%" + imagefs.available: "5%" + imagefs.inodesFree: "5%" + properties: + imageFSAvailable: + description: ImageFSAvailable is the threshold for the + free disk space in the imagefs filesystem (docker images + and container writable layers). + type: string + imageFSInodesFree: + description: ImageFSInodesFree is the threshold for the + available inodes in the imagefs filesystem. + type: string + memoryAvailable: + description: MemoryAvailable is the threshold for the + free memory on the host server. + type: string + nodeFSAvailable: + description: NodeFSAvailable is the threshold for the + free disk space in the nodefs filesystem (docker volumes, + logs, etc). + type: string + nodeFSInodesFree: + description: NodeFSInodesFree is the threshold for the + available inodes in the nodefs filesystem. + type: string + type: object + evictionMaxPodGracePeriod: + description: |- + EvictionMaxPodGracePeriod describes the maximum allowed grace period (in seconds) to use when terminating pods in response to a soft eviction threshold being met. + Default: 90 + format: int32 + type: integer + evictionMinimumReclaim: + description: |- + EvictionMinimumReclaim configures the amount of resources below the configured eviction threshold that the kubelet attempts to reclaim whenever the kubelet observes resource pressure. + Default: 0 for each resource + properties: + imageFSAvailable: + anyOf: + - type: integer + - type: string + description: ImageFSAvailable is the threshold for the + disk space reclaim in the imagefs filesystem (docker + images and container writable layers). + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + imageFSInodesFree: + anyOf: + - type: integer + - type: string + description: ImageFSInodesFree is the threshold for the + inodes reclaim in the imagefs filesystem. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + memoryAvailable: + anyOf: + - type: integer + - type: string + description: MemoryAvailable is the threshold for the + memory reclaim on the host server. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + nodeFSAvailable: + anyOf: + - type: integer + - type: string + description: NodeFSAvailable is the threshold for the + disk space reclaim in the nodefs filesystem (docker + volumes, logs, etc). + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + nodeFSInodesFree: + anyOf: + - type: integer + - type: string + description: NodeFSInodesFree is the threshold for the + inodes reclaim in the nodefs filesystem. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + evictionPressureTransitionPeriod: + description: |- + EvictionPressureTransitionPeriod is the duration for which the kubelet has to wait before transitioning out of an eviction pressure condition. + Default: 4m0s + type: string + evictionSoft: + description: |- + EvictionSoft describes a set of eviction thresholds (e.g. memory.available<1.5Gi) that if met over a corresponding grace period would trigger a Pod eviction. + Default: + memory.available: "200Mi/1.5Gi/10%" + nodefs.available: "10%" + nodefs.inodesFree: "10%" + imagefs.available: "10%" + imagefs.inodesFree: "10%" + properties: + imageFSAvailable: + description: ImageFSAvailable is the threshold for the + free disk space in the imagefs filesystem (docker images + and container writable layers). + type: string + imageFSInodesFree: + description: ImageFSInodesFree is the threshold for the + available inodes in the imagefs filesystem. + type: string + memoryAvailable: + description: MemoryAvailable is the threshold for the + free memory on the host server. + type: string + nodeFSAvailable: + description: NodeFSAvailable is the threshold for the + free disk space in the nodefs filesystem (docker volumes, + logs, etc). + type: string + nodeFSInodesFree: + description: NodeFSInodesFree is the threshold for the + available inodes in the nodefs filesystem. + type: string + type: object + evictionSoftGracePeriod: + description: |- + EvictionSoftGracePeriod describes a set of eviction grace periods (e.g. memory.available=1m30s) that correspond to how long a soft eviction threshold must hold before triggering a Pod eviction. + Default: + memory.available: 1m30s + nodefs.available: 1m30s + nodefs.inodesFree: 1m30s + imagefs.available: 1m30s + imagefs.inodesFree: 1m30s + properties: + imageFSAvailable: + description: ImageFSAvailable is the grace period for + the ImageFSAvailable eviction threshold. + type: string + imageFSInodesFree: + description: ImageFSInodesFree is the grace period for + the ImageFSInodesFree eviction threshold. + type: string + memoryAvailable: + description: MemoryAvailable is the grace period for the + MemoryAvailable eviction threshold. + type: string + nodeFSAvailable: + description: NodeFSAvailable is the grace period for the + NodeFSAvailable eviction threshold. + type: string + nodeFSInodesFree: + description: NodeFSInodesFree is the grace period for + the NodeFSInodesFree eviction threshold. + type: string + type: object + failSwapOn: + description: FailSwapOn makes the Kubelet fail to start if + swap is enabled on the node. (default true). + type: boolean + featureGates: + additionalProperties: + type: boolean + description: FeatureGates contains information about enabled + feature gates. + type: object + imageGCHighThresholdPercent: + description: |- + ImageGCHighThresholdPercent describes the percent of the disk usage which triggers image garbage collection. + Default: 50 + format: int32 + type: integer + imageGCLowThresholdPercent: + description: |- + ImageGCLowThresholdPercent describes the percent of the disk to which garbage collection attempts to free. + Default: 40 + format: int32 + type: integer + imageMaximumGCAge: + description: |- + ImageMaximumGCAge is the maximum age of an unused image before it can be garbage collected. + Default: 0s + type: string + imageMinimumGCAge: + description: |- + ImageMinimumGCAge is the minimum age of an unused image before it can be garbage collected. + Default: 2m0s + type: string + imagePullCredentialsVerificationPolicy: + description: |- + ImagePullCredentialsVerificationPolicy determines how credentials should be verified when pulling images that + already exist on the node. It corresponds to the kubelet's `imagePullCredentialsVerificationPolicy` field and is only + effective for Kubernetes versions >= 1.35. May be one of {"NeverVerify", "NeverVerifyPreloadedImages", + "NeverVerifyAllowlistedImages", "AlwaysVerify"}. Defaults to "NeverVerifyPreloadedImages" (the kubelet default). + type: string + kubeReserved: + description: |- + KubeReserved is the configuration for resources reserved for kubernetes node components (mainly kubelet and container runtime). + When updating these values, be aware that cgroup resizes may not succeed on active worker nodes. Look for the NodeAllocatableEnforced event to determine if the configuration was applied. + Default: cpu=80m,memory=1Gi,pid=20k + properties: + cpu: + anyOf: + - type: integer + - type: string + description: CPU is the reserved cpu. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + ephemeralStorage: + anyOf: + - type: integer + - type: string + description: EphemeralStorage is the reserved ephemeral-storage. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + memory: + anyOf: + - type: integer + - type: string + description: Memory is the reserved memory. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + pid: + anyOf: + - type: integer + - type: string + description: PID is the reserved process-ids. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + maxParallelImagePulls: + description: |- + MaxParallelImagePulls describes the maximum number of image pulls in parallel. The value must be a positive number. + This field cannot be set if SerializeImagePulls (pull one image at a time) is set to true. + Setting it to nil means no limit. + Default: nil + format: int32 + type: integer + maxPods: + description: |- + MaxPods is the maximum number of Pods that are allowed by the Kubelet. + Default: 110 + format: int32 + type: integer + memorySwap: + description: MemorySwap configures swap memory available to + container workloads. + properties: + swapBehavior: + description: |- + SwapBehavior configures swap memory available to container workloads. May be one of {"NoSwap", "LimitedSwap"} + defaults to: LimitedSwap + type: string + type: object + podPidsLimit: + description: PodPIDsLimit is the maximum number of process + IDs per pod allowed by the kubelet. + format: int64 + type: integer + preloadedImagesVerificationAllowlist: + description: |- + PreloadedImagesVerificationAllowlist specifies a list of images that are exempted from credential + re-verification for the "NeverVerifyAllowlistedImages" ImagePullCredentialsVerificationPolicy. The list accepts a + full path segment wildcard suffix "/*". Only image specs without an image tag or digest must be used. It + corresponds to the kubelet's `preloadedImagesVerificationAllowlist` field and is only effective for Kubernetes versions + >= 1.35. + items: + type: string + type: array + protectKernelDefaults: + description: |- + ProtectKernelDefaults ensures that the kernel tunables are equal to the kubelet defaults. + Defaults to true. + type: boolean + registryBurst: + description: |- + RegistryBurst is the maximum size of bursty pulls, temporarily allows pulls to burst to this number, + while still not exceeding registryPullQPS. The value must not be a negative number. + Only used if registryPullQPS is greater than 0. + Default: 10 + format: int32 + type: integer + registryPullQPS: + description: |- + RegistryPullQPS is the limit of registry pulls per second. The value must not be a negative number. + Setting it to 0 means no limit. + Default: 5 + format: int32 + type: integer + seccompDefault: + description: SeccompDefault enables the use of `RuntimeDefault` + as the default seccomp profile for all workloads. + type: boolean + serializeImagePulls: + description: |- + SerializeImagePulls describes whether the images are pulled one at a time. + Default: true + type: boolean + singleProcessOOMKill: + description: |- + SingleProcessOOMKill, if true, will prevent the `memory.oom.group` flag from being set for container + cgroups in cgroups v2. This causes processes in the container to be OOM killed individually instead of + as a group. It means that if true, the behavior aligns with the behavior of cgroups v1. + type: boolean + streamingConnectionIdleTimeout: + description: |- + StreamingConnectionIdleTimeout is the maximum time a streaming connection can be idle before the connection is automatically closed. + This field cannot be set lower than "30s" or greater than "4h". + Default: "5m". + type: string + type: object + version: + description: |- + Version is the semantic Kubernetes version to use for the Shoot cluster. + Defaults to the highest supported minor and patch version given in the referenced cloud profile. + The version can be omitted completely or partially specified, e.g. `.`. + type: string + verticalPodAutoscaler: + description: VerticalPodAutoscaler contains the configuration + flags for the Kubernetes vertical pod autoscaler. + properties: + cpuHistogramDecayHalfLife: + description: |- + CPUHistogramDecayHalfLife is the amount of time it takes a historical CPU usage sample to lose half of its weight. + (default: 24h) + type: string + enabled: + description: Enabled specifies whether the Kubernetes VPA + shall be enabled for the shoot cluster. + type: boolean + evictAfterOOMThreshold: + description: |- + EvictAfterOOMThreshold defines the threshold that will lead to pod eviction in case it OOMed in less than the given + threshold since its start and if it has only one container (default: 10m0s). + type: string + evictionRateBurst: + description: 'EvictionRateBurst defines the burst of pods + that can be evicted (default: 1)' + format: int32 + type: integer + evictionRateLimit: + description: |- + EvictionRateLimit defines the number of pods that can be evicted per second. A rate limit set to 0 or -1 will + disable the rate limiter (default: -1). + type: number + evictionTolerance: + description: |- + EvictionTolerance defines the fraction of replica count that can be evicted for update in case more than one + pod can be evicted (default: 0.5). + type: number + featureGates: + additionalProperties: + type: boolean + description: FeatureGates contains information about enabled + feature gates. + type: object + maxAllowed: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + MaxAllowed specifies the global maximum allowed (maximum amount of resources) that vpa-recommender can recommend for a container. + The VerticalPodAutoscaler-level maximum allowed takes precedence over the global maximum allowed. + For more information, see https://github.com/kubernetes/autoscaler/blob/master/vertical-pod-autoscaler/docs/examples.md#specifying-global-maximum-allowed-resources-to-prevent-pods-from-being-unschedulable. + + Defaults to nil (no maximum). + type: object + memoryAggregationInterval: + description: |- + MemoryAggregationInterval is the length of a single interval, for which the peak memory usage is computed. + (default: 24h) + type: string + memoryAggregationIntervalCount: + description: |- + MemoryAggregationIntervalCount is the number of consecutive memory-aggregation-intervals which make up the + MemoryAggregationWindowLength which in turn is the period for memory usage aggregation by VPA. In other words, + `MemoryAggregationWindowLength = memory-aggregation-interval * memory-aggregation-interval-count`. + (default: 8) + format: int64 + type: integer + memoryHistogramDecayHalfLife: + description: |- + MemoryHistogramDecayHalfLife is the amount of time it takes a historical memory usage sample to lose half of its weight. + (default: 24h) + type: string + recommendationLowerBoundCPUPercentile: + description: |- + RecommendationLowerBoundCPUPercentile is the usage percentile that will be used for the lower bound on CPU recommendation. + (default: 0.5) + type: number + recommendationLowerBoundMemoryPercentile: + description: |- + RecommendationLowerBoundMemoryPercentile is the usage percentile that will be used for the lower bound on memory recommendation. + (default: 0.5) + type: number + recommendationMarginFraction: + description: |- + RecommendationMarginFraction is the fraction of usage added as the safety margin to the recommended request + (default: 0.15). + type: number + recommendationUpperBoundCPUPercentile: + description: |- + RecommendationUpperBoundCPUPercentile is the usage percentile that will be used for the upper bound on CPU recommendation. + (default: 0.95) + type: number + recommendationUpperBoundMemoryPercentile: + description: |- + RecommendationUpperBoundMemoryPercentile is the usage percentile that will be used for the upper bound on memory recommendation. + (default: 0.95) + type: number + recommenderInterval: + description: 'RecommenderInterval is the interval how often + metrics should be fetched (default: 1m0s).' + type: string + recommenderUpdateWorkerCount: + description: |- + RecommenderUpdateWorkerCount is the number of workers used in the vpa-recommender for updating VPAs and VPACheckpoints in parallel. + (default: 10) + format: int64 + type: integer + targetCPUPercentile: + description: |- + TargetCPUPercentile is the usage percentile that will be used as a base for CPU target recommendation. + Doesn't affect CPU lower bound, CPU upper bound nor memory recommendations. + (default: 0.9) + type: number + targetMemoryPercentile: + description: |- + TargetMemoryPercentile is the usage percentile that will be used as a base for memory target recommendation. + Doesn't affect memory lower bound nor memory upper bound. + (default: 0.9) + type: number + updaterInterval: + description: 'UpdaterInterval is the interval how often the + updater should run (default: 1m0s).' + type: string + required: + - enabled + type: object + type: object + maintenance: + description: |- + Maintenance contains information about the time window for maintenance operations and which + operations should be performed. + properties: + autoRotation: + description: AutoRotation contains information about which rotations + should be automatically performed. + properties: + credentials: + description: Credentials contains information about which + credentials should be automatically rotated. + properties: + etcdEncryptionKey: + description: ETCDEncryptionKey configures the automatic + rotation for the etcd encryption key. + properties: + rotationPeriod: + description: |- + RotationPeriod is the period between a completed rotation and the start of a new rotation (default: 7d). + The allowed rotation period is between 30m and 90d. When set to 0, rotation is disabled. + type: string + type: object + observability: + description: Observability configures the automatic rotation + for the observability credentials. + properties: + rotationPeriod: + description: |- + RotationPeriod is the period between a completed rotation and the start of a new rotation (default: 7d). + The allowed rotation period is between 30m and 90d. When set to 0, rotation is disabled. + type: string + type: object + sshKeypair: + description: SSHKeypair configures the automatic rotation + for the ssh keypair for worker nodes. + properties: + rotationPeriod: + description: |- + RotationPeriod is the period between a completed rotation and the start of a new rotation (default: 7d). + The allowed rotation period is between 30m and 90d. When set to 0, rotation is disabled. + type: string + type: object + type: object + type: object + autoUpdate: + description: AutoUpdate contains information about which constraints + should be automatically updated. + properties: + kubernetesVersion: + description: 'KubernetesVersion indicates whether the patch + Kubernetes version may be automatically updated (default: + true).' + type: boolean + machineImageVersion: + description: 'MachineImageVersion indicates whether the machine + image version may be automatically updated (default: true).' + type: boolean + required: + - kubernetesVersion + type: object + confineSpecUpdateRollout: + description: |- + ConfineSpecUpdateRollout prevents that changes/updates to the shoot specification will be rolled out immediately. + Instead, they are rolled out during the shoot's maintenance time window. There is one exception that will trigger + an immediate roll out which is changes to the Spec.Hibernation.Enabled field. + type: boolean + timeWindow: + description: TimeWindow contains information about the time window + for maintenance operations. + properties: + begin: + description: |- + Begin is the beginning of the time window in the format HHMMSS±ZONE, e.g. "220000+0100" or "220000-0500". + If not present, a random value will be computed. + pattern: ([0-1][0-9]|2[0-3])[0-5][0-9][0-5][0-9]([+](0[0-9]|1[0-4])|[-](0[0-9]|1[0-2]))00 + type: string + end: + description: |- + End is the end of the time window in the format HHMMSS±ZONE, e.g. "220000+0100" or "220000-0500". + If not present, the value will be computed based on the "Begin" value. + pattern: ([0-1][0-9]|2[0-3])[0-5][0-9][0-5][0-9]([+](0[0-9]|1[0-4])|[-](0[0-9]|1[0-2]))00 + type: string + required: + - begin + - end + type: object + type: object + monitoring: + description: Monitoring contains information about custom monitoring + configurations for the shoot. + properties: + alerting: + description: Alerting contains information about the alerting + configuration for the shoot cluster. + properties: + emailReceivers: + description: MonitoringEmailReceivers is a list of recipients + for alerts + items: + type: string + type: array + type: object + type: object + networking: + description: Networking contains information about cluster networking + such as CNI Plugin type, CIDRs, ...etc. + properties: + ipFamilies: + description: |- + IPFamilies specifies the IP protocol versions to use for shoot networking. + See https://github.com/gardener/gardener/blob/master/docs/development/ipv6.md. + Defaults to ["IPv4"]. + items: + description: IPFamily is a type for specifying an IP protocol + version to use in Gardener clusters. + type: string + type: array + nodes: + description: |- + Nodes is the CIDR of the entire node network. + This field is mutable. + type: string + pods: + description: Pods is the CIDR of the pod network. This field is + immutable. + type: string + providerConfig: + description: ProviderConfig is the configuration passed to network + resource. + type: object + x-kubernetes-preserve-unknown-fields: true + services: + description: Services is the CIDR of the service network. This + field is immutable. + type: string + type: + description: Type identifies the type of the networking plugin. + This field is immutable. + type: string + type: object + provider: + description: Provider contains all provider-specific and provider-relevant + information. + properties: + controlPlaneConfig: + description: |- + ControlPlaneConfig contains the provider-specific control plane config blob. Please look up the concrete + definition in the documentation of your provider extension. + type: object + x-kubernetes-preserve-unknown-fields: true + infrastructureConfig: + description: |- + InfrastructureConfig contains the provider-specific infrastructure config blob. Please look up the concrete + definition in the documentation of your provider extension. + type: object + x-kubernetes-preserve-unknown-fields: true + type: + description: Type is the type of the provider. This field is immutable. + type: string + workers: + description: Workers is a list of worker groups. + items: + description: Worker is the base definition of a worker group. + properties: + annotations: + additionalProperties: + type: string + description: Annotations is a map of key/value pairs for + annotations for all the `Node` objects in this worker + pool. + type: object + caBundle: + description: CABundle is a certificate bundle which will + be installed onto every machine of this worker pool. + type: string + clusterAutoscaler: + description: ClusterAutoscaler contains the cluster autoscaler + configurations for the worker pool. + properties: + maxNodeProvisionTime: + description: MaxNodeProvisionTime defines how long CA + waits for node to be provisioned. + type: string + scaleDownGpuUtilizationThreshold: + description: ScaleDownGpuUtilizationThreshold defines + the threshold in fraction (0.0 - 1.0) of gpu resources + under which a node is being removed. + type: number + scaleDownUnneededTime: + description: ScaleDownUnneededTime defines how long + a node should be unneeded before it is eligible for + scale down. + type: string + scaleDownUnreadyTime: + description: ScaleDownUnreadyTime defines how long an + unready node should be unneeded before it is eligible + for scale down. + type: string + scaleDownUtilizationThreshold: + description: ScaleDownUtilizationThreshold defines the + threshold in fraction (0.0 - 1.0) under which a node + is being removed. + type: number + type: object + controlPlane: + description: |- + ControlPlane specifies that the shoot cluster control plane components should be running in this worker pool. + This is only relevant for self-hosted shoot clusters. + properties: + backup: + description: |- + Backup holds the object store configuration for the backups of shoot (currently only etcd). + If it is not specified, then there won't be any backups taken. + properties: + credentialsRef: + description: |- + CredentialsRef is reference to a resource holding the credentials used for + authentication with the object store service where the backups are stored. + Supported referenced resources are v1.Secrets and + security.gardener.cloud/v1alpha1.WorkloadIdentity + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: |- + If referring to a piece of an object instead of an entire object, this string + should contain a valid JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within a pod, this would take on a value like: + "spec.containers{name}" (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" (container with + index 2 in this pod). This syntax is chosen only to have some well-defined way of + referencing a part of an object. + type: string + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + namespace: + description: |- + Namespace of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ + type: string + resourceVersion: + description: |- + Specific resourceVersion to which this reference is made, if any. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + type: string + uid: + description: |- + UID of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids + type: string + type: object + x-kubernetes-map-type: atomic + provider: + description: Provider is a provider name. This field + is immutable. + type: string + providerConfig: + description: ProviderConfig is the configuration + passed to BackupBucket resource. + type: object + x-kubernetes-preserve-unknown-fields: true + region: + description: Region is a region name. This field + is immutable. + type: string + required: + - provider + type: object + exposure: + description: Exposure holds the exposure configuration + for the shoot (either `extension` or `dns` or omitted/empty). + properties: + dns: + description: |- + DNS specifies that this shoot will be exposed by DNS. + Mutually exclusive with Extension. + type: object + extension: + description: |- + Extension holds the type and provider config of the exposure extension. + Mutually exclusive with DNS. + properties: + providerConfig: + description: ProviderConfig holds the extension + specific configuration. + type: object + x-kubernetes-preserve-unknown-fields: true + type: + description: |- + Type defines the type of the extension exposure. + Defaults to `.spec.provider.type` + type: string + type: object + type: object + type: object + cri: + description: |- + CRI contains configurations of CRI support of every machine in the worker pool. + Defaults to a CRI with name `containerd`. + properties: + containerRuntimes: + description: ContainerRuntimes is the list of the required + container runtimes supported for a worker pool. + items: + description: ContainerRuntime contains information + about worker's available container runtime + properties: + providerConfig: + description: ProviderConfig is the configuration + passed to container runtime resource. + type: object + x-kubernetes-preserve-unknown-fields: true + type: + description: Type is the type of the Container + Runtime. + type: string + required: + - type + type: object + type: array + name: + description: The name of the CRI library. Supported + values are `containerd`. + type: string + required: + - name + type: object + dataVolumes: + description: DataVolumes contains a list of additional worker + volumes. + items: + description: DataVolume contains information about a data + volume. + properties: + encrypted: + description: Encrypted determines if the volume should + be encrypted. + type: boolean name: - description: Machine image name. + description: Name of the volume to make it referenceable. type: string - version: - description: Machine image version. + size: + description: VolumeSize is the size of the volume. type: string - status: - description: Status contains the current status of the Shoot. - type: object - scope: Namespaced - names: - plural: shoots - singular: shoot - kind: Shoot \ No newline at end of file + type: + description: Type is the type of the volume. + type: string + required: + - name + - size + type: object + type: array + kubeletDataVolumeName: + description: KubeletDataVolumeName contains the name of + a dataVolume that should be used for storing kubelet state. + type: string + kubernetes: + description: Kubernetes contains configuration for Kubernetes + components related to this worker pool. + properties: + kubelet: + description: |- + Kubelet contains configuration settings for all kubelets of this worker pool. + If set, all `spec.kubernetes.kubelet` settings will be overwritten for this worker pool (no merge of settings). + properties: + containerLogMaxFiles: + description: Maximum number of container log files + that can be present for a container. + format: int32 + type: integer + containerLogMaxSize: + anyOf: + - type: integer + - type: string + description: |- + A quantity defines the maximum size of the container log file before it is rotated. For example: "5Mi" or "256Ki". + Default: 100Mi + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + cpuCFSQuota: + description: CPUCFSQuota allows you to disable/enable + CPU throttling for Pods. + type: boolean + cpuManagerPolicy: + description: 'CPUManagerPolicy allows to set alternative + CPU management policies (default: none).' + type: string + evictionHard: + description: |- + EvictionHard describes a set of eviction thresholds (e.g. memory.available<1Gi) that if met would trigger a Pod eviction. + Default: + memory.available: "100Mi/1Gi/5%" + nodefs.available: "5%" + nodefs.inodesFree: "5%" + imagefs.available: "5%" + imagefs.inodesFree: "5%" + properties: + imageFSAvailable: + description: ImageFSAvailable is the threshold + for the free disk space in the imagefs filesystem + (docker images and container writable layers). + type: string + imageFSInodesFree: + description: ImageFSInodesFree is the threshold + for the available inodes in the imagefs filesystem. + type: string + memoryAvailable: + description: MemoryAvailable is the threshold + for the free memory on the host server. + type: string + nodeFSAvailable: + description: NodeFSAvailable is the threshold + for the free disk space in the nodefs filesystem + (docker volumes, logs, etc). + type: string + nodeFSInodesFree: + description: NodeFSInodesFree is the threshold + for the available inodes in the nodefs filesystem. + type: string + type: object + evictionMaxPodGracePeriod: + description: |- + EvictionMaxPodGracePeriod describes the maximum allowed grace period (in seconds) to use when terminating pods in response to a soft eviction threshold being met. + Default: 90 + format: int32 + type: integer + evictionMinimumReclaim: + description: |- + EvictionMinimumReclaim configures the amount of resources below the configured eviction threshold that the kubelet attempts to reclaim whenever the kubelet observes resource pressure. + Default: 0 for each resource + properties: + imageFSAvailable: + anyOf: + - type: integer + - type: string + description: ImageFSAvailable is the threshold + for the disk space reclaim in the imagefs + filesystem (docker images and container writable + layers). + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + imageFSInodesFree: + anyOf: + - type: integer + - type: string + description: ImageFSInodesFree is the threshold + for the inodes reclaim in the imagefs filesystem. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + memoryAvailable: + anyOf: + - type: integer + - type: string + description: MemoryAvailable is the threshold + for the memory reclaim on the host server. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + nodeFSAvailable: + anyOf: + - type: integer + - type: string + description: NodeFSAvailable is the threshold + for the disk space reclaim in the nodefs filesystem + (docker volumes, logs, etc). + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + nodeFSInodesFree: + anyOf: + - type: integer + - type: string + description: NodeFSInodesFree is the threshold + for the inodes reclaim in the nodefs filesystem. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + evictionPressureTransitionPeriod: + description: |- + EvictionPressureTransitionPeriod is the duration for which the kubelet has to wait before transitioning out of an eviction pressure condition. + Default: 4m0s + type: string + evictionSoft: + description: |- + EvictionSoft describes a set of eviction thresholds (e.g. memory.available<1.5Gi) that if met over a corresponding grace period would trigger a Pod eviction. + Default: + memory.available: "200Mi/1.5Gi/10%" + nodefs.available: "10%" + nodefs.inodesFree: "10%" + imagefs.available: "10%" + imagefs.inodesFree: "10%" + properties: + imageFSAvailable: + description: ImageFSAvailable is the threshold + for the free disk space in the imagefs filesystem + (docker images and container writable layers). + type: string + imageFSInodesFree: + description: ImageFSInodesFree is the threshold + for the available inodes in the imagefs filesystem. + type: string + memoryAvailable: + description: MemoryAvailable is the threshold + for the free memory on the host server. + type: string + nodeFSAvailable: + description: NodeFSAvailable is the threshold + for the free disk space in the nodefs filesystem + (docker volumes, logs, etc). + type: string + nodeFSInodesFree: + description: NodeFSInodesFree is the threshold + for the available inodes in the nodefs filesystem. + type: string + type: object + evictionSoftGracePeriod: + description: |- + EvictionSoftGracePeriod describes a set of eviction grace periods (e.g. memory.available=1m30s) that correspond to how long a soft eviction threshold must hold before triggering a Pod eviction. + Default: + memory.available: 1m30s + nodefs.available: 1m30s + nodefs.inodesFree: 1m30s + imagefs.available: 1m30s + imagefs.inodesFree: 1m30s + properties: + imageFSAvailable: + description: ImageFSAvailable is the grace period + for the ImageFSAvailable eviction threshold. + type: string + imageFSInodesFree: + description: ImageFSInodesFree is the grace + period for the ImageFSInodesFree eviction + threshold. + type: string + memoryAvailable: + description: MemoryAvailable is the grace period + for the MemoryAvailable eviction threshold. + type: string + nodeFSAvailable: + description: NodeFSAvailable is the grace period + for the NodeFSAvailable eviction threshold. + type: string + nodeFSInodesFree: + description: NodeFSInodesFree is the grace period + for the NodeFSInodesFree eviction threshold. + type: string + type: object + failSwapOn: + description: FailSwapOn makes the Kubelet fail to + start if swap is enabled on the node. (default + true). + type: boolean + featureGates: + additionalProperties: + type: boolean + description: FeatureGates contains information about + enabled feature gates. + type: object + imageGCHighThresholdPercent: + description: |- + ImageGCHighThresholdPercent describes the percent of the disk usage which triggers image garbage collection. + Default: 50 + format: int32 + type: integer + imageGCLowThresholdPercent: + description: |- + ImageGCLowThresholdPercent describes the percent of the disk to which garbage collection attempts to free. + Default: 40 + format: int32 + type: integer + imageMaximumGCAge: + description: |- + ImageMaximumGCAge is the maximum age of an unused image before it can be garbage collected. + Default: 0s + type: string + imageMinimumGCAge: + description: |- + ImageMinimumGCAge is the minimum age of an unused image before it can be garbage collected. + Default: 2m0s + type: string + imagePullCredentialsVerificationPolicy: + description: |- + ImagePullCredentialsVerificationPolicy determines how credentials should be verified when pulling images that + already exist on the node. It corresponds to the kubelet's `imagePullCredentialsVerificationPolicy` field and is only + effective for Kubernetes versions >= 1.35. May be one of {"NeverVerify", "NeverVerifyPreloadedImages", + "NeverVerifyAllowlistedImages", "AlwaysVerify"}. Defaults to "NeverVerifyPreloadedImages" (the kubelet default). + type: string + kubeReserved: + description: |- + KubeReserved is the configuration for resources reserved for kubernetes node components (mainly kubelet and container runtime). + When updating these values, be aware that cgroup resizes may not succeed on active worker nodes. Look for the NodeAllocatableEnforced event to determine if the configuration was applied. + Default: cpu=80m,memory=1Gi,pid=20k + properties: + cpu: + anyOf: + - type: integer + - type: string + description: CPU is the reserved cpu. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + ephemeralStorage: + anyOf: + - type: integer + - type: string + description: EphemeralStorage is the reserved + ephemeral-storage. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + memory: + anyOf: + - type: integer + - type: string + description: Memory is the reserved memory. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + pid: + anyOf: + - type: integer + - type: string + description: PID is the reserved process-ids. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + maxParallelImagePulls: + description: |- + MaxParallelImagePulls describes the maximum number of image pulls in parallel. The value must be a positive number. + This field cannot be set if SerializeImagePulls (pull one image at a time) is set to true. + Setting it to nil means no limit. + Default: nil + format: int32 + type: integer + maxPods: + description: |- + MaxPods is the maximum number of Pods that are allowed by the Kubelet. + Default: 110 + format: int32 + type: integer + memorySwap: + description: MemorySwap configures swap memory available + to container workloads. + properties: + swapBehavior: + description: |- + SwapBehavior configures swap memory available to container workloads. May be one of {"NoSwap", "LimitedSwap"} + defaults to: LimitedSwap + type: string + type: object + podPidsLimit: + description: PodPIDsLimit is the maximum number + of process IDs per pod allowed by the kubelet. + format: int64 + type: integer + preloadedImagesVerificationAllowlist: + description: |- + PreloadedImagesVerificationAllowlist specifies a list of images that are exempted from credential + re-verification for the "NeverVerifyAllowlistedImages" ImagePullCredentialsVerificationPolicy. The list accepts a + full path segment wildcard suffix "/*". Only image specs without an image tag or digest must be used. It + corresponds to the kubelet's `preloadedImagesVerificationAllowlist` field and is only effective for Kubernetes versions + >= 1.35. + items: + type: string + type: array + protectKernelDefaults: + description: |- + ProtectKernelDefaults ensures that the kernel tunables are equal to the kubelet defaults. + Defaults to true. + type: boolean + registryBurst: + description: |- + RegistryBurst is the maximum size of bursty pulls, temporarily allows pulls to burst to this number, + while still not exceeding registryPullQPS. The value must not be a negative number. + Only used if registryPullQPS is greater than 0. + Default: 10 + format: int32 + type: integer + registryPullQPS: + description: |- + RegistryPullQPS is the limit of registry pulls per second. The value must not be a negative number. + Setting it to 0 means no limit. + Default: 5 + format: int32 + type: integer + seccompDefault: + description: SeccompDefault enables the use of `RuntimeDefault` + as the default seccomp profile for all workloads. + type: boolean + serializeImagePulls: + description: |- + SerializeImagePulls describes whether the images are pulled one at a time. + Default: true + type: boolean + singleProcessOOMKill: + description: |- + SingleProcessOOMKill, if true, will prevent the `memory.oom.group` flag from being set for container + cgroups in cgroups v2. This causes processes in the container to be OOM killed individually instead of + as a group. It means that if true, the behavior aligns with the behavior of cgroups v1. + type: boolean + streamingConnectionIdleTimeout: + description: |- + StreamingConnectionIdleTimeout is the maximum time a streaming connection can be idle before the connection is automatically closed. + This field cannot be set lower than "30s" or greater than "4h". + Default: "5m". + type: string + type: object + version: + description: |- + Version is the semantic Kubernetes version to use for the Kubelet in this Worker Group. + If not specified the kubelet version is derived from the global shoot cluster kubernetes version. + version must be equal or lower than the version of the shoot kubernetes version. + Only one minor version difference to other worker groups and global kubernetes version is allowed. + type: string + type: object + labels: + additionalProperties: + type: string + description: Labels is a map of key/value pairs for labels + for all the `Node` objects in this worker pool. + type: object + machine: + description: Machine contains information about the machine + type and image. + properties: + architecture: + description: Architecture is CPU architecture of machines + in this worker pool. + type: string + image: + description: |- + Image holds information about the machine image to use for all nodes of this pool. It will default to the + latest version of the first image stated in the referenced CloudProfile if no value has been provided. + properties: + name: + description: Name is the name of the image. + type: string + providerConfig: + description: ProviderConfig is the shoot's individual + configuration passed to an extension resource. + type: object + x-kubernetes-preserve-unknown-fields: true + version: + description: |- + Version is the version of the shoot's image. + If version is not provided, it will be defaulted to the latest version from the CloudProfile. + type: string + required: + - name + type: object + type: + description: Type is the machine type of the worker + group. + type: string + required: + - type + type: object + machineControllerManager: + description: MachineControllerManagerSettings contains configurations + for different worker-pools. Eg. MachineDrainTimeout, MachineHealthTimeout. + properties: + autoPreserveFailedMachineMax: + description: |- + AutoPreserveFailedMachineMax is the maximum number of machines that can be auto-preserved by MCM for the worker pool. + This value is distributed across zones like Minimum and Maximum. + format: int32 + type: integer + disableHealthTimeout: + description: |- + DisableHealthTimeout if set to true, health timeout will be ignored. Leading to machine never being declared failed. + This is intended to be used only for in-place updates. + type: boolean + inPlaceUpdateTimeout: + description: MachineInPlaceUpdateTimeout is the timeout + after which in-place update is declared failed. + type: string + machineCreationTimeout: + description: MachineCreationTimeout is the period after + which creation of the machine is declared failed. + type: string + machineDrainTimeout: + description: MachineDrainTimeout is the period after + which machine is forcefully deleted. + type: string + machineHealthTimeout: + description: MachineHealthTimeout is the period after + which machine is declared failed. + type: string + machinePreserveTimeout: + description: |- + MachinePreserveTimeout defines the duration after which machine preservation is disabled. + If preservation is disabled while the machine is in the Failed phase, the machine transitions + to the Terminating phase. For machines in any other phase, disabling preservation does not + alter the current phase, and normal behavior and phase transitions continue as usual. + However, the Cluster Autoscaler (CA) may scale down the machine if required. + type: string + maxEvictRetries: + description: MaxEvictRetries are the number of eviction + retries on a pod after which drain is declared failed, + and forceful deletion is triggered. + format: int32 + type: integer + nodeConditions: + description: NodeConditions are the set of conditions + if set to true for the period of MachineHealthTimeout, + machine will be declared failed. + items: + type: string + type: array + type: object + maxSurge: + anyOf: + - type: integer + - type: string + description: |- + MaxSurge is maximum number of machines that are created during an update. + This value is divided by the number of configured zones for a fair distribution. + Defaults to 0 in case of an in-place update. + Defaults to 1 in case of a rolling update. + x-kubernetes-int-or-string: true + maxUnavailable: + anyOf: + - type: integer + - type: string + description: |- + MaxUnavailable is the maximum number of machines that can be unavailable during an update. + This value is divided by the number of configured zones for a fair distribution. + Defaults to 1 in case of an in-place update. + Defaults to 0 in case of a rolling update. + x-kubernetes-int-or-string: true + maximum: + description: |- + Maximum is the maximum number of machines to create. + This value is divided by the number of configured zones for a fair distribution. + format: int32 + type: integer + minimum: + description: |- + Minimum is the minimum number of machines to create. + This value is divided by the number of configured zones for a fair distribution. + format: int32 + type: integer + name: + description: Name is the name of the worker group. + type: string + priority: + description: Priority (or weight) is the importance by which + this worker group will be scaled by cluster autoscaling. + format: int32 + type: integer + providerConfig: + description: ProviderConfig is the provider-specific configuration + for this worker pool. + type: object + x-kubernetes-preserve-unknown-fields: true + sysctls: + additionalProperties: + type: string + description: Sysctls is a map of kernel settings to apply + on all machines in this worker pool. + type: object + systemComponents: + description: SystemComponents contains configuration for + system components related to this worker pool + properties: + allow: + description: Allow determines whether the pool should + be allowed to host system components or not (defaults + to true) + type: boolean + required: + - allow + type: object + taints: + description: Taints is a list of taints for all the `Node` + objects in this worker pool. + items: + description: |- + The node this Taint is attached to has the "effect" on + any pod that does not tolerate the Taint. + properties: + effect: + description: |- + Required. The effect of the taint on pods + that do not tolerate the taint. + Valid effects are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: Required. The taint key to be applied + to a node. + type: string + timeAdded: + description: TimeAdded represents the time at which + the taint was added. + format: date-time + type: string + value: + description: The taint value corresponding to the + taint key. + type: string + required: + - effect + - key + type: object + type: array + updateStrategy: + description: UpdateStrategy specifies the machine update + strategy for the worker pool. + type: string + volume: + description: Volume contains information about the volume + type and size. + properties: + encrypted: + description: Encrypted determines if the volume should + be encrypted. + type: boolean + name: + description: Name of the volume to make it referenceable. + type: string + size: + description: VolumeSize is the size of the volume. + type: string + type: + description: Type is the type of the volume. + type: string + required: + - size + type: object + zones: + description: |- + Zones is a list of availability zones that are used to evenly distribute this worker pool. Optional + as not every provider may support availability zones. + items: + type: string + type: array + required: + - machine + - maximum + - minimum + - name + type: object + type: array + workersSettings: + description: WorkersSettings contains settings for all workers. + properties: + sshAccess: + description: SSHAccess contains settings regarding ssh access + to the worker nodes. + properties: + enabled: + description: |- + Enabled indicates whether the SSH access to the worker nodes is ensured to be enabled or disabled in systemd. + Defaults to true. + type: boolean + required: + - enabled + type: object + type: object + required: + - type + type: object + purpose: + description: Purpose is the purpose class for this cluster. + type: string + region: + description: Region is a name of a region. This field is immutable. + type: string + resources: + description: Resources holds a list of named resource references that + can be referred to in extension configs by their names. + items: + description: NamedResourceReference is a named reference to a resource. + properties: + name: + description: Name of the resource reference. + type: string + resourceRef: + description: ResourceRef is a reference to a resource. + properties: + apiVersion: + description: apiVersion is the API version of the referent + type: string + kind: + description: 'kind is the kind of the referent; More info: + https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + name: + description: 'name is the name of the referent; More info: + https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + required: + - kind + - name + type: object + x-kubernetes-map-type: atomic + required: + - name + - resourceRef + type: object + type: array + schedulerName: + description: |- + SchedulerName is the name of the responsible scheduler which schedules the shoot. + If not specified, the default scheduler takes over. + This field is immutable. + type: string + secretBindingName: + description: |- + SecretBindingName is the name of a SecretBinding that has a reference to the provider secret. + The credentials inside the provider secret will be used to create the shoot in the respective account. + The field is mutually exclusive with CredentialsBindingName. + This field is immutable. + + Deprecated: Use CredentialsBindingName instead. See https://github.com/gardener/gardener/blob/master/docs/usage/shoot-operations/secretbinding-to-credentialsbinding-migration.md for migration instructions. + type: string + seedName: + description: SeedName is the name of the seed cluster that runs the + control plane of the Shoot. + type: string + seedSelector: + description: |- + SeedSelector is an optional selector which must match a seed's labels for the shoot to be scheduled on that seed. + Once the shoot is assigned to a seed, the selector can only be changed later if the new one still matches the assigned seed. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + providerTypes: + description: Providers is optional and can be used by restricting + seeds by their provider type. '*' can be used to enable seeds + regardless of their provider type. + items: + type: string + type: array + type: object + x-kubernetes-map-type: atomic + systemComponents: + description: SystemComponents contains the settings of system components + in the control or data plane of the Shoot cluster. + properties: + coreDNS: + description: CoreDNS contains the settings of the Core DNS components + running in the data plane of the Shoot cluster. + properties: + autoscaling: + description: Autoscaling contains the settings related to + autoscaling of the Core DNS components running in the data + plane of the Shoot cluster. + properties: + mode: + description: |- + The mode of the autoscaling to be used for the Core DNS components running in the data plane of the Shoot cluster. + Supported values are `horizontal` and `cluster-proportional`. + type: string + required: + - mode + type: object + rewriting: + description: Rewriting contains the setting related to rewriting + of requests, which are obviously incorrect due to the unnecessary + application of the search path. + properties: + commonSuffixes: + description: CommonSuffixes are expected to be the suffix + of a fully qualified domain name. Each suffix should + contain at least one or two dots ('.') to prevent accidental + clashes. + items: + type: string + type: array + type: object + type: object + nodeLocalDNS: + description: NodeLocalDNS contains the settings of the node local + DNS components running in the data plane of the Shoot cluster. + properties: + disableForwardToUpstreamDNS: + description: |- + DisableForwardToUpstreamDNS indicates whether requests from node local DNS to upstream DNS should be disabled. + Default, if unspecified, is to forward requests for external domains to upstream DNS + type: boolean + enabled: + description: Enabled indicates whether node local DNS is enabled + or not. + type: boolean + forceTCPToClusterDNS: + description: |- + ForceTCPToClusterDNS indicates whether the connection from the node local DNS to the cluster DNS (Core DNS) will be forced to TCP or not. + Default, if unspecified, is to enforce TCP. + type: boolean + forceTCPToUpstreamDNS: + description: |- + ForceTCPToUpstreamDNS indicates whether the connection from the node local DNS to the upstream DNS (infrastructure DNS) will be forced to TCP or not. + Default, if unspecified, is to enforce TCP. + type: boolean + required: + - enabled + type: object + type: object + tolerations: + description: Tolerations contains the tolerations for taints on seed + clusters. + items: + description: Toleration is a toleration for a seed taint. + properties: + key: + description: Key is the toleration key to be applied to a project + or shoot. + type: string + value: + description: Value is the toleration value corresponding to + the toleration key. + type: string + required: + - key + type: object + type: array + required: + - kubernetes + - provider + - region + type: object + status: + description: Most recently observed status of the Shoot cluster. + properties: + advertisedAddresses: + description: |- + List of addresses that are relevant to the shoot. + These include the Kube API server address and also the service account issuer. + items: + description: ShootAdvertisedAddress contains information for the + shoot's Kube API server. + properties: + application: + description: Application is the name of the application this + address belongs to. Used by UI clients. + type: string + name: + description: Name of the advertised address. e.g. external + type: string + url: + description: The URL of the API Server. e.g. https://api.foo.bar + or https://1.2.3.4 + type: string + required: + - name + - url + type: object + type: array + clusterIdentity: + description: ClusterIdentity is the identity of the Shoot cluster. + This field is immutable. + type: string + conditions: + description: Conditions represents the latest available observations + of a Shoots's current state. + items: + description: Condition holds the information about the state of + a resource. + properties: + codes: + description: Well-defined error codes in case the condition + reports a problem. + items: + description: ErrorCode is a string alias. + type: string + type: array + lastTransitionTime: + description: Last time the condition transitioned from one status + to another. + format: date-time + type: string + lastUpdateTime: + description: Last time the condition was updated. + format: date-time + type: string + message: + description: A human readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: Type of the condition. + type: string + required: + - lastTransitionTime + - lastUpdateTime + - message + - reason + - status + - type + type: object + type: array + constraints: + description: Constraints represents conditions of a Shoot's current + state that constraint some operations on it. + items: + description: Condition holds the information about the state of + a resource. + properties: + codes: + description: Well-defined error codes in case the condition + reports a problem. + items: + description: ErrorCode is a string alias. + type: string + type: array + lastTransitionTime: + description: Last time the condition transitioned from one status + to another. + format: date-time + type: string + lastUpdateTime: + description: Last time the condition was updated. + format: date-time + type: string + message: + description: A human readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: Type of the condition. + type: string + required: + - lastTransitionTime + - lastUpdateTime + - message + - reason + - status + - type + type: object + type: array + credentials: + description: Credentials contains information about the shoot credentials. + properties: + encryptionAtRest: + description: EncryptionAtRest contains information about Shoot + data encryption at rest. + properties: + provider: + description: Provider contains information about Shoot encryption + provider. + properties: + type: + description: Type is the used encryption provider type. + type: string + required: + - type + type: object + resources: + description: |- + Resources is the list of resources in the Shoot which are currently encrypted. + Secrets are encrypted by default and are not part of the list. + See https://github.com/gardener/gardener/blob/master/docs/usage/security/etcd_encryption_config.md for more details. + items: + type: string + type: array + required: + - provider + type: object + rotation: + description: Rotation contains information about the credential + rotations. + properties: + certificateAuthorities: + description: CertificateAuthorities contains information about + the certificate authority credential rotation. + properties: + lastCompletionTime: + description: |- + LastCompletionTime is the most recent time when the certificate authority credential rotation was successfully + completed. + format: date-time + type: string + lastCompletionTriggeredTime: + description: |- + LastCompletionTriggeredTime is the recent time when the certificate authority credential rotation completion was + triggered. + format: date-time + type: string + lastInitiationFinishedTime: + description: |- + LastInitiationFinishedTime is the recent time when the certificate authority credential rotation initiation was + completed. + format: date-time + type: string + lastInitiationTime: + description: LastInitiationTime is the most recent time + when the certificate authority credential rotation was + initiated. + format: date-time + type: string + pendingWorkersRollouts: + description: |- + PendingWorkersRollouts contains the name of a worker pool and the initiation time of their last rollout due to + credentials rotation. + items: + description: PendingWorkersRollout contains the name + of a worker pool and the initiation time of their + last rollout. + properties: + lastInitiationTime: + description: LastInitiationTime is the most recent + time when the worker rollout was initiated. + format: date-time + type: string + name: + description: Name is the name of a worker pool. + type: string + required: + - name + type: object + type: array + phase: + description: Phase describes the phase of the certificate + authority credential rotation. + type: string + required: + - phase + type: object + etcdEncryptionKey: + description: ETCDEncryptionKey contains information about + the ETCD encryption key credential rotation. + properties: + autoCompleteAfterPrepared: + description: |- + AutoCompleteAfterPrepared indicates whether the current ETCD encryption key rotation should be auto completed after the preparation phase has finished. + Such rotation can be triggered by the `rotate-etcd-encryption-key` annotation. + This field is needed while we support two types of key rotations: two-operation and single operation rotation. + + Deprecated: This field will be removed in a future release. The field will be no longer needed with + the removal `rotate-etcd-encryption-key-start` & `rotate-etcd-encryption-key-complete` annotations. + type: boolean + lastCompletionTime: + description: |- + LastCompletionTime is the most recent time when the ETCD encryption key credential rotation was successfully + completed. + format: date-time + type: string + lastCompletionTriggeredTime: + description: |- + LastCompletionTriggeredTime is the recent time when the ETCD encryption key credential rotation completion was + triggered. + format: date-time + type: string + lastInitiationFinishedTime: + description: |- + LastInitiationFinishedTime is the recent time when the ETCD encryption key credential rotation initiation was + completed. + format: date-time + type: string + lastInitiationTime: + description: LastInitiationTime is the most recent time + when the ETCD encryption key credential rotation was + initiated. + format: date-time + type: string + phase: + description: Phase describes the phase of the ETCD encryption + key credential rotation. + type: string + required: + - phase + type: object + observability: + description: Observability contains information about the + observability credential rotation. + properties: + lastCompletionTime: + description: LastCompletionTime is the most recent time + when the observability credential rotation was successfully + completed. + format: date-time + type: string + lastInitiationTime: + description: LastInitiationTime is the most recent time + when the observability credential rotation was initiated. + format: date-time + type: string + type: object + serviceAccountKey: + description: ServiceAccountKey contains information about + the service account key credential rotation. + properties: + lastCompletionTime: + description: |- + LastCompletionTime is the most recent time when the service account key credential rotation was successfully + completed. + format: date-time + type: string + lastCompletionTriggeredTime: + description: |- + LastCompletionTriggeredTime is the recent time when the service account key credential rotation completion was + triggered. + format: date-time + type: string + lastInitiationFinishedTime: + description: |- + LastInitiationFinishedTime is the recent time when the service account key credential rotation initiation was + completed. + format: date-time + type: string + lastInitiationTime: + description: LastInitiationTime is the most recent time + when the service account key credential rotation was + initiated. + format: date-time + type: string + pendingWorkersRollouts: + description: |- + PendingWorkersRollouts contains the name of a worker pool and the initiation time of their last rollout due to + credentials rotation. + items: + description: PendingWorkersRollout contains the name + of a worker pool and the initiation time of their + last rollout. + properties: + lastInitiationTime: + description: LastInitiationTime is the most recent + time when the worker rollout was initiated. + format: date-time + type: string + name: + description: Name is the name of a worker pool. + type: string + required: + - name + type: object + type: array + phase: + description: Phase describes the phase of the service + account key credential rotation. + type: string + required: + - phase + type: object + sshKeypair: + description: SSHKeypair contains information about the ssh-keypair + credential rotation. + properties: + lastCompletionTime: + description: LastCompletionTime is the most recent time + when the ssh-keypair credential rotation was successfully + completed. + format: date-time + type: string + lastInitiationTime: + description: LastInitiationTime is the most recent time + when the ssh-keypair credential rotation was initiated. + format: date-time + type: string + type: object + type: object + type: object + gardener: + description: Gardener holds information about the Gardener which last + acted on the Shoot. + properties: + id: + description: ID is the container id of the Gardener which last + acted on a resource. + type: string + name: + description: Name is the hostname (pod name) of the Gardener which + last acted on a resource. + type: string + version: + description: Version is the version of the Gardener which last + acted on a resource. + type: string + required: + - id + - name + - version + type: object + hibernated: + description: IsHibernated indicates whether the Shoot is currently + hibernated. + type: boolean + inPlaceUpdates: + description: InPlaceUpdates contains information about in-place updates + for the Shoot workers. + properties: + pendingWorkerUpdates: + description: PendingWorkerUpdates contains information about worker + pools pending in-place updates. + properties: + autoInPlaceUpdate: + description: AutoInPlaceUpdate contains the names of the pending + worker pools with strategy AutoInPlaceUpdate. + items: + type: string + type: array + manualInPlaceUpdate: + description: ManualInPlaceUpdate contains the names of the + pending worker pools with strategy ManualInPlaceUpdate. + items: + type: string + type: array + type: object + type: object + lastErrors: + description: LastErrors holds information about the last occurred + error(s) during an operation. + items: + description: LastError indicates the last occurred error for an + operation on a resource. + properties: + codes: + description: Well-defined error codes of the last error(s). + items: + description: ErrorCode is a string alias. + type: string + type: array + description: + description: A human readable message indicating details about + the last error. + type: string + lastUpdateTime: + description: Last time the error was reported + format: date-time + type: string + taskID: + description: ID of the task which caused this last error + type: string + required: + - description + type: object + type: array + lastHibernationTriggerTime: + description: |- + LastHibernationTriggerTime indicates the last time when the hibernation controller + managed to change the hibernation settings of the cluster + format: date-time + type: string + lastMaintenance: + description: LastMaintenance holds information about the last maintenance + operations on the Shoot. + properties: + description: + description: A human-readable message containing details about + the operations performed in the last maintenance. + type: string + failureReason: + description: FailureReason holds the information about the last + maintenance operation failure reason. + type: string + state: + description: Status of the last maintenance operation, one of + Processing, Succeeded, Error. + type: string + triggeredTime: + description: TriggeredTime is the time when maintenance was triggered. + format: date-time + type: string + required: + - description + - state + - triggeredTime + type: object + lastOperation: + description: LastOperation holds information about the last operation + on the Shoot. + properties: + description: + description: A human readable message indicating details about + the last operation. + type: string + lastUpdateTime: + description: Last time the operation state transitioned from one + to another. + format: date-time + type: string + progress: + description: The progress in percentage (0-100) of the last operation. + format: int32 + type: integer + state: + description: Status of the last operation, one of Aborted, Processing, + Succeeded, Error, Failed. + type: string + type: + description: Type of the last operation, one of Create, Reconcile, + Delete, Migrate, Restore. + type: string + required: + - description + - lastUpdateTime + - progress + - state + - type + type: object + liveMigration: + description: LiveMigration contains information about an ongoing live + control plane migration of the Shoot. + properties: + conditions: + description: Conditions represents the progress of the live migration, + one condition per migration step. + items: + description: Condition holds the information about the state + of a resource. + properties: + codes: + description: Well-defined error codes in case the condition + reports a problem. + items: + description: ErrorCode is a string alias. + type: string + type: array + lastTransitionTime: + description: Last time the condition transitioned from one + status to another. + format: date-time + type: string + lastUpdateTime: + description: Last time the condition was updated. + format: date-time + type: string + message: + description: A human readable message indicating details + about the transition. + type: string + reason: + description: The reason for the condition's last transition. + type: string + status: + description: Status of the condition, one of True, False, + Unknown. + type: string + type: + description: Type of the condition. + type: string + required: + - lastTransitionTime + - lastUpdateTime + - message + - reason + - status + - type + type: object + type: array + type: object + manualWorkerPoolRollout: + description: ManualWorkerPoolRollout contains information about the + worker pool rollout progress. + properties: + pendingWorkersRollouts: + description: PendingWorkersRollouts contains the names of the + worker pools that are still pending rollout. + items: + description: PendingWorkersRollout contains the name of a worker + pool and the initiation time of their last rollout. + properties: + lastInitiationTime: + description: LastInitiationTime is the most recent time + when the worker rollout was initiated. + format: date-time + type: string + name: + description: Name is the name of a worker pool. + type: string + required: + - name + type: object + type: array + type: object + migrationStartTime: + description: MigrationStartTime is the time when a migration to a + different seed was initiated. + format: date-time + type: string + networking: + description: Networking contains information about cluster networking + such as CIDRs. + properties: + egressCIDRs: + description: |- + EgressCIDRs is a list of CIDRs used by the shoot as the source IP for egress traffic as reported by the used + Infrastructure extension controller. For certain environments the egress IPs may not be stable in which case the + extension controller may opt to not populate this field. + items: + type: string + type: array + nodes: + description: Nodes are the CIDRs of the node network. + items: + type: string + type: array + pods: + description: Pods are the CIDRs of the pod network. + items: + type: string + type: array + services: + description: Services are the CIDRs of the service network. + items: + type: string + type: array + type: object + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed for this Shoot. It corresponds to the + Shoot's generation, which is updated on mutation by the API Server. + format: int64 + type: integer + retryCycleStartTime: + description: |- + RetryCycleStartTime is the start time of the last retry cycle (used to determine how often an operation + must be retried until we give up). + format: date-time + type: string + seedName: + description: |- + SeedName is the name of the seed cluster that runs the control plane of the Shoot. This value is only written + after a successful create/reconcile operation. It will be used when control planes are moved between Seeds. + type: string + technicalID: + description: |- + TechnicalID is a unique technical ID for this Shoot. It is used for the infrastructure resources, and + basically everything that is related to this particular Shoot. For regular shoot clusters, this is also the name + of the namespace in the seed cluster running the shoot's control plane. This field is immutable. + type: string + uid: + description: |- + UID is a unique identifier for the Shoot cluster to avoid portability between Kubernetes clusters. + It is used to compute unique hashes. This field is immutable. + type: string + required: + - gardener + - hibernated + - technicalID + - uid + type: object + type: object + served: true + storage: true From 90d3dfbb840c692a3196e8333611c234eefb06e9 Mon Sep 17 00:00:00 2001 From: sapcc-bot Date: Wed, 26 Aug 2026 11:59:13 +0000 Subject: [PATCH 14/23] Run go-makefile-maker Signed-off-by: C5421281 --- .github/renovate.json5 | 2 +- .github/workflows/checks.yaml | 4 ++-- .github/workflows/ci.yaml | 4 ++-- .github/workflows/codeql.yaml | 2 +- .github/workflows/container-registry-ghcr.yaml | 2 +- .golangci.yaml | 5 ++++- go.mod | 2 +- shell.nix | 2 +- 8 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 71c2829..9bea6e9 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -12,7 +12,7 @@ ], "commitMessageAction": "Renovate: Update", "constraints": { - "go": "1.26" + "go": "1.27" }, "dependencyDashboardOSVVulnerabilitySummary": "all", "osvVulnerabilityAlerts": true, diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 5a38a7c..88a993d 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -31,11 +31,11 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: check-latest: true - go-version: 1.26.6 + go-version: 1.27.0 - name: Run golangci-lint uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 with: - version: v2.12.2 + version: v2.13.1 - name: Delete pre-installed shellcheck run: sudo rm -f "$(which shellcheck)" - name: Run shellcheck diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9bd17f5..1003598 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,7 +34,7 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: check-latest: true - go-version: 1.26.6 + go-version: 1.27.0 - name: Build all binaries run: make build-all code_coverage: @@ -72,7 +72,7 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: check-latest: true - go-version: 1.26.6 + go-version: 1.27.0 - name: Run tests and generate coverage report run: make build/cover.out - name: Archive code coverage results diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 17caa4a..5a6caa2 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -34,7 +34,7 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: check-latest: true - go-version: 1.26.6 + go-version: 1.27.0 - name: Initialize CodeQL uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 with: diff --git a/.github/workflows/container-registry-ghcr.yaml b/.github/workflows/container-registry-ghcr.yaml index 66df9aa..2306ab8 100644 --- a/.github/workflows/container-registry-ghcr.yaml +++ b/.github/workflows/container-registry-ghcr.yaml @@ -49,7 +49,7 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - name: Build and push Docker image uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: diff --git a/.golangci.yaml b/.golangci.yaml index 83ff1b7..a3d2fb3 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -74,7 +74,8 @@ linters: settings: dupword: # Do not choke on SQL statements like `INSERT INTO things (foo, bar, baz) VALUES (TRUE, TRUE, TRUE)`. - ignore: [ "TRUE", "FALSE", "NULL" ] + # Also do not choke on repeated "endif" in Makefile snippets in the go-makefile-maker repo. + ignore: [ "TRUE", "FALSE", "NULL", "endif" ] errcheck: check-type-assertions: false # Report about assignment of errors to blank identifier. @@ -163,6 +164,8 @@ linters: require-specific: true modernize: disable: + # embedlit is new in Go 1.27 and triggers in a ton of places. We will enable this once Go 1.27 has settled a bit. (TODO: revisit this in a few weeks) + - embedlit # omitzero requires removing omitempty tags in kubernetes api struct types which are nested, which is interpreted by controller-gen and breaks the CRDs. - omitzero perfsprint: diff --git a/go.mod b/go.mod index 2c99806..a967cb0 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/cobaltcore-dev/cloud-profile-sync -go 1.26.0 +go 1.27 require ( github.com/blang/semver/v4 v4.0.0 diff --git a/shell.nix b/shell.nix index 3b42cfd..826627a 100644 --- a/shell.nix +++ b/shell.nix @@ -9,7 +9,7 @@ mkShell { nativeBuildInputs = [ addlicense go-licence-detector - go_1_26 + go_1_27 golangci-lint gotools # goimports kubernetes-controller-tools # controller-gen From d42478e7f66bbc64b91344e7bce9c098017e386b Mon Sep 17 00:00:00 2001 From: sapcc-bot Date: Thu, 27 Aug 2026 09:46:10 +0000 Subject: [PATCH 15/23] Run go-makefile-maker Signed-off-by: C5421281 --- .golangci.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.golangci.yaml b/.golangci.yaml index a3d2fb3..d042c24 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -100,6 +100,10 @@ linters: msg: github.com/howeyc/gopass is archived, use golang.org/x/term instead - pkg: ^github\.com/containers/image/v5 msg: github.com/containers/image/v5 is deprecated and was replaced with go.podman.io/image/v5 + - pkg: ^github.com/gofrs/uuid + msg: github.com/gofrs/uuid should be replaced with the stdlib uuid package introduced in Go 1.27 + - pkg: ^github.com/google/uuid + msg: github.com/google/uuid should be replaced with the stdlib uuid package introduced in Go 1.27 goconst: min-occurrences: 5 ignore-tests: true From 75283ace7f77cf7cc11409fe309ac942c212c7f6 Mon Sep 17 00:00:00 2001 From: Anton Paulovich Date: Fri, 28 Aug 2026 14:34:30 +0200 Subject: [PATCH 16/23] Improve logging and fix status patch issue (#47) Signed-off-by: Anton Paulovich Signed-off-by: C5421281 --- Dockerfile | 2 +- .../k8ssync/source/landscape/landscape_source.go | 4 ++-- cloudprofilesync/ossync/os_image_updater.go | 8 ++++---- cloudprofilesync/ossync/os_image_updater_test.go | 4 ++-- .../ossync/source/glance/os_source.go | 1 - controllers/cloud_profile.go | 16 +++++++++++++--- 6 files changed, 22 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index ef67192..ad97555 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 # Build the manager binary -FROM golang:1.26-alpine AS builder +FROM golang:1.27-alpine AS builder WORKDIR /workspace ENV GOTOOLCHAIN=local diff --git a/cloudprofilesync/k8ssync/source/landscape/landscape_source.go b/cloudprofilesync/k8ssync/source/landscape/landscape_source.go index 6cb6656..1708756 100644 --- a/cloudprofilesync/k8ssync/source/landscape/landscape_source.go +++ b/cloudprofilesync/k8ssync/source/landscape/landscape_source.go @@ -338,8 +338,8 @@ func parseProviderVersions(raw []byte, provider string) ([]gardenerv1beta1.Expir for _, v := range p.Versions { result = append(result, gardenerv1beta1.ExpirableVersion{ Version: v.Version, - Classification: v.Classification, - ExpirationDate: convertExpirationDate(v.ExpirationDate), + Classification: v.Classification, //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + ExpirationDate: convertExpirationDate(v.ExpirationDate), //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate }) } diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index 498f979..cd9dc90 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -205,8 +205,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, - ExpirationDate: iu.resolveExpiration(sourceImage, nil), + 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 }, Architectures: sourceImage.Architectures, }) @@ -238,8 +238,8 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou v := gardenerv1beta1.MachineImageVersion{ ExpirableVersion: gardenerv1beta1.ExpirableVersion{ Version: sourceImage.CleanVersion, - Classification: sourceImage.Classification, - ExpirationDate: iu.resolveExpiration(sourceImage, nil), + 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 }, 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 3db6be3..d78084d 100644 --- a/cloudprofilesync/ossync/os_image_updater_test.go +++ b/cloudprofilesync/ossync/os_image_updater_test.go @@ -545,8 +545,8 @@ var _ = Describe("ImageUpdater", func() { {Name: "test", Versions: []gardencorev1beta1.MachineImageVersion{ {ExpirableVersion: gardencorev1beta1.ExpirableVersion{ Version: "1.0.0", - Classification: &deprecated, - ExpirationDate: &existing, + Classification: &deprecated, //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + ExpirationDate: &existing, //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate }, Architectures: []string{"amd64"}}, }}, }, diff --git a/cloudprofilesync/ossync/source/glance/os_source.go b/cloudprofilesync/ossync/source/glance/os_source.go index f7d8bd2..80fb283 100644 --- a/cloudprofilesync/ossync/source/glance/os_source.go +++ b/cloudprofilesync/ossync/source/glance/os_source.go @@ -290,7 +290,6 @@ func compareSemverDesc(a, b string) int { // parseVersion extracts the semver version from a matching image name. func (g *Glance) parseVersion(name string) (string, bool) { if strings.Contains(name, usiVariantMarker) { - g.log.V(1).Info("skipping usi image variant", "name", name) return "", false } diff --git a/controllers/cloud_profile.go b/controllers/cloud_profile.go index 2e5a1e3..564adb0 100644 --- a/controllers/cloud_profile.go +++ b/controllers/cloud_profile.go @@ -45,13 +45,13 @@ func (r *Reconciler) reconcileCloudProfile(ctx context.Context, log logr.Logger, cloudProfile.Spec = CloudProfileSpecToGardener(&mcp.Spec.CloudProfile) errs := make([]error, 0) for _, updates := range mcp.Spec.MachineImageUpdates { - log.Info("updating machine images", "cloudProfile", cloudProfile.Name) + 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) } } if mcp.Spec.KubernetesVersionUpdateConfig != nil { - log.Info("updating kubernetes versions", "cloudProfile", cloudProfile.Name) + log.V(1).Info("updating kubernetes versions", "cloudProfile", cloudProfile.Name) if updateErr := r.updateKubernetesVersions(ctx, *mcp.Spec.KubernetesVersionUpdateConfig, &cloudProfile.Spec); updateErr != nil { errs = append(errs, updateErr) } @@ -65,7 +65,7 @@ func (r *Reconciler) reconcileCloudProfile(ctx context.Context, log logr.Logger, Status: metav1.ConditionFalse, ObservedGeneration: mcp.Generation, Reason: "ApplyFailed", - Message: fmt.Sprintf("Failed to apply CloudProfile: %s", err), + Message: truncateConditionMessage(fmt.Sprintf("Failed to apply CloudProfile: %s", err)), }) if statusErr != nil { return fmt.Errorf("failed to patch ManagedCloudProfile status: %w", statusErr) @@ -254,3 +254,13 @@ func (r *Reconciler) landscapeSetupSource(ctx context.Context, ls v1alpha1.Lands return landscapeSource, nil } + +const maxConditionMessageLen = 32768 + +func truncateConditionMessage(msg string) string { + if len(msg) <= maxConditionMessageLen { + return msg + } + const suffix = "...[truncated]" + return msg[:maxConditionMessageLen-len(suffix)] + suffix +} From b86758244b053e78afa1034686b32d7c1707b6d5 Mon Sep 17 00:00:00 2001 From: C5421281 Date: Tue, 1 Sep 2026 10:43:44 +0200 Subject: [PATCH 17/23] fix: bug with reconcile loop every second Signed-off-by: C5421281 --- .../ossync/provider/openstack/provider.go | 7 +++++ controllers/cloud_profile.go | 27 +++++++++++++++++++ controllers/managedcloudprofile_controller.go | 8 +++++- 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/cloudprofilesync/ossync/provider/openstack/provider.go b/cloudprofilesync/ossync/provider/openstack/provider.go index 24253cf..d00d306 100644 --- a/cloudprofilesync/ossync/provider/openstack/provider.go +++ b/cloudprofilesync/ossync/provider/openstack/provider.go @@ -4,6 +4,7 @@ package openstack // SPDX-License-Identifier: Apache-2.0 import ( + "cmp" "encoding/json" "slices" @@ -68,6 +69,12 @@ 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 { + return cmp.Compare(a.Name, b.Name) + }) } raw, err := json.Marshal(cfg) diff --git a/controllers/cloud_profile.go b/controllers/cloud_profile.go index 564adb0..c79d926 100644 --- a/controllers/cloud_profile.go +++ b/controllers/cloud_profile.go @@ -42,6 +42,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 { @@ -50,6 +51,7 @@ func (r *Reconciler) reconcileCloudProfile(ctx context.Context, log logr.Logger, errs = append(errs, updateErr) } } + applyExpirationDates(cloudProfile.Spec.MachineImages, storedExpirations) if mcp.Spec.KubernetesVersionUpdateConfig != nil { log.V(1).Info("updating kubernetes versions", "cloudProfile", cloudProfile.Name) if updateErr := r.updateKubernetesVersions(ctx, *mcp.Spec.KubernetesVersionUpdateConfig, &cloudProfile.Spec); updateErr != nil { @@ -257,6 +259,31 @@ func (r *Reconciler) landscapeSetupSource(ctx context.Context, ls v1alpha1.Lands const maxConditionMessageLen = 32768 +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[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] + if v.ExpirationDate == nil { //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + if exp, ok := stored[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 44ae50a..2b09588 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) } From 2f3f3e55bd6527cecd29e52f03379f3245e94c29 Mon Sep 17 00:00:00 2001 From: C5421281 Date: Wed, 2 Sep 2026 13:57:30 +0200 Subject: [PATCH 18/23] fix: move ExpirationDate from os_image_updater.go to source/glance/os_source Signed-off-by: C5421281 --- cloudprofilesync/ossync/os_image_updater.go | 17 +++--------- .../ossync/os_image_updater_test.go | 11 -------- .../ossync/provider/openstack/provider.go | 3 +-- .../ossync/source/glance/os_source.go | 9 ++++++- .../ossync/source/glance/os_source_test.go | 26 +++++++++++++++++++ controllers/cloud_profile.go | 13 +++++----- 6 files changed, 46 insertions(+), 33 deletions(-) diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index 723c168..486593f 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,13 @@ type ImageUpdater struct { EnableCapabilities bool } -// resolveExpiration decides the expiration date to write for a source image. +// resolveExpiration preserves an already-stamped expiration date, otherwise takes +// the date the source derived. The updater never invents a date. 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 src.ExpirationDate } // mergeCapabilityFlavor appends the flavor from src to existing if not already present. @@ -245,8 +237,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 { 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 d00d306..e1d2000 100644 --- a/cloudprofilesync/ossync/provider/openstack/provider.go +++ b/cloudprofilesync/ossync/provider/openstack/provider.go @@ -1,7 +1,6 @@ -package openstack - // SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company // SPDX-License-Identifier: Apache-2.0 +package openstack import ( "cmp" 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 75f82f5..02bbceb 100644 --- a/cloudprofilesync/ossync/source/glance/os_source_test.go +++ b/cloudprofilesync/ossync/source/glance/os_source_test.go @@ -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() diff --git a/controllers/cloud_profile.go b/controllers/cloud_profile.go index bccbd2f..df1b137 100644 --- a/controllers/cloud_profile.go +++ b/controllers/cloud_profile.go @@ -6,7 +6,6 @@ import ( "context" "errors" "fmt" - "net/http" gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" "github.com/go-logr/logr" @@ -234,12 +233,16 @@ 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[img.Name+"/"+v.Version] = v.ExpirationDate //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 } } } @@ -250,10 +253,8 @@ func applyExpirationDates(images []gardenerv1beta1.MachineImage, stored map[stri for i := range images { for j := range images[i].Versions { v := &images[i].Versions[j] - if v.ExpirationDate == nil { //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate - if exp, ok := stored[images[i].Name+"/"+v.Version]; ok { - v.ExpirationDate = exp //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate - } + if exp, ok := stored[expirationDateKey(images[i].Name, v.Version)]; ok { + v.ExpirationDate = exp //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate } } } From c53d4272e95ea01cec4f8801f1467efeeaadd0fe Mon Sep 17 00:00:00 2001 From: C5421281 Date: Wed, 2 Sep 2026 14:41:32 +0200 Subject: [PATCH 19/23] fix: bug when existing version has ExpirationDate Signed-off-by: C5421281 --- cloudprofilesync/ossync/os_image_updater.go | 3 +++ controllers/cloud_profile.go | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index 486593f..11c1f67 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -113,6 +113,9 @@ type ImageUpdater struct { // resolveExpiration preserves an already-stamped expiration date, otherwise takes // the date the source derived. The updater never invents a date. func (iu *ImageUpdater) resolveExpiration(src SourceImage, existing *metav1.Time) *metav1.Time { + if src.ExpirationDate == nil { + return nil + } if existing != nil { return existing } diff --git a/controllers/cloud_profile.go b/controllers/cloud_profile.go index df1b137..30be5b4 100644 --- a/controllers/cloud_profile.go +++ b/controllers/cloud_profile.go @@ -227,7 +227,6 @@ func (r *Reconciler) landscapeSetupSource(ctx context.Context, ls v1alpha1.Lands if err != nil { return nil, fmt.Errorf("initializing landscape source: %w", err) } - return landscapeSource, nil } @@ -253,6 +252,10 @@ func applyExpirationDates(images []gardenerv1beta1.MachineImage, stored map[stri 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 } From 5e6872074a6ac23a6a940dbfbd56b8f61d27266a Mon Sep 17 00:00:00 2001 From: C5421281 Date: Wed, 2 Sep 2026 15:18:18 +0200 Subject: [PATCH 20/23] fix: CopilotAI rewiev about test case Signed-off-by: C5421281 --- cloudprofilesync/ossync/os_image_updater.go | 11 ++++++--- .../ossync/os_image_updater_test.go | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index 11c1f67..2cef6b0 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -110,10 +110,15 @@ type ImageUpdater struct { EnableCapabilities bool } -// resolveExpiration preserves an already-stamped expiration date, otherwise takes -// the date the source derived. The updater never invents a date. +// resolveExpiration decides a version's expiration date from the source's +// classification. A version the source reports as supported carries no date, so +// any previously stamped date is cleared (e.g. it returned to supported after a +// KeepLatest increase). For a deprecated version the existing date is preserved +// so the timestamp does not drift on every reconcile; otherwise the source's +// date is used. The updater never invents a date. func (iu *ImageUpdater) resolveExpiration(src SourceImage, existing *metav1.Time) *metav1.Time { - if src.ExpirationDate == nil { + isDeprecated := src.Classification != nil && *src.Classification == gardenerv1beta1.ClassificationDeprecated + if !isDeprecated { return nil } if existing != nil { diff --git a/cloudprofilesync/ossync/os_image_updater_test.go b/cloudprofilesync/ossync/os_image_updater_test.go index c846163..d041d64 100644 --- a/cloudprofilesync/ossync/os_image_updater_test.go +++ b/cloudprofilesync/ossync/os_image_updater_test.go @@ -718,5 +718,28 @@ var _ = Describe("ImageUpdater", func() { 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 }) + + It("clears a stale expiration date when a version returns to supported (e.g. KeepLatest increase)", func(ctx SpecContext) { + supported := gardencorev1beta1.ClassificationSupported + 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, //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + ExpirationDate: &existing, //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + }, Architectures: []string{"amd64"}}, + }}, + }, + } + mockSource.images = []ossync.SourceImage{ + {Version: "1.0.0", Architectures: []string{"amd64"}, Classification: &supported}, + } + 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(BeNil()) //nolint:staticcheck // legacy field; Lifecycle needs the VersionClassificationLifecycle feature gate + }) }) }) From 8082f6cd3bfa8452f09f02dfe518f64a875ce177 Mon Sep 17 00:00:00 2001 From: C5421281 Date: Wed, 2 Sep 2026 16:16:56 +0200 Subject: [PATCH 21/23] fix: change logic of resolveExpiration Signed-off-by: C5421281 --- cloudprofilesync/ossync/os_image_updater.go | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index 2cef6b0..726ea74 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -117,14 +117,7 @@ type ImageUpdater struct { // so the timestamp does not drift on every reconcile; otherwise the source's // date is used. The updater never invents a date. func (iu *ImageUpdater) resolveExpiration(src SourceImage, existing *metav1.Time) *metav1.Time { - isDeprecated := src.Classification != nil && *src.Classification == gardenerv1beta1.ClassificationDeprecated - if !isDeprecated { - return nil - } - if existing != nil { - return existing - } - return src.ExpirationDate + return cmp.Or(existing, src.ExpirationDate) } // mergeCapabilityFlavor appends the flavor from src to existing if not already present. @@ -259,8 +252,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, }) @@ -292,8 +285,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), From 5f3ad0fb90cfd0eb25c32b9c7eefb84f35bf80ad Mon Sep 17 00:00:00 2001 From: C5421281 Date: Wed, 2 Sep 2026 16:24:00 +0200 Subject: [PATCH 22/23] test: remove stale-expiration test that no longer matches resolveExpiration Signed-off-by: C5421281 --- .../ossync/os_image_updater_test.go | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/cloudprofilesync/ossync/os_image_updater_test.go b/cloudprofilesync/ossync/os_image_updater_test.go index d041d64..c846163 100644 --- a/cloudprofilesync/ossync/os_image_updater_test.go +++ b/cloudprofilesync/ossync/os_image_updater_test.go @@ -718,28 +718,5 @@ var _ = Describe("ImageUpdater", func() { 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 }) - - It("clears a stale expiration date when a version returns to supported (e.g. KeepLatest increase)", func(ctx SpecContext) { - supported := gardencorev1beta1.ClassificationSupported - 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, //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate - ExpirationDate: &existing, //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate - }, Architectures: []string{"amd64"}}, - }}, - }, - } - mockSource.images = []ossync.SourceImage{ - {Version: "1.0.0", Architectures: []string{"amd64"}, Classification: &supported}, - } - 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(BeNil()) //nolint:staticcheck // legacy field; Lifecycle needs the VersionClassificationLifecycle feature gate - }) }) }) From f8ba935ac45ddd9837fac55e9dd9311f4f92327a Mon Sep 17 00:00:00 2001 From: C5421281 Date: Wed, 2 Sep 2026 16:35:41 +0200 Subject: [PATCH 23/23] fix: improve code Signed-off-by: C5421281 --- cloudprofilesync/ossync/os_image_updater.go | 6 ------ cloudprofilesync/ossync/provider/openstack/provider.go | 5 ++++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index 726ea74..acc8942 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -110,12 +110,6 @@ type ImageUpdater struct { EnableCapabilities bool } -// resolveExpiration decides a version's expiration date from the source's -// classification. A version the source reports as supported carries no date, so -// any previously stamped date is cleared (e.g. it returned to supported after a -// KeepLatest increase). For a deprecated version the existing date is preserved -// so the timestamp does not drift on every reconcile; otherwise the source's -// date is used. The updater never invents a date. func (iu *ImageUpdater) resolveExpiration(src SourceImage, existing *metav1.Time) *metav1.Time { return cmp.Or(existing, src.ExpirationDate) } diff --git a/cloudprofilesync/ossync/provider/openstack/provider.go b/cloudprofilesync/ossync/provider/openstack/provider.go index e1d2000..82e02f0 100644 --- a/cloudprofilesync/ossync/provider/openstack/provider.go +++ b/cloudprofilesync/ossync/provider/openstack/provider.go @@ -72,7 +72,10 @@ func (p *OpenStackProvider) Configure(cpSpec *gardencorev1beta1.CloudProfileSpec // 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 { - return cmp.Compare(a.Name, b.Name) + if c := cmp.Compare(a.Name, b.Name); c != 0 { + return c + } + return cmp.Compare(a.ID, b.ID) }) }