diff --git a/hack/api-reference/api.md b/hack/api-reference/api.md index de6c66c1..7fc2e9ed 100644 --- a/hack/api-reference/api.md +++ b/hack/api-reference/api.md @@ -1207,6 +1207,76 @@ string

Architecture is the CPU architecture of the machine image

+ + +capabilities
+ +Capabilities + + + +

Capabilities of the machine image.

+ + + + + + + +

MachineImageFlavor +

+ + +

+(Appears on:MachineImageVersion) +

+ +

+MachineImageFlavor groups all RegionAMIMappings for a specific set of capabilities. +

+ + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+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.

+
@@ -1266,6 +1336,17 @@ string

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.

+ + @@ -1635,7 +1716,7 @@ Purpose is a purpose of a resource.

-(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..4fd217d8 --- /dev/null +++ b/pkg/admission/mutator/cloudprofile.go @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package mutator + +import ( + "context" + "fmt" + "slices" + + extensionswebhook "github.com/gardener/gardener/extensions/pkg/webhook" + gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + + stackitv1alpha1 "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) + } + + if profile.DeletionTimestamp != nil || profile.Spec.ProviderConfig == nil || len(profile.Spec.MachineCapabilities) == 0 { + return nil + } + + specConfig := &stackitv1alpha1.CloudProfileConfig{} + if _, _, err := p.decoder.Decode(profile.Spec.ProviderConfig.Raw, nil, specConfig); err != nil { + return fmt.Errorf("could not decode providerConfig of cloudProfile for %q: %w", profile.Name, err) + } + + overwriteMachineImageCapabilityFlavors(profile, specConfig) + return nil +} + +func overwriteMachineImageCapabilityFlavors(profile *gardencorev1beta1.CloudProfile, config *stackitv1alpha1.CloudProfileConfig) { + for _, providerMachineImage := range config.MachineImages { + imageIdx := slices.IndexFunc(profile.Spec.MachineImages, func(mi gardencorev1beta1.MachineImage) bool { + return mi.Name == providerMachineImage.Name + }) + if imageIdx == -1 { + continue + } + + for _, providerVersion := range providerMachineImage.Versions { + versionIdx := slices.IndexFunc(profile.Spec.MachineImages[imageIdx].Versions, func(miv gardencorev1beta1.MachineImageVersion) bool { + return miv.Version == providerVersion.Version + }) + if versionIdx == -1 { + continue + } + + profile.Spec.MachineImages[imageIdx].Versions[versionIdx].CapabilityFlavors = convertCapabilityFlavors(providerVersion.CapabilityFlavors) + } + } +} + +func convertCapabilityFlavors(providerFlavors []stackitv1alpha1.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/mutator.go b/pkg/admission/mutator/mutator.go index 520215f7..be896427 100644 --- a/pkg/admission/mutator/mutator.go +++ b/pkg/admission/mutator/mutator.go @@ -29,6 +29,7 @@ func New(mgr manager.Manager) (*extensionswebhook.Webhook, error) { Name: Name, Path: "/webhooks/mutate", Mutators: map[extensionswebhook.Mutator][]extensionswebhook.Type{ + NewCloudProfileMutator(mgr): {{Obj: &gardencorev1beta1.CloudProfile{}}}, NewShootMutator(mgr): {{Obj: &gardencorev1beta1.Shoot{}}}, NewNamespacedCloudProfileMutator(mgr): {{Obj: &gardencorev1beta1.NamespacedCloudProfile{}, Subresource: new("status")}}, }, diff --git a/pkg/admission/mutator/namespacedcloudprofile.go b/pkg/admission/mutator/namespacedcloudprofile.go index 4c944a2e..0c72cf0d 100644 --- a/pkg/admission/mutator/namespacedcloudprofile.go +++ b/pkg/admission/mutator/namespacedcloudprofile.go @@ -19,6 +19,7 @@ import ( "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/helper" stackitv1alpha1 "github.com/stackitcloud/gardener-extension-provider-stackit/v2/pkg/apis/stackit/v1alpha1" ) @@ -42,9 +43,7 @@ func (p *namespacedCloudProfile) Mutate(_ context.Context, newObj, _ client.Obje 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 shouldSkipMutation(profile) { return nil } @@ -57,22 +56,33 @@ func (p *namespacedCloudProfile) Mutate(_ context.Context, newObj, _ client.Obje return fmt.Errorf("could not decode providerConfig of namespacedCloudProfile status for '%s': %w", profile.Name, err) } - statusConfig.MachineImages = mergeMachineImages(specConfig.MachineImages, statusConfig.MachineImages) + uniformSpecConfig := helper.TransformProviderConfigToParentFormat(specConfig, profile.Status.CloudProfileSpec.MachineCapabilities) + statusConfig.MachineImages = mergeMachineImages(uniformSpecConfig.MachineImages, statusConfig.MachineImages) // Overwrite API endpoints from spec if specConfig.APIEndpoints != nil { statusConfig.APIEndpoints = specConfig.APIEndpoints } - modifiedStatusConfig, err := json.Marshal(statusConfig) + return p.updateProfileStatus(profile, statusConfig) +} + +func (p *namespacedCloudProfile) updateProfileStatus(profile *gardencorev1beta1.NamespacedCloudProfile, config *stackitv1alpha1.CloudProfileConfig) error { + modifiedStatusConfig, err := json.Marshal(config) if err != nil { - return err + return fmt.Errorf("failed to marshal status config: %w", err) } profile.Status.CloudProfileSpec.ProviderConfig.Raw = modifiedStatusConfig - return nil } +func shouldSkipMutation(profile *gardencorev1beta1.NamespacedCloudProfile) bool { + return profile.DeletionTimestamp != nil || + profile.Generation != profile.Status.ObservedGeneration || + profile.Spec.ProviderConfig == nil || + 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/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..1f35272d 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" @@ -21,7 +23,7 @@ import ( "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/helper" + stackithelper "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" "github.com/stackitcloud/gardener-extension-provider-stackit/v2/pkg/apis/stackit/validation" ) @@ -48,7 +50,7 @@ func (p *namespacedCloudProfile) Validate(ctx context.Context, newObj, _ client. return nil } - cpConfig, err := helper.CloudProfileConfigFromRawExtension(profile.Spec.ProviderConfig) + cpConfig, err := stackithelper.CloudProfileConfigFromRawExtension(profile.Spec.ProviderConfig) if err != nil { return err } @@ -62,13 +64,20 @@ 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 { allErrs := field.ErrorList{} + if err := p.validateMachineImagesAndAPIEndpointsOnlyInNamespacedCloudProfile(cpConfig); err != nil { + allErrs = append(allErrs, err.(*field.Error)) + } + if err := stackithelper.SimulateTransformToParentFormat(cpConfig, profile, parentProfile.Spec.MachineCapabilities); err != nil { + return err + } + + allErrs = append(allErrs, p.validateMachineImages(cpConfig, profile.Spec.MachineImages, parentProfile.Spec)...) + return allErrs.ToAggregate() +} + +func (p *namespacedCloudProfile) validateMachineImagesAndAPIEndpointsOnlyInNamespacedCloudProfile(providerConfig *stackitv1alpha1.CloudProfileConfig) error { validationProviderConfig := &stackitv1alpha1.CloudProfileConfig{ TypeMeta: metav1.TypeMeta{ APIVersion: stackitv1alpha1.SchemeGroupVersion.String(), @@ -78,15 +87,12 @@ func (p *namespacedCloudProfile) validateNamespacedCloudProfileProviderConfig(pr APIEndpoints: providerConfig.APIEndpoints, } if !equality.Semantic.DeepEqual(validationProviderConfig, providerConfig) { - allErrs = append(allErrs, field.Forbidden( + return field.Forbidden( field.NewPath("spec.providerConfig"), "must only set machineImages and stackitAPIEndpoints", - )) + ) } - - 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 +101,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) @@ -120,9 +126,18 @@ 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), )) + continue } - allErrs = append(allErrs, validateMachineImageArchitectures(machineImage, version, providerImageVersion)...) + if len(parentSpec.MachineCapabilities) == 0 { + allErrs = append(allErrs, validateMachineImageArchitectures(machineImage, version, providerImageVersion)...) + } else { + var v1beta1Version gardencorev1beta1.MachineImageVersion + if err := gardencoreapi.Scheme.Convert(&version, &v1beta1Version, nil); err != nil { + return append(allErrs, field.InternalError(machineImagesPath, err)) + } + allErrs = append(allErrs, validateMachineImageCapabilities(machineImage, v1beta1Version, providerImageVersion, parentSpec.MachineCapabilities)...) + } } } for imageIdx, machineImage := range providerConfig.MachineImages { @@ -159,6 +174,65 @@ 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) + regionsCapabilitiesMap := map[string][]gardencorev1beta1.Capabilities{} + + 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) + } + } + + 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 + } + + 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 +} + func validateMachineImageArchitectures(machineImage core.MachineImage, version core.MachineImageVersion, providerImageVersion stackitv1alpha1.MachineImageVersion) field.ErrorList { allErrs := field.ErrorList{} regionsArchitectureMap := map[string][]string{} diff --git a/pkg/apis/stackit/helper/cloudprofiletransformer.go b/pkg/apis/stackit/helper/cloudprofiletransformer.go new file mode 100644 index 00000000..90a5bf54 --- /dev/null +++ b/pkg/apis/stackit/helper/cloudprofiletransformer.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package helper + +import ( + "slices" + + gardencoreapi "github.com/gardener/gardener/pkg/api" + "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" + gutil "github.com/gardener/gardener/pkg/utils/gardener" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" + + stackitv1alpha1 "github.com/stackitcloud/gardener-extension-provider-stackit/v2/pkg/apis/stackit/v1alpha1" +) + +// SimulateTransformToParentFormat simulates the transformation of the given NamespacedCloudProfile and providerConfig +// to the parent CloudProfile format. +func SimulateTransformToParentFormat(cloudProfileConfig *stackitv1alpha1.CloudProfileConfig, cloudProfile *core.NamespacedCloudProfile, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition) error { + namespacedCloudProfileSpecV1beta1 := gardencorev1beta1.NamespacedCloudProfileSpec{} + if err := gardencoreapi.Scheme.Convert(&cloudProfile.Spec, &namespacedCloudProfileSpecV1beta1, nil); err != nil { + return field.InternalError(field.NewPath("spec"), err) + } + + *cloudProfileConfig = *TransformProviderConfigToParentFormat(cloudProfileConfig, capabilityDefinitions) + transformedSpec := gutil.TransformSpecToParentFormat(namespacedCloudProfileSpecV1beta1, capabilityDefinitions) + + if err := gardencoreapi.Scheme.Convert(&transformedSpec, &cloudProfile.Spec, nil); err != nil { + return field.InternalError(field.NewPath("spec"), err) + } + return nil +} + +// TransformProviderConfigToParentFormat supports migration between deprecated architecture fields and architecture capabilities. +func TransformProviderConfigToParentFormat(config *stackitv1alpha1.CloudProfileConfig, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition) *stackitv1alpha1.CloudProfileConfig { + if config == nil { + return &stackitv1alpha1.CloudProfileConfig{} + } + + transformedConfig := config.DeepCopy() + transformedConfig.MachineImages = transformMachineImages(config.MachineImages, capabilityDefinitions) + return transformedConfig +} + +func transformMachineImages(images []stackitv1alpha1.MachineImages, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition) []stackitv1alpha1.MachineImages { + result := make([]stackitv1alpha1.MachineImages, 0, len(images)) + + for _, img := range images { + result = append(result, stackitv1alpha1.MachineImages{ + Name: img.Name, + Versions: transformImageVersions(img.Versions, capabilityDefinitions), + }) + } + + return result +} + +func transformImageVersions(versions []stackitv1alpha1.MachineImageVersion, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition) []stackitv1alpha1.MachineImageVersion { + result := make([]stackitv1alpha1.MachineImageVersion, 0, len(versions)) + + for _, version := range versions { + transformed := stackitv1alpha1.MachineImageVersion{ + Version: version.Version, + Image: version.Image, + } + if len(capabilityDefinitions) != 0 { + transformed.CapabilityFlavors = transformToCapabilityFormat(version, capabilityDefinitions) + } else { + transformed.Regions = transformToLegacyFormat(version) + } + result = append(result, transformed) + } + + return result +} + +func transformToCapabilityFormat(version stackitv1alpha1.MachineImageVersion, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition) []stackitv1alpha1.MachineImageFlavor { + if len(version.CapabilityFlavors) > 0 { + return version.CapabilityFlavors + } + if len(version.Regions) == 0 { + return nil + } + + architectureGroups := make(map[string][]stackitv1alpha1.RegionIDMapping) + for _, region := range version.Regions { + arch := ptr.Deref(region.Architecture, v1beta1constants.ArchitectureAMD64) + architectureGroups[arch] = append(architectureGroups[arch], stackitv1alpha1.RegionIDMapping{ + Name: region.Name, + ID: region.ID, + }) + } + + imageFlavors := make([]stackitv1alpha1.MachineImageFlavor, 0, len(architectureGroups)) + for arch, regions := range architectureGroups { + sortRegions(regions) + imageFlavors = append(imageFlavors, stackitv1alpha1.MachineImageFlavor{ + Regions: regions, + Capabilities: gardencorev1beta1.Capabilities{ + v1beta1constants.ArchitectureName: []string{arch}, + }, + }) + } + + slices.SortFunc(imageFlavors, func(a, b stackitv1alpha1.MachineImageFlavor) int { + return cmpStrings(getFirstArchitectureValue(a.Capabilities, capabilityDefinitions), getFirstArchitectureValue(b.Capabilities, capabilityDefinitions)) + }) + + return imageFlavors +} + +func transformToLegacyFormat(version stackitv1alpha1.MachineImageVersion) []stackitv1alpha1.RegionIDMapping { + if len(version.Regions) > 0 { + return version.Regions + } + if len(version.CapabilityFlavors) == 0 { + return nil + } + + var regions []stackitv1alpha1.RegionIDMapping + for _, flavor := range version.CapabilityFlavors { + arch := getFirstArchitectureValue(flavor.Capabilities, nil) + for _, region := range flavor.Regions { + regions = append(regions, stackitv1alpha1.RegionIDMapping{ + Name: region.Name, + ID: region.ID, + Architecture: &arch, + }) + } + } + sortRegions(regions) + return regions +} + +func sortRegions(regions []stackitv1alpha1.RegionIDMapping) { + slices.SortFunc(regions, func(a, b stackitv1alpha1.RegionIDMapping) int { + return cmpStrings(a.Name, b.Name) + }) +} + +func getFirstArchitectureValue(capabilities gardencorev1beta1.Capabilities, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition) string { + defaultedCapabilities := capabilities + if len(capabilityDefinitions) > 0 { + defaultedCapabilities = gardencorev1beta1.GetCapabilitiesWithAppliedDefaults(capabilities, capabilityDefinitions) + } + if defaultedCapabilities == nil { + return v1beta1constants.ArchitectureAMD64 + } + architectures := defaultedCapabilities[v1beta1constants.ArchitectureName] + if len(architectures) == 0 { + return v1beta1constants.ArchitectureAMD64 + } + return architectures[0] +} + +func cmpStrings(a, b string) int { + if a < b { + return -1 + } + if a > b { + return 1 + } + return 0 +} diff --git a/pkg/apis/stackit/helper/helper.go b/pkg/apis/stackit/helper/helper.go index 3d84a46f..f1c93f5b 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" @@ -97,6 +100,121 @@ func FindImageFromCloudProfile(cloudProfileConfig *stackitv1alpha1.CloudProfileC return nil, fmt.Errorf("could not find an image for name %q in version %q for region %q", imageName, imageVersion, regionName) } +// FindImageInCloudProfile finds the best matching machine image flavor for the given image, region, architecture, and capabilities. +func FindImageInCloudProfile( + cloudProfileConfig *stackitv1alpha1.CloudProfileConfig, + name, version, region string, + arch *string, + machineCapabilities gardencorev1beta1.Capabilities, + capabilityDefinitions []gardencorev1beta1.CapabilityDefinition, +) (*stackitv1alpha1.MachineImageFlavor, error) { + if cloudProfileConfig == nil { + return nil, fmt.Errorf("cloud profile config is nil") + } + + capabilitySet, err := findMachineImageFlavor(cloudProfileConfig.MachineImages, name, version, region, arch, machineCapabilities, capabilityDefinitions) + if err != nil { + return nil, fmt.Errorf("could not find an image for region %q, image %q, version %q that supports %v: %w", region, name, version, machineCapabilities, err) + } + if capabilitySet != nil && len(capabilitySet.Regions) > 0 && (capabilitySet.Regions[0].ID != "" || capabilitySet.Image != "") { + return capabilitySet, nil + } + return nil, fmt.Errorf("could not find an image for region %q, image %q, version %q that supports %v", region, name, version, machineCapabilities) +} + +// FindImageInWorkerStatus finds a previously selected machine image in the Worker provider status. +func FindImageInWorkerStatus(machineImages []stackitv1alpha1.MachineImage, name, version string, architecture *string, machineCapabilities gardencorev1beta1.Capabilities, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition) (*stackitv1alpha1.MachineImage, error) { + if len(capabilityDefinitions) == 0 { + for _, statusMachineImage := range machineImages { + if statusMachineImage.Architecture == nil { + statusMachineImage.Architecture = ptr.To(v1beta1constants.ArchitectureAMD64) + } + if statusMachineImage.Name == name && statusMachineImage.Version == version && ptr.Equal(architecture, statusMachineImage.Architecture) { + return &statusMachineImage, nil + } + } + return nil, fmt.Errorf("no machine image found for image %q with version %q and architecture %q", name, version, ptr.Deref(architecture, "")) + } + + 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) +} + +func findMachineImageFlavor( + machineImages []stackitv1alpha1.MachineImages, + imageName, imageVersion, region string, + arch *string, + machineCapabilities gardencorev1beta1.Capabilities, + capabilityDefinitions []gardencorev1beta1.CapabilityDefinition, +) (*stackitv1alpha1.MachineImageFlavor, error) { + for _, machineImage := range machineImages { + if machineImage.Name != imageName { + continue + } + for _, version := range machineImage.Versions { + if imageVersion != version.Version { + continue + } + + if len(capabilityDefinitions) == 0 { + for _, mapping := range version.Regions { + if region == mapping.Name && ptr.Equal(arch, mapping.Architecture) { + return &stackitv1alpha1.MachineImageFlavor{ + Image: version.Image, + Regions: []stackitv1alpha1.RegionIDMapping{mapping}, + Capabilities: gardencorev1beta1.Capabilities{}, + }, nil + } + } + if version.Image != "" && ptr.Deref(arch, v1beta1constants.ArchitectureAMD64) == v1beta1constants.ArchitectureAMD64 { + return &stackitv1alpha1.MachineImageFlavor{ + Image: version.Image, + Regions: []stackitv1alpha1.RegionIDMapping{{ + Name: region, + Architecture: ptr.To(v1beta1constants.ArchitectureAMD64), + }}, + Capabilities: gardencorev1beta1.Capabilities{}, + }, nil + } + continue + } + + filteredCapabilityFlavors := filterCapabilityFlavorsByRegion(version.CapabilityFlavors, region) + 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, nil +} + +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, + }) + } + } + return compatibleFlavors +} + // FindKeyStoneURL takes a list of keystone URLs and tries to find the first entry // whose region matches with the given region. If no such entry is found then it tries to use the non-regional // keystone URL. If this is not specified then an error will be returned. diff --git a/pkg/apis/stackit/v1alpha1/types_cloudprofile.go b/pkg/apis/stackit/v1alpha1/types_cloudprofile.go index bc565426..130ef59f 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,23 @@ 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. + 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..eb38784b 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,8 @@ type MachineImage struct { // Architecture is the CPU architecture of the machine image // +optional Architecture *string `json:"architecture,omitempty"` + // Capabilities of the machine image. + 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 aa21ec41..97695fae 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" @@ -559,6 +560,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 } @@ -572,6 +588,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 @@ -582,6 +636,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..f2d1eae5 100644 --- a/pkg/apis/stackit/validation/cloudprofile.go +++ b/pkg/apis/stackit/validation/cloudprofile.go @@ -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,8 +25,9 @@ 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, args ...any) field.ErrorList { allErrs := field.ErrorList{} + capabilityDefinitions, fldPath := parseCloudProfileValidationArgs(args...) floatingPoolPath := fldPath.Child("constraints", "floatingPools") combinationFound := sets.NewString() @@ -64,9 +68,9 @@ 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)) @@ -125,36 +129,80 @@ func ValidateCloudProfileConfig(cloudProfile *stackitv1alpha1.CloudProfileConfig return allErrs } +func parseCloudProfileValidationArgs(args ...any) ([]gardencorev1beta1.CapabilityDefinition, *field.Path) { + switch len(args) { + case 1: + return nil, args[0].(*field.Path) + case 2: + return args[0].([]gardencorev1beta1.CapabilityDefinition), args[1].(*field.Path) + default: + panic("ValidateCloudProfileConfig expects fldPath or capabilityDefinitions and fldPath") + } +} + // 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 { + for j, version := range providerImage.Versions { jdxPath := validationPath.Child("versions").Index(j) + allErrs = append(allErrs, validateMachineImageVersion(version, capabilityDefinitions, jdxPath)...) + } + + return allErrs +} + +func validateMachineImageVersion(version stackitv1alpha1.MachineImageVersion, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + if len(version.Version) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("version"), "must provide a version")) + } - if len(version.Version) == 0 { - allErrs = append(allErrs, field.Required(jdxPath.Child("version"), "must provide a version")) + if len(capabilityDefinitions) > 0 { + if len(version.Regions) > 0 { + allErrs = append(allErrs, field.Forbidden(fldPath.Child("regions"), "must not be set as CloudProfile defines capabilities. Use capabilityFlavors.regions instead.")) } + for k, capabilityFlavor := range version.CapabilityFlavors { + kdxPath := fldPath.Child("capabilityFlavors").Index(k) + allErrs = append(allErrs, gardener.ValidateCapabilities(capabilityFlavor.Capabilities, capabilityDefinitions, kdxPath.Child("capabilities"))...) + allErrs = append(allErrs, validateRegions(capabilityFlavor.Regions, capabilityDefinitions, kdxPath)...) + } + return allErrs + } + + allErrs = append(allErrs, validateRegions(version.Regions, capabilityDefinitions, fldPath)...) + if len(version.CapabilityFlavors) > 0 { + allErrs = append(allErrs, field.Forbidden(fldPath.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) +func validateRegions(regions []stackitv1alpha1.RegionIDMapping, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition, fldPath *field.Path) field.ErrorList { + 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")) - } - if !slices.Contains(v1beta1constants.ValidArchitectures, ptr.Deref(region.Architecture, v1beta1constants.ArchitectureAMD64)) { - allErrs = append(allErrs, field.NotSupported(kdxPath.Child("architecture"), *region.Architecture, v1beta1constants.ValidArchitectures)) - } + for k, region := range regions { + kdxPath := fldPath.Child("regions").Index(k) + architecture := 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 len(capabilityDefinitions) == 0 && !slices.Contains(v1beta1constants.ValidArchitectures, architecture) { + allErrs = append(allErrs, field.NotSupported(kdxPath.Child("architecture"), architecture, v1beta1constants.ValidArchitectures)) + } + if len(capabilityDefinitions) > 0 && region.Architecture != nil { + allErrs = append(allErrs, field.Forbidden(kdxPath.Child("architecture"), "must be defined in .capabilities.architecture")) } } @@ -172,7 +220,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,26 +239,27 @@ 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) + imageVersion, exists := providerImages.GetImageVersion(machineImage.Name, version.Version) + if !exists { + allErrs = append(allErrs, field.Required(machineImageVersionPath, + fmt.Sprintf("machine image version %s@%s is not defined in the providerConfig", + machineImage.Name, version.Version), + )) + continue + } + + if len(capabilityDefinitions) > 0 { + allErrs = append(allErrs, validateImageFlavorMapping(machineImage, version, machineImageVersionPath, capabilityDefinitions, imageVersion)...) + continue + } + 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("machine image version %s@%s is not defined in the providerConfig", - machineImage.Name, version.Version), - )) - continue + allErrs = append(allErrs, field.NotSupported(machineImageVersionPath.Child("architectures"), expectedArchitecture, v1beta1constants.ValidArchitectures)) } // 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) }) @@ -220,7 +269,6 @@ func validateMachineImageMapping(machineImages []core.MachineImage, cpConfig *st fmt.Sprintf("missing providerConfig mapping for machine image version %s@%s and architecture: %s", machineImage.Name, version.Version, expectedArchitecture), )) - continue } } } @@ -229,3 +277,29 @@ func validateMachineImageMapping(machineImages []core.MachineImage, cpConfig *st return allErrs } + +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) + for idxCapability, defaultedCapabilitySet := range defaultedCapabilityFlavors { + isFound := false + 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))) + } + } + return allErrs +} diff --git a/pkg/controller/worker/machine_images.go b/pkg/controller/worker/machine_images.go index 48e9ac0e..af10add3 100644 --- a/pkg/controller/worker/machine_images.go +++ b/pkg/controller/worker/machine_images.go @@ -9,8 +9,9 @@ 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" "github.com/stackitcloud/gardener-extension-provider-stackit/v2/pkg/apis/stackit/helper" @@ -38,40 +39,87 @@ 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) (*stackitv1alpha1.MachineImage, error) { + selectedMachineImage := &stackitv1alpha1.MachineImage{ + Name: name, + Version: version, + } + + if capabilitySet, err := helper.FindImageInCloudProfile(w.cloudProfileConfig, name, version, region, arch, machineCapabilities, w.cluster.CloudProfile.Spec.MachineCapabilities); err == nil { + selectedMachineImage.Capabilities = capabilitySet.Capabilities + if capabilitySet.Regions[0].ID == "" { + selectedMachineImage.Image = capabilitySet.Image + } else { + selectedMachineImage.ID = capabilitySet.Regions[0].ID + } + + if len(selectedMachineImage.Capabilities[v1beta1constants.ArchitectureName]) > 0 { + selectedArch := selectedMachineImage.Capabilities[v1beta1constants.ArchitectureName][0] + if arch != nil && selectedArch != *arch { + return nil, fmt.Errorf("architecture does not match for machine image") + } + } else { + selectedMachineImage.Architecture = capabilitySet.Regions[0].Architecture + } + + return selectedMachineImage, nil } - // Try to look up machine image in worker provider status as it was not found in componentconfig. if providerStatus := w.worker.Status.ProviderStatus; providerStatus != nil { workerStatus := &stackitv1alpha1.WorkerStatus{} if _, _, err := w.decoder.Decode(providerStatus.Raw, nil, workerStatus); err != nil { 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) - } + 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, ptrDerefString(arch), region) +} + +func appendMachineImage(machineImages []stackitv1alpha1.MachineImage, machineImage stackitv1alpha1.MachineImage, capabilityDefinitions []gardencorev1beta1.CapabilityDefinition) []stackitv1alpha1.MachineImage { + if len(capabilityDefinitions) == 0 { + for _, image := range machineImages { + if image.Name == machineImage.Name && image.Version == machineImage.Version && ptrEqualString(machineImage.Architecture, image.Architecture) { + return machineImages + } } + return append(machineImages, stackitv1alpha1.MachineImage{ + Name: machineImage.Name, + Version: machineImage.Version, + Image: machineImage.Image, + ID: machineImage.ID, + Architecture: machineImage.Architecture, + }) + } - return machineImage, nil + defaultedCapabilities := gardencorev1beta1.GetCapabilitiesWithAppliedDefaults(machineImage.Capabilities, capabilityDefinitions) + for _, existingMachineImage := range machineImages { + existingDefaultedCapabilities := gardencorev1beta1.GetCapabilitiesWithAppliedDefaults(existingMachineImage.Capabilities, capabilityDefinitions) + if existingMachineImage.Name == machineImage.Name && existingMachineImage.Version == machineImage.Version && gardencorev1beta1helper.AreCapabilitiesEqual(defaultedCapabilities, existingDefaultedCapabilities) { + return machineImages + } } - return nil, worker.ErrorMachineImageNotFound(name, version) + return append(machineImages, stackitv1alpha1.MachineImage{ + Name: machineImage.Name, + Version: machineImage.Version, + Image: machineImage.Image, + ID: machineImage.ID, + Capabilities: machineImage.Capabilities, + }) +} + +func ptrEqualString(a, b *string) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b } -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) +func ptrDerefString(s *string) string { + if s == nil { + return "" } - return machineImages + return *s } diff --git a/pkg/controller/worker/machines.go b/pkg/controller/worker/machines.go index e3b7ddd3..110da753 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" @@ -109,12 +110,18 @@ func (w *workerDelegate) generateMachineConfig(ctx context.Context) error { // nolint:gosec // check above ensures no overflow can occur zoneLen := int32(len(pool.Zones)) + var machineCapabilities gardencorev1beta1.Capabilities + if machineTypeFromCloudProfile := gardencorev1beta1helper.FindMachineTypeByName(w.cluster.CloudProfile.Spec.MachineTypes, pool.MachineType); machineTypeFromCloudProfile != nil { + machineCapabilities = machineTypeFromCloudProfile.Capabilities + } + architecture := ptr.Deref(pool.Architecture, v1beta1constants.ArchitectureAMD64) - machineImage, err := w.findMachineImage(pool.MachineImage.Name, pool.MachineImage.Version, architecture) + machineImage, err := w.selectMachineImageForWorkerPool(pool.MachineImage.Name, pool.MachineImage.Version, w.worker.Spec.Region, &architecture, machineCapabilities) 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 { @@ -331,3 +338,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/test/integration/testdata/upstream-crds/10-crd-extensions.gardener.cloud_clusters.yaml b/test/integration/testdata/upstream-crds/10-crd-extensions.gardener.cloud_clusters.yaml index c670d242..801c9378 100644 --- a/test/integration/testdata/upstream-crds/10-crd-extensions.gardener.cloud_clusters.yaml +++ b/test/integration/testdata/upstream-crds/10-crd-extensions.gardener.cloud_clusters.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.19.0 + controller-gen.kubebuilder.io/version: v0.20.1 name: clusters.extensions.gardener.cloud spec: group: extensions.gardener.cloud diff --git a/test/integration/testdata/upstream-crds/10-crd-extensions.gardener.cloud_infrastructures.yaml b/test/integration/testdata/upstream-crds/10-crd-extensions.gardener.cloud_infrastructures.yaml index a829864c..0c1dc33f 100644 --- a/test/integration/testdata/upstream-crds/10-crd-extensions.gardener.cloud_infrastructures.yaml +++ b/test/integration/testdata/upstream-crds/10-crd-extensions.gardener.cloud_infrastructures.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.19.0 + controller-gen.kubebuilder.io/version: v0.20.1 name: infrastructures.extensions.gardener.cloud spec: group: extensions.gardener.cloud