diff --git a/hack/api-reference/api.md b/hack/api-reference/api.md index b902b9ff..878f3546 100644 --- a/hack/api-reference/api.md +++ b/hack/api-reference/api.md @@ -1257,6 +1257,77 @@ string
Architecture is the CPU architecture of the machine image
+capabilities
+
+Capabilities
+
+Capabilities of the machine image.
++(Appears on:MachineImageVersion) +
+ ++MachineImageFlavor groups all RegionAMIMappings for a specific set of capabilities. +
+ +| Field | +Description | +
|---|---|
+regions
+
+RegionIDMapping array
+
+ |
+
+ Regions is a mapping to the correct Image ID for the machine image in the supported regions. + |
+
+image
+
+string
+
+ |
+
+ Image is the name of the image. + |
+
+capabilities
+
+Capabilities
+
+ |
+
+ Capabilities that are supported by the Image ID in this set. + |
+
Regions is an optional mapping to the correct Image ID for the machine image in the supported regions.
+capabilityFlavors
+
+MachineImageFlavor array
+
+CapabilityFlavors is grouping of region AMIs by capabilities.
+-(Appears on:MachineImageVersion) +(Appears on:MachineImageFlavor, MachineImageVersion)
diff --git a/pkg/admission/mutator/cloudprofile.go b/pkg/admission/mutator/cloudprofile.go new file mode 100644 index 00000000..2b15d80b --- /dev/null +++ b/pkg/admission/mutator/cloudprofile.go @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package mutator + +import ( + "context" + "fmt" + "slices" + "sort" + + extensionswebhook "github.com/gardener/gardener/extensions/pkg/webhook" + gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + v1beta1constants "github.com/gardener/gardener/pkg/apis/core/v1beta1/constants" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + + "github.com/stackitcloud/gardener-extension-provider-stackit/v2/pkg/apis/stackit/v1alpha1" +) + +// NewCloudProfileMutator returns a new instance of a CloudProfile mutator. +func NewCloudProfileMutator(mgr manager.Manager) extensionswebhook.Mutator { + return &cloudProfile{ + client: mgr.GetClient(), + decoder: serializer.NewCodecFactory(mgr.GetScheme(), serializer.EnableStrict).UniversalDecoder(), + } +} + +type cloudProfile struct { + client client.Client + decoder runtime.Decoder +} + +// Mutate mutates the given CloudProfile object. +func (p *cloudProfile) Mutate(_ context.Context, newObj, _ client.Object) error { + profile, ok := newObj.(*gardencorev1beta1.CloudProfile) + if !ok { + return fmt.Errorf("wrong object type %T", newObj) + } + + // Skip mutation if CloudProfile is being deleted or when no capabilities used in that profile + if profile.DeletionTimestamp != nil || profile.Spec.ProviderConfig == nil || len(profile.Spec.MachineCapabilities) == 0 { + return nil + } + + specConfig := &v1alpha1.CloudProfileConfig{} + if _, _, err := p.decoder.Decode(profile.Spec.ProviderConfig.Raw, nil, specConfig); err != nil { + return fmt.Errorf("could not decode providerConfig of cloudProfile for '%s': %w", profile.Name, err) + } + + overwriteMachineImageCapabilityFlavors(profile, specConfig) + return nil +} + +// overwriteMachineImageCapabilityFlavors updates the capability flavors of machine images in the CloudProfile +func overwriteMachineImageCapabilityFlavors(profile *gardencorev1beta1.CloudProfile, config *v1alpha1.CloudProfileConfig) { + mutateMachineImageCapabilityFlavors(profile.Spec.MachineImages, config) +} + +// mutateMachineImageCapabilityFlavors populates capabilityFlavors on machineImages from the provider config. +func mutateMachineImageCapabilityFlavors(machineImages []gardencorev1beta1.MachineImage, config *v1alpha1.CloudProfileConfig) { + for _, providerMachineImage := range config.MachineImages { + // Find the corresponding machine image + imageIdx := slices.IndexFunc(machineImages, func(mi gardencorev1beta1.MachineImage) bool { + return mi.Name == providerMachineImage.Name + }) + if imageIdx == -1 { + continue + } + + // Iterate over versions in the provider's machine image + for _, providerVersion := range providerMachineImage.Versions { + // Find the corresponding version in the machine image + versionIdx := slices.IndexFunc(machineImages[imageIdx].Versions, func(miv gardencorev1beta1.MachineImageVersion) bool { + return miv.Version == providerVersion.Version + }) + if versionIdx == -1 { + continue + } + + machineImages[imageIdx].Versions[versionIdx].CapabilityFlavors = convertProviderVersionToCapabilityFlavors(providerVersion) + } + } +} + +// convertProviderVersionToCapabilityFlavors converts a provider MachineImageVersion to core capability flavors. +// Supports both new format (capabilityFlavors) and old format (regions with architecture). +func convertProviderVersionToCapabilityFlavors(version v1alpha1.MachineImageVersion) []gardencorev1beta1.MachineImageFlavor { + if len(version.CapabilityFlavors) > 0 { + return convertCapabilityFlavors(version.CapabilityFlavors) + } + if len(version.Regions) > 0 { + return convertRegionsToCapabilityFlavors(version.Regions) + } + return nil +} + +// convertRegionsToCapabilityFlavors converts old format regions with architecture to core capability flavors. +func convertRegionsToCapabilityFlavors(regions []v1alpha1.RegionIDMapping) []gardencorev1beta1.MachineImageFlavor { + // Group regions by architecture + architectureSet := make(map[string]struct{}) + for _, region := range regions { + arch := ptr.Deref(region.Architecture, v1beta1constants.ArchitectureAMD64) + architectureSet[arch] = struct{}{} + } + + // Create a core MachineImageFlavor per architecture (capabilities only, no region details) + capabilityFlavors := make([]gardencorev1beta1.MachineImageFlavor, 0, len(architectureSet)) + for arch := range architectureSet { + capabilityFlavors = append(capabilityFlavors, gardencorev1beta1.MachineImageFlavor{ + Capabilities: gardencorev1beta1.Capabilities{ + v1beta1constants.ArchitectureName: []string{arch}, + }, + }) + } + + // Sort for deterministic output + sort.Slice(capabilityFlavors, func(i, j int) bool { + archI := capabilityFlavors[i].Capabilities[v1beta1constants.ArchitectureName] + archJ := capabilityFlavors[j].Capabilities[v1beta1constants.ArchitectureName] + if len(archI) > 0 && len(archJ) > 0 { + return archI[0] < archJ[0] + } + return false + }) + + return capabilityFlavors +} + +// convertCapabilityFlavors converts provider capability flavors to CloudProfile capability flavors +func convertCapabilityFlavors(providerFlavors []v1alpha1.MachineImageFlavor) []gardencorev1beta1.MachineImageFlavor { + capabilityFlavors := make([]gardencorev1beta1.MachineImageFlavor, 0, len(providerFlavors)) + for _, providerFlavor := range providerFlavors { + capabilityFlavors = append(capabilityFlavors, gardencorev1beta1.MachineImageFlavor{ + Capabilities: providerFlavor.GetCapabilities(), + }) + } + return capabilityFlavors +} diff --git a/pkg/admission/mutator/cloudprofile_test.go b/pkg/admission/mutator/cloudprofile_test.go new file mode 100644 index 00000000..8da03fe8 --- /dev/null +++ b/pkg/admission/mutator/cloudprofile_test.go @@ -0,0 +1,367 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package mutator_test + +import ( + "context" + "fmt" + + extensionswebhook "github.com/gardener/gardener/extensions/pkg/webhook" + "github.com/gardener/gardener/pkg/apis/core/v1beta1" + "github.com/gardener/gardener/pkg/utils/test" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/manager" + + "github.com/stackitcloud/gardener-extension-provider-stackit/v2/pkg/admission/mutator" + "github.com/stackitcloud/gardener-extension-provider-stackit/v2/pkg/apis/stackit/install" +) + +var _ = Describe("CloudProfile Mutator", func() { + var ( + fakeClient client.Client + fakeManager manager.Manager + ctx = context.Background() + + cloudProfileMutator extensionswebhook.Mutator + cloudProfile *v1beta1.CloudProfile + ) + + BeforeEach(func() { + scheme := runtime.NewScheme() + utilruntime.Must(install.AddToScheme(scheme)) + utilruntime.Must(v1beta1.AddToScheme(scheme)) + fakeClient = fakeclient.NewClientBuilder().WithScheme(scheme).Build() + fakeManager = &test.FakeManager{ + Client: fakeClient, + Scheme: scheme, + } + + cloudProfileMutator = mutator.NewCloudProfileMutator(fakeManager) + + imageVersion := "1.0.0" + latestImageVersion := "1.0.1" + imageName := "os-1" + + machineImages := []v1beta1.MachineImage{ + { + Name: imageName, + Versions: []v1beta1.MachineImageVersion{{ + ExpirableVersion: v1beta1.ExpirableVersion{ + Version: imageVersion, + }, + }, { + ExpirableVersion: v1beta1.ExpirableVersion{ + Version: latestImageVersion, + }, + }, + }, + }, + } + + cloudProfile = &v1beta1.CloudProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "stackit", + }, + Spec: v1beta1.CloudProfileSpec{ + MachineImages: machineImages, + }, + } + }) + + Describe("#Mutate", func() { + Context("CloudProfile without machineCapabilities", func() { + BeforeEach(func() { + cloudProfile.Spec.ProviderConfig = nil + }) + + It("should succeed and not modify the CloudProfile", func() { + cloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig", +"machineImages":[ + {"name":"image-1","versions":[{"version":"1.1","regions":[{"name":"eu2","id":"id-124","architecture":"armhf"}]}]} +]}`)} + expectedProfileSpec := cloudProfile.Spec.DeepCopy() + Expect(cloudProfileMutator.Mutate(ctx, cloudProfile, nil)).To(Succeed()) + + Expect(cloudProfile.Spec.MachineImages).To(Equal(expectedProfileSpec.MachineImages)) + }) + }) + + Context("CloudProfile with machineCapabilities", func() { + BeforeEach(func() { + cloudProfile.Spec.MachineCapabilities = []v1beta1.CapabilityDefinition{{ + Name: "architecture", + Values: []string{"amd64", "arm64", "armhf"}, + }, { + Name: "gpu", + Values: []string{"true", "false"}, + }} + }) + It("should succeed for CloudProfile without provider config", func() { + expectedProfile := cloudProfile.DeepCopy() + Expect(cloudProfileMutator.Mutate(ctx, cloudProfile, nil)).To(Succeed()) + Expect(cloudProfile).To(Equal(expectedProfile)) + + }) + + It("should skip if CloudProfile is in deletion phase", func() { + cloudProfile.DeletionTimestamp = new(metav1.Now()) + expectedProfile := cloudProfile.DeepCopy() + + Expect(cloudProfileMutator.Mutate(ctx, cloudProfile, nil)).To(Succeed()) + + Expect(cloudProfile).To(Equal(expectedProfile)) + }) + + It("should fill capabilityFlavors based on provider config", func() { + image1IDMappings := `"capabilityFlavors":[ +{"capabilities":{"architecture":["arm64"]},"regions":[{"name":"image-region-1","id":"id-img-reg-1"}]}, +{"capabilities":{"architecture":["amd64"]},"regions":[{"name":"image-region-2","id":"id-img-reg-2"}]} +]` + image1FallbackMappings := `"capabilityFlavors":[ +{"capabilities":{"architecture":["amd64"]},"regions":[{"name":"image-region-2","id":"id-img-reg-2"}]} +]` + + cloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(fmt.Sprintf(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig", +"machineImages":[ + {"name":"os-1","versions":[ + {"version":"1.0.0",%s}, + {"version":"1.0.1",%s} + ]} +]}`, image1IDMappings, image1FallbackMappings))} + Expect(cloudProfileMutator.Mutate(ctx, cloudProfile, nil)).To(Succeed()) + Expect(cloudProfile.Spec.MachineImages).To(Equal([]v1beta1.MachineImage{ + { + Name: "os-1", + Versions: []v1beta1.MachineImageVersion{ + { + ExpirableVersion: v1beta1.ExpirableVersion{Version: "1.0.0"}, + CapabilityFlavors: []v1beta1.MachineImageFlavor{ + {Capabilities: v1beta1.Capabilities{"architecture": []string{"arm64"}}}, + {Capabilities: v1beta1.Capabilities{"architecture": []string{"amd64"}}}, + }, + }, + { + ExpirableVersion: v1beta1.ExpirableVersion{Version: "1.0.1"}, + CapabilityFlavors: []v1beta1.MachineImageFlavor{ + {Capabilities: v1beta1.Capabilities{"architecture": []string{"amd64"}}}, + }, + }, + }, + }, + })) + }) + + It("should overwrite capabilityFlavors when some versions already have them", func() { + twoFlavors := `"capabilityFlavors":[ +{"capabilities":{"architecture":["arm64"]},"regions":[{"name":"image-region-1","id":"id-img-reg-1"}]}, +{"capabilities":{"architecture":["amd64"]},"regions":[{"name":"image-region-2","id":"id-img-reg-2"}]} +]` + oneFlavors := `"capabilityFlavors":[ +{"capabilities":{"architecture":["amd64"]},"regions":[{"name":"image-region-2","id":"id-img-reg-2"}]} +]` + cloudProfile.Spec.MachineImages = []v1beta1.MachineImage{ + { + Name: "os-1", + Versions: []v1beta1.MachineImageVersion{ + { + ExpirableVersion: v1beta1.ExpirableVersion{Version: "1.0.0"}, + CapabilityFlavors: []v1beta1.MachineImageFlavor{ + {Capabilities: v1beta1.Capabilities{"architecture": []string{"not-existing"}}}, + {Capabilities: v1beta1.Capabilities{"architecture": []string{"amd64"}}}, + }, + }, + {ExpirableVersion: v1beta1.ExpirableVersion{Version: "1.0.1"}}, + }, + }, + { + Name: "os-2", + Versions: []v1beta1.MachineImageVersion{ + {ExpirableVersion: v1beta1.ExpirableVersion{Version: "1.0.0"}}, + {ExpirableVersion: v1beta1.ExpirableVersion{Version: "1.0.1"}}, + }, + }, + } + cloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(fmt.Sprintf(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig", +"machineImages":[ + {"name":"os-1","versions":[ + {"version":"1.0.0",%s}, + {"version":"1.0.1",%s} + ]}, + {"name":"os-2","versions":[ + {"version":"1.0.0",%s}, + {"version":"1.0.1",%s} + ]} +]}`, twoFlavors, oneFlavors, oneFlavors, twoFlavors))} + Expect(cloudProfileMutator.Mutate(ctx, cloudProfile, nil)).To(Succeed()) + Expect(cloudProfile.Spec.MachineImages).To(ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("os-1"), + "Versions": ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "ExpirableVersion": MatchFields(IgnoreExtras, Fields{"Version": Equal("1.0.0")}), + "CapabilityFlavors": ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"arm64"}}), + }), + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"amd64"}}), + }), + ), + }), + MatchFields(IgnoreExtras, Fields{ + "ExpirableVersion": MatchFields(IgnoreExtras, Fields{"Version": Equal("1.0.1")}), + "CapabilityFlavors": ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"amd64"}}), + }), + ), + }), + ), + }), + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("os-2"), + "Versions": ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "ExpirableVersion": MatchFields(IgnoreExtras, Fields{"Version": Equal("1.0.0")}), + "CapabilityFlavors": ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"amd64"}}), + }), + ), + }), + MatchFields(IgnoreExtras, Fields{ + "ExpirableVersion": MatchFields(IgnoreExtras, Fields{"Version": Equal("1.0.1")}), + "CapabilityFlavors": ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"arm64"}}), + }), + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"amd64"}}), + }), + ), + }), + ), + }), + )) + }) + + It("should convert old-format regions to capabilityFlavors", func() { + cloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig", +"machineImages":[ + {"name":"os-1","versions":[ + {"version":"1.0.0","regions":[ + {"name":"eu01","id":"os1-eu01-amd64","architecture":"amd64"}, + {"name":"eu01","id":"os1-eu01-arm64","architecture":"arm64"} + ]}, + {"version":"1.0.1","regions":[ + {"name":"eu01","id":"os1-eu01-amd64-v2"} + ]} + ]} +]}`)} + Expect(cloudProfileMutator.Mutate(ctx, cloudProfile, nil)).To(Succeed()) + Expect(cloudProfile.Spec.MachineImages).To(ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("os-1"), + "Versions": ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "ExpirableVersion": MatchFields(IgnoreExtras, Fields{"Version": Equal("1.0.0")}), + "CapabilityFlavors": ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"amd64"}}), + }), + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"arm64"}}), + }), + ), + }), + MatchFields(IgnoreExtras, Fields{ + "ExpirableVersion": MatchFields(IgnoreExtras, Fields{"Version": Equal("1.0.1")}), + "CapabilityFlavors": ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"amd64"}}), + }), + ), + }), + ), + }), + )) + }) + + It("should handle mixed format across versions within the same image", func() { + cloudProfile.Spec.MachineImages = []v1beta1.MachineImage{ + { + Name: "os-1", + Versions: []v1beta1.MachineImageVersion{ + {ExpirableVersion: v1beta1.ExpirableVersion{Version: "1.0.0"}}, + {ExpirableVersion: v1beta1.ExpirableVersion{Version: "1.0.1"}}, + }, + }, + } + // v1.0.0 uses old format (regions with architecture), v1.0.1 uses new format (capabilityFlavors) + cloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig", +"machineImages":[ + {"name":"os-1","versions":[ + {"version":"1.0.0","regions":[ + {"name":"eu01","id":"os1-eu01-amd64","architecture":"amd64"}, + {"name":"eu01","id":"os1-eu01-arm64","architecture":"arm64"} + ]}, + {"version":"1.0.1","capabilityFlavors":[ + {"capabilities":{"architecture":["amd64"]},"regions":[{"name":"eu01","id":"os1-eu01-amd64-v2"}]}, + {"capabilities":{"architecture":["arm64"]},"regions":[{"name":"eu01","id":"os1-eu01-arm64-v2"}]} + ]} + ]} +]}`)} + Expect(cloudProfileMutator.Mutate(ctx, cloudProfile, nil)).To(Succeed()) + Expect(cloudProfile.Spec.MachineImages).To(ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("os-1"), + "Versions": ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "ExpirableVersion": MatchFields(IgnoreExtras, Fields{"Version": Equal("1.0.0")}), + "CapabilityFlavors": ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"amd64"}}), + }), + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"arm64"}}), + }), + ), + }), + MatchFields(IgnoreExtras, Fields{ + "ExpirableVersion": MatchFields(IgnoreExtras, Fields{"Version": Equal("1.0.1")}), + "CapabilityFlavors": ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"amd64"}}), + }), + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"arm64"}}), + }), + ), + }), + ), + }), + )) + }) + }) + + }) +}) diff --git a/pkg/admission/mutator/mutator.go b/pkg/admission/mutator/mutator.go index af385423..9c832f92 100644 --- a/pkg/admission/mutator/mutator.go +++ b/pkg/admission/mutator/mutator.go @@ -29,7 +29,8 @@ func New(mgr manager.Manager) (*extensionswebhook.Webhook, error) { Name: Name, Path: "/webhooks/mutate", Mutators: map[extensionswebhook.Mutator][]extensionswebhook.Type{ - NewNamespacedCloudProfileMutator(mgr): {{Obj: &gardencorev1beta1.NamespacedCloudProfile{}, Subresource: new("status")}}, + NewCloudProfileMutator(mgr): {{Obj: &gardencorev1beta1.CloudProfile{}}}, + NewNamespacedCloudProfileMutator(mgr): {{Obj: &gardencorev1beta1.NamespacedCloudProfile{}}, {Obj: &gardencorev1beta1.NamespacedCloudProfile{}, Subresource: new("status")}}, }, Target: extensionswebhook.TargetSeed, ObjectSelector: &metav1.LabelSelector{ diff --git a/pkg/admission/mutator/namespacedcloudprofile.go b/pkg/admission/mutator/namespacedcloudprofile.go index 4c944a2e..719e99af 100644 --- a/pkg/admission/mutator/namespacedcloudprofile.go +++ b/pkg/admission/mutator/namespacedcloudprofile.go @@ -23,6 +23,7 @@ import ( ) // NewNamespacedCloudProfileMutator returns a new instance of a NamespacedCloudProfile mutator. +// It handles both spec mutations (populating capabilityFlavors) and status mutations (merging provider config). func NewNamespacedCloudProfileMutator(mgr manager.Manager) extensionswebhook.Mutator { return &namespacedCloudProfile{ client: mgr.GetClient(), @@ -36,15 +37,16 @@ type namespacedCloudProfile struct { } // Mutate mutates the given NamespacedCloudProfile object. -func (p *namespacedCloudProfile) Mutate(_ context.Context, newObj, _ client.Object) error { +// It performs two independent mutations: +// 1. Populates capabilityFlavors on spec.machineImages from the providerConfig (for spec create/update) +// 2. Merges spec providerConfig into status providerConfig (for status subresource updates) +func (p *namespacedCloudProfile) Mutate(ctx context.Context, newObj, _ client.Object) error { profile, ok := newObj.(*gardencorev1beta1.NamespacedCloudProfile) if !ok { return fmt.Errorf("wrong object type %T", newObj) } - // Ignore NamespacedCloudProfiles being deleted and wait for core mutator to patch the status. - if profile.DeletionTimestamp != nil || profile.Generation != profile.Status.ObservedGeneration || - profile.Spec.ProviderConfig == nil || profile.Status.CloudProfileSpec.ProviderConfig == nil { + if profile.DeletionTimestamp != nil || profile.Spec.ProviderConfig == nil { return nil } @@ -52,6 +54,41 @@ func (p *namespacedCloudProfile) Mutate(_ context.Context, newObj, _ client.Obje if _, _, err := p.decoder.Decode(profile.Spec.ProviderConfig.Raw, nil, specConfig); err != nil { return fmt.Errorf("could not decode providerConfig of namespacedCloudProfile spec for '%s': %w", profile.Name, err) } + + // Mutation 1: Populate capabilityFlavors on spec.machineImages if parent has machineCapabilities + if err := p.mutateSpecCapabilityFlavors(ctx, profile, specConfig); err != nil { + return err + } + + // Mutation 2: Merge spec providerConfig into status (only when status is available) + if shouldMergeStatus(profile) { + if err := p.mergeStatusProviderConfig(profile, specConfig); err != nil { + return err + } + } + + return nil +} + +// mutateSpecCapabilityFlavors populates capabilityFlavors on spec.machineImages versions from the providerConfig. +func (p *namespacedCloudProfile) mutateSpecCapabilityFlavors(ctx context.Context, profile *gardencorev1beta1.NamespacedCloudProfile, specConfig *stackitv1alpha1.CloudProfileConfig) error { + // Fetch parent CloudProfile to check for machineCapabilities + parentProfile := &gardencorev1beta1.CloudProfile{} + if err := p.client.Get(ctx, client.ObjectKey{Name: profile.Spec.Parent.Name}, parentProfile); err != nil { + return fmt.Errorf("could not get parent CloudProfile %q: %w", profile.Spec.Parent.Name, err) + } + + // Skip if parent has no machineCapabilities + if len(parentProfile.Spec.MachineCapabilities) == 0 { + return nil + } + + mutateMachineImageCapabilityFlavors(profile.Spec.MachineImages, specConfig) + return nil +} + +// mergeStatusProviderConfig merges the spec providerConfig into the status providerConfig. +func (p *namespacedCloudProfile) mergeStatusProviderConfig(profile *gardencorev1beta1.NamespacedCloudProfile, specConfig *stackitv1alpha1.CloudProfileConfig) error { statusConfig := &stackitv1alpha1.CloudProfileConfig{} if _, _, err := p.decoder.Decode(profile.Status.CloudProfileSpec.ProviderConfig.Raw, nil, statusConfig); err != nil { return fmt.Errorf("could not decode providerConfig of namespacedCloudProfile status for '%s': %w", profile.Name, err) @@ -66,13 +103,19 @@ func (p *namespacedCloudProfile) Mutate(_ context.Context, newObj, _ client.Obje modifiedStatusConfig, err := json.Marshal(statusConfig) if err != nil { - return err + return fmt.Errorf("failed to marshal status config: %w", err) } profile.Status.CloudProfileSpec.ProviderConfig.Raw = modifiedStatusConfig - return nil } +// shouldMergeStatus checks if the status merge should be performed. +// Status merge is only applicable when the status has been populated (status subresource update). +func shouldMergeStatus(profile *gardencorev1beta1.NamespacedCloudProfile) bool { + return profile.Generation == profile.Status.ObservedGeneration && + profile.Status.CloudProfileSpec.ProviderConfig != nil +} + func mergeMachineImages(specMachineImages, statusMachineImages []stackitv1alpha1.MachineImages) []stackitv1alpha1.MachineImages { specImages := utils.CreateMapFromSlice(specMachineImages, func(mi stackitv1alpha1.MachineImages) string { return mi.Name }) statusImages := utils.CreateMapFromSlice(statusMachineImages, func(mi stackitv1alpha1.MachineImages) string { return mi.Name }) diff --git a/pkg/admission/mutator/namespacedcloudprofile_test.go b/pkg/admission/mutator/namespacedcloudprofile_test.go index 04133e56..53fdf754 100644 --- a/pkg/admission/mutator/namespacedcloudprofile_test.go +++ b/pkg/admission/mutator/namespacedcloudprofile_test.go @@ -23,7 +23,7 @@ import ( "github.com/stackitcloud/gardener-extension-provider-stackit/v2/pkg/admission/mutator" "github.com/stackitcloud/gardener-extension-provider-stackit/v2/pkg/apis/stackit/helper" - stackitinstall "github.com/stackitcloud/gardener-extension-provider-stackit/v2/pkg/apis/stackit/install" + "github.com/stackitcloud/gardener-extension-provider-stackit/v2/pkg/apis/stackit/install" stackitv1alpha1 "github.com/stackitcloud/gardener-extension-provider-stackit/v2/pkg/apis/stackit/v1alpha1" ) @@ -33,15 +33,14 @@ var _ = Describe("NamespacedCloudProfile Mutator", func() { fakeManager manager.Manager namespace string ctx = context.Background() - scheme *runtime.Scheme namespacedCloudProfileMutator extensionswebhook.Mutator namespacedCloudProfile *v1beta1.NamespacedCloudProfile ) BeforeEach(func() { - scheme = runtime.NewScheme() - utilruntime.Must(stackitinstall.AddToScheme(scheme)) + scheme := runtime.NewScheme() + utilruntime.Must(install.AddToScheme(scheme)) utilruntime.Must(v1beta1.AddToScheme(scheme)) fakeClient = fakeclient.NewClientBuilder().WithScheme(scheme).Build() fakeManager = &test.FakeManager{ @@ -56,6 +55,12 @@ var _ = Describe("NamespacedCloudProfile Mutator", func() { Name: "profile-1", Namespace: namespace, }, + Spec: v1beta1.NamespacedCloudProfileSpec{ + Parent: v1beta1.CloudProfileReference{ + Kind: "CloudProfile", + Name: "parent-profile", + }, + }, } }) @@ -73,7 +78,138 @@ var _ = Describe("NamespacedCloudProfile Mutator", func() { Expect(namespacedCloudProfile).To(DeepEqual(expectedProfile)) }) + Describe("populate capabilityFlavors on spec.machineImages", func() { + It("should skip if parent has no machineCapabilities", func() { + Expect(fakeClient.Create(ctx, &v1beta1.CloudProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "parent-profile"}, + Spec: v1beta1.CloudProfileSpec{ + MachineCapabilities: nil, + }, + })).To(Succeed()) + + namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig", +"machineImages":[ + {"name":"image-1","versions":[{"version":"1.0","capabilityFlavors":[ +{"capabilities":{"architecture":["amd64"]},"regions":[{"name":"eu1","id":"id-1"}]} +]}]} +]}`)} + namespacedCloudProfile.Spec.MachineImages = []v1beta1.MachineImage{ + {Name: "image-1", Versions: []v1beta1.MachineImageVersion{ + {ExpirableVersion: v1beta1.ExpirableVersion{Version: "1.0"}}, + }}, + } + + expectedImages := namespacedCloudProfile.Spec.MachineImages + + Expect(namespacedCloudProfileMutator.Mutate(ctx, namespacedCloudProfile, nil)).To(Succeed()) + Expect(namespacedCloudProfile.Spec.MachineImages).To(Equal(expectedImages)) + }) + + It("should populate capabilityFlavors from new-format provider config", func() { + Expect(fakeClient.Create(ctx, &v1beta1.CloudProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "parent-profile"}, + Spec: v1beta1.CloudProfileSpec{ + MachineCapabilities: []v1beta1.CapabilityDefinition{{ + Name: "architecture", + Values: []string{"amd64", "arm64"}, + }}, + }, + })).To(Succeed()) + + namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig", +"machineImages":[ + {"name":"image-1","versions":[{"version":"1.0","capabilityFlavors":[ +{"capabilities":{"architecture":["amd64"]},"regions":[{"name":"eu1","id":"id-amd64"}]}, +{"capabilities":{"architecture":["arm64"]},"regions":[{"name":"eu1","id":"id-arm64"}]} +]}]} +]}`)} + namespacedCloudProfile.Spec.MachineImages = []v1beta1.MachineImage{ + {Name: "image-1", Versions: []v1beta1.MachineImageVersion{ + {ExpirableVersion: v1beta1.ExpirableVersion{Version: "1.0"}}, + }}, + } + + Expect(namespacedCloudProfileMutator.Mutate(ctx, namespacedCloudProfile, nil)).To(Succeed()) + Expect(namespacedCloudProfile.Spec.MachineImages).To(ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("image-1"), + "Versions": ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "ExpirableVersion": MatchFields(IgnoreExtras, Fields{"Version": Equal("1.0")}), + "CapabilityFlavors": ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"amd64"}}), + }), + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"arm64"}}), + }), + ), + }), + ), + }), + )) + }) + + It("should populate capabilityFlavors from old-format regions provider config", func() { + Expect(fakeClient.Create(ctx, &v1beta1.CloudProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "parent-profile"}, + Spec: v1beta1.CloudProfileSpec{ + MachineCapabilities: []v1beta1.CapabilityDefinition{{ + Name: "architecture", + Values: []string{"amd64", "arm64"}, + }}, + }, + })).To(Succeed()) + + namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig", +"machineImages":[ + {"name":"image-1","versions":[{"version":"1.0","regions":[ +{"name":"eu1","id":"id-amd64","architecture":"amd64"}, +{"name":"eu1","id":"id-arm64","architecture":"arm64"} +]}]} +]}`)} + namespacedCloudProfile.Spec.MachineImages = []v1beta1.MachineImage{ + {Name: "image-1", Versions: []v1beta1.MachineImageVersion{ + {ExpirableVersion: v1beta1.ExpirableVersion{Version: "1.0"}}, + }}, + } + + Expect(namespacedCloudProfileMutator.Mutate(ctx, namespacedCloudProfile, nil)).To(Succeed()) + Expect(namespacedCloudProfile.Spec.MachineImages).To(ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("image-1"), + "Versions": ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "ExpirableVersion": MatchFields(IgnoreExtras, Fields{"Version": Equal("1.0")}), + "CapabilityFlavors": ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"amd64"}}), + }), + MatchFields(IgnoreExtras, Fields{ + "Capabilities": Equal(v1beta1.Capabilities{"architecture": []string{"arm64"}}), + }), + ), + }), + ), + }), + )) + }) + }) + Describe("merge the provider configurations from a NamespacedCloudProfile and the parent CloudProfile", func() { + BeforeEach(func() { + // Create a parent profile without machineCapabilities for status merge tests + Expect(fakeClient.Create(ctx, &v1beta1.CloudProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "parent-profile"}, + })).To(Succeed()) + }) + It("should correctly merge apiEndpoints from spec", func() { namespacedCloudProfile.Status.CloudProfileSpec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ "apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", @@ -129,6 +265,164 @@ var _ = Describe("NamespacedCloudProfile Mutator", func() { }), )) }) + It("should correctly merge extended machineImages using capabilities ", func() { + Expect(fakeClient.Delete(ctx, &v1beta1.CloudProfile{ObjectMeta: metav1.ObjectMeta{Name: "parent-profile"}})).To(Succeed()) + Expect(fakeClient.Create(ctx, &v1beta1.CloudProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "parent-profile"}, + Spec: v1beta1.CloudProfileSpec{ + MachineCapabilities: []v1beta1.CapabilityDefinition{{ + Name: "architecture", + Values: []string{"amd64", "arm64"}, + }}, + }, + })).To(Succeed()) + + namespacedCloudProfile.Status.CloudProfileSpec.MachineCapabilities = []v1beta1.CapabilityDefinition{{ + Name: "architecture", + Values: []string{"amd64", "arm64"}, + }} + namespacedCloudProfile.Status.CloudProfileSpec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig", +"machineImages":[ + {"name":"image-1","versions":[{"version":"1.0","capabilityFlavors":[ +{"capabilities":{"architecture":["amd64"]},"regions":[{"name":"eu1","id":"id-img-reg-1"}]} +]}]} +]}`)} + namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig", +"machineImages":[ + {"name":"image-1","versions":[{"version":"1.1","capabilityFlavors":[ +{"capabilities":{"architecture":["arm64"]},"regions":[{"name":"eu2","id":"id-img-reg-2"}]} +]}]}, + {"name":"image-2","versions":[{"version":"2.0","capabilityFlavors":[ +{"capabilities":{"architecture":["amd64"]},"regions":[{"name":"eu3","id":"id-img-reg-3"}]} +]}]} +]}`)} + + Expect(namespacedCloudProfileMutator.Mutate(ctx, namespacedCloudProfile, nil)).To(Succeed()) + + mergedConfig, err := helper.CloudProfileConfigFromRawExtension(namespacedCloudProfile.Status.CloudProfileSpec.ProviderConfig) + Expect(err).ToNot(HaveOccurred()) + Expect(mergedConfig.MachineImages).To(ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("image-1"), + "Versions": ContainElements( + stackitv1alpha1.MachineImageVersion{Version: "1.0", + CapabilityFlavors: []stackitv1alpha1.MachineImageFlavor{{ + Capabilities: v1beta1.Capabilities{"architecture": []string{"amd64"}}, + Regions: []stackitv1alpha1.RegionIDMapping{{Name: "eu1", ID: "id-img-reg-1"}}, + }}, + }, + stackitv1alpha1.MachineImageVersion{Version: "1.1", + CapabilityFlavors: []stackitv1alpha1.MachineImageFlavor{{ + Capabilities: v1beta1.Capabilities{"architecture": []string{"arm64"}}, + Regions: []stackitv1alpha1.RegionIDMapping{{Name: "eu2", ID: "id-img-reg-2"}}, + }}, + }, + ), + }), + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("image-2"), + "Versions": ContainElements( + stackitv1alpha1.MachineImageVersion{Version: "2.0", + CapabilityFlavors: []stackitv1alpha1.MachineImageFlavor{{ + Capabilities: v1beta1.Capabilities{"architecture": []string{"amd64"}}, + Regions: []stackitv1alpha1.RegionIDMapping{{Name: "eu3", ID: "id-img-reg-3"}}, + }}, + }), + }), + )) + }) + + It("should correctly merge mixed format machineImages preserving both old and new format", func() { + Expect(fakeClient.Delete(ctx, &v1beta1.CloudProfile{ObjectMeta: metav1.ObjectMeta{Name: "parent-profile"}})).To(Succeed()) + Expect(fakeClient.Create(ctx, &v1beta1.CloudProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "parent-profile"}, + Spec: v1beta1.CloudProfileSpec{ + MachineCapabilities: []v1beta1.CapabilityDefinition{{ + Name: "architecture", + Values: []string{"amd64", "arm64"}, + }}, + }, + })).To(Succeed()) + + namespacedCloudProfile.Status.CloudProfileSpec.MachineCapabilities = []v1beta1.CapabilityDefinition{{ + Name: "architecture", + Values: []string{"amd64", "arm64"}, + }} + // Parent status has new-format capabilityFlavors + namespacedCloudProfile.Status.CloudProfileSpec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig", +"machineImages":[ + {"name":"image-1","versions":[{"version":"1.0","capabilityFlavors":[ +{"capabilities":{"architecture":["amd64"]},"regions":[{"name":"eu1","id":"id-cap-amd64"}]} +]}]} +]}`)} + // Spec has mixed: one version old-format regions, one version new-format capabilityFlavors + namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig", +"machineImages":[ + {"name":"image-1","versions":[ + {"version":"1.1","regions":[ + {"name":"eu1","id":"id-old-amd64","architecture":"amd64"}, + {"name":"eu1","id":"id-old-arm64","architecture":"arm64"} + ]} + ]}, + {"name":"image-2","versions":[ + {"version":"2.0","capabilityFlavors":[ + {"capabilities":{"architecture":["amd64"]},"regions":[{"name":"eu1","id":"id-new-amd64"}]}, + {"capabilities":{"architecture":["arm64"]},"regions":[{"name":"eu1","id":"id-new-arm64"}]} + ]} + ]} +]}`)} + + Expect(namespacedCloudProfileMutator.Mutate(ctx, namespacedCloudProfile, nil)).To(Succeed()) + + mergedConfig, err := helper.CloudProfileConfigFromRawExtension(namespacedCloudProfile.Status.CloudProfileSpec.ProviderConfig) + Expect(err).ToNot(HaveOccurred()) + Expect(mergedConfig.MachineImages).To(ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("image-1"), + "Versions": ContainElements( + // Parent version preserved as-is (new format) + stackitv1alpha1.MachineImageVersion{Version: "1.0", + CapabilityFlavors: []stackitv1alpha1.MachineImageFlavor{{ + Capabilities: v1beta1.Capabilities{"architecture": []string{"amd64"}}, + Regions: []stackitv1alpha1.RegionIDMapping{{Name: "eu1", ID: "id-cap-amd64"}}, + }}, + }, + // Spec version preserved as-is (old format with regions) + stackitv1alpha1.MachineImageVersion{Version: "1.1", + Regions: []stackitv1alpha1.RegionIDMapping{ + {Name: "eu1", ID: "id-old-amd64", Architecture: new("amd64")}, + {Name: "eu1", ID: "id-old-arm64", Architecture: new("arm64")}, + }, + }, + ), + }), + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("image-2"), + "Versions": ContainElements( + // Spec version preserved as-is (new format) + stackitv1alpha1.MachineImageVersion{Version: "2.0", + CapabilityFlavors: []stackitv1alpha1.MachineImageFlavor{ + { + Capabilities: v1beta1.Capabilities{"architecture": []string{"amd64"}}, + Regions: []stackitv1alpha1.RegionIDMapping{{Name: "eu1", ID: "id-new-amd64"}}, + }, + { + Capabilities: v1beta1.Capabilities{"architecture": []string{"arm64"}}, + Regions: []stackitv1alpha1.RegionIDMapping{{Name: "eu1", ID: "id-new-arm64"}}, + }, + }, + }), + }), + )) + }) }) }) }) diff --git a/pkg/admission/validator/cloudprofile.go b/pkg/admission/validator/cloudprofile.go index c3c1886b..3cc0d5d2 100644 --- a/pkg/admission/validator/cloudprofile.go +++ b/pkg/admission/validator/cloudprofile.go @@ -9,6 +9,7 @@ import ( "fmt" extensionswebhook "github.com/gardener/gardener/extensions/pkg/webhook" + gardencorev1beta1helper "github.com/gardener/gardener/pkg/api/core/v1beta1/helper" "github.com/gardener/gardener/pkg/apis/core" "k8s.io/apimachinery/pkg/util/validation/field" "sigs.k8s.io/controller-runtime/pkg/client" @@ -42,5 +43,10 @@ func (cp *cloudProfile) Validate(_ context.Context, newObj, _ client.Object) err return err } - return stackitvalidation.ValidateCloudProfileConfig(cpConfig, cloudProfile.Spec.MachineImages, providerConfigPath).ToAggregate() + capabilityDefinitions, err := gardencorev1beta1helper.ConvertV1beta1CapabilityDefinitions(cloudProfile.Spec.MachineCapabilities) + if err != nil { + return field.InternalError(field.NewPath("spec").Child("machineCapabilities"), err) + } + + return stackitvalidation.ValidateCloudProfileConfig(cpConfig, cloudProfile.Spec.MachineImages, capabilityDefinitions, providerConfigPath).ToAggregate() } diff --git a/pkg/admission/validator/namespacedcloudprofile.go b/pkg/admission/validator/namespacedcloudprofile.go index da3e2641..41072b7f 100644 --- a/pkg/admission/validator/namespacedcloudprofile.go +++ b/pkg/admission/validator/namespacedcloudprofile.go @@ -10,6 +10,8 @@ import ( "slices" extensionswebhook "github.com/gardener/gardener/extensions/pkg/webhook" + gardencoreapi "github.com/gardener/gardener/pkg/api" + gardencorev1beta1helper "github.com/gardener/gardener/pkg/api/core/v1beta1/helper" "github.com/gardener/gardener/pkg/apis/core" gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" "github.com/gardener/gardener/pkg/apis/core/v1beta1/constants" @@ -62,31 +64,36 @@ func (p *namespacedCloudProfile) Validate(ctx context.Context, newObj, _ client. return err } - return p.validateNamespacedCloudProfileProviderConfig(cpConfig, profile.Spec, parentProfile.Spec).ToAggregate() -} - -// validateNamespacedCloudProfileProviderConfig validates the CloudProfileConfig passed with a NamespacedCloudProfile. -func (p *namespacedCloudProfile) validateNamespacedCloudProfileProviderConfig(providerConfig *stackitv1alpha1.CloudProfileConfig, profileSpec core.NamespacedCloudProfileSpec, parentSpec gardencorev1beta1.CloudProfileSpec) field.ErrorList { + //nolint:prealloc // size is not known yet allErrs := field.ErrorList{} + allErrs = append(allErrs, p.validateValidOverrideOptions(cpConfig)...) + allErrs = append(allErrs, p.validateMachineImages(cpConfig, profile.Spec.MachineImages, parentProfile.Spec)...) + return allErrs.ToAggregate() +} + +// validateValidOverrideOptions checks that only machineImages or apiEndpoints are set in the CloudProfileConfig passed with a NamespacedCloudProfile. +func (p *namespacedCloudProfile) validateValidOverrideOptions(providerConfig *stackitv1alpha1.CloudProfileConfig) field.ErrorList { validationProviderConfig := &stackitv1alpha1.CloudProfileConfig{ TypeMeta: metav1.TypeMeta{ APIVersion: stackitv1alpha1.SchemeGroupVersion.String(), Kind: "CloudProfileConfig", }, MachineImages: providerConfig.MachineImages, - APIEndpoints: providerConfig.APIEndpoints, } + + // Optionally also allow APIEndpoints override + if providerConfig.APIEndpoints != nil { + validationProviderConfig.APIEndpoints = providerConfig.APIEndpoints + } + if !equality.Semantic.DeepEqual(validationProviderConfig, providerConfig) { - allErrs = append(allErrs, field.Forbidden( + return field.ErrorList{field.Forbidden( field.NewPath("spec.providerConfig"), - "must only set machineImages and stackitAPIEndpoints", - )) + "must only set machineImages or apiEndpoints", + )} } - - allErrs = append(allErrs, p.validateMachineImages(providerConfig, profileSpec.MachineImages, parentSpec)...) - - return allErrs + return nil } func (p *namespacedCloudProfile) validateMachineImages(providerConfig *stackitv1alpha1.CloudProfileConfig, machineImages []core.MachineImage, parentSpec gardencorev1beta1.CloudProfileSpec) field.ErrorList { @@ -95,7 +102,7 @@ func (p *namespacedCloudProfile) validateMachineImages(providerConfig *stackitv1 machineImagesPath := field.NewPath("spec.providerConfig.machineImages") for i, machineImage := range providerConfig.MachineImages { idxPath := machineImagesPath.Index(i) - allErrs = append(allErrs, validation.ValidateProviderMachineImage(idxPath, machineImage)...) + allErrs = append(allErrs, validation.ValidateProviderMachineImage(machineImage, parentSpec.MachineCapabilities, idxPath)...) } profileImages := gardener.NewCoreImagesContext(machineImages) @@ -110,6 +117,7 @@ func (p *namespacedCloudProfile) validateMachineImages(providerConfig *stackitv1 field.NewPath("spec.providerConfig.machineImages"), fmt.Sprintf("machine image %s is not defined in the NamespacedCloudProfile providerConfig", machineImage.Name), )) + continue } for _, version := range machineImage.Versions { @@ -120,9 +128,26 @@ func (p *namespacedCloudProfile) validateMachineImages(providerConfig *stackitv1 field.NewPath("spec.providerConfig.machineImages"), fmt.Sprintf("machine image version %s@%s is not defined in the NamespacedCloudProfile providerConfig", machineImage.Name, version.Version), )) + + // no need to check the capabilities and architectures if the version is not defined in the providerConfig + continue + } + + // If the version exists in the parent and has no providerConfig entry, it's an + // expirationDate-only override that doesn't change image mappings — skip validation. + if existsInParent && !exists { + continue } - allErrs = append(allErrs, validateMachineImageArchitectures(machineImage, version, providerImageVersion)...) + if len(parentSpec.MachineCapabilities) == 0 { + allErrs = append(allErrs, validateMachineImageArchitectures(machineImage, version, providerImageVersion)...) + } else { + var v1betaVersion gardencorev1beta1.MachineImageVersion + if err := gardencoreapi.Scheme.Convert(&version, &v1betaVersion, nil); err != nil { + return append(allErrs, field.InternalError(machineImagesPath, err)) + } + allErrs = append(allErrs, validateMachineImageCapabilities(machineImage, v1betaVersion, providerImageVersion, parentSpec.MachineCapabilities)...) + } } } for imageIdx, machineImage := range providerConfig.MachineImages { @@ -159,6 +184,137 @@ func (p *namespacedCloudProfile) validateMachineImages(providerConfig *stackitv1 return allErrs } +func validateMachineImageCapabilities(machineImage core.MachineImage, version gardencorev1beta1.MachineImageVersion, providerImageVersion stackitv1alpha1.MachineImageVersion, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition) field.ErrorList { + allErrs := field.ErrorList{} + path := field.NewPath("spec.providerConfig.machineImages") + defaultedCapabilityFlavors := gardencorev1beta1helper.GetImageFlavorsWithAppliedDefaults(version.CapabilityFlavors, capabilityDefinitions) + + switch { + case len(providerImageVersion.CapabilityFlavors) > 0: + allErrs = append(allErrs, validateCapabilityFlavorsFormat(machineImage, version, providerImageVersion, capabilityDefinitions, defaultedCapabilityFlavors, path)...) + + case len(providerImageVersion.Regions) > 0: + allErrs = append(allErrs, validateRegionsFormatWithCapabilities(machineImage, version, providerImageVersion, defaultedCapabilityFlavors, path)...) + + default: + // Neither regions nor capabilityFlavors set - error for each expected capability flavor + for _, coreDefaultedCapabilityFlavor := range defaultedCapabilityFlavors { + allErrs = append(allErrs, field.Required(path, + fmt.Sprintf("machine image version %s@%s has a capabilityFlavor %v not defined in the NamespacedCloudProfile providerConfig", + machineImage.Name, version.Version, coreDefaultedCapabilityFlavor.Capabilities))) + } + } + + return allErrs +} + +// validateCapabilityFlavorsFormat validates provider config using the new capabilityFlavors format. +func validateCapabilityFlavorsFormat(machineImage core.MachineImage, version gardencorev1beta1.MachineImageVersion, providerImageVersion stackitv1alpha1.MachineImageVersion, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition, defaultedCapabilityFlavors []gardencorev1beta1.MachineImageFlavor, path *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + regionsCapabilitiesMap := map[string][]gardencorev1beta1.Capabilities{} + + // 1. Create an error for each capabilityFlavor in the providerConfig that is not defined in the core machine image version + for _, capabilityFlavor := range providerImageVersion.CapabilityFlavors { + isFound := false + for _, coreDefaultedCapabilitySet := range defaultedCapabilityFlavors { + defaultedProviderCapabilities := gardencorev1beta1.GetCapabilitiesWithAppliedDefaults(capabilityFlavor.Capabilities, capabilityDefinitions) + if gardencorev1beta1helper.AreCapabilitiesEqual(coreDefaultedCapabilitySet.Capabilities, defaultedProviderCapabilities) { + isFound = true + } + } + if !isFound { + allErrs = append(allErrs, field.Forbidden(path, + fmt.Sprintf("machine image version %s@%s has an excess capabilityFlavor %v, which is not defined in the machineImages spec", + machineImage.Name, version.Version, capabilityFlavor.Capabilities))) + } + + for _, regionMapping := range capabilityFlavor.Regions { + regionsCapabilitiesMap[regionMapping.Name] = append(regionsCapabilitiesMap[regionMapping.Name], capabilityFlavor.Capabilities) + } + } + + // 2. Create an error for each capabilityFlavor in the core machine image version that is not defined in the providerConfig + for _, coreDefaultedCapabilityFlavor := range defaultedCapabilityFlavors { + isFound := false + for _, capabilityFlavor := range providerImageVersion.CapabilityFlavors { + defaultedProviderCapabilities := gardencorev1beta1.GetCapabilitiesWithAppliedDefaults(capabilityFlavor.Capabilities, capabilityDefinitions) + if gardencorev1beta1helper.AreCapabilitiesEqual(coreDefaultedCapabilityFlavor.Capabilities, defaultedProviderCapabilities) { + isFound = true + } + } + if !isFound { + allErrs = append(allErrs, field.Required(path, + fmt.Sprintf("machine image version %s@%s has a capabilityFlavor %v not defined in the NamespacedCloudProfile providerConfig", + machineImage.Name, version.Version, coreDefaultedCapabilityFlavor.Capabilities))) + continue + } + + // 3. Create an error for each region that is not part of every capabilityFlavor + for region, regionCapabilities := range regionsCapabilitiesMap { + isFound := false + for _, capabilities := range regionCapabilities { + regionDefaultedCapabilities := gardencorev1beta1.GetCapabilitiesWithAppliedDefaults(capabilities, capabilityDefinitions) + if gardencorev1beta1helper.AreCapabilitiesEqual(regionDefaultedCapabilities, coreDefaultedCapabilityFlavor.Capabilities) { + isFound = true + } + } + if !isFound { + allErrs = append(allErrs, field.Required(path, + fmt.Sprintf("machine image version %s@%s is missing region %q in capabilityFlavor %v in the NamespacedCloudProfile providerConfig", + machineImage.Name, version.Version, region, coreDefaultedCapabilityFlavor.Capabilities))) + } + } + } + + return allErrs +} + +// validateRegionsFormatWithCapabilities validates provider config using the old regions format +// in a capabilities-enabled CloudProfile. +func validateRegionsFormatWithCapabilities(machineImage core.MachineImage, version gardencorev1beta1.MachineImageVersion, providerImageVersion stackitv1alpha1.MachineImageVersion, defaultedCapabilityFlavors []gardencorev1beta1.MachineImageFlavor, path *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + // Collect unique architectures from regions + architectureSet := make(map[string]struct{}) + for _, region := range providerImageVersion.Regions { + arch := ptr.Deref(region.Architecture, constants.ArchitectureAMD64) + architectureSet[arch] = struct{}{} + } + + // Check for excess architectures not in spec + for arch := range architectureSet { + isFound := false + for _, coreDefaultedCapabilityFlavor := range defaultedCapabilityFlavors { + archValues, hasArch := coreDefaultedCapabilityFlavor.Capabilities[constants.ArchitectureName] + if hasArch && len(archValues) > 0 && archValues[0] == arch { + isFound = true + break + } + } + if !isFound { + allErrs = append(allErrs, field.Forbidden(path, + fmt.Sprintf("machine image version %s@%s has an excess architecture %q in regions, which is not defined in the machineImages spec", + machineImage.Name, version.Version, arch))) + } + } + + // Check that each expected capability flavor has a corresponding architecture in regions + for _, coreDefaultedCapabilityFlavor := range defaultedCapabilityFlavors { + archValues, hasArch := coreDefaultedCapabilityFlavor.Capabilities[constants.ArchitectureName] + if !hasArch || len(archValues) == 0 { + continue + } + expectedArch := archValues[0] + if _, exists := architectureSet[expectedArch]; !exists { + allErrs = append(allErrs, field.Required(path, + fmt.Sprintf("machine image version %s@%s has a capabilityFlavor %v not defined in the NamespacedCloudProfile providerConfig", + machineImage.Name, version.Version, coreDefaultedCapabilityFlavor.Capabilities))) + } + } + + return allErrs +} + func validateMachineImageArchitectures(machineImage core.MachineImage, version core.MachineImageVersion, providerImageVersion stackitv1alpha1.MachineImageVersion) field.ErrorList { allErrs := field.ErrorList{} regionsArchitectureMap := map[string][]string{} diff --git a/pkg/admission/validator/namespacedcloudprofile_test.go b/pkg/admission/validator/namespacedcloudprofile_test.go index b717fdcb..a32492c7 100644 --- a/pkg/admission/validator/namespacedcloudprofile_test.go +++ b/pkg/admission/validator/namespacedcloudprofile_test.go @@ -6,10 +6,12 @@ package validator_test import ( "context" + "fmt" extensionswebhook "github.com/gardener/gardener/extensions/pkg/webhook" "github.com/gardener/gardener/pkg/apis/core" "github.com/gardener/gardener/pkg/apis/core/v1beta1" + v1beta1constants "github.com/gardener/gardener/pkg/apis/core/v1beta1/constants" "github.com/gardener/gardener/pkg/utils/test" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -26,7 +28,7 @@ import ( "github.com/stackitcloud/gardener-extension-provider-stackit/v2/pkg/apis/stackit/install" ) -var _ = Describe("NamespacedCloudProfile Validator", func() { +var _ = DescribeTableSubtree("NamespacedCloudProfile Validator", func(isCapabilitiesCloudProfile bool) { var ( fakeClient client.Client fakeManager manager.Manager @@ -36,9 +38,15 @@ var _ = Describe("NamespacedCloudProfile Validator", func() { namespacedCloudProfileValidator extensionswebhook.Validator namespacedCloudProfile *core.NamespacedCloudProfile cloudProfile *v1beta1.CloudProfile + capabilityDefinitions []v1beta1.CapabilityDefinition ) BeforeEach(func() { + if isCapabilitiesCloudProfile { + capabilityDefinitions = []v1beta1.CapabilityDefinition{ + {Name: v1beta1constants.ArchitectureName, Values: []string{"amd64"}}, + } + } scheme := runtime.NewScheme() utilruntime.Must(install.AddToScheme(scheme)) utilruntime.Must(v1beta1.AddToScheme(scheme)) @@ -66,6 +74,9 @@ var _ = Describe("NamespacedCloudProfile Validator", func() { ObjectMeta: metav1.ObjectMeta{ Name: "cloud-profile", }, + Spec: v1beta1.CloudProfileSpec{ + MachineCapabilities: capabilityDefinitions, + }, } }) @@ -82,19 +93,26 @@ var _ = Describe("NamespacedCloudProfile Validator", func() { }) It("should succeed if the NamespacedCloudProfile correctly defines new machine images and types", func() { - cloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ + + imageIDMappings := `"regions":[{"name":"image-region-1","id":"id-img-reg-1"}]` + namespacedImageIDMappings := `{"name":"image-1","versions":[{"version":"1.1","regions":[{"name":"image-region-1","id":"id-img-reg-1"}]}]}, + {"name":"image-2","versions":[{"version":"2.0","regions":[{"name":"image-region-1","id":"id-img-reg-1"}]}]}` + if isCapabilitiesCloudProfile { + imageIDMappings = `"capabilityFlavors":[{"regions":[{"name":"image-region-1","id":"id-img-reg-1"}]}]` + namespacedImageIDMappings = `{"name":"image-1","versions":[{"version":"1.1","capabilityFlavors":[{"regions":[{"name":"image-region-1","id":"id-img-reg-1"}]}]}]}, + {"name":"image-2","versions":[{"version":"2.0","capabilityFlavors":[{"regions":[{"name":"image-region-1","id":"id-img-reg-1"}]}]}]}` + } + cloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(fmt.Sprintf(`{ "apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", "kind":"CloudProfileConfig", -"machineImages":[{"name":"image-1","versions":[{"version":"1.0","image":"image-name-1","regions":[{"name":"image-region-1","id":"id-img-reg-1"}]}]}] -}`)} - namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ +"machineImages":[{"name":"image-1","versions":[{"version":"1.0","image":"image-name-1", %s}]}] +}`, imageIDMappings))} + + namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(fmt.Sprintf(`{ "apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", "kind":"CloudProfileConfig", -"machineImages":[ - {"name":"image-1","versions":[{"version":"1.1","image":"image-name-2","regions":[{"name":"image-region-2","id":"id-img-reg-2"}]}]}, - {"name":"image-2","versions":[{"version":"2.0","image":"image-name-3","regions":[{"name":"image-region-3","id":"id-img-reg-3"}]}]} -] -}`)} +"machineImages":[%s] +}`, namespacedImageIDMappings))} namespacedCloudProfile.Spec.MachineImages = []core.MachineImage{ { Name: "image-1", @@ -113,6 +131,119 @@ var _ = Describe("NamespacedCloudProfile Validator", func() { Expect(namespacedCloudProfileValidator.Validate(ctx, namespacedCloudProfile, nil)).To(Succeed()) }) + It("should succeed with old-format regions in a capabilities CloudProfile", func() { + if !isCapabilitiesCloudProfile { + Skip("mixed format tests only apply to capabilities CloudProfiles") + } + + // Parent uses new format + cloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig", +"machineImages":[{"name":"image-1","versions":[{"version":"1.0","capabilityFlavors":[{"regions":[{"name":"reg-1","id":"id-1"}]}]}]}] +}`)} + + // Namespaced provider config uses old format (regions with architecture) + namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig", +"machineImages":[{"name":"image-1","versions":[{"version":"1.1","regions":[{"name":"reg-1","id":"id-new","architecture":"amd64"}]}]}] +}`)} + namespacedCloudProfile.Spec.MachineImages = []core.MachineImage{ + { + Name: "image-1", + Versions: []core.MachineImageVersion{{ + ExpirableVersion: core.ExpirableVersion{Version: "1.1"}, + Architectures: []string{"amd64"}, + CapabilityFlavors: []core.MachineImageFlavor{{Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"amd64"}}}}, + }}, + }, + } + + Expect(fakeClient.Create(ctx, cloudProfile)).To(Succeed()) + Expect(namespacedCloudProfileValidator.Validate(ctx, namespacedCloudProfile, nil)).To(Succeed()) + }) + + It("should succeed with mixed format across versions within the same image", func() { + if !isCapabilitiesCloudProfile { + Skip("mixed format tests only apply to capabilities CloudProfiles") + } + + cloudProfile.Spec.MachineCapabilities[0].Values = []string{"amd64", "arm64"} + + cloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig" +}`)} + + // One version uses old format, another uses new format + namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig", +"machineImages":[{"name":"image-1","versions":[ + {"version":"1.0","regions":[{"name":"reg-1","id":"id-amd64","architecture":"amd64"},{"name":"reg-1","id":"id-arm64","architecture":"arm64"}]}, + {"version":"2.0","capabilityFlavors":[ + {"capabilities":{"architecture":["amd64"]},"regions":[{"name":"reg-1","id":"id-amd64-v2"}]}, + {"capabilities":{"architecture":["arm64"]},"regions":[{"name":"reg-1","id":"id-arm64-v2"}]} + ]} +]}] +}`)} + namespacedCloudProfile.Spec.MachineImages = []core.MachineImage{ + { + Name: "image-1", + Versions: []core.MachineImageVersion{ + { + ExpirableVersion: core.ExpirableVersion{Version: "1.0"}, + CapabilityFlavors: []core.MachineImageFlavor{ + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"amd64"}}}, + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"arm64"}}}, + }, + }, + { + ExpirableVersion: core.ExpirableVersion{Version: "2.0"}, + CapabilityFlavors: []core.MachineImageFlavor{ + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"amd64"}}}, + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"arm64"}}}, + }, + }, + }, + }, + } + + Expect(fakeClient.Create(ctx, cloudProfile)).To(Succeed()) + Expect(namespacedCloudProfileValidator.Validate(ctx, namespacedCloudProfile, nil)).To(Succeed()) + }) + + It("should succeed for expirationDate-only override of a parent version without providerConfig entry", func() { + cloudProfile.Spec.MachineImages = []v1beta1.MachineImage{ + {Name: "ubuntu", Versions: []v1beta1.MachineImageVersion{ + {ExpirableVersion: v1beta1.ExpirableVersion{Version: "22.04"}, Architectures: []string{"amd64"}}, + }}, + } + if isCapabilitiesCloudProfile { + cloudProfile.Spec.MachineImages[0].Versions[0].CapabilityFlavors = []v1beta1.MachineImageFlavor{ + {Capabilities: v1beta1.Capabilities{v1beta1constants.ArchitectureName: []string{"amd64"}}}, + } + } + + // NCP overrides only the expirationDate, no providerConfig entry for ubuntu + namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ +"apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", +"kind":"CloudProfileConfig" +}`)} + namespacedCloudProfile.Spec.MachineImages = []core.MachineImage{ + { + Name: "ubuntu", + Versions: []core.MachineImageVersion{ + {ExpirableVersion: core.ExpirableVersion{Version: "22.04", ExpirationDate: new(metav1.Now())}}, + }, + }, + } + + Expect(fakeClient.Create(ctx, cloudProfile)).To(Succeed()) + Expect(namespacedCloudProfileValidator.Validate(ctx, namespacedCloudProfile, nil)).To(Succeed()) + }) + It("should fail for NamespacedCloudProfile with invalid parent kind", func() { namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ "apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", @@ -127,18 +258,25 @@ var _ = Describe("NamespacedCloudProfile Validator", func() { }) It("should fail for NamespacedCloudProfile trying to override an already existing machine image version", func() { + + regionIDMappings := `"regions":[{"name":"image-region-1","id":"id-img-reg-1"}]` + + if isCapabilitiesCloudProfile { + regionIDMappings = `"capabilityFlavors":[{"regions":[{"name":"image-region-1","id":"id-img-reg-1"}]}]` + } + cloudProfile.Spec.MachineImages = []v1beta1.MachineImage{ {Name: "image-1", Versions: []v1beta1.MachineImageVersion{{ExpirableVersion: v1beta1.ExpirableVersion{Version: "1.0"}}}}, } cloudProfile.Spec.MachineTypes = []v1beta1.MachineType{{Name: "type-1"}} - namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ + namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(fmt.Sprintf(`{ "apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", "kind":"CloudProfileConfig", "machineImages":[ - {"name":"image-1","versions":[{"version":"1.0","image":"image-name-1","regions":[{"name":"image-region-1","id":"id-img-reg-1"}]}]} + {"name":"image-1","versions":[{"version":"1.0","image":"image-name-1", %s}]} ] -}`)} +}`, regionIDMappings))} namespacedCloudProfile.Spec.MachineImages = []core.MachineImage{ { Name: "image-1", @@ -159,13 +297,19 @@ var _ = Describe("NamespacedCloudProfile Validator", func() { }) It("should fail for NamespacedCloudProfile specifying provider config without the according version in the spec.machineImages", func() { - namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ + imageIDMappings := `"regions":[{"name":"image-region-1","id":"id-img-reg-1"}]` + if isCapabilitiesCloudProfile { + imageIDMappings = `"capabilityFlavors":[{"regions":[{"name":"image-region-1","id":"id-img-reg-1"}]}]` + } + + namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(fmt.Sprintf(`{ "apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", "kind":"CloudProfileConfig", "machineImages":[ - {"name":"image-1","versions":[{"version":"1.1","image":"image-name-1","regions":[{"name":"image-region-1","id":"id-img-reg-1"}]}]} + {"name":"image-1","versions":[{"version":"1.1","image":"image-name-1", %s}]} ] -}`)} +}`, imageIDMappings))} + namespacedCloudProfile.Spec.MachineImages = []core.MachineImage{ { Name: "image-1", @@ -215,57 +359,124 @@ var _ = Describe("NamespacedCloudProfile Validator", func() { }) It("should fail for NamespacedCloudProfile specifying new spec.machineImages without the according version and architecture entries in the provider config", func() { - namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(`{ + image1IDMappings := `"regions":[ +{"name":"image-region-1","id":"id-img-reg-1","architecture":"arm64"}, +{"name":"image-region-2","id":"id-img-reg-2","architecture":"amd64"} +]` + image1FallbackMappings := `"regions":[ {"name":"image-region-2","id":"id-img-reg-2"}]` + image1OldRegionsMappings := "" + if isCapabilitiesCloudProfile { + image1IDMappings = `"capabilityFlavors":[ +{"capabilities":{"architecture":["arm64"]},"regions":[{"name":"image-region-1","id":"id-img-reg-1"}]}, +{"capabilities":{"architecture":["amd64"]},"regions":[{"name":"image-region-2","id":"id-img-reg-2"}]} +]` + image1FallbackMappings = `"capabilityFlavors":[ +{"capabilities":{"architecture":["amd64"]},"regions":[{"name":"image-region-2","id":"id-img-reg-2"}]} +]` + // Old-format regions: has excess amd64 and is missing required arm64 + image1OldRegionsMappings = `, + {"version":"1.1-old-regions","regions":[{"name":"image-region-1","id":"id-old-amd64","architecture":"amd64"}]}` + cloudProfile.Spec.MachineCapabilities[0].Values = []string{"amd64", "arm64"} + } + namespacedCloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: []byte(fmt.Sprintf(`{ "apiVersion":"stackit.provider.extensions.gardener.cloud/v1alpha1", "kind":"CloudProfileConfig", "machineImages":[ {"name":"image-1","versions":[ - {"version":"1.1-regions","image":"image-regions-1","regions":[ - {"name":"image-region-1","id":"id-img-reg-1","architecture":"arm64"}, - {"name":"image-region-2","id":"id-img-reg-2","architecture":"amd64"} - ]}, - {"version":"1.1-fallback","image":"image-fallback-1","regions":[ - {"name":"image-region-2","id":"id-img-reg-2"} - ]}, - {"version":"1.1-missing","image":"image-missing-1","regions":[]} + {"version":"1.1-regions",%s}, + {"version":"1.1-fallback",%s}%s ]} -] -}`)} +]}`, image1IDMappings, image1FallbackMappings, image1OldRegionsMappings))} + + oldRegionsVersions := []core.MachineImageVersion{} + if isCapabilitiesCloudProfile { + // Old-format regions version: spec requires both amd64 and arm64, but provider only has amd64 + oldRegionsVersions = append(oldRegionsVersions, core.MachineImageVersion{ + ExpirableVersion: core.ExpirableVersion{Version: "1.1-old-regions"}, + CapabilityFlavors: []core.MachineImageFlavor{ + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"amd64"}}}, + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"arm64"}}}, + }, + }) + } + namespacedCloudProfile.Spec.MachineImages = []core.MachineImage{ { Name: "image-1", - Versions: []core.MachineImageVersion{ - {ExpirableVersion: core.ExpirableVersion{Version: "1.1-regions"}, Architectures: []string{"amd64", "arm64"}}, - {ExpirableVersion: core.ExpirableVersion{Version: "1.1-fallback"}, Architectures: []string{"arm64"}}, - {ExpirableVersion: core.ExpirableVersion{Version: "1.1-missing"}, Architectures: []string{"arm64"}}, - }, + Versions: append([]core.MachineImageVersion{ + {ExpirableVersion: core.ExpirableVersion{Version: "1.1-regions"}, Architectures: []string{"amd64", "arm64"}, + CapabilityFlavors: []core.MachineImageFlavor{ + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"amd64"}}}, + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"arm64"}}}, + }}, + {ExpirableVersion: core.ExpirableVersion{Version: "1.1-fallback"}, Architectures: []string{"arm64"}, + CapabilityFlavors: []core.MachineImageFlavor{ + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"arm64"}}}, + }}, + {ExpirableVersion: core.ExpirableVersion{Version: "1.1-missing"}, Architectures: []string{"arm64"}, + CapabilityFlavors: []core.MachineImageFlavor{ + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"arm64"}}}, + }}, + }, oldRegionsVersions...), }, } - Expect(fakeClient.Create(ctx, cloudProfile)).To(Succeed()) err := namespacedCloudProfileValidator.Validate(ctx, namespacedCloudProfile, nil) - Expect(err).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(field.ErrorTypeRequired), - "Field": Equal("spec.providerConfig.machineImages"), - "Detail": Equal("machine image version image-1@1.1-regions for region \"image-region-1\" with architecture \"amd64\" is not defined in the NamespacedCloudProfile providerConfig"), - })), PointTo(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(field.ErrorTypeRequired), - "Field": Equal("spec.providerConfig.machineImages"), - "Detail": Equal("machine image version image-1@1.1-regions for region \"image-region-2\" with architecture \"arm64\" is not defined in the NamespacedCloudProfile providerConfig"), - })), PointTo(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(field.ErrorTypeForbidden), - "Field": Equal("spec.providerConfig.machineImages"), - "Detail": Equal("machine image version image-1@1.1-fallback in region \"image-region-2\" has an excess entry for architecture \"amd64\", which is not defined in the machineImages spec"), - })), PointTo(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(field.ErrorTypeRequired), - "Field": Equal("spec.providerConfig.machineImages"), - "Detail": Equal("machine image version image-1@1.1-fallback for region \"image-region-2\" with architecture \"arm64\" is not defined in the NamespacedCloudProfile providerConfig"), - })), PointTo(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(field.ErrorTypeRequired), - "Field": Equal("spec.providerConfig.machineImages"), - "Detail": Equal("machine image version image-1@1.1-missing with architecture \"arm64\" is not defined in the NamespacedCloudProfile providerConfig"), - })))) + + fieldMatcher := Equal("spec.providerConfig.machineImages") + + if isCapabilitiesCloudProfile { + Expect(err).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeRequired), + "Field": fieldMatcher, + "Detail": Equal("machine image version image-1@1.1-regions is missing region \"image-region-1\" in capabilityFlavor map[architecture:[amd64]] in the NamespacedCloudProfile providerConfig"), + })), PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeRequired), + "Field": fieldMatcher, + "Detail": Equal("machine image version image-1@1.1-regions is missing region \"image-region-2\" in capabilityFlavor map[architecture:[arm64]] in the NamespacedCloudProfile providerConfig"), + })), PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeForbidden), + "Field": fieldMatcher, + "Detail": Equal("machine image version image-1@1.1-fallback has an excess capabilityFlavor map[architecture:[amd64]], which is not defined in the machineImages spec"), + })), PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeRequired), + "Field": fieldMatcher, + "Detail": Equal("machine image version image-1@1.1-fallback has a capabilityFlavor map[architecture:[arm64]] not defined in the NamespacedCloudProfile providerConfig"), + })), PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeRequired), + "Field": fieldMatcher, + "Detail": Equal("machine image version image-1@1.1-missing is not defined in the NamespacedCloudProfile providerConfig"), + })), + // Old-format regions: missing arm64 architecture + PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeRequired), + "Field": fieldMatcher, + "Detail": Equal("machine image version image-1@1.1-old-regions has a capabilityFlavor map[architecture:[arm64]] not defined in the NamespacedCloudProfile providerConfig"), + })))) + } else { + Expect(err).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeRequired), + "Field": fieldMatcher, + "Detail": Equal("machine image version image-1@1.1-regions for region \"image-region-1\" with architecture \"amd64\" is not defined in the NamespacedCloudProfile providerConfig"), + })), PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeRequired), + "Field": fieldMatcher, + "Detail": Equal("machine image version image-1@1.1-regions for region \"image-region-2\" with architecture \"arm64\" is not defined in the NamespacedCloudProfile providerConfig"), + })), PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeForbidden), + "Field": fieldMatcher, + "Detail": Equal("machine image version image-1@1.1-fallback in region \"image-region-2\" has an excess entry for architecture \"amd64\", which is not defined in the machineImages spec"), + })), PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeRequired), + "Field": fieldMatcher, + "Detail": Equal("machine image version image-1@1.1-fallback for region \"image-region-2\" with architecture \"arm64\" is not defined in the NamespacedCloudProfile providerConfig"), + })), PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeRequired), + "Field": fieldMatcher, + "Detail": Equal("machine image version image-1@1.1-missing is not defined in the NamespacedCloudProfile providerConfig"), + })))) + } }) It("should fail for NamespacedCloudProfile specifying an invalid field in the provider config", func() { @@ -281,7 +492,7 @@ var _ = Describe("NamespacedCloudProfile Validator", func() { Expect(err).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ "Type": Equal(field.ErrorTypeForbidden), "Field": Equal("spec.providerConfig"), - "Detail": Equal("must only set machineImages and stackitAPIEndpoints"), + "Detail": Equal("must only set machineImages or apiEndpoints"), })))) }) @@ -318,4 +529,7 @@ var _ = Describe("NamespacedCloudProfile Validator", func() { Expect(namespacedCloudProfileValidator.Validate(ctx, namespacedCloudProfile, nil)).To(Succeed()) }) }) -}) +}, + Entry("CloudProfile uses regions only", false), + Entry("CloudProfile uses capabilities", true), +) diff --git a/pkg/apis/stackit/helper/helper.go b/pkg/apis/stackit/helper/helper.go index 3d84a46f..215ed94f 100644 --- a/pkg/apis/stackit/helper/helper.go +++ b/pkg/apis/stackit/helper/helper.go @@ -7,6 +7,9 @@ package helper import ( "fmt" + "github.com/gardener/gardener/extensions/pkg/controller/worker" + gardencorev1beta1helper "github.com/gardener/gardener/pkg/api/core/v1beta1/helper" + gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" v1beta1constants "github.com/gardener/gardener/pkg/apis/core/v1beta1/constants" "k8s.io/utils/ptr" @@ -38,63 +41,215 @@ func FindSecurityGroupByPurpose(securityGroups []stackitv1alpha1.SecurityGroup, return nil, fmt.Errorf("cannot find security group with purpose %q", purpose) } -// FindMachineImage takes a list of machine images and tries to find the first entry -// whose name, version, and zone matches with the given name, version, and cloud profile. If no such -// entry is found then an error will be returned. -func FindMachineImage(machineImages []stackitv1alpha1.MachineImage, name, version, architecture string) (*stackitv1alpha1.MachineImage, error) { +// FindImageInCloudProfile takes a list of machine images and tries to find the first entry whose name, version and capabilities +// matches with the machineTypeCapabilities. If no such entry is found then an error will be returned. +// Note: capabilityDefinitions and machineTypeCapabilities are expected to be normalized +// by the caller using NormalizeCapabilityDefinitions() and NormalizeMachineTypeCapabilities() +func FindImageInCloudProfile( + cloudProfileConfig *stackitv1alpha1.CloudProfileConfig, + name, version, region string, + machineCapabilities gardencorev1beta1.Capabilities, + capabilityDefinitions []gardencorev1beta1.CapabilityDefinition, +) (*stackitv1alpha1.MachineImageFlavor, error) { + if cloudProfileConfig == nil { + return nil, fmt.Errorf("cloud profile config is nil") + } + if len(capabilityDefinitions) == 0 { + return nil, fmt.Errorf("capabilityDefinitions must not be empty, use NormalizeCapabilityDefinitions() to ensure defaults") + } + machineImages := cloudProfileConfig.MachineImages + for _, machineImage := range machineImages { - // If the architecture field is not present, ignore it for backwards-compatibility. - if machineImage.Name == name && machineImage.Version == version && - (machineImage.Architecture == nil || *machineImage.Architecture == architecture) { - return &machineImage, nil + if machineImage.Name != name { + continue + } + + // Collect all versions with matching version string (mixed format support) + var matchingVersions []stackitv1alpha1.MachineImageVersion + for _, v := range machineImage.Versions { + if version == v.Version { + matchingVersions = append(matchingVersions, v) + } + } + + if len(matchingVersions) == 0 { + continue + } + + // Convert old format (regions with architecture) versions to capability flavors if required + // as there may be multiple version entries for the same version with different architectures + // the normalization for capability flavors is done here instead of the caller to keep the caller code simpler + capabilityFlavors := convertLegacyVersionsToCapabilityFlavors(matchingVersions) + + // Filter capability flavors by region + filteredCapabilityFlavors := filterCapabilityFlavorsByRegion(capabilityFlavors, region) + + if len(filteredCapabilityFlavors) > 0 { + bestMatch, err := worker.FindBestImageFlavor(filteredCapabilityFlavors, machineCapabilities, capabilityDefinitions) + if err != nil { + return nil, fmt.Errorf("could not determine best flavor: %w", err) + } + return bestMatch, nil + } + } + return nil, fmt.Errorf("could not find an image for region %q, image %q, version %q that supports %v", region, name, version, machineCapabilities) +} + +// convertLegacyVersionsToCapabilityFlavors converts old format (regions with architecture) versions +// to capability flavors for mixed format support. +func convertLegacyVersionsToCapabilityFlavors(versions []stackitv1alpha1.MachineImageVersion) []stackitv1alpha1.MachineImageFlavor { + var capabilityFlavors []stackitv1alpha1.MachineImageFlavor + for _, version := range versions { + switch { + case len(version.CapabilityFlavors) > 0: + // New format: use capability flavors directly + capabilityFlavors = append(capabilityFlavors, version.CapabilityFlavors...) + + case len(version.Regions) > 0: + // Old format: regions with architecture - convert to capability flavors + capabilityFlavors = append(capabilityFlavors, convertRegionsToCapabilityFlavors(version.Regions, version.Image)...) + + case version.Image != "": + // Legacy format: only global image name, no regions - synthesize an amd64 capability flavor + capabilityFlavors = append(capabilityFlavors, stackitv1alpha1.MachineImageFlavor{ + Capabilities: gardencorev1beta1.Capabilities{ + v1beta1constants.ArchitectureName: []string{v1beta1constants.ArchitectureAMD64}, + }, + Image: version.Image, + }) + } + } + return capabilityFlavors +} + +// convertRegionsToCapabilityFlavors converts old format (regions with architecture) to capability flavors. +// Groups regions by architecture, preserves the Image field, and strips Architecture from RegionIDMapping. +func convertRegionsToCapabilityFlavors(regions []stackitv1alpha1.RegionIDMapping, image string) []stackitv1alpha1.MachineImageFlavor { + // Group regions by architecture + architectureRegions := make(map[string][]stackitv1alpha1.RegionIDMapping) + for _, region := range regions { + arch := ptr.Deref(region.Architecture, v1beta1constants.ArchitectureAMD64) + // Remove architecture field from region mapping when converting to capability flavors + // as architecture is now expressed through the Capabilities field + regionWithoutArch := stackitv1alpha1.RegionIDMapping{ + Name: region.Name, + ID: region.ID, } + architectureRegions[arch] = append(architectureRegions[arch], regionWithoutArch) + } + + // Create a capability flavor for each architecture + capabilityFlavors := make([]stackitv1alpha1.MachineImageFlavor, 0, len(architectureRegions)) + for arch, regionMappings := range architectureRegions { + capabilityFlavors = append(capabilityFlavors, stackitv1alpha1.MachineImageFlavor{ + Capabilities: gardencorev1beta1.Capabilities{ + v1beta1constants.ArchitectureName: []string{arch}, + }, + Regions: regionMappings, + Image: image, + }) } - return nil, fmt.Errorf("no machine image with name %q, version %q found", name, version) + + return capabilityFlavors } -// FindImageFromCloudProfile takes a list of machine images, and the desired image name and version. It tries -// to find the image with the given name and version in the desired cloud profile. If it cannot be found then an error -// is returned. -func FindImageFromCloudProfile(cloudProfileConfig *stackitv1alpha1.CloudProfileConfig, imageName, imageVersion, regionName, architecture string) (*stackitv1alpha1.MachineImage, error) { - if cloudProfileConfig != nil { - for _, machineImage := range cloudProfileConfig.MachineImages { - if machineImage.Name != imageName { - continue +// FindImageInWorkerStatus takes a list of machine images from the worker status and tries to find the first entry whose name, version, architecture +// capabilities and zone matches with the machineTypeCapabilities. If no such entry is found then an error will be returned. +// The worker status is an external source that may contain images in either legacy format (Architecture field) +// or capability format (Capabilities field). The capabilityDefinitions parameter should be the original +// (non-normalized) spec.MachineCapabilities from the CloudProfile to distinguish the two cases. +// An empty architecture is treated as the default architecture (amd64). +func FindImageInWorkerStatus(machineImages []stackitv1alpha1.MachineImage, name string, version string, architecture string, machineCapabilities gardencorev1beta1.Capabilities, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition) (*stackitv1alpha1.MachineImage, error) { + if architecture == "" { + architecture = v1beta1constants.ArchitectureAMD64 + } + if len(capabilityDefinitions) == 0 { + for _, statusMachineImage := range machineImages { + if statusMachineImage.Name == name && statusMachineImage.Version == version && architecture == ptr.Deref(statusMachineImage.Architecture, v1beta1constants.ArchitectureAMD64) { + return &statusMachineImage, nil } - for _, version := range machineImage.Versions { - if imageVersion != version.Version { - continue - } - for _, region := range version.Regions { - if regionName == region.Name && architecture == ptr.Deref(region.Architecture, v1beta1constants.ArchitectureAMD64) { - return &stackitv1alpha1.MachineImage{ - Name: imageName, - Version: imageVersion, - Architecture: &architecture, - ID: region.ID, - }, nil - } - } - - // if we haven't found a region mapping, fallback to the image name - if version.Image != "" && architecture == v1beta1constants.ArchitectureAMD64 { - // The fallback image name doesn't specify an architecture, but we assume it is amd64 as arm was not supported - // previously. - // Referencing images by name is error-prone and is highly discouraged anyways. - // If people want to use arm images in their CloudProfile, they need to specify a region mapping and can't - // use the fallback MachineImage by name. - return &stackitv1alpha1.MachineImage{ - Name: imageName, - Version: imageVersion, - Architecture: new(v1beta1constants.ArchitectureAMD64), - Image: version.Image, - }, nil - } + } + return nil, fmt.Errorf("no machine image found for image %q with version %q and architecture %q", name, version, architecture) + } + + // Capability format: find the best matching capability set. + for _, statusMachineImage := range machineImages { + if statusMachineImage.Name == name && statusMachineImage.Version == version && gardencorev1beta1helper.AreCapabilitiesCompatible(statusMachineImage.Capabilities, machineCapabilities, capabilityDefinitions) { + return &statusMachineImage, nil + } + } + return nil, fmt.Errorf("no machine image found for image %q with version %q and capabilities %v", name, version, machineCapabilities) +} + +// filterCapabilityFlavorsByRegion returns a new list with capabilityFlavors that only contain RegionIDMappings +// of the region to filter for. Flavors with a global Image name but no matching region mapping are included +// without region details (they fall back to the global image name). +func filterCapabilityFlavorsByRegion(capabilityFlavors []stackitv1alpha1.MachineImageFlavor, regionName string) []*stackitv1alpha1.MachineImageFlavor { + var compatibleFlavors []*stackitv1alpha1.MachineImageFlavor + + for _, capabilityFlavor := range capabilityFlavors { + var regionIDMapping *stackitv1alpha1.RegionIDMapping + for _, region := range capabilityFlavor.Regions { + if region.Name == regionName { + regionIDMapping = ®ion } } + if regionIDMapping != nil { + compatibleFlavors = append(compatibleFlavors, &stackitv1alpha1.MachineImageFlavor{ + Regions: []stackitv1alpha1.RegionIDMapping{*regionIDMapping}, + Image: capabilityFlavor.Image, + Capabilities: capabilityFlavor.Capabilities, + }) + } else if capabilityFlavor.Image != "" { + compatibleFlavors = append(compatibleFlavors, &stackitv1alpha1.MachineImageFlavor{ + Image: capabilityFlavor.Image, + Capabilities: capabilityFlavor.Capabilities, + }) + } + } + return compatibleFlavors +} + +// NormalizeCapabilityDefinitions ensures that capability definitions always include at least +// the architecture capability. This allows all downstream code to assume capabilities are always present, +// eliminating the need for conditional logic based on whether capabilities are defined. +func NormalizeCapabilityDefinitions(capabilityDefinitions []gardencorev1beta1.CapabilityDefinition) []gardencorev1beta1.CapabilityDefinition { + if len(capabilityDefinitions) > 0 { + return capabilityDefinitions + } + return []gardencorev1beta1.CapabilityDefinition{{ + Name: v1beta1constants.ArchitectureName, + Values: []string{v1beta1constants.ArchitectureAMD64, v1beta1constants.ArchitectureARM64}, + }} +} + +// NormalizeMachineTypeCapabilities ensures that machine type capabilities include the architecture +// capability. This transforms the legacy architecture-based selection into capability-based selection. +// The architecture is determined in the following priority order: +// 1. If capabilities already has architecture, use it as-is +// 2. If capabilityDefinitions has exactly one architecture value, use that value +// 3. Otherwise, use workerArchitecture (defaulting to amd64) +func NormalizeMachineTypeCapabilities(capabilities gardencorev1beta1.Capabilities, workerArchitecture *string, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition) gardencorev1beta1.Capabilities { + if capabilities == nil { + capabilities = make(gardencorev1beta1.Capabilities) + } + // If architecture capability is already present, return as-is + if _, hasArch := capabilities[v1beta1constants.ArchitectureName]; hasArch { + return capabilities + } + + // Check if capabilityDefinitions has exactly one architecture value + for _, def := range capabilityDefinitions { + if def.Name == v1beta1constants.ArchitectureName && len(def.Values) == 1 { + capabilities[v1beta1constants.ArchitectureName] = []string{def.Values[0]} + return capabilities + } } - return nil, fmt.Errorf("could not find an image for name %q in version %q for region %q", imageName, imageVersion, regionName) + // Fall back to workerArchitecture or default + arch := ptr.Deref(workerArchitecture, v1beta1constants.ArchitectureAMD64) + capabilities[v1beta1constants.ArchitectureName] = []string{arch} + return capabilities } // FindKeyStoneURL takes a list of keystone URLs and tries to find the first entry diff --git a/pkg/apis/stackit/helper/helper_test.go b/pkg/apis/stackit/helper/helper_test.go index 047cc5e2..3ab0b96b 100644 --- a/pkg/apis/stackit/helper/helper_test.go +++ b/pkg/apis/stackit/helper/helper_test.go @@ -5,8 +5,10 @@ package helper_test import ( + gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "k8s.io/utils/ptr" . "github.com/stackitcloud/gardener-extension-provider-stackit/v2/pkg/apis/stackit/helper" stackitv1alpha1 "github.com/stackitcloud/gardener-extension-provider-stackit/v2/pkg/apis/stackit/v1alpha1" @@ -42,75 +44,17 @@ var _ = Describe("Helper", func() { Entry("entry exists", []stackitv1alpha1.SecurityGroup{{Name: "bar", Purpose: purpose}}, purpose, &stackitv1alpha1.SecurityGroup{Name: "bar", Purpose: purpose}, false), ) - DescribeTable("#FindMachineImage", - func(machineImages []stackitv1alpha1.MachineImage, name, version, architecture string, expectedMachineImage *stackitv1alpha1.MachineImage, expectErr bool) { - machineImage, err := FindMachineImage(machineImages, name, version, architecture) - expectResults(machineImage, expectedMachineImage, err, expectErr) - }, - - Entry("list is nil", - nil, - "foo", "1.2.3", "", - nil, true, - ), - Entry("empty list", - []stackitv1alpha1.MachineImage{}, - "foo", "1.2.3", "", - nil, true, - ), - Entry("entry not found (name mismatch)", - []stackitv1alpha1.MachineImage{{Name: "bar", Version: "1.2.3"}}, - "foo", "1.2.3", "", - nil, true, - ), - Entry("entry not found (version mismatch)", - []stackitv1alpha1.MachineImage{{Name: "bar", Version: "1.2.3"}}, - "foo", "1.2.3", "", - nil, true, - ), - Entry("entry not found (architecture mismatch)", - []stackitv1alpha1.MachineImage{{Name: "bar", Version: "1.2.3", Architecture: new("amd64")}}, - "bar", "1.2.3", "arm64", - nil, true, - ), - Entry("entry exists (architecture is ignored, amd64)", - []stackitv1alpha1.MachineImage{{Name: "bar", Version: "1.2.3"}}, - "bar", "1.2.3", "amd64", - &stackitv1alpha1.MachineImage{Name: "bar", Version: "1.2.3"}, false, - ), - Entry("entry exists (architecture is ignored, arm64)", - []stackitv1alpha1.MachineImage{{Name: "bar", Version: "1.2.3"}}, - "bar", "1.2.3", "arm64", - &stackitv1alpha1.MachineImage{Name: "bar", Version: "1.2.3"}, false, - ), - Entry("entry exists (architecture amd64)", - []stackitv1alpha1.MachineImage{{Name: "bar", Version: "1.2.3", Architecture: new("amd64")}}, - "bar", "1.2.3", "amd64", - &stackitv1alpha1.MachineImage{Name: "bar", Version: "1.2.3", Architecture: new("amd64")}, false, - ), - Entry("entry exists (architecture arm64)", - []stackitv1alpha1.MachineImage{{Name: "bar", Version: "1.2.3", Architecture: new("arm64")}}, - "bar", "1.2.3", "arm64", - &stackitv1alpha1.MachineImage{Name: "bar", Version: "1.2.3", Architecture: new("arm64")}, false, - ), - Entry("entry exists (multiple architectures)", - []stackitv1alpha1.MachineImage{ - {Name: "bar", Version: "1.2.3", ID: "amd", Architecture: new("amd64")}, - {Name: "bar", Version: "1.2.3", ID: "arm", Architecture: new("arm64")}, - }, - "bar", "1.2.3", "amd64", - &stackitv1alpha1.MachineImage{Name: "bar", Version: "1.2.3", ID: "amd", Architecture: new("amd64")}, false, - ), - ) - regionName := "eu-de-1" - Describe("#FindImageForCloudProfile", func() { + Describe("#FindImageInCloudProfile (legacy format)", func() { var ( - cfg *stackitv1alpha1.CloudProfileConfig + cfg *stackitv1alpha1.CloudProfileConfig + capabilityDefinitions []gardencorev1beta1.CapabilityDefinition ) BeforeEach(func() { + capabilityDefinitions = NormalizeCapabilityDefinitions(nil) + cfg = &stackitv1alpha1.CloudProfileConfig{ MachineImages: []stackitv1alpha1.MachineImages{ { @@ -151,106 +95,154 @@ var _ = Describe("Helper", func() { } }) - Context("no image found", func() { - It("should not find image in nil list", func() { - cfg.MachineImages = nil - - image, err := FindImageFromCloudProfile(cfg, "flatcar", "1.0", "eu01", "amd64") - Expect(image).To(BeNil()) - Expect(err).To(MatchError(ContainSubstring("could not find an image"))) - }) - - It("should not find image in empty list", func() { - cfg.MachineImages = []stackitv1alpha1.MachineImages{} - - image, err := FindImageFromCloudProfile(cfg, "flatcar", "1.0", "eu01", "amd64") - Expect(image).To(BeNil()) - Expect(err).To(MatchError(ContainSubstring("could not find an image"))) - }) - - It("should not find image for wrong image name", func() { - image, err := FindImageFromCloudProfile(cfg, "gardenlinux", "1.0", "eu01", "amd64") - Expect(image).To(BeNil()) - Expect(err).To(MatchError(ContainSubstring("could not find an image"))) - }) - - It("should not find image for wrong version", func() { - image, err := FindImageFromCloudProfile(cfg, "flatcar", "1.1", "eu01", "amd64") - Expect(image).To(BeNil()) - Expect(err).To(MatchError(ContainSubstring("could not find an image"))) - }) - - }) - - Context("without region mapping", func() { - It("should fallback to image name (amd64)", func() { - image, err := FindImageFromCloudProfile(cfg, "flatcar", "1.0", "eu01", "amd64") + Context("without region mapping (global image name only)", func() { + It("should find image for amd64 via global image name", func() { + caps := gardencorev1beta1.Capabilities{"architecture": []string{"amd64"}} + flavor, err := FindImageInCloudProfile(cfg, "flatcar", "1.0", "eu01", caps, capabilityDefinitions) Expect(err).NotTo(HaveOccurred()) - Expect(image).To(Equal(&stackitv1alpha1.MachineImage{ - Name: "flatcar", - Version: "1.0", - Image: "flatcar_1.0", - Architecture: new("amd64"), - })) + Expect(flavor.Image).To(Equal("flatcar_1.0")) + Expect(flavor.Capabilities).To(Equal(gardencorev1beta1.Capabilities{"architecture": []string{"amd64"}})) }) - It("should not fallback to image name (not amd64)", func() { - image, err := FindImageFromCloudProfile(cfg, "flatcar", "1.0", "eu01", "arm64") - Expect(image).To(BeNil()) - Expect(err).To(MatchError(ContainSubstring("could not find an image"))) + It("should not find image for arm64 (only amd64 default available)", func() { + caps := gardencorev1beta1.Capabilities{"architecture": []string{"arm64"}} + _, err := FindImageInCloudProfile(cfg, "flatcar", "1.0", "eu01", caps, capabilityDefinitions) + Expect(err).To(HaveOccurred()) }) }) Context("with region mapping, without architectures", func() { - It("should fallback to image name if region is not mapped", func() { - image, err := FindImageFromCloudProfile(cfg, "flatcar", "2.0", "eu02", "amd64") + It("should use the region ID for mapped region", func() { + caps := gardencorev1beta1.Capabilities{"architecture": []string{"amd64"}} + flavor, err := FindImageInCloudProfile(cfg, "flatcar", "2.0", "eu01", caps, capabilityDefinitions) Expect(err).NotTo(HaveOccurred()) - Expect(image).To(Equal(&stackitv1alpha1.MachineImage{ - Name: "flatcar", - Version: "2.0", - Image: "flatcar_2.0", - Architecture: new("amd64"), - })) + Expect(flavor.Regions[0].ID).To(Equal("flatcar_eu01_2.0")) + Expect(flavor.Image).To(Equal("flatcar_2.0")) }) - It("should use the correct mapping (without architecture)", func() { - image, err := FindImageFromCloudProfile(cfg, "flatcar", "2.0", "eu01", "amd64") + It("should fallback to global image name for unmapped region", func() { + caps := gardencorev1beta1.Capabilities{"architecture": []string{"amd64"}} + flavor, err := FindImageInCloudProfile(cfg, "flatcar", "2.0", "eu02", caps, capabilityDefinitions) Expect(err).NotTo(HaveOccurred()) - Expect(image).To(Equal(&stackitv1alpha1.MachineImage{ - Name: "flatcar", - Version: "2.0", - ID: "flatcar_eu01_2.0", - Architecture: new("amd64"), - })) + Expect(flavor.Regions).To(BeEmpty()) + Expect(flavor.Image).To(Equal("flatcar_2.0")) }) - It("should not find image because of non-amd64 architecture", func() { - image, err := FindImageFromCloudProfile(cfg, "flatcar", "2.0", "eu01", "arm64") - Expect(image).To(BeNil()) - Expect(err).To(MatchError(ContainSubstring("could not find an image"))) + It("should not find image for non-amd64 architecture", func() { + caps := gardencorev1beta1.Capabilities{"architecture": []string{"arm64"}} + _, err := FindImageInCloudProfile(cfg, "flatcar", "2.0", "eu01", caps, capabilityDefinitions) + Expect(err).To(HaveOccurred()) }) }) Context("with region mapping and architectures", func() { It("should not find image if architecture is not mapped", func() { - image, err := FindImageFromCloudProfile(cfg, "flatcar", "3.0", "eu01", "ppc64") - Expect(image).To(BeNil()) - Expect(err).To(MatchError(ContainSubstring("could not find an image"))) + caps := gardencorev1beta1.Capabilities{"architecture": []string{"ppc64"}} + _, err := FindImageInCloudProfile(cfg, "flatcar", "3.0", "eu01", caps, capabilityDefinitions) + Expect(err).To(HaveOccurred()) + }) + + It("should pick the correctly mapped architecture (arm64)", func() { + caps := gardencorev1beta1.Capabilities{"architecture": []string{"arm64"}} + flavor, err := FindImageInCloudProfile(cfg, "flatcar", "3.0", "eu01", caps, capabilityDefinitions) + Expect(err).NotTo(HaveOccurred()) + Expect(flavor.Regions[0].ID).To(Equal("flatcar_eu01_3.0_arm64")) + Expect(flavor.Capabilities).To(Equal(gardencorev1beta1.Capabilities{"architecture": []string{"arm64"}})) }) - It("should pick the correctly mapped architecture", func() { - image, err := FindImageFromCloudProfile(cfg, "flatcar", "3.0", "eu01", "arm64") + It("should pick the correctly mapped architecture (amd64)", func() { + caps := gardencorev1beta1.Capabilities{"architecture": []string{"amd64"}} + flavor, err := FindImageInCloudProfile(cfg, "flatcar", "3.0", "eu01", caps, capabilityDefinitions) Expect(err).NotTo(HaveOccurred()) - Expect(image).To(Equal(&stackitv1alpha1.MachineImage{ - Name: "flatcar", - Version: "3.0", - ID: "flatcar_eu01_3.0_arm64", - Architecture: new("arm64"), - })) + Expect(flavor.Regions[0].ID).To(Equal("flatcar_eu01_3.0_amd64")) + Expect(flavor.Capabilities).To(Equal(gardencorev1beta1.Capabilities{"architecture": []string{"amd64"}})) }) }) }) + DescribeTableSubtree("Select Worker Images", func(hasCapabilities bool) { + var capabilityDefinitions []gardencorev1beta1.CapabilityDefinition + var workerStatusCapabilityDefinitions []gardencorev1beta1.CapabilityDefinition + var machineTypeCapabilities gardencorev1beta1.Capabilities + var imageCapabilities gardencorev1beta1.Capabilities + region := "europe" + + if hasCapabilities { + capabilityDefinitions = []gardencorev1beta1.CapabilityDefinition{ + {Name: "architecture", Values: []string{"amd64", "arm64"}}, + {Name: "capability1", Values: []string{"value1", "value2", "value3"}}, + } + workerStatusCapabilityDefinitions = capabilityDefinitions + machineTypeCapabilities = gardencorev1beta1.Capabilities{ + "architecture": []string{"amd64"}, + "capability1": []string{"value2"}, + } + imageCapabilities = gardencorev1beta1.Capabilities{ + "architecture": []string{"amd64"}, + "capability1": []string{"value2"}, + } + } else { + // For FindImageInCloudProfile: normalized defaults (always non-empty) + capabilityDefinitions = NormalizeCapabilityDefinitions(nil) + // For FindImageInWorkerStatus: original (empty) spec.MachineCapabilities + // since the worker status is an external source that may be in legacy format + workerStatusCapabilityDefinitions = nil + machineTypeCapabilities = gardencorev1beta1.Capabilities{} + } + + DescribeTable("#FindImageInWorkerStatus", + func(machineImages []stackitv1alpha1.MachineImage, name, version string, arch string, expectedMachineImage *stackitv1alpha1.MachineImage, expectErr bool) { + if hasCapabilities { + machineTypeCapabilities["architecture"] = []string{arch} + if expectedMachineImage != nil { + expectedMachineImage.Capabilities = imageCapabilities + expectedMachineImage.Architecture = nil + } + } + machineImage, err := FindImageInWorkerStatus(machineImages, name, version, arch, machineTypeCapabilities, workerStatusCapabilityDefinitions) + expectResults(machineImage, expectedMachineImage, err, expectErr) + }, + Entry("list is nil", nil, "bar", "1.2.3", "amd64", nil, true), + Entry("empty list", []stackitv1alpha1.MachineImage{}, "image", "1.2.3", "amd64", nil, true), + Entry("entry not found (no name)", makeStatusMachineImages("bar", "1.2.3", "id-1234", new("amd64"), imageCapabilities), "foo", "1.2.3", "amd64", nil, true), + Entry("entry not found (no version)", makeStatusMachineImages("bar", "1.2.3", "id-1234", new("amd64"), imageCapabilities), "bar", "1.2.ś", "amd64", nil, true), + Entry("entry not found (no architecture)", []stackitv1alpha1.MachineImage{{Name: "bar", Version: "1.2.3", Architecture: new("arm64"), Capabilities: gardencorev1beta1.Capabilities{"architecture": []string{"arm64"}}}}, "bar", "1.2.3", "amd64", nil, true), + Entry("entry exists if architecture is nil", makeStatusMachineImages("bar", "1.2.3", "id-1234", nil, imageCapabilities), "bar", "1.2.3", "amd64", &stackitv1alpha1.MachineImage{Name: "bar", Version: "1.2.3", ID: "id-1234", Architecture: nil}, false), + Entry("entry exists", makeStatusMachineImages("bar", "1.2.3", "id-1234", new("amd64"), imageCapabilities), "bar", "1.2.3", "amd64", &stackitv1alpha1.MachineImage{Name: "bar", Version: "1.2.3", ID: "id-1234", Architecture: new("amd64")}, false), + ) + + DescribeTable("#FindImageInCloudProfile", + func(profileImages []stackitv1alpha1.MachineImages, imageName, version, regionName string, arch *string, expectedID string) { + machineTypeCapabilities["architecture"] = []string{ptr.Deref(arch, "amd64")} + cfg := &stackitv1alpha1.CloudProfileConfig{} + cfg.MachineImages = profileImages + + imageFlavor, err := FindImageInCloudProfile(cfg, imageName, version, regionName, machineTypeCapabilities, capabilityDefinitions) + + if expectedID != "" { + Expect(err).NotTo(HaveOccurred()) + Expect(imageFlavor.Regions[0].ID).To(Equal(expectedID)) + } else { + Expect(err).To(HaveOccurred()) + } + }, + + Entry("list is nil", nil, "ubuntu", "1", region, new("amd64"), ""), + + Entry("profile empty list", []stackitv1alpha1.MachineImages{}, "ubuntu", "1", region, new("amd64"), ""), + Entry("profile entry not found (image does not exist)", makeProfileMachineImages("debian", "1", region, "0", new("amd64"), imageCapabilities), "ubuntu", "1", region, new("amd64"), ""), + Entry("profile entry not found (version does not exist)", makeProfileMachineImages("ubuntu", "2", region, "0", new("amd64"), imageCapabilities), "ubuntu", "1", region, new("amd64"), ""), + Entry("profile entry not found (architecture does not exist)", makeProfileMachineImages("ubuntu", "1", region, "0", new("amd64"), imageCapabilities), "ubuntu", "1", region, new("arm64"), ""), + Entry("profile entry", makeProfileMachineImages("ubuntu", "1", region, "id-1234", new("amd64"), imageCapabilities), "ubuntu", "1", region, new("amd64"), "id-1234"), + Entry("profile entry (architecture not defined)", makeProfileMachineImages("ubuntu", "1", region, "id-1234", nil, imageCapabilities), "ubuntu", "1", region, new("amd64"), "id-1234"), + Entry("profile non matching region", makeProfileMachineImages("ubuntu", "1", region, "id-1234", new("amd64"), imageCapabilities), "ubuntu", "1", "china", new("amd64"), ""), + ) + + }, + Entry("without capabilities", false), + Entry("with capabilities", true), + ) + DescribeTable("#FindKeyStoneURL", func(keyStoneURLs []stackitv1alpha1.KeyStoneURL, keystoneURL, region, expectedKeyStoneURL string, expectErr bool) { result, err := FindKeyStoneURL(keyStoneURLs, keystoneURL, region) @@ -291,7 +283,61 @@ var _ = Describe("Helper", func() { ) }) +//nolint:unparam +func makeProfileMachineImages(name, version, region, id string, arch *string, capabilities gardencorev1beta1.Capabilities) []stackitv1alpha1.MachineImages { + versions := []stackitv1alpha1.MachineImageVersion{{ + Version: version, + }} + + if capabilities == nil { + versions[0].Regions = []stackitv1alpha1.RegionIDMapping{{ + Name: region, + ID: id, + Architecture: arch, + }} + } else { + versions[0].CapabilityFlavors = []stackitv1alpha1.MachineImageFlavor{{ + Capabilities: capabilities, + Regions: []stackitv1alpha1.RegionIDMapping{{ + Name: region, + ID: id, + }}, + }} + } + + return []stackitv1alpha1.MachineImages{ + { + Name: name, + Versions: versions, + }, + } +} + +//nolint:unparam +func makeStatusMachineImages(name, version, id string, arch *string, capabilities gardencorev1beta1.Capabilities) []stackitv1alpha1.MachineImage { + if capabilities != nil { + capabilities["architecture"] = []string{ptr.Deref(arch, "")} + return []stackitv1alpha1.MachineImage{ + { + Name: name, + Version: version, + ID: id, + Capabilities: capabilities, + }, + } + } + return []stackitv1alpha1.MachineImage{ + { + Name: name, + Version: version, + ID: id, + Architecture: arch, + }, + } +} + func expectResults(result, expected any, err error, expectErr bool) { + GinkgoHelper() if !expectErr { Expect(result).To(Equal(expected)) Expect(err).NotTo(HaveOccurred()) diff --git a/pkg/apis/stackit/v1alpha1/types_cloudprofile.go b/pkg/apis/stackit/v1alpha1/types_cloudprofile.go index c57a2e87..9173ce27 100644 --- a/pkg/apis/stackit/v1alpha1/types_cloudprofile.go +++ b/pkg/apis/stackit/v1alpha1/types_cloudprofile.go @@ -5,6 +5,7 @@ package v1alpha1 import ( + gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -165,6 +166,24 @@ type MachineImageVersion struct { Image string `json:"image,omitempty"` // Regions is an optional mapping to the correct Image ID for the machine image in the supported regions. Regions []RegionIDMapping `json:"regions,omitempty"` + // CapabilityFlavors is grouping of region AMIs by capabilities. + // +optional + CapabilityFlavors []MachineImageFlavor `json:"capabilityFlavors,omitempty"` +} + +// MachineImageFlavor groups all RegionAMIMappings for a specific set of capabilities. +type MachineImageFlavor struct { + // Regions is a mapping to the correct Image ID for the machine image in the supported regions. + Regions []RegionIDMapping `json:"regions,omitempty"` + // Image is the name of the image. + Image string `json:"image,omitempty"` + // Capabilities that are supported by the Image ID in this set. + Capabilities gardencorev1beta1.Capabilities `json:"capabilities,omitempty"` +} + +// GetCapabilities returns the Capabilities of a MachineImageFlavor +func (cs *MachineImageFlavor) GetCapabilities() gardencorev1beta1.Capabilities { + return cs.Capabilities } // RegionIDMapping is a mapping to the correct ID for the machine image in the given region. diff --git a/pkg/apis/stackit/v1alpha1/types_worker.go b/pkg/apis/stackit/v1alpha1/types_worker.go index e5f2e87b..0ee48fa7 100644 --- a/pkg/apis/stackit/v1alpha1/types_worker.go +++ b/pkg/apis/stackit/v1alpha1/types_worker.go @@ -5,6 +5,7 @@ package v1alpha1 import ( + gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" extensionsv1alpha1 "github.com/gardener/gardener/pkg/apis/extensions/v1alpha1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -42,6 +43,9 @@ type MachineImage struct { // Architecture is the CPU architecture of the machine image // +optional Architecture *string `json:"architecture,omitempty"` + // Capabilities of the machine image. + // +optional + Capabilities gardencorev1beta1.Capabilities `json:"capabilities,omitempty"` } // ServerGroupDependency is a reference to an external machine dependency of OpenStack server groups. diff --git a/pkg/apis/stackit/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/stackit/v1alpha1/zz_generated.deepcopy.go index 5dc114d5..585a10f9 100644 --- a/pkg/apis/stackit/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/stackit/v1alpha1/zz_generated.deepcopy.go @@ -8,6 +8,7 @@ package v1alpha1 import ( + v1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" extensionsv1alpha1 "github.com/gardener/gardener/pkg/apis/extensions/v1alpha1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" @@ -580,6 +581,21 @@ func (in *MachineImage) DeepCopyInto(out *MachineImage) { *out = new(string) **out = **in } + if in.Capabilities != nil { + in, out := &in.Capabilities, &out.Capabilities + *out = make(v1beta1.Capabilities, len(*in)) + for key, val := range *in { + var outVal []string + if val == nil { + (*out)[key] = nil + } else { + in, out := &val, &outVal + *out = make(v1beta1.CapabilityValues, len(*in)) + copy(*out, *in) + } + (*out)[key] = outVal + } + } return } @@ -593,6 +609,44 @@ func (in *MachineImage) DeepCopy() *MachineImage { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MachineImageFlavor) DeepCopyInto(out *MachineImageFlavor) { + *out = *in + if in.Regions != nil { + in, out := &in.Regions, &out.Regions + *out = make([]RegionIDMapping, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Capabilities != nil { + in, out := &in.Capabilities, &out.Capabilities + *out = make(v1beta1.Capabilities, len(*in)) + for key, val := range *in { + var outVal []string + if val == nil { + (*out)[key] = nil + } else { + in, out := &val, &outVal + *out = make(v1beta1.CapabilityValues, len(*in)) + copy(*out, *in) + } + (*out)[key] = outVal + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineImageFlavor. +func (in *MachineImageFlavor) DeepCopy() *MachineImageFlavor { + if in == nil { + return nil + } + out := new(MachineImageFlavor) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MachineImageVersion) DeepCopyInto(out *MachineImageVersion) { *out = *in @@ -603,6 +657,13 @@ func (in *MachineImageVersion) DeepCopyInto(out *MachineImageVersion) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.CapabilityFlavors != nil { + in, out := &in.CapabilityFlavors, &out.CapabilityFlavors + *out = make([]MachineImageFlavor, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } return } diff --git a/pkg/apis/stackit/validation/cloudprofile.go b/pkg/apis/stackit/validation/cloudprofile.go index 65a83351..e5306ab1 100644 --- a/pkg/apis/stackit/validation/cloudprofile.go +++ b/pkg/apis/stackit/validation/cloudprofile.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and Gardener contributors // // SPDX-License-Identifier: Apache-2.0 @@ -10,7 +10,10 @@ import ( "net" "slices" + gardencoreapi "github.com/gardener/gardener/pkg/api" + gardencorev1beta1helper "github.com/gardener/gardener/pkg/api/core/v1beta1/helper" "github.com/gardener/gardener/pkg/apis/core" + gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" v1beta1constants "github.com/gardener/gardener/pkg/apis/core/v1beta1/constants" "github.com/gardener/gardener/pkg/utils" "github.com/gardener/gardener/pkg/utils/gardener" @@ -22,7 +25,7 @@ import ( ) // ValidateCloudProfileConfig validates a CloudProfileConfig object. -func ValidateCloudProfileConfig(cloudProfile *stackitv1alpha1.CloudProfileConfig, machineImages []core.MachineImage, fldPath *field.Path) field.ErrorList { +func ValidateCloudProfileConfig(cloudProfile *stackitv1alpha1.CloudProfileConfig, machineImages []core.MachineImage, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition, fldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} floatingPoolPath := fldPath.Child("constraints", "floatingPools") @@ -64,9 +67,10 @@ func ValidateCloudProfileConfig(cloudProfile *stackitv1alpha1.CloudProfileConfig } for i, machineImage := range cloudProfile.MachineImages { idxPath := machineImagesPath.Index(i) - allErrs = append(allErrs, ValidateProviderMachineImage(idxPath, machineImage)...) + allErrs = append(allErrs, ValidateProviderMachineImage(machineImage, capabilityDefinitions, idxPath)...) } - allErrs = append(allErrs, validateMachineImageMapping(machineImages, cloudProfile, field.NewPath("spec").Child("machineImages"))...) + allErrs = append(allErrs, validateMachineImageMapping(machineImages, cloudProfile, capabilityDefinitions, field.NewPath("spec").Child("machineImages"))...) + //nolint:staticcheck // SA1019: needed for migration purposes if ca := cloudProfile.KeyStoneCACert; ca != nil && len(*ca) > 0 { _, err := utils.DecodeCertificate([]byte(*ca)) @@ -126,38 +130,96 @@ func ValidateCloudProfileConfig(cloudProfile *stackitv1alpha1.CloudProfileConfig } // ValidateProviderMachineImage validates a CloudProfileConfig MachineImages entry. -func ValidateProviderMachineImage(validationPath *field.Path, machineImage stackitv1alpha1.MachineImages) field.ErrorList { +func ValidateProviderMachineImage(providerImage stackitv1alpha1.MachineImages, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition, validationPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} - if len(machineImage.Name) == 0 { + if len(providerImage.Name) == 0 { allErrs = append(allErrs, field.Required(validationPath.Child("name"), "must provide a name")) } - if len(machineImage.Versions) == 0 { - allErrs = append(allErrs, field.Required(validationPath.Child("versions"), fmt.Sprintf("must provide at least one version for machine image %q", machineImage.Name))) + if len(providerImage.Versions) == 0 { + allErrs = append(allErrs, field.Required(validationPath.Child("versions"), fmt.Sprintf("must provide at least one version for machine image %q", providerImage.Name))) } - for j, version := range machineImage.Versions { + + // Validate each version + for j, version := range providerImage.Versions { jdxPath := validationPath.Child("versions").Index(j) + allErrs = append(allErrs, validateMachineImageVersion(capabilityDefinitions, version, jdxPath)...) + } - if len(version.Version) == 0 { - allErrs = append(allErrs, field.Required(jdxPath.Child("version"), "must provide a version")) + return allErrs +} + +// validates a specific machine image version +func validateMachineImageVersion(capabilityDefinitions []gardencorev1beta1.CapabilityDefinition, version stackitv1alpha1.MachineImageVersion, jdxPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + if len(version.Version) == 0 { + allErrs = append(allErrs, field.Required(jdxPath.Child("version"), "must provide a version")) + } + + if len(capabilityDefinitions) > 0 { + // Both regions and capabilityFlavors on the same version is forbidden + if len(version.Regions) > 0 && len(version.CapabilityFlavors) > 0 { + allErrs = append(allErrs, field.Forbidden(jdxPath, "must not set both regions and capabilityFlavors on the same version")) + return allErrs } + if len(version.CapabilityFlavors) > 0 { + allErrs = append(allErrs, validateCapabilityFlavors(version, capabilityDefinitions, jdxPath)...) + } else if len(version.Regions) > 0 { + // Old format with regions in a capabilities-enabled CloudProfile (mixed format) + allErrs = append(allErrs, validateRegions(version.Regions, false, jdxPath)...) + } + } else { + allErrs = append(allErrs, validateRegions(version.Regions, false, jdxPath)...) + if len(version.CapabilityFlavors) > 0 { + allErrs = append(allErrs, field.Forbidden(jdxPath.Child("capabilityFlavors"), "must not be set as CloudProfile does not define capabilities. Use regions instead.")) + } + } + return allErrs +} - for k, region := range version.Regions { - kdxPath := jdxPath.Child("regions").Index(k) +// validates the capability flavors of a machine image version. +func validateCapabilityFlavors(version stackitv1alpha1.MachineImageVersion, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition, jdxPath *field.Path) field.ErrorList { + //nolint:prealloc // size is not known yet + allErrs := field.ErrorList{} - if len(region.Name) == 0 { - allErrs = append(allErrs, field.Required(kdxPath.Child("name"), "must provide a name")) - } - if len(region.ID) == 0 { - allErrs = append(allErrs, field.Required(kdxPath.Child("id"), "must provide an image ID")) + // Validate each flavor's capabilities and regions + for k, capabilitySet := range version.CapabilityFlavors { + kdxPath := jdxPath.Child("capabilityFlavors").Index(k) + allErrs = append(allErrs, gardener.ValidateCapabilities(capabilitySet.Capabilities, capabilityDefinitions, kdxPath.Child("capabilities"))...) + allErrs = append(allErrs, validateRegions(capabilitySet.Regions, true, kdxPath)...) + } + return allErrs +} + +// validates the regions of a machine image version or capability flavor. +// When isCapabilityFlavor is true, Architecture field in regions is forbidden. +// When isCapabilityFlavor is false, Architecture field is validated against valid architectures. +func validateRegions(regions []stackitv1alpha1.RegionIDMapping, isCapabilityFlavor bool, jdxPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + for k, region := range regions { + kdxPath := jdxPath.Child("regions").Index(k) + arch := ptr.Deref(region.Architecture, v1beta1constants.ArchitectureAMD64) + + if len(region.Name) == 0 { + allErrs = append(allErrs, field.Required(kdxPath.Child("name"), "must provide a name")) + } + if len(region.ID) == 0 { + allErrs = append(allErrs, field.Required(kdxPath.Child("id"), "must provide an image ID")) + } + if !isCapabilityFlavor { + if !slices.Contains(v1beta1constants.ValidArchitectures, arch) { + allErrs = append(allErrs, field.NotSupported(kdxPath.Child("architecture"), arch, v1beta1constants.ValidArchitectures)) } - if !slices.Contains(v1beta1constants.ValidArchitectures, ptr.Deref(region.Architecture, v1beta1constants.ArchitectureAMD64)) { - allErrs = append(allErrs, field.NotSupported(kdxPath.Child("architecture"), *region.Architecture, v1beta1constants.ValidArchitectures)) + } + if isCapabilityFlavor { + if region.Architecture != nil { + allErrs = append(allErrs, field.Forbidden(kdxPath.Child("architecture"), "must be defined in .capabilities.architecture")) } } } - return allErrs } @@ -172,7 +234,7 @@ func NewProviderImagesContext(providerImages []stackitv1alpha1.MachineImages) *g } // validateMachineImageMapping validates that for each machine image there is a corresponding cpConfig image. -func validateMachineImageMapping(machineImages []core.MachineImage, cpConfig *stackitv1alpha1.CloudProfileConfig, fldPath *field.Path) field.ErrorList { +func validateMachineImageMapping(machineImages []core.MachineImage, cpConfig *stackitv1alpha1.CloudProfileConfig, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition, fldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} providerImages := NewProviderImagesContext(cpConfig.MachineImages) @@ -191,14 +253,8 @@ func validateMachineImageMapping(machineImages []core.MachineImage, cpConfig *st // validate that for each machine image version entry a mapped entry in cpConfig exists for idxVersion, version := range machineImage.Versions { machineImageVersionPath := machineImagePath.Child("versions").Index(idxVersion) - for _, expectedArchitecture := range version.Architectures { - // validate machine image version architectures - if !slices.Contains(v1beta1constants.ValidArchitectures, expectedArchitecture) { - allErrs = append(allErrs, field.NotSupported( - machineImageVersionPath.Child("architectures"), - expectedArchitecture, v1beta1constants.ValidArchitectures)) - } - // validate that machine image version exists in cpConfig + if len(capabilityDefinitions) > 0 { + // check that each MachineImageFlavor in version.CapabilityFlavors has a corresponding imageVersion.CapabilityFlavors imageVersion, exists := providerImages.GetImageVersion(machineImage.Name, version.Version) if !exists { allErrs = append(allErrs, field.Required(machineImageVersionPath, @@ -207,21 +263,40 @@ func validateMachineImageMapping(machineImages []core.MachineImage, cpConfig *st )) continue } - - // Regions is an optional field - if len(imageVersion.Regions) > 0 { - // validate that machine image version with architecture x exists in cpConfig - architecturesMap := utils.CreateMapFromSlice(imageVersion.Regions, func(re stackitv1alpha1.RegionIDMapping) string { - return ptr.Deref(re.Architecture, v1beta1constants.ArchitectureAMD64) - }) - architectures := slices.Collect(maps.Keys(architecturesMap)) - if !slices.Contains(architectures, expectedArchitecture) { + allErrs = append(allErrs, validateImageFlavorMapping(machineImage, version, machineImageVersionPath, capabilityDefinitions, imageVersion)...) + } else { + for _, expectedArchitecture := range version.Architectures { + // validate machine image version architectures + if !slices.Contains(v1beta1constants.ValidArchitectures, expectedArchitecture) { + allErrs = append(allErrs, field.NotSupported( + machineImageVersionPath.Child("architectures"), + expectedArchitecture, v1beta1constants.ValidArchitectures)) + } + // validate that machine image version exists in cpConfig + imageVersion, exists := providerImages.GetImageVersion(machineImage.Name, version.Version) + if !exists { allErrs = append(allErrs, field.Required(machineImageVersionPath, - fmt.Sprintf("missing providerConfig mapping for machine image version %s@%s and architecture: %s", - machineImage.Name, version.Version, expectedArchitecture), + fmt.Sprintf("machine image version %s@%s is not defined in the providerConfig", + machineImage.Name, version.Version), )) continue } + + // Regions is an optional field + if len(imageVersion.Regions) > 0 { + // validate that machine image version with architecture x exists in cpConfig + architecturesMap := utils.CreateMapFromSlice(imageVersion.Regions, func(re stackitv1alpha1.RegionIDMapping) string { + return ptr.Deref(re.Architecture, v1beta1constants.ArchitectureAMD64) + }) + architectures := slices.Collect(maps.Keys(architecturesMap)) + if !slices.Contains(architectures, expectedArchitecture) { + allErrs = append(allErrs, field.Required(machineImageVersionPath, + fmt.Sprintf("missing providerConfig mapping for machine image version %s@%s and architecture: %s", + machineImage.Name, version.Version, expectedArchitecture), + )) + continue + } + } } } } @@ -229,3 +304,60 @@ func validateMachineImageMapping(machineImages []core.MachineImage, cpConfig *st return allErrs } + +// validateImageFlavorMapping validates that each flavor in a version has a corresponding mapping +func validateImageFlavorMapping(machineImage core.MachineImage, version core.MachineImageVersion, machineImageVersionPath *field.Path, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition, imageVersion stackitv1alpha1.MachineImageVersion) field.ErrorList { + allErrs := field.ErrorList{} + + var v1beta1Version gardencorev1beta1.MachineImageVersion + if err := gardencoreapi.Scheme.Convert(&version, &v1beta1Version, nil); err != nil { + return append(allErrs, field.InternalError(machineImageVersionPath, err)) + } + + defaultedCapabilityFlavors := gardencorev1beta1helper.GetImageFlavorsWithAppliedDefaults(v1beta1Version.CapabilityFlavors, capabilityDefinitions) + + switch { + // New format: validate against capability flavors + case len(imageVersion.CapabilityFlavors) > 0: + for idxCapability, defaultedCapabilitySet := range defaultedCapabilityFlavors { + isFound := false + // search for the corresponding imageVersion.MachineImageFlavor + for _, providerCapabilitySet := range imageVersion.CapabilityFlavors { + providerDefaultedCapabilities := gardencorev1beta1.GetCapabilitiesWithAppliedDefaults(providerCapabilitySet.Capabilities, capabilityDefinitions) + if gardencorev1beta1helper.AreCapabilitiesEqual(defaultedCapabilitySet.Capabilities, providerDefaultedCapabilities) { + isFound = true + break + } + } + if !isFound { + allErrs = append(allErrs, field.Required(machineImageVersionPath.Child("capabilityFlavors").Index(idxCapability), + fmt.Sprintf("missing providerConfig mapping for machine image version %s@%s and capabilitySet %v", machineImage.Name, version.Version, defaultedCapabilitySet.Capabilities))) + } + } + // Old format (mixed): validate that regions cover all required architectures from capability flavors + case len(imageVersion.Regions) > 0: + architecturesMap := utils.CreateMapFromSlice(imageVersion.Regions, func(re stackitv1alpha1.RegionIDMapping) string { + return ptr.Deref(re.Architecture, v1beta1constants.ArchitectureAMD64) + }) + availableArchitectures := slices.Collect(maps.Keys(architecturesMap)) + + for idxCapability, defaultedCapabilitySet := range defaultedCapabilityFlavors { + archValues, hasArch := defaultedCapabilitySet.Capabilities[v1beta1constants.ArchitectureName] + if !hasArch || len(archValues) == 0 { + continue + } + expectedArch := archValues[0] + if !slices.Contains(availableArchitectures, expectedArch) { + allErrs = append(allErrs, field.Required(machineImageVersionPath.Child("capabilityFlavors").Index(idxCapability), + fmt.Sprintf("missing providerConfig mapping for machine image version %s@%s and architecture %s", machineImage.Name, version.Version, expectedArch))) + } + } + // Neither regions nor capabilityFlavors set + default: + for idxCapability, defaultedCapabilitySet := range defaultedCapabilityFlavors { + allErrs = append(allErrs, field.Required(machineImageVersionPath.Child("capabilityFlavors").Index(idxCapability), + fmt.Sprintf("missing providerConfig mapping for machine image version %s@%s and capabilitySet %v", machineImage.Name, version.Version, defaultedCapabilitySet.Capabilities))) + } + } + return allErrs +} diff --git a/pkg/apis/stackit/validation/cloudprofile_test.go b/pkg/apis/stackit/validation/cloudprofile_test.go index be408e95..27311879 100644 --- a/pkg/apis/stackit/validation/cloudprofile_test.go +++ b/pkg/apis/stackit/validation/cloudprofile_test.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and Gardener contributors // // SPDX-License-Identifier: Apache-2.0 @@ -6,10 +6,12 @@ package validation_test import ( "github.com/gardener/gardener/pkg/apis/core" + "github.com/gardener/gardener/pkg/apis/core/v1beta1" v1beta1constants "github.com/gardener/gardener/pkg/apis/core/v1beta1/constants" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" . "github.com/onsi/gomega/gstruct" + "github.com/onsi/gomega/types" "k8s.io/apimachinery/pkg/util/validation/field" stackitv1alpha1 "github.com/stackitcloud/gardener-extension-provider-stackit/v2/pkg/apis/stackit/v1alpha1" @@ -17,16 +19,37 @@ import ( ) var _ = Describe("CloudProfileConfig validation", func() { - Describe("#ValidateCloudProfileConfig", func() { + DescribeTableSubtree("#ValidateCloudProfileConfig", func(isCapabilitiesCloudProfile bool) { var ( - cloudProfileConfig *stackitv1alpha1.CloudProfileConfig - machineImages []core.MachineImage - machineImageName string - machineImageVersion string - fldPath *field.Path + capabilityDefinitions []v1beta1.CapabilityDefinition + cloudProfileConfig *stackitv1alpha1.CloudProfileConfig + machineImages []core.MachineImage + machineImageName string + machineImageVersion string + fldPath *field.Path ) BeforeEach(func() { + regions := []stackitv1alpha1.RegionIDMapping{{ + Name: "eu01", + ID: "9afa968b-ed9e-4ba0-a394-f74cbb0313w2", + }} + var capabilityFlavors []stackitv1alpha1.MachineImageFlavor + + if isCapabilitiesCloudProfile { + capabilityDefinitions = []v1beta1.CapabilityDefinition{{ + Name: v1beta1constants.ArchitectureName, + Values: []string{"amd64"}, + }} + capabilityFlavors = []stackitv1alpha1.MachineImageFlavor{{ + Regions: regions, + Capabilities: v1beta1.Capabilities{ + v1beta1constants.ArchitectureName: []string{"amd64"}, + }}} + regions = nil + } else { + regions[0].Architecture = new("amd64") + } machineImageName = "ubuntu" machineImageVersion = "1.2.3" cloudProfileConfig = &stackitv1alpha1.CloudProfileConfig{ @@ -45,13 +68,10 @@ var _ = Describe("CloudProfileConfig validation", func() { Name: machineImageName, Versions: []stackitv1alpha1.MachineImageVersion{ { - Version: machineImageVersion, - Image: "ubuntu-1.2.3", - Regions: []stackitv1alpha1.RegionIDMapping{{ - Name: "eu01", - ID: "9afa968b-ed9e-4ba0-a394-f74cbb0313w2", - Architecture: new(v1beta1constants.ArchitectureAMD64), - }}, + Version: machineImageVersion, + Image: "ubuntu-1.2.3", + Regions: regions, + CapabilityFlavors: capabilityFlavors, }, }, }, @@ -82,7 +102,7 @@ var _ = Describe("CloudProfileConfig validation", func() { }, } - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ "Type": Equal(field.ErrorTypeRequired), @@ -127,7 +147,7 @@ var _ = Describe("CloudProfileConfig validation", func() { }, } - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(ConsistOf( PointTo(MatchFields(IgnoreExtras, Fields{ @@ -154,7 +174,8 @@ var _ = Describe("CloudProfileConfig validation", func() { cloudProfileConfig.KeyStoneURL = "" //nolint:staticcheck // SA1019: needed for migration purposes cloudProfileConfig.KeyStoneURLs = []stackitv1alpha1.KeyStoneURL{{}} - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ "Type": Equal(field.ErrorTypeRequired), @@ -180,7 +201,7 @@ var _ = Describe("CloudProfileConfig validation", func() { }, } - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ "Type": Equal(field.ErrorTypeDuplicate), @@ -193,7 +214,7 @@ var _ = Describe("CloudProfileConfig validation", func() { //nolint:staticcheck // SA1019: needed for migration purposes cloudProfileConfig.KeyStoneCACert = new("foo") - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ "Type": Equal(field.ErrorTypeInvalid), "Field": Equal("root.caCert"), @@ -205,7 +226,7 @@ var _ = Describe("CloudProfileConfig validation", func() { It("should forbid not invalid dns server ips", func() { cloudProfileConfig.DNSServers = []string{"not-a-valid-ip"} - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ "Type": Equal(field.ErrorTypeInvalid), @@ -219,7 +240,7 @@ var _ = Describe("CloudProfileConfig validation", func() { //nolint:staticcheck // SA1019: needed for migration purposes cloudProfileConfig.DHCPDomain = new("") - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ "Type": Equal(field.ErrorTypeRequired), @@ -230,20 +251,20 @@ var _ = Describe("CloudProfileConfig validation", func() { Context("machine image validation", func() { It("should pass validation", func() { - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(BeEmpty()) }) It("should pass validation even without regions in the machineImage version", func() { cloudProfileConfig.MachineImages[0].Versions[0].Regions = nil - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(BeEmpty()) }) It("should enforce that at least one machine image has been defined", func() { cloudProfileConfig.MachineImages = []stackitv1alpha1.MachineImages{} - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ "Type": Equal(field.ErrorTypeRequired), @@ -257,7 +278,7 @@ var _ = Describe("CloudProfileConfig validation", func() { It("should forbid unsupported machine image configuration", func() { cloudProfileConfig.MachineImages = []stackitv1alpha1.MachineImages{{}} - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ "Type": Equal(field.ErrorTypeRequired), @@ -279,7 +300,7 @@ var _ = Describe("CloudProfileConfig validation", func() { }, } - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ "Type": Equal(field.ErrorTypeRequired), @@ -290,108 +311,240 @@ var _ = Describe("CloudProfileConfig validation", func() { })))) }) - It("should forbid missing architecture mapping", func() { - machineImages[0].Versions[0].Architectures = []string{"arm64"} - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) - + It("should forbid missing architecture or capabilitySet mapping", func() { + var fieldMatcher types.GomegaMatcher + if isCapabilitiesCloudProfile { + machineImages[0].Versions[0].CapabilityFlavors = []core.MachineImageFlavor{ + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"arm64"}}}, + } + fieldMatcher = Equal("spec.machineImages[0].versions[0].capabilityFlavors[0]") + } else { + machineImages[0].Versions[0].Architectures = []string{"arm64"} + fieldMatcher = Equal("spec.machineImages[0].versions[0]") + } + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(ConsistOf( - PointTo(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(field.ErrorTypeRequired), - "Field": Equal("spec.machineImages[0].versions[0]"), - })), + PointTo(MatchFields(IgnoreExtras, Fields{"Type": Equal(field.ErrorTypeRequired), "Field": fieldMatcher})), )) }) - - It("should automatically use amd64", func() { - cloudProfileConfig.MachineImages[0].Versions[0].Regions[0].Architecture = nil - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + It("should automatically use amd64 (or default to capabilityDefinitions)", func() { + if !isCapabilitiesCloudProfile { + cloudProfileConfig.MachineImages[0].Versions[0].Regions[0].Architecture = nil + } + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(BeEmpty()) }) Context("region mapping validation", func() { It("should forbid empty region name", func() { - cloudProfileConfig.MachineImages = []stackitv1alpha1.MachineImages{ - { - Name: "abc", - Versions: []stackitv1alpha1.MachineImageVersion{{ - Version: "foo", - Regions: []stackitv1alpha1.RegionIDMapping{{ - ID: "abc_foo", - }}, - }}, - }, + var fieldMatcher string + var regions = []stackitv1alpha1.RegionIDMapping{{ + ID: "abc_foo", + }} + + if isCapabilitiesCloudProfile { + fieldMatcher = "root.machineImages[0].versions[0].capabilityFlavors[0].regions[0].name" + cloudProfileConfig.MachineImages[0].Versions[0].CapabilityFlavors[0].Regions = regions + } else { + fieldMatcher = "root.machineImages[0].versions[0].regions[0].name" + cloudProfileConfig.MachineImages[0].Versions[0].Regions = regions } - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ "Type": Equal(field.ErrorTypeRequired), - "Field": Equal("root.machineImages[0].versions[0].regions[0].name"), - })), PointTo(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(field.ErrorTypeRequired), - "Field": Equal("spec.machineImages[0]"), + "Field": Equal(fieldMatcher), })))) }) It("should forbid empty image ID", func() { - cloudProfileConfig.MachineImages = []stackitv1alpha1.MachineImages{ - { - Name: "abc", - Versions: []stackitv1alpha1.MachineImageVersion{{ - Version: "foo", - Regions: []stackitv1alpha1.RegionIDMapping{{ - Name: "eu01", - }}, - }}, - }, + var fieldMatcher string + + var regions = []stackitv1alpha1.RegionIDMapping{{ + Name: "eu01", + }} + if isCapabilitiesCloudProfile { + fieldMatcher = "root.machineImages[0].versions[0].capabilityFlavors[0].regions[0].id" + cloudProfileConfig.MachineImages[0].Versions[0].CapabilityFlavors[0].Regions = regions + } else { + fieldMatcher = "root.machineImages[0].versions[0].regions[0].id" + cloudProfileConfig.MachineImages[0].Versions[0].Regions = regions } - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ "Type": Equal(field.ErrorTypeRequired), - "Field": Equal("root.machineImages[0].versions[0].regions[0].id"), - })), PointTo(MatchFields(IgnoreExtras, Fields{ - "Type": Equal(field.ErrorTypeRequired), - "Field": Equal("spec.machineImages[0]"), + "Field": Equal(fieldMatcher), })))) }) It("should forbid unknown architectures", func() { - cloudProfileConfig.MachineImages = []stackitv1alpha1.MachineImages{ - { - Name: "abc", - Versions: []stackitv1alpha1.MachineImageVersion{{ - Version: "foo", - Regions: []stackitv1alpha1.RegionIDMapping{ - { - Name: "eu01", - ID: "abc_foo_amd64", - Architecture: new("amd64"), - }, - { - Name: "eu01", - ID: "abc_foo_arm64", - Architecture: new("arm64"), - }, - { - Name: "eu01", - ID: "abc_foo_ppc64", - Architecture: new("ppc64"), - }, - }, - }}, - }, - } - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + var notSupportedField, requiredField types.GomegaMatcher + if isCapabilitiesCloudProfile { + cloudProfileConfig.MachineImages[0].Versions[0].CapabilityFlavors[0].Capabilities = v1beta1.Capabilities{ + v1beta1constants.ArchitectureName: []string{"foo"}, + } + notSupportedField = Equal("root.machineImages[0].versions[0].capabilityFlavors[0].capabilities.architecture[0]") + requiredField = Equal("spec.machineImages[0].versions[0].capabilityFlavors[0]") + } else { + cloudProfileConfig.MachineImages[0].Versions[0].Regions[0].Architecture = new("foo") + notSupportedField = Equal("root.machineImages[0].versions[0].regions[0].architecture") + requiredField = Equal("spec.machineImages[0].versions[0]") + + } + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ "Type": Equal(field.ErrorTypeNotSupported), - "Field": Equal("root.machineImages[0].versions[0].regions[2].architecture"), + "Field": notSupportedField, })), PointTo(MatchFields(IgnoreExtras, Fields{ "Type": Equal(field.ErrorTypeRequired), - "Field": Equal("spec.machineImages[0]"), + "Field": requiredField, + })))) + + }) + }) + + Context("mixed format validation (capabilities CloudProfile)", func() { + BeforeEach(func() { + if !isCapabilitiesCloudProfile { + Skip("mixed format tests only apply to capabilities CloudProfiles") + } + }) + + It("should allow old-format regions in a capabilities CloudProfile", func() { + // Use old format (regions with architecture) instead of capabilityFlavors + cloudProfileConfig.MachineImages[0].Versions[0].CapabilityFlavors = nil + cloudProfileConfig.MachineImages[0].Versions[0].Regions = []stackitv1alpha1.RegionIDMapping{{ + Name: "eu01", + ID: "ubuntu-amd64-id", + Architecture: new("amd64"), + }} + + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) + Expect(errorList).To(BeEmpty()) + }) + + It("should forbid both regions AND capabilityFlavors on the same version", func() { + cloudProfileConfig.MachineImages[0].Versions[0].Regions = []stackitv1alpha1.RegionIDMapping{{ + Name: "eu01", + ID: "ubuntu-amd64-id", + Architecture: new("amd64"), + }} + // capabilityFlavors is already set from BeforeEach + + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) + Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeForbidden), + "Field": Equal("root.machineImages[0].versions[0]"), + })))) + }) + + It("should allow mixed format across versions within the same image", func() { + capabilityDefinitions = []v1beta1.CapabilityDefinition{{ + Name: v1beta1constants.ArchitectureName, + Values: []string{"amd64", "arm64"}, + }} + // Version 1.2.3: old format (regions with architecture) + cloudProfileConfig.MachineImages[0].Versions[0].CapabilityFlavors = nil + cloudProfileConfig.MachineImages[0].Versions[0].Regions = []stackitv1alpha1.RegionIDMapping{ + {Name: "eu01", ID: "ubuntu-1.2.3-amd64", Architecture: new("amd64")}, + {Name: "eu01", ID: "ubuntu-1.2.3-arm64", Architecture: new("arm64")}, + } + // Version 2.0.0: new format (capabilityFlavors) + cloudProfileConfig.MachineImages[0].Versions = append(cloudProfileConfig.MachineImages[0].Versions, stackitv1alpha1.MachineImageVersion{ + Version: "2.0.0", + CapabilityFlavors: []stackitv1alpha1.MachineImageFlavor{ + { + Regions: []stackitv1alpha1.RegionIDMapping{{Name: "eu01", ID: "ubuntu-2.0.0-amd64"}}, + Capabilities: v1beta1.Capabilities{v1beta1constants.ArchitectureName: []string{"amd64"}}, + }, + { + Regions: []stackitv1alpha1.RegionIDMapping{{Name: "eu01", ID: "ubuntu-2.0.0-arm64"}}, + Capabilities: v1beta1.Capabilities{v1beta1constants.ArchitectureName: []string{"arm64"}}, + }, + }, + }) + + // Spec version for 1.2.3: uses CapabilityFlavors with separate per-arch entries + machineImages[0].Versions[0].CapabilityFlavors = []core.MachineImageFlavor{ + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"amd64"}}}, + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"arm64"}}}, + } + // Spec version for 2.0.0: also uses CapabilityFlavors + machineImages[0].Versions = append(machineImages[0].Versions, core.MachineImageVersion{ + ExpirableVersion: core.ExpirableVersion{Version: "2.0.0"}, + CapabilityFlavors: []core.MachineImageFlavor{ + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"amd64"}}}, + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"arm64"}}}, + }, + }) + + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) + Expect(errorList).To(BeEmpty()) + }) + + It("should fail when old-format regions are missing a required architecture", func() { + capabilityDefinitions = []v1beta1.CapabilityDefinition{{ + Name: v1beta1constants.ArchitectureName, + Values: []string{"amd64", "arm64"}, + }} + // Old format regions only provide amd64 + cloudProfileConfig.MachineImages[0].Versions[0].CapabilityFlavors = nil + cloudProfileConfig.MachineImages[0].Versions[0].Regions = []stackitv1alpha1.RegionIDMapping{{ + Name: "eu01", + ID: "ubuntu-amd64-id", + Architecture: new("amd64"), + }} + // Spec requires both amd64 and arm64 via separate CapabilityFlavors + machineImages[0].Versions[0].CapabilityFlavors = []core.MachineImageFlavor{ + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"amd64"}}}, + {Capabilities: core.Capabilities{v1beta1constants.ArchitectureName: []string{"arm64"}}}, + } + + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) + Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeRequired), + "Field": Equal("spec.machineImages[0].versions[0].capabilityFlavors[1]"), + "Detail": ContainSubstring("arm64"), + })))) + }) + + It("should default architecture to amd64 for old-format regions without explicit architecture", func() { + cloudProfileConfig.MachineImages[0].Versions[0].CapabilityFlavors = nil + cloudProfileConfig.MachineImages[0].Versions[0].Regions = []stackitv1alpha1.RegionIDMapping{{ + Name: "eu01", + ID: "ubuntu-amd64-id", + // No Architecture set - should default to amd64 + }} + + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) + Expect(errorList).To(BeEmpty()) + }) + + It("should forbid architecture field in capability flavor regions", func() { + cloudProfileConfig.MachineImages[0].Versions[0].CapabilityFlavors[0].Regions = []stackitv1alpha1.RegionIDMapping{{ + Name: "eu01", + ID: "ubuntu-amd64-id", + Architecture: new("amd64"), + }} + + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) + Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeForbidden), + "Field": Equal("root.machineImages[0].versions[0].capabilityFlavors[0].regions[0].architecture"), + })))) + }) + + It("should forbid capabilityFlavors in a non-capabilities CloudProfile", func() { + // This test verifies the inverse: capabilityFlavors are forbidden when no capabilityDefinitions + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, nil, fldPath) + Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ + "Type": Equal(field.ErrorTypeForbidden), + "Field": Equal("root.machineImages[0].versions[0].capabilityFlavors"), })))) }) }) @@ -405,7 +558,7 @@ var _ = Describe("CloudProfileConfig validation", func() { "", } - errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, fldPath) + errorList := ValidateCloudProfileConfig(cloudProfileConfig, machineImages, capabilityDefinitions, fldPath) Expect(errorList).To(ConsistOf(PointTo(MatchFields(IgnoreExtras, Fields{ "Type": Equal(field.ErrorTypeRequired), @@ -413,5 +566,7 @@ var _ = Describe("CloudProfileConfig validation", func() { })))) }) }) - }) + }, + Entry("CloudProfile uses regions only", false), + Entry("CloudProfile uses capabilities", true)) }) diff --git a/pkg/controller/worker/machine_images.go b/pkg/controller/worker/machine_images.go index 48e9ac0e..2f12868e 100644 --- a/pkg/controller/worker/machine_images.go +++ b/pkg/controller/worker/machine_images.go @@ -9,6 +9,8 @@ import ( "fmt" "github.com/gardener/gardener/extensions/pkg/controller/worker" + gardencorev1beta1helper "github.com/gardener/gardener/pkg/api/core/v1beta1/helper" + gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" v1beta1constants "github.com/gardener/gardener/pkg/apis/core/v1beta1/constants" "k8s.io/utils/ptr" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -38,10 +40,22 @@ func (w *workerDelegate) UpdateMachineImagesStatus(ctx context.Context) error { return nil } -func (w *workerDelegate) findMachineImage(name, version, architecture string) (*stackitv1alpha1.MachineImage, error) { - image, err := helper.FindImageFromCloudProfile(w.cloudProfileConfig, name, version, w.cluster.Shoot.Spec.Region, architecture) - if err == nil { - return image, nil +func (w *workerDelegate) selectMachineImageForWorkerPool(name, version, region string, arch string, machineCapabilities gardencorev1beta1.Capabilities, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition) (*stackitv1alpha1.MachineImage, error) { + selectedMachineImage := &stackitv1alpha1.MachineImage{ + Name: name, + Version: version, + } + + if capabilitySet, err := helper.FindImageInCloudProfile(w.cloudProfileConfig, name, version, region, machineCapabilities, capabilityDefinitions); err == nil { + selectedMachineImage.Capabilities = capabilitySet.Capabilities + // ID takes precedence over Image attribute. Reminder: Image is global name while ID is region specific. + if len(capabilitySet.Regions) > 0 && capabilitySet.Regions[0].ID != "" { + selectedMachineImage.ID = capabilitySet.Regions[0].ID + } else { + selectedMachineImage.Image = capabilitySet.Image + } + + return selectedMachineImage, nil } // Try to look up machine image in worker provider status as it was not found in componentconfig. @@ -51,27 +65,58 @@ func (w *workerDelegate) findMachineImage(name, version, architecture string) (* return nil, fmt.Errorf("could not decode worker status of worker '%s': %w", k8sclient.ObjectKeyFromObject(w.worker), err) } - machineImage, err := helper.FindMachineImage(workerStatus.MachineImages, name, version, architecture) - if err != nil { - return nil, worker.ErrorMachineImageNotFound(name, version) - } + // Pass the original (non-normalized) MachineCapabilities so FindImageInWorkerStatus + // can distinguish legacy format (Architecture field) from capability format (Capabilities field). + return helper.FindImageInWorkerStatus(workerStatus.MachineImages, name, version, arch, machineCapabilities, w.cluster.CloudProfile.Spec.MachineCapabilities) + } - // The architecture field might not be present in the WorkerStatus if the Shoot has been created before introduction - // of the field. Hence, initialize it if it's empty. - machineImage = machineImage.DeepCopy() - if machineImage.Architecture == nil { - machineImage.Architecture = &architecture - } + return nil, worker.ErrorMachineImageNotFound(name, version, arch, region) +} - return machineImage, nil +func appendMachineImage(machineImages []stackitv1alpha1.MachineImage, machineImage stackitv1alpha1.MachineImage, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition) []stackitv1alpha1.MachineImage { + // support for cloudprofile machine images without capabilities + if len(capabilityDefinitions) == 0 { + // Extract architecture from Capabilities if Architecture is not set + // (happens when image came from capability-based lookup with normalized definitions) + architecture := machineImage.Architecture + if architecture == nil { + if archValues, ok := machineImage.Capabilities[v1beta1constants.ArchitectureName]; ok && len(archValues) > 0 { + architecture = &archValues[0] + } + } + for _, image := range machineImages { + if image.Name == machineImage.Name && image.Version == machineImage.Version && ptr.Deref(architecture, "") == ptr.Deref(image.Architecture, "") { + // If the image already exists without capabilities, we can just return the existing list. + return machineImages + } + } + return append(machineImages, stackitv1alpha1.MachineImage{ + Name: machineImage.Name, + Version: machineImage.Version, + Image: machineImage.Image, + ID: machineImage.ID, + Architecture: architecture, + }) } - return nil, worker.ErrorMachineImageNotFound(name, version) -} + defaultedCapabilities := gardencorev1beta1.GetCapabilitiesWithAppliedDefaults(machineImage.Capabilities, capabilityDefinitions) -func appendMachineImage(machineImages []stackitv1alpha1.MachineImage, machineImage stackitv1alpha1.MachineImage) []stackitv1alpha1.MachineImage { - if _, err := helper.FindMachineImage(machineImages, machineImage.Name, machineImage.Version, ptr.Deref(machineImage.Architecture, v1beta1constants.ArchitectureAMD64)); err != nil { - return append(machineImages, machineImage) + for _, existingMachineImage := range machineImages { + existingDefaultedCapabilities := gardencorev1beta1.GetCapabilitiesWithAppliedDefaults(existingMachineImage.Capabilities, capabilityDefinitions) + if existingMachineImage.Name == machineImage.Name && existingMachineImage.Version == machineImage.Version && gardencorev1beta1helper.AreCapabilitiesEqual(defaultedCapabilities, existingDefaultedCapabilities) { + // If the image already exists with the same capabilities return the existing list. + return machineImages + } } + + // If the image does not exist, we create a new machine image entry with the capabilities. + machineImages = append(machineImages, stackitv1alpha1.MachineImage{ + Name: machineImage.Name, + Version: machineImage.Version, + Image: machineImage.Image, + ID: machineImage.ID, + Capabilities: machineImage.Capabilities, + }) + return machineImages } diff --git a/pkg/controller/worker/machines.go b/pkg/controller/worker/machines.go index fe7df94c..98704bf1 100644 --- a/pkg/controller/worker/machines.go +++ b/pkg/controller/worker/machines.go @@ -18,6 +18,7 @@ import ( genericworkeractuator "github.com/gardener/gardener/extensions/pkg/controller/worker/genericactuator" gardencorev1beta1helper "github.com/gardener/gardener/pkg/api/core/v1beta1/helper" extensionsv1alpha1helper "github.com/gardener/gardener/pkg/api/extensions/v1alpha1/helper" + gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" v1beta1constants "github.com/gardener/gardener/pkg/apis/core/v1beta1/constants" extensionsv1alpha1 "github.com/gardener/gardener/pkg/apis/extensions/v1alpha1" "github.com/gardener/gardener/pkg/client/kubernetes" @@ -110,12 +111,21 @@ func (w *workerDelegate) generateMachineConfig(ctx context.Context) error { // nolint:gosec // check above ensures no overflow can occur zoneLen := int32(len(pool.Zones)) + machineTypeFromCloudProfile := gardencorev1beta1helper.FindMachineTypeByName(w.cluster.CloudProfile.Spec.MachineTypes, pool.MachineType) + if machineTypeFromCloudProfile == nil { + return fmt.Errorf("machine type %q not found in cloud profile %q", pool.MachineType, w.cluster.CloudProfile.Name) + } + + capabilityDefinitions := helper.NormalizeCapabilityDefinitions(w.cluster.CloudProfile.Spec.MachineCapabilities) architecture := ptr.Deref(pool.Architecture, v1beta1constants.ArchitectureAMD64) - machineImage, err := w.findMachineImage(pool.MachineImage.Name, pool.MachineImage.Version, architecture) + machineTypeCapabilities := helper.NormalizeMachineTypeCapabilities(machineTypeFromCloudProfile.Capabilities, &architecture, capabilityDefinitions) + + machineImage, err := w.selectMachineImageForWorkerPool(pool.MachineImage.Name, pool.MachineImage.Version, w.worker.Spec.Region, architecture, machineTypeCapabilities, capabilityDefinitions) if err != nil { return err } - machineImages = appendMachineImage(machineImages, *machineImage) + machineImages = EnsureUniformMachineImages(machineImages, w.cluster.CloudProfile.Spec.MachineCapabilities) + machineImages = appendMachineImage(machineImages, *machineImage, w.cluster.CloudProfile.Spec.MachineCapabilities) var volumeSize int if pool.Volume != nil { @@ -156,7 +166,7 @@ func (w *workerDelegate) generateMachineConfig(ctx context.Context) error { }, ) if feature.UseStackitMachineControllerManager(w.cluster) { - region = stackit.DetermineRegion(w.cluster) + region = stackit.DetermineRegionFromWorkerSpec(w.worker) securityGroups = []string{nodesSecurityGroup.ID} tags = map[string]string{ stackitutils.ClusterLabelKey(w.customLabelDomain): w.cluster.Shoot.Status.TechnicalID, @@ -285,7 +295,7 @@ func (w *workerDelegate) generateMachineConfig(ctx context.Context) error { w.machineDeployments = machineDeployments w.machineClasses = machineClasses - w.machineImages = machineImages + w.machineImages = EnsureUniformMachineImages(machineImages, w.cluster.CloudProfile.Spec.MachineCapabilities) return nil } @@ -332,3 +342,48 @@ func addTopologyLabel(labels map[string]string, zone string) map[string]string { openstack.CSISTACKITDriverTopologyKey: zone, }) } + +// EnsureUniformMachineImages ensures that all machine images use the same legacy or capability-based format. +func EnsureUniformMachineImages(images []stackitv1alpha1.MachineImage, definitions []gardencorev1beta1.CapabilityDefinition) []stackitv1alpha1.MachineImage { + var uniformMachineImages []stackitv1alpha1.MachineImage + + if len(definitions) == 0 { + for _, img := range images { + if len(img.Capabilities) == 0 { + uniformMachineImages = appendMachineImage(uniformMachineImages, img, definitions) + continue + } + var architecture *string + if len(img.Capabilities[v1beta1constants.ArchitectureName]) > 0 { + architecture = &img.Capabilities[v1beta1constants.ArchitectureName][0] + } + uniformMachineImages = appendMachineImage(uniformMachineImages, stackitv1alpha1.MachineImage{ + Name: img.Name, + Version: img.Version, + Image: img.Image, + ID: img.ID, + Architecture: architecture, + }, definitions) + } + return uniformMachineImages + } + + for _, img := range images { + if len(img.Capabilities) > 0 { + uniformMachineImages = appendMachineImage(uniformMachineImages, img, definitions) + continue + } + + architecture := ptr.Deref(img.Architecture, v1beta1constants.ArchitectureAMD64) + uniformMachineImages = appendMachineImage(uniformMachineImages, stackitv1alpha1.MachineImage{ + Name: img.Name, + Version: img.Version, + Image: img.Image, + ID: img.ID, + Capabilities: gardencorev1beta1.Capabilities{ + v1beta1constants.ArchitectureName: []string{architecture}, + }, + }, definitions) + } + return uniformMachineImages +} diff --git a/pkg/controller/worker/machines_test.go b/pkg/controller/worker/machines_test.go index 90758bbb..e9ca7f57 100644 --- a/pkg/controller/worker/machines_test.go +++ b/pkg/controller/worker/machines_test.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and Gardener contributors // // SPDX-License-Identifier: Apache-2.0 @@ -32,7 +32,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/intstr" - utilruntime "k8s.io/apimachinery/pkg/util/runtime" "sigs.k8s.io/controller-runtime/pkg/client" fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -61,9 +60,7 @@ var _ = Describe("Machines", func() { chartApplier = mockkubernetes.NewMockChartApplier(ctrl) scheme = runtime.NewScheme() - utilruntime.Must(corev1.AddToScheme(scheme)) - utilruntime.Must(extensionsv1alpha1.AddToScheme(scheme)) - utilruntime.Must(stackitv1alpha1.AddToScheme(scheme)) + _ = stackitv1alpha1.AddToScheme(scheme) }) AfterEach(func() { @@ -71,6 +68,7 @@ var _ = Describe("Machines", func() { }) Context("workerDelegate", func() { + BeforeEach(func() { workerDelegate, _ = NewWorkerDelegate(nil, scheme, nil, "", nil, nil, "") }) @@ -93,7 +91,8 @@ var _ = Describe("Machines", func() { }) }) - Describe("#GenerateMachineDeployments, #DeployMachineClasses", func() { + DescribeTableSubtree("#GenerateMachineDeployments, #DeployMachineClasses", func(isCapabilitiesCloudProfile bool, usesGlobalImageNames bool, useStackitMCM bool) { + var ( namespace string technicalID string @@ -101,7 +100,6 @@ var _ = Describe("Machines", func() { openstackAuthURL string region string - regionWithImages string machineImageName string machineImageVersion string @@ -111,16 +109,17 @@ var _ = Describe("Machines", func() { archAMD string archARM string - keyName string - machineType string - userData []byte - userDataSecretName string - userDataSecretDataKey string - nodeAgentSecretName string - networkID string - podCIDR string - subnetID string - securityGroupName string + keyName string + machineType, machineTypeArm string + userData []byte + userDataSecretName string + userDataSecretDataKey string + nodeAgentSecretName string + networkID string + podCIDR string + subnetID string + securityGroupName string + securityGroupID string namePool1 string minPool1 int32 @@ -140,7 +139,14 @@ var _ = Describe("Machines", func() { zone1 string zone2 string - nodeCapacity corev1.ResourceList + nodeCapacity corev1.ResourceList + nodeTemplatePool1Zone1 machinev1alpha1.NodeTemplate + nodeTemplatePool2Zone1 machinev1alpha1.NodeTemplate + nodeTemplatePool3Zone1 machinev1alpha1.NodeTemplate + nodeTemplatePool1Zone2 machinev1alpha1.NodeTemplate + nodeTemplatePool2Zone2 machinev1alpha1.NodeTemplate + nodeTemplatePool3Zone2 machinev1alpha1.NodeTemplate + machineConfiguration *machinev1alpha1.MachineConfiguration workerPoolHash1 string @@ -149,43 +155,59 @@ var _ = Describe("Machines", func() { shootVersionMajorMinor string shootVersion string - cloudProfileConfig *stackitv1alpha1.CloudProfileConfig - cloudProfileConfigJSON []byte + customLabelDomain string clusterWithoutImages *extensionscontroller.Cluster cluster *extensionscontroller.Cluster w *extensionsv1alpha1.Worker emptyClusterAutoscalerAnnotations map[string]string + capabilitiesAmd, capabilitiesArm gardencorev1beta1.Capabilities + capabilityDefinitions []gardencorev1beta1.CapabilityDefinition ) - newFakeClient := func(objects ...client.Object) client.Client { - return fakeclient.NewClientBuilder(). - WithScheme(scheme). - WithStatusSubresource(&extensionsv1alpha1.Worker{}). - WithObjects(objects...). - Build() - } - BeforeEach(func() { + // Disable STACKIT feature flags for OpenStack-only machineclass tests + DeferCleanup(testutils.WithFeatureGate(feature.MutableGate, feature.UseSTACKITMachineControllerManager, useStackitMCM)) + if useStackitMCM { + customLabelDomain = "kubernetes.io" + } + if isCapabilitiesCloudProfile { + capabilityDefinitions = []gardencorev1beta1.CapabilityDefinition{ + {Name: "some-capability", Values: []string{"a", "b", "c"}}, + {Name: v1beta1constants.ArchitectureName, Values: []string{v1beta1constants.ArchitectureAMD64, v1beta1constants.ArchitectureARM64}}, + } + capabilitiesAmd = gardencorev1beta1.Capabilities{ + v1beta1constants.ArchitectureName: []string{v1beta1constants.ArchitectureAMD64}, + } + capabilitiesArm = gardencorev1beta1.Capabilities{ + v1beta1constants.ArchitectureName: []string{"arm64"}, + } + + } + if usesGlobalImageNames { + machineImageID = "" + } else { + machineImageID = "my-image-ID" + } + namespace = "control-plane-namespace" technicalID = "shoot--foobar--openstack" cloudProfileName = "openstack" region = "eu-de-1" - regionWithImages = "eu-de-2" openstackAuthURL = "auth-url" machineImageName = "my-os" machineImageVersion = "123.4.5-foo+bar123" machineImage = "my-image-in-glance" - machineImageID = "my-image-id" archAMD = "amd64" archARM = "arm64" keyName = "key-name" machineType = "large" + machineTypeArm = "large-arm" userData = []byte("some-user-data") userDataSecretName = "userdata-secret-name" userDataSecretDataKey = "userdata-secret-key" @@ -194,6 +216,7 @@ var _ = Describe("Machines", func() { podCIDR = "1.2.3.4/5" subnetID = "subnetID" securityGroupName = "nodes-sec-group" + securityGroupID = "sg-12345" namePool1 = "pool-1" minPool1 = 5 @@ -226,20 +249,64 @@ var _ = Describe("Machines", func() { "gpu": resource.MustParse("1"), "memory": resource.MustParse("128Gi"), } + nodeTemplatePool1Zone1 = machinev1alpha1.NodeTemplate{ + Capacity: nodeCapacity, + InstanceType: machineType, + Region: region, + Zone: zone1, + Architecture: &archAMD, + } + nodeTemplatePool1Zone2 = machinev1alpha1.NodeTemplate{ + Capacity: nodeCapacity, + InstanceType: machineType, + Region: region, + Zone: zone2, + Architecture: &archAMD, + } + + nodeTemplatePool2Zone1 = machinev1alpha1.NodeTemplate{ + Capacity: nodeCapacity, + InstanceType: machineType, + Region: region, + Zone: zone1, + Architecture: &archAMD, + } + nodeTemplatePool2Zone2 = machinev1alpha1.NodeTemplate{ + Capacity: nodeCapacity, + InstanceType: machineType, + Region: region, + Zone: zone2, + Architecture: &archAMD, + } + + nodeTemplatePool3Zone1 = machinev1alpha1.NodeTemplate{ + Capacity: nodeCapacity, + InstanceType: machineTypeArm, + Region: region, + Zone: zone1, + Architecture: &archARM, + } + nodeTemplatePool3Zone2 = machinev1alpha1.NodeTemplate{ + Capacity: nodeCapacity, + InstanceType: machineTypeArm, + Region: region, + Zone: zone2, + Architecture: &archARM, + } machineConfiguration = &machinev1alpha1.MachineConfiguration{} - shootVersionMajorMinor = "1.28" - shootVersion = shootVersionMajorMinor + ".3" + shootVersionMajorMinor = "1.32" + shootVersion = shootVersionMajorMinor + ".0" - cloudProfileConfig = &stackitv1alpha1.CloudProfileConfig{ + cloudProfileConfig := &stackitv1alpha1.CloudProfileConfig{ TypeMeta: metav1.TypeMeta{ APIVersion: stackitv1alpha1.SchemeGroupVersion.String(), Kind: "CloudProfileConfig", }, KeyStoneURL: openstackAuthURL, } - cloudProfileConfigJSON, _ = json.Marshal(cloudProfileConfig) + cloudProfileConfigJSON, _ := json.Marshal(cloudProfileConfig) clusterWithoutImages = &extensionscontroller.Cluster{ CloudProfile: &gardencorev1beta1.CloudProfile{ @@ -267,36 +334,93 @@ var _ = Describe("Machines", func() { }, } - cloudProfileConfig.MachineImages = []stackitv1alpha1.MachineImages{ + machineImages := []stackitv1alpha1.MachineImages{ { Name: machineImageName, Versions: []stackitv1alpha1.MachineImageVersion{ { Version: machineImageVersion, - Image: machineImage, - Regions: []stackitv1alpha1.RegionIDMapping{ + CapabilityFlavors: []stackitv1alpha1.MachineImageFlavor{ { - Name: regionWithImages, - ID: machineImageID, - Architecture: &archARM, + Capabilities: capabilitiesArm, + Image: machineImage, + Regions: []stackitv1alpha1.RegionIDMapping{ + { + Name: region, + ID: machineImageID, + }, + }, }, { - Name: regionWithImages, - ID: machineImageID, - Architecture: &archAMD, + Capabilities: capabilitiesAmd, + Image: machineImage, + Regions: []stackitv1alpha1.RegionIDMapping{ + { + Name: region, + ID: machineImageID, + }, + }, + }, + }, + }, + }, + }, + } + + if !isCapabilitiesCloudProfile { + machineImages = []stackitv1alpha1.MachineImages{ + { + Name: machineImageName, + Versions: []stackitv1alpha1.MachineImageVersion{ + { + Version: machineImageVersion, + Image: machineImage, + Regions: []stackitv1alpha1.RegionIDMapping{ + { + Name: region, + ID: machineImageID, + Architecture: new(archARM), + }, + { + Name: region, + ID: machineImageID, + Architecture: new(archAMD), + }, }, }, }, }, + } + } + + cloudProfileConfig2 := &stackitv1alpha1.CloudProfileConfig{ + TypeMeta: metav1.TypeMeta{ + APIVersion: stackitv1alpha1.SchemeGroupVersion.String(), + Kind: "CloudProfileConfig", }, + KeyStoneURL: openstackAuthURL, + MachineImages: machineImages, } - cloudProfileConfigJSON, _ = json.Marshal(cloudProfileConfig) + + cloudProfileConfigJSON, _ = json.Marshal(cloudProfileConfig2) cluster = &extensionscontroller.Cluster{ CloudProfile: &gardencorev1beta1.CloudProfile{ ObjectMeta: metav1.ObjectMeta{ Name: cloudProfileName, }, Spec: gardencorev1beta1.CloudProfileSpec{ + MachineCapabilities: capabilityDefinitions, + MachineTypes: []gardencorev1beta1.MachineType{ + { + Name: machineType, + Capabilities: capabilitiesAmd, + }, + { + Name: machineTypeArm, + Architecture: new(archARM), + Capabilities: capabilitiesArm, + }, + }, ProviderConfig: &runtime.RawExtension{ Raw: cloudProfileConfigJSON, }, @@ -322,6 +446,7 @@ var _ = Describe("Machines", func() { { Purpose: stackitv1alpha1.PurposeNodes, Name: securityGroupName, + ID: securityGroupID, }, }, Node: stackitv1alpha1.NodeStatus{ @@ -398,9 +523,9 @@ var _ = Describe("Machines", func() { Maximum: maxPool2, Priority: priorityPool2, MaxSurge: maxSurgePool2, - Architecture: &archAMD, + Architecture: &archARM, MaxUnavailable: maxUnavailablePool2, - MachineType: machineType, + MachineType: machineTypeArm, MachineImage: extensionsv1alpha1.MachineImage{ Name: machineImageName, Version: machineImageVersion, @@ -428,360 +553,309 @@ var _ = Describe("Machines", func() { workerPoolHash2, _ = worker.WorkerPoolHash(w.Spec.Pools[1], cluster, nil, nil) workerPoolHash3, _ = worker.WorkerPoolHash(w.Spec.Pools[2], cluster, nil, nil) - c = newFakeClient( - w.DeepCopy(), - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: userDataSecretName, - Namespace: namespace, + fakeScheme := runtime.NewScheme() + Expect(corev1.AddToScheme(fakeScheme)).To(Succeed()) + Expect(extensionsv1alpha1.AddToScheme(fakeScheme)).To(Succeed()) + c = fakeclient.NewClientBuilder(). + WithScheme(fakeScheme). + WithObjects( + w, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: userDataSecretName, + Namespace: namespace, + }, + Data: map[string][]byte{userDataSecretDataKey: userData}, }, - Data: map[string][]byte{userDataSecretDataKey: userData}, - }, - ) + ). + WithStatusSubresource(&extensionsv1alpha1.Worker{}). + Build() - workerDelegate, _ = NewWorkerDelegate(c, scheme, chartApplier, "", w, clusterWithoutImages, "") + workerDelegate, _ = NewWorkerDelegate(c, scheme, chartApplier, "", w, clusterWithoutImages, customLabelDomain) }) - expectWorkerStatus := func(workerObj *extensionsv1alpha1.Worker, expectedStatus *stackitv1alpha1.WorkerStatus) { - persistedWorker := &extensionsv1alpha1.Worker{} - Expect(c.Get(ctx, client.ObjectKeyFromObject(workerObj), persistedWorker)).To(Succeed()) - Expect(persistedWorker.Status.ProviderStatus).NotTo(BeNil()) - - rawStatus, err := persistedWorker.Status.GetProviderStatus().MarshalJSON() - Expect(err).NotTo(HaveOccurred()) - - actualStatus := &stackitv1alpha1.WorkerStatus{} - Expect(json.Unmarshal(rawStatus, actualStatus)).To(Succeed()) - Expect(actualStatus).To(Equal(expectedStatus)) - } + Describe("machine images", func() { + var ( + defaultMachineClass map[string]any + machineDeployments worker.MachineDeployments + machineClasses map[string]any + workerWithRegion *extensionsv1alpha1.Worker + clusterWithRegion *extensionscontroller.Cluster + ) + BeforeEach(func() { - setupMachineTest := func(region, name, imageID, architecture string, useStackitMCM bool, defaultMachineClass *map[string]any, machineDeployments *worker.MachineDeployments, machineClasses *map[string]any, workerWithRegion **extensionsv1alpha1.Worker, clusterWithRegion **extensionscontroller.Cluster) { - securityGroupID := "sg-12345" - *workerWithRegion = w.DeepCopy() - zone1 = region + "a" - zone2 = region + "b" - (*workerWithRegion).Spec.Region = region - (*workerWithRegion).Spec.Pools[0].Architecture = &architecture - (*workerWithRegion).Spec.Pools[1].Architecture = &architecture - (*workerWithRegion).Spec.Pools[2].Architecture = &architecture - - (*workerWithRegion).Spec.Pools[0].Zones = []string{zone1, zone2} - (*workerWithRegion).Spec.Pools[1].Zones = []string{zone1, zone2} - (*workerWithRegion).Spec.Pools[2].Zones = []string{zone1, zone2} - - // Update infrastructure status to include security group ID (for STACKIT) - (*workerWithRegion).Spec.InfrastructureProviderStatus = &runtime.RawExtension{ - Raw: encode(&stackitv1alpha1.InfrastructureStatus{ - SecurityGroups: []stackitv1alpha1.SecurityGroup{ - { - Purpose: stackitv1alpha1.PurposeNodes, - Name: securityGroupName, - ID: securityGroupID, - }, - }, - Node: stackitv1alpha1.NodeStatus{ - KeyName: keyName, + workerWithRegion = w.DeepCopy() + zone1 = region + "a" + zone2 = region + "b" + workerWithRegion.Spec.Region = region + workerWithRegion.Spec.Pools[0].Zones = []string{zone1, zone2} + workerWithRegion.Spec.Pools[1].Zones = []string{zone1, zone2} + workerWithRegion.Spec.Pools[2].Zones = []string{zone1, zone2} + + clusterWithRegion = &extensionscontroller.Cluster{ + CloudProfile: cluster.CloudProfile, + Shoot: cluster.Shoot.DeepCopy(), + Seed: cluster.Seed, + } + clusterWithRegion.Shoot.Spec.Region = region + + defaultMachineClass = map[string]any{ + "region": region, + "keyName": keyName, + "networkID": networkID, + "podNetworkCIDRs": []string{podCIDR}, + "securityGroups": []string{securityGroupName}, + "tags": map[string]string{ + fmt.Sprintf("kubernetes.io-cluster-%s", technicalID): "1", + "kubernetes.io-role-node": "1", }, - Networks: stackitv1alpha1.NetworkStatus{ - ID: networkID, - Subnets: []stackitv1alpha1.Subnet{ - { - Purpose: stackitv1alpha1.PurposeNodes, - ID: subnetID, - }, - }, + "secret": map[string]any{ + "cloudConfig": string(userData), }, - }), - } - - *clusterWithRegion = &extensionscontroller.Cluster{ - CloudProfile: cluster.CloudProfile, - Shoot: cluster.Shoot.DeepCopy(), - Seed: cluster.Seed, - } - (*clusterWithRegion).Shoot.Spec.Region = region - - c = newFakeClient( - (*workerWithRegion).DeepCopy(), - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: userDataSecretName, - Namespace: namespace, + "operatingSystem": map[string]any{ + "operatingSystemName": machineImageName, + "operatingSystemVersion": strings.ReplaceAll(machineImageVersion, "+", "_"), }, - Data: map[string][]byte{userDataSecretDataKey: userData}, - }, - ) - - // For STACKIT, region is determined using DetermineRegion which handles RegionOne -> eu01 mapping - effectiveRegion := region - if useStackitMCM && region == "RegionOne" { - effectiveRegion = "eu01" - } - - *defaultMachineClass = map[string]any{ - "region": effectiveRegion, - "machineType": machineType, - "keyName": keyName, - "networkID": networkID, - "podNetworkCIDRs": []string{podCIDR}, - "secret": map[string]any{ - "cloudConfig": string(userData), - }, - "operatingSystem": map[string]any{ - "operatingSystemName": machineImageName, - "operatingSystemVersion": strings.ReplaceAll(machineImageVersion, "+", "_"), - }, - } - - // STACKIT-specific vs OpenStack-specific fields - if useStackitMCM { - // STACKIT uses security group IDs and simplified tags - (*defaultMachineClass)["securityGroups"] = []string{securityGroupID} - (*defaultMachineClass)["tags"] = map[string]string{ - "kubernetes.io/cluster": technicalID, } - // Note: subnetID is NOT included for STACKIT - } else { - // OpenStack uses security group names, full tags, and subnetID - (*defaultMachineClass)["securityGroups"] = []string{securityGroupName} - (*defaultMachineClass)["tags"] = map[string]string{ - fmt.Sprintf("kubernetes.io-cluster-%s", technicalID): "1", - "kubernetes.io-role-node": "1", - } - (*defaultMachineClass)["subnetID"] = subnetID - } - - if imageID == "" { - (*defaultMachineClass)["imageName"] = name - } else { - (*defaultMachineClass)["imageID"] = imageID - } - - newNodeTemplateZone1 := machinev1alpha1.NodeTemplate{ - Capacity: nodeCapacity, - InstanceType: machineType, - Region: effectiveRegion, - Zone: zone1, - Architecture: &architecture, - } - newNodeTemplateZone2 := machinev1alpha1.NodeTemplate{ - Capacity: nodeCapacity, - InstanceType: machineType, - Region: effectiveRegion, - Zone: zone2, - Architecture: &architecture, - } + if useStackitMCM { + securityGroupID := "sg-12345" + // STACKIT uses security group IDs and simplified tags + defaultMachineClass["securityGroups"] = []string{securityGroupID} + defaultMachineClass["tags"] = map[string]string{ + "kubernetes.io/cluster": technicalID, + } + // Note: subnetID is NOT included for STACKIT + } else { + // OpenStack uses security group names, full tags, and subnetID + defaultMachineClass["securityGroups"] = []string{securityGroupName} + defaultMachineClass["tags"] = map[string]string{ + fmt.Sprintf("kubernetes.io-cluster-%s", technicalID): "1", + "kubernetes.io-role-node": "1", + } + defaultMachineClass["subnetID"] = subnetID + } - var ( - machineClassPool1Zone1 = useDefaultMachineClass(*defaultMachineClass, zone1) - machineClassPool1Zone2 = useDefaultMachineClass(*defaultMachineClass, zone2) - machineClassPool2Zone1 = useDefaultMachineClass(*defaultMachineClass, zone1) - machineClassPool2Zone2 = useDefaultMachineClass(*defaultMachineClass, zone2) - machineClassPool3Zone1 = useDefaultMachineClass(*defaultMachineClass, zone1) - machineClassPool3Zone2 = useDefaultMachineClass(*defaultMachineClass, zone2) - - machineClassNamePool1Zone1 = fmt.Sprintf("%s-%s-z1", technicalID, namePool1) - machineClassNamePool1Zone2 = fmt.Sprintf("%s-%s-z2", technicalID, namePool1) - machineClassNamePool2Zone1 = fmt.Sprintf("%s-%s-z1", technicalID, namePool2) - machineClassNamePool2Zone2 = fmt.Sprintf("%s-%s-z2", technicalID, namePool2) - machineClassNamePool3Zone1 = fmt.Sprintf("%s-%s-z1", technicalID, namePool3) - machineClassNamePool3Zone2 = fmt.Sprintf("%s-%s-z2", technicalID, namePool3) - - machineClassWithHashPool1Zone1 = fmt.Sprintf("%s-%s", machineClassNamePool1Zone1, workerPoolHash1) - machineClassWithHashPool1Zone2 = fmt.Sprintf("%s-%s", machineClassNamePool1Zone2, workerPoolHash1) - machineClassWithHashPool2Zone1 = fmt.Sprintf("%s-%s", machineClassNamePool2Zone1, workerPoolHash2) - machineClassWithHashPool2Zone2 = fmt.Sprintf("%s-%s", machineClassNamePool2Zone2, workerPoolHash2) - machineClassWithHashPool3Zone1 = fmt.Sprintf("%s-%s", machineClassNamePool3Zone1, workerPoolHash3) - machineClassWithHashPool3Zone2 = fmt.Sprintf("%s-%s", machineClassNamePool3Zone2, workerPoolHash3) - ) + if usesGlobalImageNames { + defaultMachineClass["imageName"] = machineImage + } else { + defaultMachineClass["imageID"] = machineImageID + } - addNameAndSecretToMachineClass(machineClassPool1Zone1, machineClassWithHashPool1Zone1, w.Spec.SecretRef) - addNameAndSecretToMachineClass(machineClassPool1Zone2, machineClassWithHashPool1Zone2, w.Spec.SecretRef) - addNameAndSecretToMachineClass(machineClassPool2Zone1, machineClassWithHashPool2Zone1, w.Spec.SecretRef) - addNameAndSecretToMachineClass(machineClassPool2Zone2, machineClassWithHashPool2Zone2, w.Spec.SecretRef) - addNameAndSecretToMachineClass(machineClassPool3Zone1, machineClassWithHashPool3Zone1, w.Spec.SecretRef) - addNameAndSecretToMachineClass(machineClassPool3Zone2, machineClassWithHashPool3Zone2, w.Spec.SecretRef) - - addNodeTemplateToMachineClass(machineClassPool1Zone1, newNodeTemplateZone1) - addNodeTemplateToMachineClass(machineClassPool1Zone2, newNodeTemplateZone2) - addNodeTemplateToMachineClass(machineClassPool2Zone1, newNodeTemplateZone1) - addNodeTemplateToMachineClass(machineClassPool2Zone2, newNodeTemplateZone2) - addNodeTemplateToMachineClass(machineClassPool3Zone1, newNodeTemplateZone1) - addNodeTemplateToMachineClass(machineClassPool3Zone2, newNodeTemplateZone2) - - *machineClasses = map[string]any{"machineClasses": []map[string]any{ - machineClassPool1Zone1, - machineClassPool1Zone2, - machineClassPool2Zone1, - machineClassPool2Zone2, - machineClassPool3Zone1, - machineClassPool3Zone2, - }} - - labelsZone1 := map[string]string{openstack.CSIDiskDriverTopologyKey: zone1, openstack.CSISTACKITDriverTopologyKey: zone1} - labelsZone2 := map[string]string{openstack.CSIDiskDriverTopologyKey: zone2, openstack.CSISTACKITDriverTopologyKey: zone2} - *machineDeployments = worker.MachineDeployments{ - { - Name: machineClassNamePool1Zone1, - ClassName: machineClassWithHashPool1Zone1, - SecretName: machineClassWithHashPool1Zone1, - Minimum: worker.DistributeOverZones(0, minPool1, 2), - Maximum: worker.DistributeOverZones(0, maxPool1, 2), - PoolName: namePool1, - Strategy: machinev1alpha1.MachineDeploymentStrategy{ - Type: machinev1alpha1.RollingUpdateMachineDeploymentStrategyType, - RollingUpdate: &machinev1alpha1.RollingUpdateMachineDeployment{ - UpdateConfiguration: machinev1alpha1.UpdateConfiguration{ - MaxUnavailable: new(worker.DistributePositiveIntOrPercent(0, maxUnavailablePool1, 2, minPool1)), - MaxSurge: new(worker.DistributePositiveIntOrPercent(0, maxSurgePool1, 2, maxPool1)), + newNodeTemplatePool1Zone1 := &nodeTemplatePool1Zone1 + newNodeTemplatePool1Zone2 := &nodeTemplatePool1Zone2 + newNodeTemplatePool2Zone1 := &nodeTemplatePool2Zone1 + newNodeTemplatePool2Zone2 := &nodeTemplatePool2Zone2 + newNodeTemplatePool3Zone1 := &nodeTemplatePool3Zone1 + newNodeTemplatePool3Zone2 := &nodeTemplatePool3Zone2 + + var ( + machineClassPool1Zone1 = addKeyValueToMap(defaultMachineClass, "availabilityZone", zone1) + machineClassPool1Zone2 = addKeyValueToMap(defaultMachineClass, "availabilityZone", zone2) + machineClassPool2Zone1 = addKeyValueToMap(defaultMachineClass, "availabilityZone", zone1) + machineClassPool2Zone2 = addKeyValueToMap(defaultMachineClass, "availabilityZone", zone2) + machineClassPool3Zone1 = addKeyValueToMap(defaultMachineClass, "availabilityZone", zone1) + machineClassPool3Zone2 = addKeyValueToMap(defaultMachineClass, "availabilityZone", zone2) + + machineClassNamePool1Zone1 = fmt.Sprintf("%s-%s-z1", technicalID, namePool1) + machineClassNamePool1Zone2 = fmt.Sprintf("%s-%s-z2", technicalID, namePool1) + machineClassNamePool2Zone1 = fmt.Sprintf("%s-%s-z1", technicalID, namePool2) + machineClassNamePool2Zone2 = fmt.Sprintf("%s-%s-z2", technicalID, namePool2) + machineClassNamePool3Zone1 = fmt.Sprintf("%s-%s-z1", technicalID, namePool3) + machineClassNamePool3Zone2 = fmt.Sprintf("%s-%s-z2", technicalID, namePool3) + + machineClassWithHashPool1Zone1 = fmt.Sprintf("%s-%s", machineClassNamePool1Zone1, workerPoolHash1) + machineClassWithHashPool1Zone2 = fmt.Sprintf("%s-%s", machineClassNamePool1Zone2, workerPoolHash1) + machineClassWithHashPool2Zone1 = fmt.Sprintf("%s-%s", machineClassNamePool2Zone1, workerPoolHash2) + machineClassWithHashPool2Zone2 = fmt.Sprintf("%s-%s", machineClassNamePool2Zone2, workerPoolHash2) + machineClassWithHashPool3Zone1 = fmt.Sprintf("%s-%s", machineClassNamePool3Zone1, workerPoolHash3) + machineClassWithHashPool3Zone2 = fmt.Sprintf("%s-%s", machineClassNamePool3Zone2, workerPoolHash3) + ) + machineClassPool1Zone1 = addKeyValueToMap(machineClassPool1Zone1, "machineType", machineType) + machineClassPool1Zone2 = addKeyValueToMap(machineClassPool1Zone2, "machineType", machineType) + machineClassPool2Zone1 = addKeyValueToMap(machineClassPool2Zone1, "machineType", machineType) + machineClassPool2Zone2 = addKeyValueToMap(machineClassPool2Zone2, "machineType", machineType) + machineClassPool3Zone1 = addKeyValueToMap(machineClassPool3Zone1, "machineType", machineTypeArm) + machineClassPool3Zone2 = addKeyValueToMap(machineClassPool3Zone2, "machineType", machineTypeArm) + + addNameAndSecretToMachineClass(machineClassPool1Zone1, machineClassWithHashPool1Zone1, w.Spec.SecretRef) + addNameAndSecretToMachineClass(machineClassPool1Zone2, machineClassWithHashPool1Zone2, w.Spec.SecretRef) + addNameAndSecretToMachineClass(machineClassPool2Zone1, machineClassWithHashPool2Zone1, w.Spec.SecretRef) + addNameAndSecretToMachineClass(machineClassPool2Zone2, machineClassWithHashPool2Zone2, w.Spec.SecretRef) + addNameAndSecretToMachineClass(machineClassPool3Zone1, machineClassWithHashPool3Zone1, w.Spec.SecretRef) + addNameAndSecretToMachineClass(machineClassPool3Zone2, machineClassWithHashPool3Zone2, w.Spec.SecretRef) + + addNodeTemplateToMachineClass(machineClassPool1Zone1, *newNodeTemplatePool1Zone1) + addNodeTemplateToMachineClass(machineClassPool1Zone2, *newNodeTemplatePool1Zone2) + addNodeTemplateToMachineClass(machineClassPool2Zone1, *newNodeTemplatePool2Zone1) + addNodeTemplateToMachineClass(machineClassPool2Zone2, *newNodeTemplatePool2Zone2) + addNodeTemplateToMachineClass(machineClassPool3Zone1, *newNodeTemplatePool3Zone1) + addNodeTemplateToMachineClass(machineClassPool3Zone2, *newNodeTemplatePool3Zone2) + + machineClasses = map[string]any{"machineClasses": []map[string]any{ + machineClassPool1Zone1, + machineClassPool1Zone2, + machineClassPool2Zone1, + machineClassPool2Zone2, + machineClassPool3Zone1, + machineClassPool3Zone2, + }} + + labelsZone1 := map[string]string{openstack.CSIDiskDriverTopologyKey: zone1, openstack.CSISTACKITDriverTopologyKey: zone1} + labelsZone2 := map[string]string{openstack.CSIDiskDriverTopologyKey: zone2, openstack.CSISTACKITDriverTopologyKey: zone2} + machineDeployments = worker.MachineDeployments{ + { + Name: machineClassNamePool1Zone1, + ClassName: machineClassWithHashPool1Zone1, + SecretName: machineClassWithHashPool1Zone1, + Minimum: worker.DistributeOverZones(0, minPool1, 2), + Maximum: worker.DistributeOverZones(0, maxPool1, 2), + PoolName: namePool1, + Strategy: machinev1alpha1.MachineDeploymentStrategy{ + Type: machinev1alpha1.RollingUpdateMachineDeploymentStrategyType, + RollingUpdate: &machinev1alpha1.RollingUpdateMachineDeployment{ + UpdateConfiguration: machinev1alpha1.UpdateConfiguration{ + MaxUnavailable: new(worker.DistributePositiveIntOrPercent(0, maxUnavailablePool1, 2, minPool1)), + MaxSurge: new(worker.DistributePositiveIntOrPercent(0, maxSurgePool1, 2, maxPool1)), + }, }, }, + Labels: labelsZone1, + MachineConfiguration: machineConfiguration, + ClusterAutoscalerAnnotations: emptyClusterAutoscalerAnnotations, }, - Labels: labelsZone1, - MachineConfiguration: machineConfiguration, - ClusterAutoscalerAnnotations: emptyClusterAutoscalerAnnotations, - }, - { - Name: machineClassNamePool1Zone2, - ClassName: machineClassWithHashPool1Zone2, - SecretName: machineClassWithHashPool1Zone2, - Minimum: worker.DistributeOverZones(1, minPool1, 2), - Maximum: worker.DistributeOverZones(1, maxPool1, 2), - PoolName: namePool1, - Strategy: machinev1alpha1.MachineDeploymentStrategy{ - Type: machinev1alpha1.RollingUpdateMachineDeploymentStrategyType, - RollingUpdate: &machinev1alpha1.RollingUpdateMachineDeployment{ - UpdateConfiguration: machinev1alpha1.UpdateConfiguration{ - MaxUnavailable: new(worker.DistributePositiveIntOrPercent(1, maxUnavailablePool1, 2, minPool1)), - MaxSurge: new(worker.DistributePositiveIntOrPercent(1, maxSurgePool1, 2, maxPool1)), + { + Name: machineClassNamePool1Zone2, + ClassName: machineClassWithHashPool1Zone2, + SecretName: machineClassWithHashPool1Zone2, + Minimum: worker.DistributeOverZones(1, minPool1, 2), + Maximum: worker.DistributeOverZones(1, maxPool1, 2), + PoolName: namePool1, + Strategy: machinev1alpha1.MachineDeploymentStrategy{ + Type: machinev1alpha1.RollingUpdateMachineDeploymentStrategyType, + RollingUpdate: &machinev1alpha1.RollingUpdateMachineDeployment{ + UpdateConfiguration: machinev1alpha1.UpdateConfiguration{ + MaxUnavailable: new(worker.DistributePositiveIntOrPercent(1, maxUnavailablePool1, 2, minPool1)), + MaxSurge: new(worker.DistributePositiveIntOrPercent(1, maxSurgePool1, 2, maxPool1)), + }, }, }, + Labels: labelsZone2, + MachineConfiguration: machineConfiguration, + ClusterAutoscalerAnnotations: emptyClusterAutoscalerAnnotations, }, - Labels: labelsZone2, - MachineConfiguration: machineConfiguration, - ClusterAutoscalerAnnotations: emptyClusterAutoscalerAnnotations, - }, - { - Name: machineClassNamePool2Zone1, - ClassName: machineClassWithHashPool2Zone1, - SecretName: machineClassWithHashPool2Zone1, - Minimum: worker.DistributeOverZones(0, minPool2, 2), - Maximum: worker.DistributeOverZones(0, maxPool2, 2), - Priority: priorityPool2, - PoolName: namePool2, - Strategy: machinev1alpha1.MachineDeploymentStrategy{ - Type: machinev1alpha1.InPlaceUpdateMachineDeploymentStrategyType, - InPlaceUpdate: &machinev1alpha1.InPlaceUpdateMachineDeployment{ - OrchestrationType: machinev1alpha1.OrchestrationTypeAuto, - UpdateConfiguration: machinev1alpha1.UpdateConfiguration{ - MaxUnavailable: new(worker.DistributePositiveIntOrPercent(0, maxUnavailablePool2, 2, minPool2)), - MaxSurge: new(worker.DistributePositiveIntOrPercent(0, maxSurgePool2, 2, maxPool2)), + { + Name: machineClassNamePool2Zone1, + ClassName: machineClassWithHashPool2Zone1, + SecretName: machineClassWithHashPool2Zone1, + Minimum: worker.DistributeOverZones(0, minPool2, 2), + Maximum: worker.DistributeOverZones(0, maxPool2, 2), + Priority: priorityPool2, + PoolName: namePool2, + Strategy: machinev1alpha1.MachineDeploymentStrategy{ + Type: machinev1alpha1.InPlaceUpdateMachineDeploymentStrategyType, + InPlaceUpdate: &machinev1alpha1.InPlaceUpdateMachineDeployment{ + OrchestrationType: machinev1alpha1.OrchestrationTypeAuto, + UpdateConfiguration: machinev1alpha1.UpdateConfiguration{ + MaxUnavailable: new(worker.DistributePositiveIntOrPercent(0, maxUnavailablePool2, 2, minPool2)), + MaxSurge: new(worker.DistributePositiveIntOrPercent(0, maxSurgePool2, 2, maxPool2)), + }, }, }, + Labels: labelsZone1, + MachineConfiguration: machineConfiguration, + ClusterAutoscalerAnnotations: emptyClusterAutoscalerAnnotations, }, - Labels: labelsZone1, - MachineConfiguration: machineConfiguration, - ClusterAutoscalerAnnotations: emptyClusterAutoscalerAnnotations, - }, - { - Name: machineClassNamePool2Zone2, - ClassName: machineClassWithHashPool2Zone2, - SecretName: machineClassWithHashPool2Zone2, - Minimum: worker.DistributeOverZones(1, minPool2, 2), - Maximum: worker.DistributeOverZones(1, maxPool2, 2), - Priority: priorityPool2, - PoolName: namePool2, - Strategy: machinev1alpha1.MachineDeploymentStrategy{ - Type: machinev1alpha1.InPlaceUpdateMachineDeploymentStrategyType, - InPlaceUpdate: &machinev1alpha1.InPlaceUpdateMachineDeployment{ - OrchestrationType: machinev1alpha1.OrchestrationTypeAuto, - UpdateConfiguration: machinev1alpha1.UpdateConfiguration{ - MaxUnavailable: new(worker.DistributePositiveIntOrPercent(1, maxUnavailablePool2, 2, minPool2)), - MaxSurge: new(worker.DistributePositiveIntOrPercent(1, maxSurgePool2, 2, maxPool2)), + { + Name: machineClassNamePool2Zone2, + ClassName: machineClassWithHashPool2Zone2, + SecretName: machineClassWithHashPool2Zone2, + Minimum: worker.DistributeOverZones(1, minPool2, 2), + Maximum: worker.DistributeOverZones(1, maxPool2, 2), + Priority: priorityPool2, + PoolName: namePool2, + Strategy: machinev1alpha1.MachineDeploymentStrategy{ + Type: machinev1alpha1.InPlaceUpdateMachineDeploymentStrategyType, + InPlaceUpdate: &machinev1alpha1.InPlaceUpdateMachineDeployment{ + OrchestrationType: machinev1alpha1.OrchestrationTypeAuto, + UpdateConfiguration: machinev1alpha1.UpdateConfiguration{ + MaxUnavailable: new(worker.DistributePositiveIntOrPercent(1, maxUnavailablePool2, 2, minPool2)), + MaxSurge: new(worker.DistributePositiveIntOrPercent(1, maxSurgePool2, 2, maxPool2)), + }, }, }, + Labels: labelsZone2, + MachineConfiguration: machineConfiguration, + ClusterAutoscalerAnnotations: emptyClusterAutoscalerAnnotations, }, - Labels: labelsZone2, - MachineConfiguration: machineConfiguration, - ClusterAutoscalerAnnotations: emptyClusterAutoscalerAnnotations, - }, - { - Name: machineClassNamePool3Zone1, - ClassName: machineClassWithHashPool3Zone1, - SecretName: machineClassWithHashPool3Zone1, - Minimum: worker.DistributeOverZones(0, minPool2, 2), - Maximum: worker.DistributeOverZones(0, maxPool2, 2), - Priority: priorityPool2, - PoolName: namePool3, - Strategy: machinev1alpha1.MachineDeploymentStrategy{ - Type: machinev1alpha1.InPlaceUpdateMachineDeploymentStrategyType, - InPlaceUpdate: &machinev1alpha1.InPlaceUpdateMachineDeployment{ - OrchestrationType: machinev1alpha1.OrchestrationTypeManual, - UpdateConfiguration: machinev1alpha1.UpdateConfiguration{ - MaxUnavailable: new(worker.DistributePositiveIntOrPercent(0, maxUnavailablePool2, 2, minPool2)), - MaxSurge: new(worker.DistributePositiveIntOrPercent(0, maxSurgePool2, 2, maxPool2)), + { + Name: machineClassNamePool3Zone1, + ClassName: machineClassWithHashPool3Zone1, + SecretName: machineClassWithHashPool3Zone1, + Minimum: worker.DistributeOverZones(0, minPool2, 2), + Maximum: worker.DistributeOverZones(0, maxPool2, 2), + Priority: priorityPool2, + PoolName: namePool3, + Strategy: machinev1alpha1.MachineDeploymentStrategy{ + Type: machinev1alpha1.InPlaceUpdateMachineDeploymentStrategyType, + InPlaceUpdate: &machinev1alpha1.InPlaceUpdateMachineDeployment{ + OrchestrationType: machinev1alpha1.OrchestrationTypeManual, + UpdateConfiguration: machinev1alpha1.UpdateConfiguration{ + MaxUnavailable: new(worker.DistributePositiveIntOrPercent(0, maxUnavailablePool2, 2, minPool2)), + MaxSurge: new(worker.DistributePositiveIntOrPercent(0, maxSurgePool2, 2, maxPool2)), + }, }, }, + Labels: labelsZone1, + MachineConfiguration: machineConfiguration, + ClusterAutoscalerAnnotations: emptyClusterAutoscalerAnnotations, }, - Labels: labelsZone1, - MachineConfiguration: machineConfiguration, - ClusterAutoscalerAnnotations: emptyClusterAutoscalerAnnotations, - }, - { - Name: machineClassNamePool3Zone2, - ClassName: machineClassWithHashPool3Zone2, - SecretName: machineClassWithHashPool3Zone2, - Minimum: worker.DistributeOverZones(1, minPool2, 2), - Maximum: worker.DistributeOverZones(1, maxPool2, 2), - Priority: priorityPool2, - PoolName: namePool3, - Strategy: machinev1alpha1.MachineDeploymentStrategy{ - Type: machinev1alpha1.InPlaceUpdateMachineDeploymentStrategyType, - InPlaceUpdate: &machinev1alpha1.InPlaceUpdateMachineDeployment{ - OrchestrationType: machinev1alpha1.OrchestrationTypeManual, - UpdateConfiguration: machinev1alpha1.UpdateConfiguration{ - MaxUnavailable: new(worker.DistributePositiveIntOrPercent(1, maxUnavailablePool2, 2, minPool2)), - MaxSurge: new(worker.DistributePositiveIntOrPercent(1, maxSurgePool2, 2, maxPool2)), + { + Name: machineClassNamePool3Zone2, + ClassName: machineClassWithHashPool3Zone2, + SecretName: machineClassWithHashPool3Zone2, + Minimum: worker.DistributeOverZones(1, minPool2, 2), + Maximum: worker.DistributeOverZones(1, maxPool2, 2), + Priority: priorityPool2, + PoolName: namePool3, + Strategy: machinev1alpha1.MachineDeploymentStrategy{ + Type: machinev1alpha1.InPlaceUpdateMachineDeploymentStrategyType, + InPlaceUpdate: &machinev1alpha1.InPlaceUpdateMachineDeployment{ + OrchestrationType: machinev1alpha1.OrchestrationTypeManual, + UpdateConfiguration: machinev1alpha1.UpdateConfiguration{ + MaxUnavailable: new(worker.DistributePositiveIntOrPercent(1, maxUnavailablePool2, 2, minPool2)), + MaxSurge: new(worker.DistributePositiveIntOrPercent(1, maxSurgePool2, 2, maxPool2)), + }, }, }, + Labels: labelsZone2, + MachineConfiguration: machineConfiguration, + ClusterAutoscalerAnnotations: emptyClusterAutoscalerAnnotations, }, - Labels: labelsZone2, - MachineConfiguration: machineConfiguration, - ClusterAutoscalerAnnotations: emptyClusterAutoscalerAnnotations, - }, - } - } + } - Describe("machine images", func() { - var ( - defaultMachineClass map[string]any - machineDeployments worker.MachineDeployments - machineClasses map[string]any - workerWithRegion *extensionsv1alpha1.Worker - clusterWithRegion *extensionscontroller.Cluster - ) + workerPoolHash1, _ = worker.WorkerPoolHash(w.Spec.Pools[0], cluster, nil, nil) + workerPoolHash2, _ = worker.WorkerPoolHash(w.Spec.Pools[1], cluster, nil, nil) + workerPoolHash3, _ = worker.WorkerPoolHash(w.Spec.Pools[2], cluster, nil, nil) - BeforeEach(func() { - // Disable STACKIT feature flags for OpenStack-only machineclass tests - DeferCleanup(testutils.WithFeatureGate(feature.MutableGate, feature.UseSTACKITMachineControllerManager, false)) }) - setup := func(region, name, imageID, architecture string) { - setupMachineTest(region, name, imageID, architecture, false, &defaultMachineClass, &machineDeployments, &machineClasses, &workerWithRegion, &clusterWithRegion) - } - It("should return the expected machine deployments for profile image types", func() { - setup(region, machineImage, "", archAMD) - workerDelegate, _ := NewWorkerDelegate(c, scheme, chartApplier, "", w, cluster, "") + workerDelegate, _ := NewWorkerDelegate(c, scheme, chartApplier, "", w, cluster, customLabelDomain) // Test workerDelegate.DeployMachineClasses() + + machineClassPath := filepath.Join("internal", "machineclass") + if useStackitMCM { + machineClassPath = filepath.Join("internal", "machineclass-stackit") + } + chartApplier. EXPECT(). ApplyFromEmbeddedFS( ctx, charts.InternalChart, - filepath.Join("internal", "machineclass"), + machineClassPath, namespace, "machineclass", kubernetes.Values(machineClasses), @@ -792,30 +866,10 @@ var _ = Describe("Machines", func() { Expect(err).NotTo(HaveOccurred()) // Test workerDelegate.UpdateMachineDeployments() - - expectedImages := &stackitv1alpha1.WorkerStatus{ - TypeMeta: metav1.TypeMeta{ - APIVersion: stackitv1alpha1.SchemeGroupVersion.String(), - Kind: "WorkerStatus", - }, - MachineImages: []stackitv1alpha1.MachineImage{ - { - Name: machineImageName, - Version: machineImageVersion, - Image: machineImage, - Architecture: new(v1beta1constants.ArchitectureAMD64), - }, - }, - } - - workerWithExpectedImages := w.DeepCopy() - workerWithExpectedImages.Status.ProviderStatus = &runtime.RawExtension{ - Object: expectedImages, - } - + // When using global image names (machineImageID==""), Image field is set; + // Test WorkerDelegate.UpdateMachineDeployments() err = workerDelegate.UpdateMachineImagesStatus(ctx) Expect(err).NotTo(HaveOccurred()) - expectWorkerStatus(w, expectedImages) // Test workerDelegate.GenerateMachineDeployments() @@ -825,50 +879,26 @@ var _ = Describe("Machines", func() { }) It("should return the expected machine deployments for profile image types with id", func() { - setup(regionWithImages, "", machineImageID, archARM) - workerDelegate, _ := NewWorkerDelegate(c, scheme, chartApplier, "", workerWithRegion, clusterWithRegion, "") + // setup(region, "", machineImageID, archARM) + workerDelegate, _ := NewWorkerDelegate(c, scheme, chartApplier, "", workerWithRegion, clusterWithRegion, customLabelDomain) clusterWithRegion.Shoot.Spec.Hibernation = &gardencorev1beta1.Hibernation{Enabled: new(true)} // Test workerDelegate.DeployMachineClasses() - chartApplier. - EXPECT(). - ApplyFromEmbeddedFS( - ctx, - charts.InternalChart, - filepath.Join("internal", "machineclass"), - namespace, - "machineclass", - kubernetes.Values(machineClasses), - ). - Return(nil) - err := workerDelegate.DeployMachineClasses(ctx) - Expect(err).NotTo(HaveOccurred()) - - // Test workerDelegate.GetMachineImages() - expectedImages := &stackitv1alpha1.WorkerStatus{ - TypeMeta: metav1.TypeMeta{ - APIVersion: stackitv1alpha1.SchemeGroupVersion.String(), - Kind: "WorkerStatus", - }, - MachineImages: []stackitv1alpha1.MachineImage{ - { - Name: machineImageName, - Version: machineImageVersion, - ID: machineImageID, - Architecture: new(v1beta1constants.ArchitectureARM64), - }, - }, + machineClassPath := filepath.Join("internal", "machineclass") + if useStackitMCM { + machineClassPath = filepath.Join("internal", "machineclass-stackit") } - workerWithExpectedImages := workerWithRegion.DeepCopy() - workerWithExpectedImages.Status.ProviderStatus = &runtime.RawExtension{ - Object: expectedImages, - } + chartApplier.EXPECT().ApplyFromEmbeddedFS(ctx, charts.InternalChart, machineClassPath, namespace, "machineclass", kubernetes.Values(machineClasses)).Return(nil) + err := workerDelegate.DeployMachineClasses(ctx) + Expect(err).NotTo(HaveOccurred()) + // When using global image names (machineImageID==""), Image field is set; + // Test workerDelegate.GetMachineImages() + ctx := ctx err = workerDelegate.UpdateMachineImagesStatus(ctx) Expect(err).NotTo(HaveOccurred()) - expectWorkerStatus(workerWithRegion, expectedImages) // Test workerDelegate.GenerateMachineDeployments() @@ -879,7 +909,7 @@ var _ = Describe("Machines", func() { Context("Machine Labels", func() { It("should consider rolling machine labels for the worker pool hash", func() { - setup(region, machineImage, "", archAMD) + // setup(region, machineImage, "") applyLabelsAndPolicy := func(labels []stackitv1alpha1.MachineLabel) string { w.Spec.Pools[0].Labels = utils.MergeStringMaps(w.Spec.Pools[0].Labels, map[string]string{"k1": "v1"}) @@ -890,11 +920,10 @@ var _ = Describe("Machines", func() { }, MachineLabels: labels, } - w.Spec.Pools[0].ProviderConfig = &runtime.RawExtension{ Raw: encode(workerConfig), } - workerDelegate, _ := NewWorkerDelegate(c, scheme, chartApplier, "", w, cluster, "") + workerDelegate, _ := NewWorkerDelegate(c, scheme, chartApplier, "", w, cluster, customLabelDomain) result, err := workerDelegate.GenerateMachineDeployments(ctx) Expect(err).NotTo(HaveOccurred()) @@ -941,136 +970,10 @@ var _ = Describe("Machines", func() { }) }) - Describe("machine images with STACKIT MCM", func() { - var ( - defaultMachineClass map[string]any - machineDeployments worker.MachineDeployments - machineClasses map[string]any - workerWithRegion *extensionsv1alpha1.Worker - clusterWithRegion *extensionscontroller.Cluster - ) - - setup := func(region, name, imageID, architecture string) { - setupMachineTest(region, name, imageID, architecture, true, &defaultMachineClass, &machineDeployments, &machineClasses, &workerWithRegion, &clusterWithRegion) - } - - It("should return the expected machine deployments for STACKIT with profile image types", func() { - setup(region, machineImage, "", archAMD) - workerDelegate, _ := NewWorkerDelegate(c, scheme, chartApplier, "", workerWithRegion, clusterWithRegion, "kubernetes.io") - - // Test workerDelegate.DeployMachineClasses() - chartApplier. - EXPECT(). - ApplyFromEmbeddedFS( - ctx, - charts.InternalChart, - filepath.Join("internal", "machineclass-stackit"), - namespace, - "machineclass", - kubernetes.Values(machineClasses), - ). - Return(nil) - - err := workerDelegate.DeployMachineClasses(ctx) - Expect(err).NotTo(HaveOccurred()) - - // Test workerDelegate.UpdateMachineImagesStatus() - expectedImages := &stackitv1alpha1.WorkerStatus{ - TypeMeta: metav1.TypeMeta{ - APIVersion: stackitv1alpha1.SchemeGroupVersion.String(), - Kind: "WorkerStatus", - }, - MachineImages: []stackitv1alpha1.MachineImage{ - { - Name: machineImageName, - Version: machineImageVersion, - Image: machineImage, - Architecture: new(v1beta1constants.ArchitectureAMD64), - }, - }, - } - - workerWithExpectedImages := workerWithRegion.DeepCopy() - workerWithExpectedImages.Status.ProviderStatus = &runtime.RawExtension{ - Object: expectedImages, - } - - err = workerDelegate.UpdateMachineImagesStatus(ctx) - Expect(err).NotTo(HaveOccurred()) - expectWorkerStatus(workerWithRegion, expectedImages) - - // Test workerDelegate.GenerateMachineDeployments() - result, err := workerDelegate.GenerateMachineDeployments(ctx) - Expect(err).NotTo(HaveOccurred()) - Expect(result).To(Equal(machineDeployments)) - }) - - It("should return the expected machine deployments for STACKIT with profile image types with id", func() { - setup(regionWithImages, "", machineImageID, archARM) - workerDelegate, _ := NewWorkerDelegate(c, scheme, chartApplier, "", workerWithRegion, clusterWithRegion, "kubernetes.io") - clusterWithRegion.Shoot.Spec.Hibernation = &gardencorev1beta1.Hibernation{Enabled: new(true)} - - // Test workerDelegate.DeployMachineClasses() - chartApplier. - EXPECT(). - ApplyFromEmbeddedFS( - ctx, - charts.InternalChart, - filepath.Join("internal", "machineclass-stackit"), - namespace, - "machineclass", - kubernetes.Values(machineClasses), - ). - Return(nil) - - err := workerDelegate.DeployMachineClasses(ctx) - Expect(err).NotTo(HaveOccurred()) - - // Test workerDelegate.UpdateMachineImagesStatus() - expectedImages := &stackitv1alpha1.WorkerStatus{ - TypeMeta: metav1.TypeMeta{ - APIVersion: stackitv1alpha1.SchemeGroupVersion.String(), - Kind: "WorkerStatus", - }, - MachineImages: []stackitv1alpha1.MachineImage{ - { - Name: machineImageName, - Version: machineImageVersion, - ID: machineImageID, - Architecture: new(v1beta1constants.ArchitectureARM64), - }, - }, - } - - workerWithExpectedImages := workerWithRegion.DeepCopy() - workerWithExpectedImages.Status.ProviderStatus = &runtime.RawExtension{ - Object: expectedImages, - } - - err = workerDelegate.UpdateMachineImagesStatus(ctx) - Expect(err).NotTo(HaveOccurred()) - expectWorkerStatus(workerWithRegion, expectedImages) - - // Test workerDelegate.GenerateMachineDeployments() - result, err := workerDelegate.GenerateMachineDeployments(ctx) - Expect(err).NotTo(HaveOccurred()) - Expect(result).To(Equal(machineDeployments)) - }) - }) - - It("should fail because the version is invalid", func() { - w.Spec.Pools[1].KubernetesVersion = new("invalid") - workerDelegate, _ = NewWorkerDelegate(c, scheme, chartApplier, "", w, cluster, "") - - result, err := workerDelegate.GenerateMachineDeployments(ctx) - Expect(err).To(HaveOccurred()) - Expect(result).To(BeNil()) - }) - It("should fail because the infrastructure status cannot be decoded", func() { w.Spec.InfrastructureProviderStatus = &runtime.RawExtension{} - workerDelegate, _ = NewWorkerDelegate(c, scheme, chartApplier, "", w, cluster, "") + workerDelegate, _ = NewWorkerDelegate(c, scheme, chartApplier, "", w, cluster, customLabelDomain) result, err := workerDelegate.GenerateMachineDeployments(ctx) Expect(err).To(HaveOccurred()) @@ -1082,7 +985,7 @@ var _ = Describe("Machines", func() { Raw: encode(&stackitv1alpha1.InfrastructureStatus{}), } - workerDelegate, _ = NewWorkerDelegate(c, scheme, chartApplier, "", w, cluster, "") + workerDelegate, _ = NewWorkerDelegate(c, scheme, chartApplier, "", w, cluster, customLabelDomain) result, err := workerDelegate.GenerateMachineDeployments(ctx) Expect(err).To(HaveOccurred()) @@ -1092,7 +995,7 @@ var _ = Describe("Machines", func() { It("should fail because the machine image for this cloud profile cannot be found", func() { clusterWithoutImages.CloudProfile.Name = "another-cloud-profile" - workerDelegate, _ = NewWorkerDelegate(c, scheme, chartApplier, "", w, clusterWithoutImages, "") + workerDelegate, _ = NewWorkerDelegate(c, scheme, chartApplier, "", w, clusterWithoutImages, customLabelDomain) result, err := workerDelegate.GenerateMachineDeployments(ctx) Expect(err).To(HaveOccurred()) @@ -1113,7 +1016,7 @@ var _ = Describe("Machines", func() { NodeConditions: testNodeConditions, } - workerDelegate, _ = NewWorkerDelegate(c, scheme, chartApplier, "", w, cluster, "") + workerDelegate, _ = NewWorkerDelegate(c, scheme, chartApplier, "", w, cluster, customLabelDomain) result, err := workerDelegate.GenerateMachineDeployments(ctx) resultSettings := result[0].MachineConfiguration @@ -1136,7 +1039,7 @@ var _ = Describe("Machines", func() { ScaleDownUtilizationThreshold: new("0.5"), } w.Spec.Pools[1].ClusterAutoscaler = nil - workerDelegate, _ = NewWorkerDelegate(c, scheme, chartApplier, "", w, cluster, "") + workerDelegate, _ = NewWorkerDelegate(c, scheme, chartApplier, "", w, cluster, customLabelDomain) result, err := workerDelegate.GenerateMachineDeployments(ctx) Expect(err).NotTo(HaveOccurred()) @@ -1163,40 +1066,124 @@ var _ = Describe("Machines", func() { Expect(result[1].ClusterAutoscalerAnnotations[extensionsv1alpha1.ScaleDownUnreadyTimeAnnotation]).To(Equal("3m0s")) Expect(result[1].ClusterAutoscalerAnnotations[extensionsv1alpha1.ScaleDownUtilizationThresholdAnnotation]).To(Equal("0.5")) }) - - DescribeTable("customLabelDomain in machineclass helm chart", - func(customDomain string) { - workerDelegate, _ := NewWorkerDelegate(c, scheme, chartApplier, "", w, cluster, customDomain) - - chartApplier. - EXPECT(). - ApplyFromEmbeddedFS( - ctx, - charts.InternalChart, - filepath.Join("internal", "machineclass-stackit"), - namespace, - "machineclass", - gomock.Any(), - ). - Return(nil) - - err := workerDelegate.DeployMachineClasses(ctx) - Expect(err).NotTo(HaveOccurred()) + }, + Entry("with capabilities and using imageIDs", true, false, false), + Entry("with capabilities and using imageIDs with STACKIT mcm", true, false, true), + Entry("with capabilities and using ImageNames", true, true, false), + Entry("without capabilities and using imageIDs", false, false, false), + Entry("without capabilities and using ImageNames", false, true, false), + ) + + DescribeTable("EnsureUniformMachineImages", func(capabilityDefinitions []gardencorev1beta1.CapabilityDefinition, expectedImages []stackitv1alpha1.MachineImage) { + machineImages := []stackitv1alpha1.MachineImage{ + // images with capability sets + { + Name: "some-image", + Version: "1.2.1", + ID: "id-for-arm64", + Capabilities: gardencorev1beta1.Capabilities{ + v1beta1constants.ArchitectureName: []string{"arm64"}, + }, }, - Entry("with default kubernetes.io domain", - "kubernetes.io", - ), - Entry("with custom ske.stackit.cloud domain", - "ske.stackit.cloud", - ), - Entry("with custom example.com domain", - "example.com", - ), - Entry("with empty domain", - "", - ), - ) - }) + { + Name: "some-image", + Version: "1.2.2", + ID: "id-for-amd64", + Capabilities: gardencorev1beta1.Capabilities{ + v1beta1constants.ArchitectureName: []string{"amd64"}, + }, + }, + // legacy image entry without capability sets + { + Name: "some-image", + Version: "1.2.3", + ID: "id-for-amd64", + Architecture: new("amd64"), + }, + { + Name: "some-image", + Version: "1.2.2", + ID: "id-for-amd64", + Architecture: new("amd64"), + }, + { + Name: "some-image", + Version: "1.2.1", + ID: "id-for-amd64", + Architecture: new("amd64"), + }, + } + actualImages := EnsureUniformMachineImages(machineImages, capabilityDefinitions) + Expect(actualImages).To(ContainElements(expectedImages)) + + }, + Entry("should return images with Architecture", nil, []stackitv1alpha1.MachineImage{ + // images with capability sets + { + Name: "some-image", + Version: "1.2.1", + ID: "id-for-arm64", + Architecture: new("arm64"), + }, + { + Name: "some-image", + Version: "1.2.2", + ID: "id-for-amd64", + Architecture: new("amd64"), + }, + // legacy image entry without capability sets + { + Name: "some-image", + Version: "1.2.3", + ID: "id-for-amd64", + Architecture: new("amd64"), + }, + { + Name: "some-image", + Version: "1.2.1", + ID: "id-for-amd64", + Architecture: new("amd64"), + }, + }), + Entry("should return images with Capabilities", []gardencorev1beta1.CapabilityDefinition{{ + Name: v1beta1constants.ArchitectureName, + Values: []string{"amd64", "arm64"}, + }}, []stackitv1alpha1.MachineImage{ + // images with capability sets + { + Name: "some-image", + Version: "1.2.1", + ID: "id-for-arm64", + Capabilities: gardencorev1beta1.Capabilities{ + v1beta1constants.ArchitectureName: []string{"arm64"}, + }, + }, + { + Name: "some-image", + Version: "1.2.2", + ID: "id-for-amd64", + Capabilities: gardencorev1beta1.Capabilities{ + v1beta1constants.ArchitectureName: []string{"amd64"}, + }, + }, + // legacy image entry without capability sets + { + Name: "some-image", + Version: "1.2.3", + ID: "id-for-amd64", + Capabilities: gardencorev1beta1.Capabilities{ + v1beta1constants.ArchitectureName: []string{"amd64"}, + }}, + { + Name: "some-image", + Version: "1.2.1", + ID: "id-for-amd64", + Capabilities: gardencorev1beta1.Capabilities{ + v1beta1constants.ArchitectureName: []string{"amd64"}, + }, + }, + }), + ) }) }) @@ -1205,12 +1192,12 @@ func encode(obj runtime.Object) []byte { return data } -func useDefaultMachineClass(def map[string]any, value any) map[string]any { +func addKeyValueToMap(def map[string]any, key string, value any) map[string]any { out := make(map[string]any, len(def)+1) maps.Copy(out, def) - out["availabilityZone"] = value + out[key] = value return out } diff --git a/pkg/stackit/region.go b/pkg/stackit/region.go index 454e9d30..c3b3c047 100644 --- a/pkg/stackit/region.go +++ b/pkg/stackit/region.go @@ -2,6 +2,7 @@ package stackit import ( extensionscontroller "github.com/gardener/gardener/extensions/pkg/controller" + extensionsv1alpha1 "github.com/gardener/gardener/pkg/apis/extensions/v1alpha1" ) // DetermineRegion returns the STACKIT region (e.g., for IaaS API) of the shoot. @@ -14,3 +15,14 @@ func DetermineRegion(cluster *extensionscontroller.Cluster) string { } return region } + +// DetermineRegionFromWorkerSpec returns the STACKIT region (e.g., for IaaS API) of the WorkerSpec. +// It handles the legacy RegionOne value from the OpenStack CloudProfile and returns eu01 instead. +// TODO: Remove this once we migrated all Shoot specs from RegionOne to eu01. +func DetermineRegionFromWorkerSpec(worker *extensionsv1alpha1.Worker) string { + region := worker.Spec.Region + if region == "RegionOne" { + return "eu01" + } + return region +}