diff --git a/api/v1alpha1/managedcloudprofile.go b/api/v1alpha1/managedcloudprofile.go index 0ff90ad..3f9523a 100644 --- a/api/v1alpha1/managedcloudprofile.go +++ b/api/v1alpha1/managedcloudprofile.go @@ -102,6 +102,15 @@ type MachineImageUpdate struct { ImageName string `json:"imageName"` } +// ImageFilter defines admission criteria for source images. +type ImageFilter struct { + // RequiredFeatureSetValues lists exact values that must all be present in the + // image's feature_set OCI annotation (e.g. "_usi", "scibase"). Images missing + // any of these values are excluded from the CloudProfile entirely. + // +optional + RequiredFeatureSetValues []string `json:"requiredFeatureSetValues,omitempty"` +} + type GarbageCollectionConfig struct { // Enabled toggles garbage collection for this image. // +optional @@ -182,6 +191,17 @@ type OCI struct { // Insecure disables TLS // +optional Insecure bool `json:"insecure,omitempty"` + // ImageFilter defines criteria for filtering source images before they are + // written into the CloudProfile. Only applies when this OCI source is used + // for machine image updates (not for Kubernetes version sources). + // +optional + ImageFilter *ImageFilter `json:"imageFilter,omitempty"` + // FeatureToCapabilityMap maps raw feature_set annotation values (e.g. "_usidev") + // to boolean CloudProfile capability names (e.g. "usidev"). For each entry, + // presence of the key in the annotation produces CapabilityName: [true], + // absence produces CapabilityName: [false]. + // +optional + FeatureToCapabilityMap map[string]string `json:"featureToCapabilityMap,omitempty"` } type MachineImageUpdateProvider struct { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 409966e..cda1cbc 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -123,6 +123,26 @@ func (in *GlanceSource) DeepCopy() *GlanceSource { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ImageFilter) DeepCopyInto(out *ImageFilter) { + *out = *in + if in.RequiredFeatureSetValues != nil { + in, out := &in.RequiredFeatureSetValues, &out.RequiredFeatureSetValues + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageFilter. +func (in *ImageFilter) DeepCopy() *ImageFilter { + if in == nil { + return nil + } + out := new(ImageFilter) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KubernetesVersionUpdateConfig) DeepCopyInto(out *KubernetesVersionUpdateConfig) { *out = *in @@ -130,7 +150,7 @@ func (in *KubernetesVersionUpdateConfig) DeepCopyInto(out *KubernetesVersionUpda if in.LandscapeSetup != nil { in, out := &in.LandscapeSetup, &out.LandscapeSetup *out = new(LandscapeSetup) - **out = **in + (*in).DeepCopyInto(*out) } } @@ -147,7 +167,7 @@ func (in *KubernetesVersionUpdateConfig) DeepCopy() *KubernetesVersionUpdateConf // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LandscapeSetup) DeepCopyInto(out *LandscapeSetup) { *out = *in - out.OCI = in.OCI + in.OCI.DeepCopyInto(&out.OCI) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LandscapeSetup. @@ -208,7 +228,7 @@ func (in *MachineImageUpdateSource) DeepCopyInto(out *MachineImageUpdateSource) if in.OCI != nil { in, out := &in.OCI, &out.OCI *out = new(OCI) - **out = **in + (*in).DeepCopyInto(*out) } if in.Glance != nil { in, out := &in.Glance, &out.Glance @@ -375,6 +395,18 @@ func (in *ManagedCloudProfileStatus) DeepCopy() *ManagedCloudProfileStatus { func (in *OCI) DeepCopyInto(out *OCI) { *out = *in out.Password = in.Password + if in.ImageFilter != nil { + in, out := &in.ImageFilter, &out.ImageFilter + *out = new(ImageFilter) + (*in).DeepCopyInto(*out) + } + if in.FeatureToCapabilityMap != nil { + in, out := &in.FeatureToCapabilityMap, &out.FeatureToCapabilityMap + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OCI. diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index acc8942..2c6b425 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -20,6 +20,10 @@ import ( // capabilityKeys since it is always populated automatically by the OCI source. const ArchitectureCapability = "architecture" +// FeatureSetAnnotation is the gardenlinux OCI annotation key that carries the image's +// feature set as a comma-separated list (e.g. "sci,_usi,vhost"). +const FeatureSetAnnotation = "feature_set" + type SourceImage struct { // Version is the full tag from the registry (used as version key for legacy images). Version string @@ -67,7 +71,7 @@ type Provider interface { Configure(cloudProfile *gardenerv1beta1.CloudProfileSpec, versions []SourceImage) error } -func filterImages(log logr.Logger, versions []SourceImage) []SourceImage { +func validateImageVersions(log logr.Logger, versions []SourceImage) []SourceImage { filtered := make([]SourceImage, 0, len(versions)) for _, version := range versions { if len(version.Architectures) == 0 { @@ -200,7 +204,7 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou if err != nil { return fmt.Errorf("failed to retrieve image versions from OCI registry: %w", err) } - sourceImages = filterImages(iu.Log, sourceImages) + sourceImages = validateImageVersions(iu.Log, sourceImages) // Images from a source arrive in no guaranteed order. A changed order // in the source images may lead to a changed order in the CloudProfile, // causing unnecesscary reconciliations. diff --git a/cloudprofilesync/ossync/source/oci/oci_internal_test.go b/cloudprofilesync/ossync/source/oci/oci_internal_test.go new file mode 100644 index 0000000..e2fc447 --- /dev/null +++ b/cloudprofilesync/ossync/source/oci/oci_internal_test.go @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package oci + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/cobaltcore-dev/cloud-profile-sync/api/v1alpha1" +) + +var _ = Describe("splitAnnotationRaw", func() { + It("returns empty map for empty string", func() { + Expect(splitAnnotationRaw("")).To(BeEmpty()) + }) + + It("returns empty map for whitespace-only string", func() { + Expect(splitAnnotationRaw(" , , ")).To(BeEmpty()) + }) + + It("preserves tokens including leading underscores", func() { + result := splitAnnotationRaw("scibase,_usi,vhost") + Expect(result).To(HaveKey("scibase")) + Expect(result).To(HaveKey("_usi")) + Expect(result).To(HaveKey("vhost")) + Expect(result).To(HaveLen(3)) + }) + + It("trims whitespace around tokens", func() { + result := splitAnnotationRaw(" scibase , _usi ") + Expect(result).To(HaveKey("scibase")) + Expect(result).To(HaveKey("_usi")) + }) + + It("deduplicates repeated tokens", func() { + result := splitAnnotationRaw("sci,sci,_usi") + Expect(result).To(HaveLen(2)) + Expect(result).To(HaveKey("sci")) + Expect(result).To(HaveKey("_usi")) + }) +}) + +var _ = Describe("supportsInPlaceUpdate", func() { + It("returns false when feature_set annotation is absent", func() { + Expect(supportsInPlaceUpdate(map[string]string{"architecture": "amd64"})).To(BeFalse()) + }) + + It("returns false when feature_set annotation is present but empty", func() { + Expect(supportsInPlaceUpdate(map[string]string{"architecture": "amd64", "feature_set": ""})).To(BeFalse()) + }) + + It("returns false when _usi is absent from feature_set", func() { + Expect(supportsInPlaceUpdate(map[string]string{"feature_set": "scibase,vhost"})).To(BeFalse()) + }) + + It("returns false when only the normalized form 'usi' is present (not '_usi')", func() { + Expect(supportsInPlaceUpdate(map[string]string{"feature_set": "scibase,usi"})).To(BeFalse()) + }) + + It("returns true when _usi is present in feature_set", func() { + Expect(supportsInPlaceUpdate(map[string]string{"feature_set": "scibase,_usi,vhost"})).To(BeTrue()) + }) +}) + +var _ = Describe("passesImageFilter", func() { + It("passes when filter is nil", func() { + Expect(passesImageFilter(map[string]struct{}{"sci": {}}, nil)).To(BeTrue()) + }) + + It("passes when RequiredFeatureSetValues is empty", func() { + filter := &v1alpha1.ImageFilter{} + Expect(passesImageFilter(map[string]struct{}{"sci": {}}, filter)).To(BeTrue()) + }) + + It("passes when all required values are present", func() { + filter := &v1alpha1.ImageFilter{RequiredFeatureSetValues: []string{"scibase", "_usi"}} + tokens := map[string]struct{}{"scibase": {}, "_usi": {}, "vhost": {}} + Expect(passesImageFilter(tokens, filter)).To(BeTrue()) + }) + + It("fails when a required value is absent", func() { + filter := &v1alpha1.ImageFilter{RequiredFeatureSetValues: []string{"scibase", "_usi"}} + tokens := map[string]struct{}{"scibase": {}} + Expect(passesImageFilter(tokens, filter)).To(BeFalse()) + }) + + It("fails when normalized form is present but raw form is required", func() { + filter := &v1alpha1.ImageFilter{RequiredFeatureSetValues: []string{"_usi"}} + tokens := map[string]struct{}{"usi": {}} + Expect(passesImageFilter(tokens, filter)).To(BeFalse()) + }) + + It("fails when token set is empty", func() { + filter := &v1alpha1.ImageFilter{RequiredFeatureSetValues: []string{"scibase"}} + Expect(passesImageFilter(map[string]struct{}{}, filter)).To(BeFalse()) + }) +}) diff --git a/cloudprofilesync/ossync/source/oci/os_source.go b/cloudprofilesync/ossync/source/oci/os_source.go index d12c796..0504a8d 100644 --- a/cloudprofilesync/ossync/source/oci/os_source.go +++ b/cloudprofilesync/ossync/source/oci/os_source.go @@ -8,7 +8,6 @@ import ( "encoding/json" "errors" "fmt" - "slices" "strings" gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" @@ -16,56 +15,49 @@ import ( "golang.org/x/sync/semaphore" "oras.land/oras-go/v2/registry/remote" + "github.com/cobaltcore-dev/cloud-profile-sync/api/v1alpha1" "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ocirepo" "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" ) const ( - // usiCapabilityValue is the normalized capability value for the gardenlinux USI - // (UEFI Secure Image) feature, which indicates support for in-place node updates. - usiCapabilityValue = "usi" - // featureSetAnnotation is the gardenlinux OCI annotation key that lists the - // feature set of an image as a comma-separated list (e.g. "sci,_usi,_pxe"). - featureSetAnnotation = "feature_set" + // usiImageFeature is the value for the gardenlinux USI (UEFI Secure Image) feature, + // which indicates support for in-place node updates. + usiImageFeature = "_usi" ) // supportsInPlaceUpdate reports whether the gardenlinux image described by the // given OCI annotations supports in-place node updates. It reads the feature_set -// annotation directly, independent of which capabilityKeys the OCI source is -// configured to expose, so USI detection is never accidentally suppressed. +// annotation directly, independent of the featureToCapabilityMap configuration, +// so USI detection is never accidentally suppressed. func supportsInPlaceUpdate(annotations map[string]string) bool { - raw, ok := annotations[featureSetAnnotation] + raw, ok := annotations[ossync.FeatureSetAnnotation] if !ok { return false } - return slices.Contains(filterAnnotationValues(raw), usiCapabilityValue) + tokens := splitAnnotationRaw(raw) + _, usi := tokens[usiImageFeature] + return usi } -// normalizeCapabilityValue strips leading underscores from a feature annotation value -// so it satisfies Gardener's requirement that capability values start with an -// alphanumeric character. Gardenlinux uses a leading '_' convention for UEFI variants -// (e.g. _usi, _pxe) that has no meaning in the Gardener capability key space. -func normalizeCapabilityValue(v string) string { - return strings.TrimLeft(v, "_") -} - -func filterAnnotationValues(raw string) []string { +// splitAnnotationRaw splits a comma-separated annotation value into a set, +// preserving the original tokens without normalization (e.g. "_usidev" stays "_usidev"). +// Used for exact FeatureSetValue matching. +func splitAnnotationRaw(raw string) map[string]struct{} { parts := strings.Split(raw, ",") - seen := make(map[string]struct{}, len(parts)) - result := make([]string, 0, len(parts)) + out := make(map[string]struct{}, len(parts)) for _, f := range parts { f = strings.TrimSpace(f) - capVal := normalizeCapabilityValue(f) - if capVal == "" { - continue - } - if _, dup := seen[capVal]; dup { - continue + if f != "" { + out[f] = struct{}{} } - seen[capVal] = struct{}{} - result = append(result, capVal) } - return result + return out +} + +type collectedImage struct { + image ossync.SourceImage + featureSetRaw string } type Result[T any] struct { @@ -74,23 +66,24 @@ type Result[T any] struct { } type OCI struct { - log logr.Logger - repo *remote.Repository - sema *semaphore.Weighted - capabilityKeys []string + log logr.Logger + repo *remote.Repository + sema *semaphore.Weighted + featureToCapabilityMap map[string]string + imageFilter *v1alpha1.ImageFilter } -func NewOCI(params ocirepo.Params, parallel int64, log logr.Logger, capabilityKeys []string) (*OCI, error) { +func NewOCI(params ocirepo.Params, parallel int64, log logr.Logger, featureToCapabilityMap map[string]string, imageFilter *v1alpha1.ImageFilter) (*OCI, error) { repo, err := ocirepo.New(params) if err != nil { return nil, err } - return &OCI{ - log: log, - repo: repo, - sema: semaphore.NewWeighted(parallel), - capabilityKeys: capabilityKeys, + log: log, + repo: repo, + sema: semaphore.NewWeighted(parallel), + featureToCapabilityMap: featureToCapabilityMap, + imageFilter: imageFilter, }, nil } @@ -104,17 +97,17 @@ func (o *OCI) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) { return nil, err } - out := make(chan Result[ossync.SourceImage]) + out := make(chan Result[collectedImage]) for _, tag := range tags { go func() { if err := o.sema.Acquire(ctx, 1); err != nil { - out <- Result[ossync.SourceImage]{err: err} + out <- Result[collectedImage]{err: err} return } defer o.sema.Release(1) _, reader, err := o.repo.FetchReference(ctx, tag) if err != nil { - out <- Result[ossync.SourceImage]{err: fmt.Errorf("tag %s: failed to fetch manifest: %w", tag, err)} + out <- Result[collectedImage]{err: fmt.Errorf("tag %s: failed to fetch manifest: %w", tag, err)} return } defer reader.Close() @@ -123,46 +116,47 @@ func (o *OCI) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) { }{} err = json.NewDecoder(reader).Decode(&manifest) if err != nil { - out <- Result[ossync.SourceImage]{err: fmt.Errorf("tag %s: failed to decode manifest: %w", tag, err)} + out <- Result[collectedImage]{err: fmt.Errorf("tag %s: failed to decode manifest: %w", tag, err)} return } arch, ok := manifest.Annotations[ossync.ArchitectureCapability] if !ok { - out <- Result[ossync.SourceImage]{err: fmt.Errorf("tag %s: architecture annotation not found", tag)} + out <- Result[collectedImage]{err: fmt.Errorf("tag %s: architecture annotation not found", tag)} return } cleanVersion, _ := manifest.Annotations["version"] + rawAnnotation := manifest.Annotations[ossync.FeatureSetAnnotation] + rawFeatureSet := splitAnnotationRaw(rawAnnotation) var capabilities gardencorev1beta1.Capabilities - if len(o.capabilityKeys) > 0 && cleanVersion != "" { - caps := make(gardencorev1beta1.Capabilities, 1+len(o.capabilityKeys)) + if len(o.featureToCapabilityMap) > 0 && cleanVersion != "" { + caps := make(gardencorev1beta1.Capabilities, 1+len(o.featureToCapabilityMap)) caps[ossync.ArchitectureCapability] = []string{arch} - for _, key := range o.capabilityKeys { - raw, ok := manifest.Annotations[key] - if !ok { - continue + for featureSetValue, capabilityName := range o.featureToCapabilityMap { + _, present := rawFeatureSet[featureSetValue] + if present { + caps[capabilityName] = []string{"true"} + } else { + caps[capabilityName] = []string{"false"} } - values := filterAnnotationValues(raw) - if len(values) > 0 { - caps[key] = values - } - } - if len(caps) > 1 { // more than just architecture - capabilities = caps } + capabilities = caps } - out <- Result[ossync.SourceImage]{ - value: ossync.SourceImage{ - Version: strings.ReplaceAll(tag, "_", "+"), // Follow the helm convention - CleanVersion: cleanVersion, - Architectures: []string{arch}, - Capabilities: capabilities, - SupportInPlaceUpdate: supportsInPlaceUpdate(manifest.Annotations), + out <- Result[collectedImage]{ + value: collectedImage{ + image: ossync.SourceImage{ + Version: strings.ReplaceAll(tag, "_", "+"), // Follow the helm convention + CleanVersion: cleanVersion, + Architectures: []string{arch}, + Capabilities: capabilities, + SupportInPlaceUpdate: supportsInPlaceUpdate(manifest.Annotations), + }, + featureSetRaw: rawAnnotation, }, } }() } - images := []ossync.SourceImage{} + var items []collectedImage var skipped []error var errs []error for range tags { @@ -175,13 +169,42 @@ func (o *OCI) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) { } continue } - images = append(images, result.value) + items = append(items, result.value) } if len(skipped) > 0 { o.log.V(1).Info("skipped tags with errors", "count", len(skipped), "errors", errors.Join(skipped...)) } - if len(errs) == 0 && len(images) == 0 && len(tags) > 0 { + images := applyImageFilter(o.log, items, o.imageFilter) + if len(errs) == 0 && len(images) == 0 && len(skipped) == len(tags) { return nil, fmt.Errorf("all %d tags were skipped; possible registry issue", len(tags)) } return images, errors.Join(errs...) } + +// applyImageFilter removes images whose feature_set annotations do not satisfy +// every required value in filter. No-op when filter is nil. +func applyImageFilter(log logr.Logger, items []collectedImage, filter *v1alpha1.ImageFilter) []ossync.SourceImage { + images := make([]ossync.SourceImage, 0, len(items)) + for _, item := range items { + if filter != nil && !passesImageFilter(splitAnnotationRaw(item.featureSetRaw), filter) { + log.V(1).Info("image excluded by imageFilter", "version", item.image.Version) + continue + } + images = append(images, item.image) + } + return images +} + +// passesImageFilter reports whether the raw feature_set token set satisfies every +// required value in filter. Always returns true when filter is nil. +func passesImageFilter(rawFeatureSet map[string]struct{}, filter *v1alpha1.ImageFilter) bool { + if filter == nil { + return true + } + for _, req := range filter.RequiredFeatureSetValues { + if _, ok := rawFeatureSet[req]; !ok { + return false + } + } + return true +} diff --git a/cloudprofilesync/ossync/source/oci/os_source_test.go b/cloudprofilesync/ossync/source/oci/os_source_test.go index 52b024c..3f58e84 100644 --- a/cloudprofilesync/ossync/source/oci/os_source_test.go +++ b/cloudprofilesync/ossync/source/oci/os_source_test.go @@ -16,6 +16,7 @@ import ( "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/registry/remote" + "github.com/cobaltcore-dev/cloud-profile-sync/api/v1alpha1" "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ocirepo" "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync" "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync/source/oci" @@ -61,19 +62,25 @@ var _ = Describe("OCISource", func() { Registry: registryAddr, Repository: "repo", Insecure: true, - }, 4, logr.Discard(), nil) + }, 4, logr.Discard(), nil, nil) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) Expect(versions).To(HaveLen(2)) Expect(versions).To(ContainElement( - ossync.SourceImage{Version: "1.0.0", Architectures: []string{"amd64"}})) + ossync.SourceImage{ + Version: "1.0.0", + Architectures: []string{"amd64"}, + })) Expect(versions).To(ContainElement( - ossync.SourceImage{Version: "1.0.1+abc", Architectures: []string{"amd64"}})) + ossync.SourceImage{ + Version: "1.0.1+abc", + Architectures: []string{"amd64"}, + })) }) - It("populates capabilities when feature_set annotation is present", func(ctx SpecContext) { - repo, err := remote.NewRepository(registryAddr + "/repo-caps") + It("expands featureSetCapabilities into boolean capabilities from feature_set annotation", func(ctx SpecContext) { + repo, err := remote.NewRepository(registryAddr + "/repo-bool-caps") Expect(err).To(Succeed()) repo.PlainHTTP = true @@ -88,7 +95,7 @@ var _ = Describe("OCISource", func() { }, Annotations: map[string]string{ "architecture": "amd64", - "feature_set": "sci,_usi,_rescue,log", + "feature_set": "scibase,_usi", "version": "2.0.0", }, } @@ -101,21 +108,120 @@ var _ = Describe("OCISource", func() { err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "2.0.0") Expect(err).To(Succeed()) + // vhost is absent → false; usidev is absent → false oci, err := oci.NewOCI(ocirepo.Params{ Registry: registryAddr, - Repository: "repo-caps", + Repository: "repo-bool-caps", Insecure: true, - }, 4, logr.Discard(), []string{"feature_set"}) + }, 4, logr.Discard(), map[string]string{ + "vhost": "vhost", + "_usidev": "usidev", + }, nil) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) Expect(versions).To(HaveLen(1)) Expect(versions[0].Version).To(Equal("2.0.0")) Expect(versions[0].CleanVersion).To(Equal("2.0.0")) - Expect(versions[0].Architectures).To(Equal([]string{"amd64"})) Expect(versions[0].Capabilities).To(Equal(gardencorev1beta1.Capabilities{ "architecture": {"amd64"}, - "feature_set": {"sci", "usi", "rescue", "log"}, // all values passed through normalized; filtering against machineCapabilities happens in the updater + "vhost": {"false"}, + "usidev": {"false"}, + })) + }) + + It("sets capability to true when featureSetValue is present in feature_set annotation", func(ctx SpecContext) { + repo, err := remote.NewRepository(registryAddr + "/repo-bool-caps-true") + Expect(err).To(Succeed()) + repo.PlainHTTP = true + + index := ocispec.Index{ + SchemaVersion: 2, + Manifests: []ocispec.Descriptor{ + { + MediaType: ocispec.MediaTypeImageManifest, + Size: 0, + Digest: ocispec.DescriptorEmptyJSON.Digest, + }, + }, + Annotations: map[string]string{ + "architecture": "amd64", + "feature_set": "sci,_usi,vhost", + "version": "3.0.0", + }, + } + indexBlob, err := json.Marshal(index) + Expect(err).To(Succeed()) + indexDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageIndex, indexBlob) + + err = repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}")) + Expect(err).To(Succeed()) + err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "3.0.0") + Expect(err).To(Succeed()) + + // vhost is present → true; usidev is absent → false + oci, err := oci.NewOCI(ocirepo.Params{ + Registry: registryAddr, + Repository: "repo-bool-caps-true", + Insecure: true, + }, 4, logr.Discard(), map[string]string{ + "vhost": "vhost", + "_usidev": "usidev", + }, nil) + Expect(err).To(Succeed()) + versions, err := oci.GetVersions(ctx) + Expect(err).To(Succeed()) + Expect(versions).To(HaveLen(1)) + Expect(versions[0].Capabilities).To(Equal(gardencorev1beta1.Capabilities{ + "architecture": {"amd64"}, + "vhost": {"true"}, + "usidev": {"false"}, + })) + }) + + It("matches FeatureSetValue exactly against raw annotation tokens", func(ctx SpecContext) { + repo, err := remote.NewRepository(registryAddr + "/repo-underscore-caps") + Expect(err).To(Succeed()) + repo.PlainHTTP = true + + index := ocispec.Index{ + SchemaVersion: 2, + Manifests: []ocispec.Descriptor{ + {MediaType: ocispec.MediaTypeImageManifest, Size: 0, Digest: ocispec.DescriptorEmptyJSON.Digest}, + }, + Annotations: map[string]string{ + "architecture": "amd64", + "feature_set": "sci,_usi,_usidev", + "version": "6.0.0", + }, + } + indexBlob, err := json.Marshal(index) + Expect(err).To(Succeed()) + indexDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageIndex, indexBlob) + + err = repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}")) + Expect(err).To(Succeed()) + err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "6.0.0") + Expect(err).To(Succeed()) + + // FeatureSetValue must match the raw annotation token exactly. + // "_usidev" matches the annotation; "vhost" does not appear → false. + oci, err := oci.NewOCI(ocirepo.Params{ + Registry: registryAddr, + Repository: "repo-underscore-caps", + Insecure: true, + }, 4, logr.Discard(), map[string]string{ + "vhost": "vhost", + "_usidev": "usidev", + }, nil) + Expect(err).To(Succeed()) + versions, err := oci.GetVersions(ctx) + Expect(err).To(Succeed()) + Expect(versions).To(HaveLen(1)) + Expect(versions[0].Capabilities).To(Equal(gardencorev1beta1.Capabilities{ + "architecture": {"amd64"}, + "vhost": {"false"}, + "usidev": {"true"}, })) }) @@ -150,7 +256,7 @@ var _ = Describe("OCISource", func() { Registry: registryAddr, Repository: "repo-legacy", Insecure: true, - }, 4, logr.Discard(), nil) + }, 4, logr.Discard(), nil, nil) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) @@ -199,7 +305,7 @@ var _ = Describe("OCISource", func() { Registry: registryAddr, Repository: "repo-missing-arch", Insecure: true, - }, 4, logr.Discard(), nil) + }, 4, logr.Discard(), nil, nil) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) @@ -207,8 +313,8 @@ var _ = Describe("OCISource", func() { Expect(versions[0].Version).To(Equal("1.0.0")) }) - It("normalizes all feature_set values and passes them through", func(ctx SpecContext) { - repo, err := remote.NewRepository(registryAddr + "/repo-no-valid-features") + It("detects SupportInPlaceUpdate from feature_set even when featureSetCapabilities is empty", func(ctx SpecContext) { + repo, err := remote.NewRepository(registryAddr + "/repo-usi-no-caps") Expect(err).To(Succeed()) repo.PlainHTTP = true @@ -219,8 +325,8 @@ var _ = Describe("OCISource", func() { }, Annotations: map[string]string{ "architecture": "amd64", - "feature_set": "_rescue,log,sap,ssh", - "version": "3.0.0", + "feature_set": "sci,_usi", + "version": "4.0.0", }, } indexBlob, err := json.Marshal(index) @@ -229,27 +335,24 @@ var _ = Describe("OCISource", func() { err = repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}")) Expect(err).To(Succeed()) - err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "3.0.0-no-valid-features") + err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "4.0.0") Expect(err).To(Succeed()) oci, err := oci.NewOCI(ocirepo.Params{ Registry: registryAddr, - Repository: "repo-no-valid-features", + Repository: "repo-usi-no-caps", Insecure: true, - }, 4, logr.Discard(), []string{"feature_set"}) + }, 4, logr.Discard(), nil, nil) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) Expect(versions).To(HaveLen(1)) - Expect(versions[0].Capabilities).To(Equal(gardencorev1beta1.Capabilities{ - "architecture": {"amd64"}, - "feature_set": {"rescue", "log", "sap", "ssh"}, - })) - Expect(versions[0].CleanVersion).To(Equal("3.0.0")) + Expect(versions[0].SupportInPlaceUpdate).To(BeTrue()) + Expect(versions[0].Capabilities).To(BeNil()) }) - It("detects SupportInPlaceUpdate from feature_set even when feature_set is not in capabilityKeys", func(ctx SpecContext) { - repo, err := remote.NewRepository(registryAddr + "/repo-usi-no-caps") + It("populates CleanVersion from version annotation even when featureSetCapabilities is empty", func(ctx SpecContext) { + repo, err := remote.NewRepository(registryAddr + "/repo-clean-version-no-caps") Expect(err).To(Succeed()) repo.PlainHTTP = true @@ -260,9 +363,8 @@ var _ = Describe("OCISource", func() { }, Annotations: map[string]string{ "architecture": "amd64", + "version": "5.0.0", "feature_set": "sci,_usi", - "version": "4.0.0", - "hypervisor": "kvm", }, } indexBlob, err := json.Marshal(index) @@ -271,63 +373,135 @@ var _ = Describe("OCISource", func() { err = repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}")) Expect(err).To(Succeed()) - err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "4.0.0") + err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "5.0.0-build-abc") Expect(err).To(Succeed()) - // capabilityKeys only includes "hypervisor", NOT "feature_set" oci, err := oci.NewOCI(ocirepo.Params{ Registry: registryAddr, - Repository: "repo-usi-no-caps", + Repository: "repo-clean-version-no-caps", Insecure: true, - }, 4, logr.Discard(), []string{"hypervisor"}) + }, 4, logr.Discard(), nil, nil) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) Expect(versions).To(HaveLen(1)) + Expect(versions[0].CleanVersion).To(Equal("5.0.0")) + Expect(versions[0].Capabilities).To(BeNil()) Expect(versions[0].SupportInPlaceUpdate).To(BeTrue()) - Expect(versions[0].Capabilities).To(Equal(gardencorev1beta1.Capabilities{ - "architecture": {"amd64"}, - "hypervisor": {"kvm"}, - })) }) - It("populates CleanVersion from version annotation even when capabilityKeys is empty", func(ctx SpecContext) { - repo, err := remote.NewRepository(registryAddr + "/repo-clean-version-no-caps") + It("returns false for SupportInPlaceUpdate when feature_set lacks _usi", func(ctx SpecContext) { + repo, err := remote.NewRepository(registryAddr + "/repo-no-usi") Expect(err).To(Succeed()) repo.PlainHTTP = true index := ocispec.Index{ SchemaVersion: 2, - Manifests: []ocispec.Descriptor{ - {MediaType: ocispec.MediaTypeImageManifest, Size: 0, Digest: ocispec.DescriptorEmptyJSON.Digest}, - }, + Manifests: []ocispec.Descriptor{{MediaType: ocispec.MediaTypeImageManifest, Size: 0, Digest: ocispec.DescriptorEmptyJSON.Digest}}, Annotations: map[string]string{ "architecture": "amd64", - "version": "5.0.0", - "feature_set": "sci,_usi", + "feature_set": "scibase,vhost", + "version": "7.0.0", }, } indexBlob, err := json.Marshal(index) Expect(err).To(Succeed()) indexDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageIndex, indexBlob) + Expect(repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}"))).To(Succeed()) + Expect(repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "7.0.0")).To(Succeed()) - err = repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}")) + o, err := oci.NewOCI(ocirepo.Params{Registry: registryAddr, Repository: "repo-no-usi", Insecure: true}, 4, logr.Discard(), nil, nil) Expect(err).To(Succeed()) - err = repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), "5.0.0-build-abc") + versions, err := o.GetVersions(ctx) Expect(err).To(Succeed()) + Expect(versions).To(HaveLen(1)) + Expect(versions[0].SupportInPlaceUpdate).To(BeFalse()) + }) - oci, err := oci.NewOCI(ocirepo.Params{ - Registry: registryAddr, - Repository: "repo-clean-version-no-caps", - Insecure: true, - }, 4, logr.Discard(), nil) + It("returns an error when all tags are skipped due to missing annotations", func(ctx SpecContext) { + repo, err := remote.NewRepository(registryAddr + "/repo-all-bad") Expect(err).To(Succeed()) - versions, err := oci.GetVersions(ctx) + repo.PlainHTTP = true + + noArch := ocispec.Index{ + SchemaVersion: 2, + Manifests: []ocispec.Descriptor{{MediaType: ocispec.MediaTypeImageManifest, Size: 0, Digest: ocispec.DescriptorEmptyJSON.Digest}}, + Annotations: map[string]string{"feature_set": "scibase"}, + } + noArchBlob, err := json.Marshal(noArch) Expect(err).To(Succeed()) - Expect(versions).To(HaveLen(1)) - Expect(versions[0].CleanVersion).To(Equal("5.0.0")) - Expect(versions[0].Capabilities).To(BeNil()) - Expect(versions[0].SupportInPlaceUpdate).To(BeTrue()) + noArchDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageIndex, noArchBlob) + Expect(repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}"))).To(Succeed()) + Expect(repo.PushReference(ctx, noArchDesc, bytes.NewReader(noArchBlob), "1.0.0")).To(Succeed()) + Expect(repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}"))).To(Succeed()) + Expect(repo.PushReference(ctx, noArchDesc, bytes.NewReader(noArchBlob), "2.0.0")).To(Succeed()) + + o, err := oci.NewOCI(ocirepo.Params{Registry: registryAddr, Repository: "repo-all-bad", Insecure: true}, 4, logr.Discard(), nil, nil) + Expect(err).To(Succeed()) + _, err = o.GetVersions(ctx) + Expect(err).To(MatchError(ContainSubstring("all 2 tags were skipped"))) + }) + +}) + +var _ = Describe("OCI imageFilter", func() { + pushImage := func(ctx SpecContext, repo *remote.Repository, tag string, annotations map[string]string) { + index := ocispec.Index{ + SchemaVersion: 2, + Manifests: []ocispec.Descriptor{{MediaType: ocispec.MediaTypeImageManifest, Size: 0, Digest: ocispec.DescriptorEmptyJSON.Digest}}, + Annotations: annotations, + } + indexBlob, err := json.Marshal(index) + Expect(err).To(Succeed()) + indexDesc := content.NewDescriptorFromBytes(ocispec.MediaTypeImageIndex, indexBlob) + Expect(repo.Push(ctx, ocispec.DescriptorEmptyJSON, strings.NewReader("{}"))).To(Succeed()) + Expect(repo.PushReference(ctx, indexDesc, bytes.NewReader(indexBlob), tag)).To(Succeed()) + } + + It("includes all images when imageFilter is nil", func(ctx SpecContext) { + repo, err := remote.NewRepository(registryAddr + "/filter-nil") + Expect(err).To(Succeed()) + repo.PlainHTTP = true + pushImage(ctx, repo, "1.0.0", map[string]string{"architecture": "amd64", "feature_set": "scibase,_pxe", "version": "1.0.0"}) + pushImage(ctx, repo, "2.0.0", map[string]string{"architecture": "amd64", "feature_set": "scibase,_usi", "version": "2.0.0"}) + + o, err := oci.NewOCI(ocirepo.Params{Registry: registryAddr, Repository: "filter-nil", Insecure: true}, 4, logr.Discard(), nil, nil) + Expect(err).To(Succeed()) + versions, err := o.GetVersions(ctx) + Expect(err).To(Succeed()) + Expect(versions).To(HaveLen(2)) }) + It("excludes images missing a required feature_set value (exact raw match)", func(ctx SpecContext) { + repo, err := remote.NewRepository(registryAddr + "/filter-required") + Expect(err).To(Succeed()) + repo.PlainHTTP = true + pushImage(ctx, repo, "1.0.0", map[string]string{"architecture": "amd64", "feature_set": "scibase,_pxe", "version": "1.0.0"}) + pushImage(ctx, repo, "2.0.0", map[string]string{"architecture": "amd64", "feature_set": "scibase,_usi", "version": "2.0.0"}) + pushImage(ctx, repo, "3.0.0", map[string]string{"architecture": "amd64", "feature_set": "scibase,_usi,vhost", "version": "3.0.0"}) + + filter := &v1alpha1.ImageFilter{RequiredFeatureSetValues: []string{"scibase", "_usi"}} + o, err := oci.NewOCI(ocirepo.Params{Registry: registryAddr, Repository: "filter-required", Insecure: true}, 4, logr.Discard(), nil, filter) + Expect(err).To(Succeed()) + versions, err := o.GetVersions(ctx) + Expect(err).To(Succeed()) + Expect(versions).To(HaveLen(2)) + versionStrings := []string{versions[0].CleanVersion, versions[1].CleanVersion} + Expect(versionStrings).To(ConsistOf("2.0.0", "3.0.0")) + }) + + It("returns empty when no images pass the filter", func(ctx SpecContext) { + repo, err := remote.NewRepository(registryAddr + "/filter-none") + Expect(err).To(Succeed()) + repo.PlainHTTP = true + pushImage(ctx, repo, "1.0.0", map[string]string{"architecture": "amd64", "feature_set": "scibase,_pxe", "version": "1.0.0"}) + pushImage(ctx, repo, "2.0.0", map[string]string{"architecture": "amd64", "feature_set": "scibase,capi", "version": "2.0.0"}) + + filter := &v1alpha1.ImageFilter{RequiredFeatureSetValues: []string{"_usi"}} + o, err := oci.NewOCI(ocirepo.Params{Registry: registryAddr, Repository: "filter-none", Insecure: true}, 4, logr.Discard(), nil, filter) + Expect(err).To(Succeed()) + versions, err := o.GetVersions(ctx) + Expect(err).To(Succeed()) + Expect(versions).To(BeEmpty()) + }) }) diff --git a/controllers/cloud_profile.go b/controllers/cloud_profile.go index 29549bb..82e8e9a 100644 --- a/controllers/cloud_profile.go +++ b/controllers/cloud_profile.go @@ -29,8 +29,8 @@ import ( // DefaultOCISourceFactory is the default implementation of OCISourceFactory. type DefaultOCISourceFactory struct{} -func (f *DefaultOCISourceFactory) Create(params ocirepo.Params, parallel int64, log logr.Logger, capabilityKeys []string) (ossync.Source, error) { - return oci.NewOCI(params, parallel, log, capabilityKeys) +func (f *DefaultOCISourceFactory) Create(params ocirepo.Params, parallel int64, log logr.Logger, featureToCapabilityMap map[string]string, imageFilter *v1alpha1.ImageFilter) (ossync.Source, error) { + return oci.NewOCI(params, parallel, log, featureToCapabilityMap, imageFilter) } func (r *Reconciler) reconcileCloudProfile(ctx context.Context, log logr.Logger, mcp *v1alpha1.ManagedCloudProfile) error { @@ -99,19 +99,13 @@ func (r *Reconciler) updateMachineImages(ctx context.Context, log logr.Logger, u if err != nil { return err } - capabilityKeys := make([]string, 0, len(cpSpec.MachineCapabilities)) - for _, cap := range cpSpec.MachineCapabilities { - if cap.Name != ossync.ArchitectureCapability { - capabilityKeys = append(capabilityKeys, cap.Name) - } - } src, err := r.OCISourceFactory.Create(ocirepo.Params{ Registry: update.Source.OCI.Registry, Repository: update.Source.OCI.Repository, Username: update.Source.OCI.Username, Password: string(password), Insecure: update.Source.OCI.Insecure, - }, 1, log, capabilityKeys) + }, 1, log, update.Source.OCI.FeatureToCapabilityMap, update.Source.OCI.ImageFilter) if err != nil { return fmt.Errorf("failed to initialize OCI source: %w", err) } diff --git a/controllers/managedcloudprofile_controller.go b/controllers/managedcloudprofile_controller.go index 201083e..a4ecc88 100644 --- a/controllers/managedcloudprofile_controller.go +++ b/controllers/managedcloudprofile_controller.go @@ -28,7 +28,7 @@ const ( // OCISourceFactory defines an interface for creating OCI sources. type OCISourceFactory interface { - Create(params ocirepo.Params, parallel int64, log logr.Logger, capabilityKeys []string) (ossync.Source, error) + Create(params ocirepo.Params, parallel int64, log logr.Logger, featureToCapabilityMap map[string]string, imageFilter *v1alpha1.ImageFilter) (ossync.Source, error) } type RegistryClient interface { diff --git a/controllers/managedcloudprofile_controller_test.go b/controllers/managedcloudprofile_controller_test.go index 4a14c95..c95e75c 100644 --- a/controllers/managedcloudprofile_controller_test.go +++ b/controllers/managedcloudprofile_controller_test.go @@ -59,17 +59,17 @@ func (f *emptyOCISource) GetVersions(ctx context.Context) ([]ossync.SourceImage, type fakeFactory struct{} -func (f *fakeFactory) Create(params ocirepo.Params, _ int64, _ logr.Logger, _ []string) (ossync.Source, error) { +func (f *fakeFactory) Create(params ocirepo.Params, _ int64, _ logr.Logger, _ map[string]string, _ *v1alpha1.ImageFilter) (ossync.Source, error) { return &fakeOCISource{}, nil } type emptyFactory struct{} -func (f *emptyFactory) Create(params ocirepo.Params, parallel int64, _ logr.Logger, _ []string) (ossync.Source, error) { +func (f *emptyFactory) Create(params ocirepo.Params, parallel int64, _ logr.Logger, _ map[string]string, _ *v1alpha1.ImageFilter) (ossync.Source, error) { return &emptyOCISource{}, nil } -func (m *mockOCIFactory) Create(params ocirepo.Params, parallel int64, _ logr.Logger, _ []string) (ossync.Source, error) { +func (m *mockOCIFactory) Create(params ocirepo.Params, parallel int64, _ logr.Logger, _ map[string]string, _ *v1alpha1.ImageFilter) (ossync.Source, error) { return m.createFunc(params, parallel) } diff --git a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml index 705317e..997e662 100644 --- a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml +++ b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml @@ -619,6 +619,30 @@ spec: description: OCI contains configuration for the OCI component-descriptor source. properties: + featureToCapabilityMap: + additionalProperties: + type: string + description: |- + FeatureToCapabilityMap maps raw feature_set annotation values (e.g. "_usidev") + to boolean CloudProfile capability names (e.g. "usidev"). For each entry, + presence of the key in the annotation produces CapabilityName: [true], + absence produces CapabilityName: [false]. + type: object + imageFilter: + description: |- + ImageFilter defines criteria for filtering source images before they are + written into the CloudProfile. Only applies when this OCI source is used + for machine image updates (not for Kubernetes version sources). + properties: + requiredFeatureSetValues: + description: |- + RequiredFeatureSetValues lists exact values that must all be present in the + image's feature_set OCI annotation (e.g. "_usi", "scibase"). Images missing + any of these values are excluded from the CloudProfile entirely. + items: + type: string + type: array + type: object insecure: description: Insecure disables TLS type: boolean @@ -772,6 +796,30 @@ spec: oci: description: OCI contains configuration for an OCI source. properties: + featureToCapabilityMap: + additionalProperties: + type: string + description: |- + FeatureToCapabilityMap maps raw feature_set annotation values (e.g. "_usidev") + to boolean CloudProfile capability names (e.g. "usidev"). For each entry, + presence of the key in the annotation produces CapabilityName: [true], + absence produces CapabilityName: [false]. + type: object + imageFilter: + description: |- + ImageFilter defines criteria for filtering source images before they are + written into the CloudProfile. Only applies when this OCI source is used + for machine image updates (not for Kubernetes version sources). + properties: + requiredFeatureSetValues: + description: |- + RequiredFeatureSetValues lists exact values that must all be present in the + image's feature_set OCI annotation (e.g. "_usi", "scibase"). Images missing + any of these values are excluded from the CloudProfile entirely. + items: + type: string + type: array + type: object insecure: description: Insecure disables TLS type: boolean