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..907db24 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,29 @@ type ImageUpdater struct { EnableCapabilities bool } +// resolveExpiration decides the expiration date to write for a source image. +func (iu *ImageUpdater) resolveExpiration(src SourceImage, existing *metav1.Time) *metav1.Time { + isDeprecated := src.Classification != nil && *src.Classification == gardenerv1beta1.ClassificationDeprecated + if !isDeprecated { + return src.ExpirationDate + } + if existing != nil { + return existing + } + if src.ExpirationDate != nil { + return src.ExpirationDate + } + now := metav1.NewTime(time.Now()) + return &now +} + +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 { @@ -120,6 +161,10 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou // Always write the full tag version (legacy path, safe for running Shoots). if idx, exists := existingVersions[sourceImage.Version]; exists { image.Versions[idx].Architectures = sourceImage.Architectures + image.Versions[idx].Classification = sourceImage.Classification //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + // Stamp expiration once on the transition to deprecated; preserve it thereafter. + image.Versions[idx].ExpirationDate = iu.resolveExpiration(sourceImage, image.Versions[idx].ExpirationDate) //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + image.Versions[idx].InPlaceUpdates = inPlaceUpdates(sourceImage.SupportInPlaceUpdate) } else { // Moving this check to filterImages() would break the core architectural goal of GEP-33 // as it intentionally decouples the OCI registry tag from the semantic OS version @@ -130,7 +175,9 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou } else { image.Versions = append(image.Versions, gardenerv1beta1.MachineImageVersion{ ExpirableVersion: gardenerv1beta1.ExpirableVersion{ - Version: sourceImage.Version, + Version: sourceImage.Version, + Classification: sourceImage.Classification, + ExpirationDate: iu.resolveExpiration(sourceImage, nil), }, Architectures: sourceImage.Architectures, }) @@ -152,15 +199,15 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou existing.Architectures = append(existing.Architectures, arch) } } - if sourceImage.SupportInPlaceUpdate { - existing.InPlaceUpdates = &gardenerv1beta1.InPlaceUpdates{ - Supported: sourceImage.SupportInPlaceUpdate, - } - } + existing.Classification = sourceImage.Classification //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + existing.ExpirationDate = iu.resolveExpiration(sourceImage, existing.ExpirationDate) //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate + existing.InPlaceUpdates = inPlaceUpdates(sourceImage.SupportInPlaceUpdate) } else { image.Versions = append(image.Versions, gardenerv1beta1.MachineImageVersion{ ExpirableVersion: gardenerv1beta1.ExpirableVersion{ - Version: sourceImage.CleanVersion, + Version: sourceImage.CleanVersion, + Classification: sourceImage.Classification, + ExpirationDate: iu.resolveExpiration(sourceImage, nil), }, Architectures: slices.Clone(sourceImage.Architectures), }) diff --git a/cloudprofilesync/ossync/os_image_updater_test.go b/cloudprofilesync/ossync/os_image_updater_test.go index 53134e2..f72d96f 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)) //nolint:staticcheck // legacy field; Lifecycle needs the VersionClassificationLifecycle feature gate + }) + + It("uses the source's expiration date for a new deprecated version", func(ctx SpecContext) { + fromSource := metav1.NewTime(time.Date(2030, 6, 1, 0, 0, 0, 0, time.UTC)) + mockSource.images = []ossync.SourceImage{ + {Version: "1.0.0", Architectures: []string{"amd64"}, Classification: &deprecated, ExpirationDate: &fromSource}, + } + updater := newUpdater() + var cpSpec gardencorev1beta1.CloudProfileSpec + Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) + Expect(cpSpec.MachineImages[0].Versions).To(HaveLen(1)) + Expect(cpSpec.MachineImages[0].Versions[0].ExpirationDate).To(Equal(&fromSource)) //nolint:staticcheck // legacy field; Lifecycle needs the VersionClassificationLifecycle feature gate + }) + + It("stamps an expiration date for a new deprecated version without one", func(ctx SpecContext) { + mockSource.images = []ossync.SourceImage{ + {Version: "1.0.0", Architectures: []string{"amd64"}, Classification: &deprecated}, + } + updater := newUpdater() + var cpSpec gardencorev1beta1.CloudProfileSpec + Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) + Expect(cpSpec.MachineImages[0].Versions).To(HaveLen(1)) + Expect(cpSpec.MachineImages[0].Versions[0].ExpirationDate).NotTo(BeNil()) //nolint:staticcheck // legacy field; Lifecycle needs the VersionClassificationLifecycle feature gate + }) + + It("does not set an expiration date for a non-deprecated version", func(ctx SpecContext) { + mockSource.images = []ossync.SourceImage{ + {Version: "1.0.0", Architectures: []string{"amd64"}}, + } + updater := newUpdater() + var cpSpec gardencorev1beta1.CloudProfileSpec + Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) + Expect(cpSpec.MachineImages[0].Versions).To(HaveLen(1)) + Expect(cpSpec.MachineImages[0].Versions[0].ExpirationDate).To(BeNil()) //nolint:staticcheck // legacy field; Lifecycle needs the VersionClassificationLifecycle feature gate + }) + }) }) diff --git a/cloudprofilesync/ossync/provider/openstack/provider.go b/cloudprofilesync/ossync/provider/openstack/provider.go new file mode 100644 index 0000000..24253cf --- /dev/null +++ b/cloudprofilesync/ossync/provider/openstack/provider.go @@ -0,0 +1,79 @@ +package openstack + +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +import ( + "encoding/json" + "slices" + + openstackv1alpha1 "github.com/gardener/gardener-extension-provider-openstack/pkg/apis/openstack/v1alpha1" + gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" +) + +type OpenStackProvider struct { + ImageName string +} + +func (p *OpenStackProvider) Configure(cpSpec *gardencorev1beta1.CloudProfileSpec, versions []ossync.SourceImage) error { + var cfg openstackv1alpha1.CloudProfileConfig + if cpSpec.ProviderConfig != nil { + if err := json.Unmarshal(cpSpec.ProviderConfig.Raw, &cfg); err != nil { + return err + } + } + + imageIndex := slices.IndexFunc(cfg.MachineImages, func(m openstackv1alpha1.MachineImages) bool { + return m.Name == p.ImageName + }) + if imageIndex == -1 { + imageIndex = len(cfg.MachineImages) + cfg.MachineImages = append(cfg.MachineImages, openstackv1alpha1.MachineImages{ + Name: p.ImageName, + Versions: []openstackv1alpha1.MachineImageVersion{}, + }) + } + image := &cfg.MachineImages[imageIndex] + + existingVersions := make(map[string]int, len(image.Versions)) + for i, v := range image.Versions { + existingVersions[v.Version] = i + } + + for _, src := range versions { + idx, exists := existingVersions[src.Version] + if !exists { + idx = len(image.Versions) + image.Versions = append(image.Versions, openstackv1alpha1.MachineImageVersion{ + Version: src.Version, + }) + existingVersions[src.Version] = idx + } + entry := &image.Versions[idx] + + for _, r := range src.Regions { + existing := slices.IndexFunc(entry.Regions, func(m openstackv1alpha1.RegionIDMapping) bool { + return m.Name == r.Region + }) + if existing == -1 { + entry.Regions = append(entry.Regions, openstackv1alpha1.RegionIDMapping{ + Name: r.Region, + ID: r.ID, + }) + continue + } + // Update in place: a rebuilt image keeps the version but gets a new UUID. + entry.Regions[existing].ID = r.ID + } + } + + raw, err := json.Marshal(cfg) + if err != nil { + return err + } + cpSpec.ProviderConfig = &runtime.RawExtension{Raw: raw} + return nil +} diff --git a/cloudprofilesync/ossync/provider/openstack/provider_test.go b/cloudprofilesync/ossync/provider/openstack/provider_test.go new file mode 100644 index 0000000..e9a2236 --- /dev/null +++ b/cloudprofilesync/ossync/provider/openstack/provider_test.go @@ -0,0 +1,227 @@ +// 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 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{ + { + Name: imageName, + Versions: []openstackv1alpha1.MachineImageVersion{ + { + Version: testVersion, + Regions: []openstackv1alpha1.RegionIDMapping{{Name: regionDE, ID: "stale-uuid"}}, + }, + }, + }, + }, + }) + + // 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: "rebuilt-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 != "rebuilt-uuid" { + t.Errorf("%s ID = %q, want rebuilt-uuid (stale mapping must be updated)", 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..f7d8bd2 --- /dev/null +++ b/cloudprofilesync/ossync/source/glance/os_source.go @@ -0,0 +1,315 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package glance + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/blang/semver/v4" + 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 ( + 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], len(g.params.Regions)) + 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) + } + + // 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"}, + Regions: []ossync.RegionImage{{Region: region, ID: img.ID}}, + }) + } + 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) + 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..8002400 --- /dev/null +++ b/cloudprofilesync/ossync/source/glance/os_source_test.go @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package glance + +import ( + "context" + "testing" + "time" + + "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) + } +} + +// 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() + 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..2c99806 100644 --- a/go.mod +++ b/go.mod @@ -5,15 +5,15 @@ 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/pkg/apis v1.144.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,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 @@ -70,13 +71,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 @@ -116,11 +117,12 @@ 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 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..59f0119 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,10 @@ 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/pkg/apis v1.144.0 h1:xlnGMbliM5TjlK5o2oCxzyFSmtxgGerP4SDwEXdHL2E= -github.com/gardener/gardener/pkg/apis v1.144.0/go.mod h1:we6hJ8r80nL1rkXzVnOQwey4q77pQXHN3pvoBgeak8g= +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= 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 +131,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= @@ -150,8 +154,8 @@ 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= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -193,8 +197,8 @@ 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= 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= @@ -342,8 +346,8 @@ 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= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=