diff --git a/cloudprofilesync/ossync/os_image_updater.go b/cloudprofilesync/ossync/os_image_updater.go index cd9dc90..723c168 100644 --- a/cloudprofilesync/ossync/os_image_updater.go +++ b/cloudprofilesync/ossync/os_image_updater.go @@ -16,6 +16,11 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// ArchitectureCapability is the well-known Gardener capability key for CPU architecture. +// It is read from the "architecture" OCI annotation and excluded from the user-configured +// capabilityKeys since it is always populated automatically by the OCI source. +const ArchitectureCapability = "architecture" + type SourceImage struct { // Version is the full tag from the registry (used as version key for legacy images). Version string @@ -158,6 +163,51 @@ func inPlaceUpdates(supported bool) *gardenerv1beta1.InPlaceUpdates { return &gardenerv1beta1.InPlaceUpdates{Supported: true} } +// allowedCapabilityValues builds a per-key set of allowed values from the +// MachineCapabilities declared in the CloudProfile spec. Only values present +// here will be written into capabilityFlavors. +func allowedCapabilityValues(caps []gardenerv1beta1.CapabilityDefinition) map[string]map[string]struct{} { + allowed := make(map[string]map[string]struct{}, len(caps)) + for _, cap := range caps { + vals := make(map[string]struct{}, len(cap.Values)) + for _, v := range cap.Values { + vals[v] = struct{}{} + } + allowed[cap.Name] = vals + } + return allowed +} + +// filterCapabilities returns a copy of caps filtered to only the key/value pairs +// declared in allowed. Keys or values absent from allowed are dropped. +// Returns nil if allowed is empty — MachineCapabilities not configured means +// no capabilities should be written to the CloudProfile. +func filterCapabilities(caps gardenerv1beta1.Capabilities, allowed map[string]map[string]struct{}) gardenerv1beta1.Capabilities { + if len(allowed) == 0 { + return nil + } + result := make(gardenerv1beta1.Capabilities, len(caps)) + for key, values := range caps { + allowedVals, ok := allowed[key] + if !ok { + continue + } + var filtered []string + for _, v := range values { + if _, ok := allowedVals[v]; ok { + filtered = append(filtered, v) + } + } + if len(filtered) > 0 { + result[key] = filtered + } + } + if len(result) == 0 { + return nil + } + return result +} + func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.CloudProfileSpec) error { sourceImages, err := iu.Source.GetVersions(ctx) if err != nil { @@ -173,6 +223,11 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou } return cmp.Compare(a.Version, b.Version) }) + + allowed := allowedCapabilityValues(cpSpec.MachineCapabilities) + for i := range sourceImages { + sourceImages[i].Capabilities = filterCapabilities(sourceImages[i].Capabilities, allowed) + } imageIndex := slices.IndexFunc(cpSpec.MachineImages, func(img gardenerv1beta1.MachineImage) bool { return img.Name == iu.ImageName }) diff --git a/cloudprofilesync/ossync/os_image_updater_test.go b/cloudprofilesync/ossync/os_image_updater_test.go index d78084d..c8ff287 100644 --- a/cloudprofilesync/ossync/os_image_updater_test.go +++ b/cloudprofilesync/ossync/os_image_updater_test.go @@ -54,7 +54,7 @@ var _ = Describe("filterImages", func() { Version: "1877.9.2.0-metal-sci-pxe-amd64", CleanVersion: "1877.9.2", Architectures: []string{"amd64"}, - Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_pxe"}}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature_set": {"sci", "pxe"}}, }, }) Expect(result).To(HaveLen(1)) @@ -67,7 +67,7 @@ var _ = Describe("filterImages", func() { Version: "2254.0.0-baremetal-sci-usi-amd64", CleanVersion: "2254.0.0", Architectures: []string{"amd64"}, - Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature_set": {"sci", "usi"}}, }, }) Expect(result).To(HaveLen(2)) @@ -89,7 +89,7 @@ var _ = Describe("filterImages", func() { Version: "1921.0.0-metal-sci-usi-amd64", CleanVersion: "1921.0", Architectures: []string{"amd64"}, - Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature_set": {"sci", "usi"}}, }, }) Expect(result).To(HaveLen(2)) @@ -197,7 +197,7 @@ var _ = Describe("ImageUpdater", func() { Version: "2254.0.0-baremetal-sci-usi-amd64", CleanVersion: "2254.0.0", Architectures: []string{"amd64"}, - Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature_set": {"sci", "usi"}}, }, } updater := ossync.ImageUpdater{ @@ -248,13 +248,25 @@ var _ = Describe("ImageUpdater", func() { }) Describe("flag ON (dual-write clean version)", func() { + // cpSpecWithCaps returns a CloudProfileSpec with MachineCapabilities covering + // the keys used by the test source images. Required because filterCapabilities + // returns nil when MachineCapabilities is empty. + cpSpecWithCaps := func() gardencorev1beta1.CloudProfileSpec { + return gardencorev1beta1.CloudProfileSpec{ + MachineCapabilities: []gardencorev1beta1.CapabilityDefinition{ + {Name: "architecture", Values: []string{"amd64", "arm64"}}, + {Name: "feature_set", Values: []string{"sci", "usi", "pxe", "scibase"}}, + }, + } + } + It("sets CapabilityFlavors when CleanVersion equals Version (semver tag with matching annotation)", func(ctx SpecContext) { mockSource.images = []ossync.SourceImage{ { Version: "2254.0.0", CleanVersion: "2254.0.0", Architectures: []string{"amd64"}, - Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature_set": {"sci", "usi"}}, }, } updater := ossync.ImageUpdater{ @@ -263,7 +275,7 @@ var _ = Describe("ImageUpdater", func() { ImageName: "test", EnableCapabilities: true, } - var cpSpec gardencorev1beta1.CloudProfileSpec + cpSpec := cpSpecWithCaps() Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) Expect(cpSpec.MachineImages[0].Versions).To(HaveLen(1)) @@ -271,7 +283,7 @@ var _ = Describe("ImageUpdater", func() { Expect(v.Version).To(Equal("2254.0.0")) Expect(v.CapabilityFlavors).To(HaveLen(1)) Expect(v.CapabilityFlavors[0].Capabilities).To(Equal( - gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature_set": {"sci", "usi"}}, )) }) @@ -281,7 +293,7 @@ var _ = Describe("ImageUpdater", func() { Version: "2254.0.0-baremetal-sci-usi-amd64", CleanVersion: "2254.0.0", Architectures: []string{"amd64"}, - Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature_set": {"sci", "usi"}}, }, } updater := ossync.ImageUpdater{ @@ -290,7 +302,7 @@ var _ = Describe("ImageUpdater", func() { ImageName: "test", EnableCapabilities: true, } - var cpSpec gardencorev1beta1.CloudProfileSpec + cpSpec := cpSpecWithCaps() Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) versions := cpSpec.MachineImages[0].Versions @@ -304,7 +316,7 @@ var _ = Describe("ImageUpdater", func() { Expect(cleanEntry).NotTo(BeNil()) Expect(cleanEntry.CapabilityFlavors).To(HaveLen(1)) Expect(cleanEntry.CapabilityFlavors[0].Capabilities).To(Equal( - gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature_set": {"sci", "usi"}}, )) }) @@ -314,13 +326,13 @@ var _ = Describe("ImageUpdater", func() { Version: "2254.0.0-baremetal-sci-usi-amd64", CleanVersion: "2254.0.0", Architectures: []string{"amd64"}, - Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature_set": {"sci", "usi"}}, }, { Version: "2254.0.0-baremetal-sci-pxe-amd64", CleanVersion: "2254.0.0", Architectures: []string{"amd64"}, - Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_pxe"}}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature_set": {"sci", "pxe"}}, }, } updater := ossync.ImageUpdater{ @@ -329,7 +341,7 @@ var _ = Describe("ImageUpdater", func() { ImageName: "test", EnableCapabilities: true, } - var cpSpec gardencorev1beta1.CloudProfileSpec + cpSpec := cpSpecWithCaps() Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) versions := cpSpec.MachineImages[0].Versions @@ -347,8 +359,8 @@ var _ = Describe("ImageUpdater", func() { cleanEntry.CapabilityFlavors[1].Capabilities, } Expect(flavors).To(ConsistOf( - gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, - gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_pxe"}}, + gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature_set": {"sci", "usi"}}, + gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature_set": {"sci", "pxe"}}, )) }) @@ -358,7 +370,7 @@ var _ = Describe("ImageUpdater", func() { Version: "2254.0.0-baremetal-sci-usi-amd64", CleanVersion: "2254.0.0", Architectures: []string{"amd64"}, - Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature_set": {"sci", "usi"}}, }, } updater := ossync.ImageUpdater{ @@ -367,7 +379,7 @@ var _ = Describe("ImageUpdater", func() { ImageName: "test", EnableCapabilities: true, } - var cpSpec gardencorev1beta1.CloudProfileSpec + cpSpec := cpSpecWithCaps() Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) @@ -418,7 +430,7 @@ var _ = Describe("ImageUpdater", func() { Version: "2254.0.0-baremetal-sci-usi-amd64", CleanVersion: "2254.0.0", Architectures: []string{"amd64"}, - Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature_set": {"sci", "usi"}}, SupportInPlaceUpdate: true, }, } @@ -444,7 +456,7 @@ var _ = Describe("ImageUpdater", func() { Version: "2254.0.0-baremetal-sci-usi-amd64", CleanVersion: "2254.0.0", Architectures: []string{"amd64"}, - Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature_set": {"sci", "usi"}}, }, } updater := ossync.ImageUpdater{ @@ -465,7 +477,7 @@ var _ = Describe("ImageUpdater", func() { Version: "1877.9.2.0-metal-sci-pxe-amd64-1877-9-2-6bb2b442", CleanVersion: "1877.9.2", Architectures: []string{"amd64"}, - Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_pxe"}}, + Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature_set": {"sci", "pxe"}}, }, } updater := ossync.ImageUpdater{ @@ -531,6 +543,130 @@ var _ = Describe("ImageUpdater", func() { }) }) + Describe("capability filtering via MachineCapabilities", func() { + It("writes no CapabilityFlavors when MachineCapabilities is empty", func(ctx SpecContext) { + mockSource.images = []ossync.SourceImage{{ + Version: "2254.0.0", + CleanVersion: "2254.0.0", + Architectures: []string{"amd64"}, + Capabilities: gardencorev1beta1.Capabilities{ + "architecture": {"amd64"}, + "feature_set": {"sci", "usi"}, + "hypervisor": {"kvm"}, + }, + }} + updater := ossync.ImageUpdater{ + Log: GinkgoLogr, + Source: &mockSource, + ImageName: "test", + EnableCapabilities: true, + } + var cpSpec gardencorev1beta1.CloudProfileSpec + Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) + v := cpSpec.MachineImages[0].Versions[0] + Expect(v.CapabilityFlavors).To(BeEmpty()) + }) + + It("drops capability values absent from MachineCapabilities", func(ctx SpecContext) { + mockSource.images = []ossync.SourceImage{{ + Version: "2254.0.0", + CleanVersion: "2254.0.0", + Architectures: []string{"amd64"}, + Capabilities: gardencorev1beta1.Capabilities{ + "architecture": {"amd64"}, + "feature_set": {"sci", "usi", "rescue"}, + }, + }} + updater := ossync.ImageUpdater{ + Log: GinkgoLogr, + Source: &mockSource, + ImageName: "test", + EnableCapabilities: true, + } + cpSpec := gardencorev1beta1.CloudProfileSpec{ + MachineCapabilities: []gardencorev1beta1.CapabilityDefinition{ + {Name: "architecture", Values: []string{"amd64", "arm64"}}, + {Name: "feature_set", Values: []string{"sci", "usi"}}, + }, + } + Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) + v := cpSpec.MachineImages[0].Versions[0] + Expect(v.CapabilityFlavors).To(HaveLen(1)) + Expect(v.CapabilityFlavors[0].Capabilities).To(Equal(gardencorev1beta1.Capabilities{ + "architecture": {"amd64"}, + "feature_set": {"sci", "usi"}, // "rescue" dropped — not in MachineCapabilities + })) + }) + + It("populates all declared capability keys into capabilityFlavors", func(ctx SpecContext) { + mockSource.images = []ossync.SourceImage{{ + Version: "2254.0.0", + CleanVersion: "2254.0.0", + Architectures: []string{"amd64"}, + Capabilities: gardencorev1beta1.Capabilities{ + "architecture": {"amd64"}, + "feature_set": {"sci", "usi"}, + "hypervisor": {"kvm"}, + }, + }} + updater := ossync.ImageUpdater{ + Log: GinkgoLogr, + Source: &mockSource, + ImageName: "test", + EnableCapabilities: true, + } + cpSpec := gardencorev1beta1.CloudProfileSpec{ + MachineCapabilities: []gardencorev1beta1.CapabilityDefinition{ + {Name: "architecture", Values: []string{"amd64", "arm64"}}, + {Name: "feature_set", Values: []string{"sci", "usi", "scibase"}}, + {Name: "hypervisor", Values: []string{"kvm", "xen"}}, + }, + } + Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) + v := cpSpec.MachineImages[0].Versions[0] + Expect(v.CapabilityFlavors).To(HaveLen(1)) + Expect(v.CapabilityFlavors[0].Capabilities).To(Equal(gardencorev1beta1.Capabilities{ + "architecture": {"amd64"}, + "feature_set": {"sci", "usi"}, + "hypervisor": {"kvm"}, + })) + }) + + It("drops a capability key entirely when none of its values are in MachineCapabilities", func(ctx SpecContext) { + mockSource.images = []ossync.SourceImage{{ + Version: "2254.0.0", + CleanVersion: "2254.0.0", + Architectures: []string{"amd64"}, + Capabilities: gardencorev1beta1.Capabilities{ + "architecture": {"amd64"}, + "feature_set": {"sci", "usi"}, + "hypervisor": {"kvm"}, + }, + }} + updater := ossync.ImageUpdater{ + Log: GinkgoLogr, + Source: &mockSource, + ImageName: "test", + EnableCapabilities: true, + } + cpSpec := gardencorev1beta1.CloudProfileSpec{ + MachineCapabilities: []gardencorev1beta1.CapabilityDefinition{ + {Name: "architecture", Values: []string{"amd64", "arm64"}}, + {Name: "feature_set", Values: []string{"sci", "usi"}}, + // hypervisor not declared → key dropped entirely + }, + } + Expect(updater.Update(ctx, &cpSpec)).To(Succeed()) + v := cpSpec.MachineImages[0].Versions[0] + Expect(v.CapabilityFlavors).To(HaveLen(1)) + Expect(v.CapabilityFlavors[0].Capabilities).To(Equal(gardencorev1beta1.Capabilities{ + "architecture": {"amd64"}, + "feature_set": {"sci", "usi"}, + })) + Expect(v.CapabilityFlavors[0].Capabilities).NotTo(HaveKey("hypervisor")) + }) + }) + Describe("expiration", func() { deprecated := gardencorev1beta1.ClassificationDeprecated diff --git a/cloudprofilesync/ossync/source/oci/os_source.go b/cloudprofilesync/ossync/source/oci/os_source.go index 52ce4a6..d12c796 100644 --- a/cloudprofilesync/ossync/source/oci/os_source.go +++ b/cloudprofilesync/ossync/source/oci/os_source.go @@ -21,47 +21,49 @@ import ( ) const ( - // chostFeature represent having containerd - chostFeature = "chost" - // pxeFeature represent pxe boot build - pxeFeature = "_pxe" - sciFeature = "sci" - sciBaseFeature = "scibase" - // capiFeature includes server, khost, and PXE; excludes SELinux and firewall - capiFeature = "capi" - // USIFeature shows UEFI build - usiFeature = "_usi" - usiDevFeature = "_usidev" - - architectureCapability = "architecture" - featureCapability = "feature" + // 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" ) -// validFeatureValues is the allowlist of feature values extracted from the feature_set annotation. -var validFeatureValues = map[string]struct{}{ - chostFeature: {}, - pxeFeature: {}, - sciFeature: {}, - sciBaseFeature: {}, - capiFeature: {}, - usiFeature: {}, - usiDevFeature: {}, +// 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. +func supportsInPlaceUpdate(annotations map[string]string) bool { + raw, ok := annotations[featureSetAnnotation] + if !ok { + return false + } + return slices.Contains(filterAnnotationValues(raw), usiCapabilityValue) +} + +// 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 filterFeatureSet(featureSet string) []string { - raw := strings.Split(featureSet, ",") - seen := make(map[string]struct{}, len(raw)) - result := make([]string, 0, len(raw)) - for _, f := range raw { +func filterAnnotationValues(raw string) []string { + parts := strings.Split(raw, ",") + seen := make(map[string]struct{}, len(parts)) + result := make([]string, 0, len(parts)) + for _, f := range parts { f = strings.TrimSpace(f) - if _, valid := validFeatureValues[f]; !valid { + capVal := normalizeCapabilityValue(f) + if capVal == "" { continue } - if _, dup := seen[f]; dup { + if _, dup := seen[capVal]; dup { continue } - seen[f] = struct{}{} - result = append(result, f) + seen[capVal] = struct{}{} + result = append(result, capVal) } return result } @@ -72,21 +74,23 @@ type Result[T any] struct { } type OCI struct { - log logr.Logger - repo *remote.Repository - sema *semaphore.Weighted + log logr.Logger + repo *remote.Repository + sema *semaphore.Weighted + capabilityKeys []string } -func NewOCI(params ocirepo.Params, parallel int64, log logr.Logger) (*OCI, error) { +func NewOCI(params ocirepo.Params, parallel int64, log logr.Logger, capabilityKeys []string) (*OCI, error) { repo, err := ocirepo.New(params) if err != nil { return nil, err } return &OCI{ - log: log, - repo: repo, - sema: semaphore.NewWeighted(parallel), + log: log, + repo: repo, + sema: semaphore.NewWeighted(parallel), + capabilityKeys: capabilityKeys, }, nil } @@ -122,26 +126,29 @@ func (o *OCI) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) { out <- Result[ossync.SourceImage]{err: fmt.Errorf("tag %s: failed to decode manifest: %w", tag, err)} return } - arch, ok := manifest.Annotations["architecture"] + arch, ok := manifest.Annotations[ossync.ArchitectureCapability] if !ok { out <- Result[ossync.SourceImage]{err: fmt.Errorf("tag %s: architecture annotation not found", tag)} return } + cleanVersion, _ := manifest.Annotations["version"] var capabilities gardencorev1beta1.Capabilities - var cleanVersion string - var supportInPlaceUpdate bool - if featureSet, ok := manifest.Annotations["feature_set"]; ok { - if version, ok := manifest.Annotations["version"]; ok { - features := filterFeatureSet(featureSet) - if len(features) > 0 { - capabilities = gardencorev1beta1.Capabilities{ - architectureCapability: {arch}, - featureCapability: features, - } - cleanVersion = version - supportInPlaceUpdate = slices.Contains(features, usiFeature) + if len(o.capabilityKeys) > 0 && cleanVersion != "" { + caps := make(gardencorev1beta1.Capabilities, 1+len(o.capabilityKeys)) + caps[ossync.ArchitectureCapability] = []string{arch} + for _, key := range o.capabilityKeys { + raw, ok := manifest.Annotations[key] + if !ok { + continue + } + values := filterAnnotationValues(raw) + if len(values) > 0 { + caps[key] = values } } + if len(caps) > 1 { // more than just architecture + capabilities = caps + } } out <- Result[ossync.SourceImage]{ value: ossync.SourceImage{ @@ -149,7 +156,7 @@ func (o *OCI) GetVersions(ctx context.Context) ([]ossync.SourceImage, error) { CleanVersion: cleanVersion, Architectures: []string{arch}, Capabilities: capabilities, - SupportInPlaceUpdate: supportInPlaceUpdate, + SupportInPlaceUpdate: supportsInPlaceUpdate(manifest.Annotations), }, } }() diff --git a/cloudprofilesync/ossync/source/oci/os_source_test.go b/cloudprofilesync/ossync/source/oci/os_source_test.go index 8f74165..64aff80 100644 --- a/cloudprofilesync/ossync/source/oci/os_source_test.go +++ b/cloudprofilesync/ossync/source/oci/os_source_test.go @@ -62,7 +62,7 @@ var _ = Describe("OCISource", func() { Registry: registryAddr, Repository: "repo", Insecure: true, - }, 4, logr.Discard()) + }, 4, logr.Discard(), nil) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) @@ -106,7 +106,7 @@ var _ = Describe("OCISource", func() { Registry: registryAddr, Repository: "repo-caps", Insecure: true, - }, 4, logr.Discard()) + }, 4, logr.Discard(), []string{"feature_set"}) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) @@ -116,7 +116,7 @@ var _ = Describe("OCISource", func() { Expect(versions[0].Architectures).To(Equal([]string{"amd64"})) Expect(versions[0].Capabilities).To(Equal(gardencorev1beta1.Capabilities{ "architecture": {"amd64"}, - "feature": {"sci", "_usi"}, // _rescue and log are filtered out + "feature_set": {"sci", "usi", "rescue", "log"}, // all values passed through normalized; filtering against machineCapabilities happens in the updater })) }) @@ -151,7 +151,7 @@ var _ = Describe("OCISource", func() { Registry: registryAddr, Repository: "repo-legacy", Insecure: true, - }, 4, logr.Discard()) + }, 4, logr.Discard(), nil) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) @@ -200,7 +200,7 @@ var _ = Describe("OCISource", func() { Registry: registryAddr, Repository: "repo-missing-arch", Insecure: true, - }, 4, logr.Discard()) + }, 4, logr.Discard(), nil) Expect(err).To(Succeed()) versions, err := oci.GetVersions(ctx) Expect(err).To(Succeed()) @@ -208,7 +208,7 @@ var _ = Describe("OCISource", func() { Expect(versions[0].Version).To(Equal("1.0.0")) }) - It("leaves Capabilities nil when feature_set contains no valid values", func(ctx SpecContext) { + It("normalizes all feature_set values and passes them through", func(ctx SpecContext) { repo, err := remote.NewRepository(registryAddr + "/repo-no-valid-features") Expect(err).To(Succeed()) repo.PlainHTTP = true @@ -237,13 +237,98 @@ var _ = Describe("OCISource", func() { Registry: registryAddr, Repository: "repo-no-valid-features", Insecure: true, - }, 4, logr.Discard()) + }, 4, logr.Discard(), []string{"feature_set"}) 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")) + }) + + 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") + Expect(err).To(Succeed()) + repo.PlainHTTP = true + + index := ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: 2}, + Manifests: []ocispec.Descriptor{ + {MediaType: ocispec.MediaTypeImageManifest, Size: 0, Digest: ocispec.DescriptorEmptyJSON.Digest}, + }, + Annotations: map[string]string{ + "architecture": "amd64", + "feature_set": "sci,_usi", + "version": "4.0.0", + "hypervisor": "kvm", + }, + } + 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), "4.0.0") + Expect(err).To(Succeed()) + + // capabilityKeys only includes "hypervisor", NOT "feature_set" + oci, err := oci.NewOCI(ocirepo.Params{ + Registry: registryAddr, + Repository: "repo-usi-no-caps", + Insecure: true, + }, 4, logr.Discard(), []string{"hypervisor"}) + Expect(err).To(Succeed()) + versions, err := oci.GetVersions(ctx) + Expect(err).To(Succeed()) + Expect(versions).To(HaveLen(1)) + 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") + Expect(err).To(Succeed()) + repo.PlainHTTP = true + + index := ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: 2}, + 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", + }, + } + 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), "5.0.0-build-abc") + Expect(err).To(Succeed()) + + oci, err := oci.NewOCI(ocirepo.Params{ + Registry: registryAddr, + Repository: "repo-clean-version-no-caps", + Insecure: true, + }, 4, logr.Discard(), 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].CleanVersion).To(BeEmpty()) + Expect(versions[0].SupportInPlaceUpdate).To(BeTrue()) }) }) diff --git a/controllers/cloud_profile.go b/controllers/cloud_profile.go index 564adb0..4441c2a 100644 --- a/controllers/cloud_profile.go +++ b/controllers/cloud_profile.go @@ -30,8 +30,8 @@ import ( // DefaultOCISourceFactory is the default implementation of OCISourceFactory. type DefaultOCISourceFactory struct{} -func (f *DefaultOCISourceFactory) Create(params ocirepo.Params, parallel int64, log logr.Logger) (ossync.Source, error) { - return oci.NewOCI(params, parallel, log) +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 (r *Reconciler) reconcileCloudProfile(ctx context.Context, log logr.Logger, mcp *v1alpha1.ManagedCloudProfile) error { @@ -96,13 +96,19 @@ 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) + }, 1, log, capabilityKeys) 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 44ae50a..0e91ae0 100644 --- a/controllers/managedcloudprofile_controller.go +++ b/controllers/managedcloudprofile_controller.go @@ -25,7 +25,7 @@ const ( // OCISourceFactory defines an interface for creating OCI sources. type OCISourceFactory interface { - Create(params ocirepo.Params, parallel int64, log logr.Logger) (ossync.Source, error) + Create(params ocirepo.Params, parallel int64, log logr.Logger, capabilityKeys []string) (ossync.Source, error) } type RegistryClient interface { diff --git a/controllers/managedcloudprofile_controller_test.go b/controllers/managedcloudprofile_controller_test.go index 57572c4..778124d 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) (ossync.Source, error) { +func (f *fakeFactory) Create(params ocirepo.Params, _ int64, _ logr.Logger, _ []string) (ossync.Source, error) { return &fakeOCISource{}, nil } type emptyFactory struct{} -func (f *emptyFactory) Create(params ocirepo.Params, parallel int64, _ logr.Logger) (ossync.Source, error) { +func (f *emptyFactory) Create(params ocirepo.Params, parallel int64, _ logr.Logger, _ []string) (ossync.Source, error) { return &emptyOCISource{}, nil } -func (m *mockOCIFactory) Create(params ocirepo.Params, parallel int64, _ logr.Logger) (ossync.Source, error) { +func (m *mockOCIFactory) Create(params ocirepo.Params, parallel int64, _ logr.Logger, _ []string) (ossync.Source, error) { return m.createFunc(params, parallel) }