Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions cloudprofilesync/ossync/os_image_updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,35 @@ func (iu *ImageUpdater) resolveExpiration(src SourceImage, existing *metav1.Time
return &now
}

// mergeCapabilityFlavor appends the flavor from src to existing if not already present.
func mergeCapabilityFlavor(existing []gardenerv1beta1.MachineImageFlavor, caps gardenerv1beta1.Capabilities) []gardenerv1beta1.MachineImageFlavor {
if len(caps) == 0 {
return existing
}
for _, f := range existing {
if capabilitiesEqual(f.Capabilities, caps) {
return existing
}
}
return append(existing, gardenerv1beta1.MachineImageFlavor{Capabilities: caps})
}

func capabilitiesEqual(a, b gardenerv1beta1.Capabilities) bool {
if len(a) != len(b) {
return false
}
for k, aVals := range a {
bVals, ok := b[k]
if !ok {
return false
}
if !slices.Equal(aVals, bVals) {
return false
}
}
return true
}

func inPlaceUpdates(supported bool) *gardenerv1beta1.InPlaceUpdates {
if !supported {
return nil
Expand Down Expand Up @@ -190,8 +219,10 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou
}
}

// When capabilities are enabled, also write the clean version entry.
if iu.EnableCapabilities && sourceImage.CleanVersion != "" && sourceImage.CleanVersion != sourceImage.Version {
// When capabilities are enabled, also write/update the clean version entry.
// When CleanVersion == Version the entry already exists from the legacy path above;
// the existing-entry branch merges the flavor onto it without re-writing other fields.
if iu.EnableCapabilities && sourceImage.CleanVersion != "" {
if idx, exists := existingVersions[sourceImage.CleanVersion]; exists {
existing := &image.Versions[idx]
for _, arch := range sourceImage.Architectures {
Expand All @@ -202,20 +233,21 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou
existing.Classification = sourceImage.Classification //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate
existing.ExpirationDate = iu.resolveExpiration(sourceImage, existing.ExpirationDate) //nolint:staticcheck // legacy fields; Lifecycle needs the VersionClassificationLifecycle feature gate
existing.InPlaceUpdates = inPlaceUpdates(sourceImage.SupportInPlaceUpdate)
existing.CapabilityFlavors = mergeCapabilityFlavor(existing.CapabilityFlavors, sourceImage.Capabilities)
Comment thread
anton-paulovich marked this conversation as resolved.
Comment thread
anton-paulovich marked this conversation as resolved.
} else {
image.Versions = append(image.Versions, gardenerv1beta1.MachineImageVersion{
v := gardenerv1beta1.MachineImageVersion{
ExpirableVersion: gardenerv1beta1.ExpirableVersion{
Version: sourceImage.CleanVersion,
Classification: sourceImage.Classification,
ExpirationDate: iu.resolveExpiration(sourceImage, nil),
},
Architectures: slices.Clone(sourceImage.Architectures),
})
Architectures: slices.Clone(sourceImage.Architectures),
CapabilityFlavors: mergeCapabilityFlavor(nil, sourceImage.Capabilities),
}
if sourceImage.SupportInPlaceUpdate {
image.Versions[len(image.Versions)-1].InPlaceUpdates = &gardenerv1beta1.InPlaceUpdates{
Supported: sourceImage.SupportInPlaceUpdate,
}
v.InPlaceUpdates = &gardenerv1beta1.InPlaceUpdates{Supported: true}
}
image.Versions = append(image.Versions, v)
existingVersions[sourceImage.CleanVersion] = len(image.Versions) - 1
}
}
Expand Down
164 changes: 164 additions & 0 deletions cloudprofilesync/ossync/os_image_updater_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,170 @@ var _ = Describe("ImageUpdater", func() {
})

Describe("flag ON (dual-write clean version)", func() {
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"}},
},
}
updater := ossync.ImageUpdater{
Log: GinkgoLogr,
Source: &mockSource,
ImageName: "test",
EnableCapabilities: true,
}
var cpSpec gardencorev1beta1.CloudProfileSpec
Expect(updater.Update(ctx, &cpSpec)).To(Succeed())

Expect(cpSpec.MachineImages[0].Versions).To(HaveLen(1))
v := cpSpec.MachineImages[0].Versions[0]
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"}},
))
})

It("sets CapabilityFlavors on the clean version entry", func(ctx SpecContext) {
mockSource.images = []ossync.SourceImage{
{
Version: "2254.0.0-baremetal-sci-usi-amd64",
CleanVersion: "2254.0.0",
Architectures: []string{"amd64"},
Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}},
},
}
updater := ossync.ImageUpdater{
Log: GinkgoLogr,
Source: &mockSource,
ImageName: "test",
EnableCapabilities: true,
}
var cpSpec gardencorev1beta1.CloudProfileSpec
Expect(updater.Update(ctx, &cpSpec)).To(Succeed())

versions := cpSpec.MachineImages[0].Versions
var cleanEntry *gardencorev1beta1.MachineImageVersion
for i := range versions {
if versions[i].Version == "2254.0.0" {
cleanEntry = &versions[i]
break
}
}
Expect(cleanEntry).NotTo(BeNil())
Expect(cleanEntry.CapabilityFlavors).To(HaveLen(1))
Expect(cleanEntry.CapabilityFlavors[0].Capabilities).To(Equal(
gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}},
))
})

It("accumulates multiple flavors under the same clean version entry", func(ctx SpecContext) {
mockSource.images = []ossync.SourceImage{
{
Version: "2254.0.0-baremetal-sci-usi-amd64",
CleanVersion: "2254.0.0",
Architectures: []string{"amd64"},
Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"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"}},
},
}
updater := ossync.ImageUpdater{
Log: GinkgoLogr,
Source: &mockSource,
ImageName: "test",
EnableCapabilities: true,
}
var cpSpec gardencorev1beta1.CloudProfileSpec
Expect(updater.Update(ctx, &cpSpec)).To(Succeed())

versions := cpSpec.MachineImages[0].Versions
var cleanEntry *gardencorev1beta1.MachineImageVersion
for i := range versions {
if versions[i].Version == "2254.0.0" {
cleanEntry = &versions[i]
break
}
}
Expect(cleanEntry).NotTo(BeNil())
Expect(cleanEntry.CapabilityFlavors).To(HaveLen(2))
flavors := []gardencorev1beta1.Capabilities{
cleanEntry.CapabilityFlavors[0].Capabilities,
cleanEntry.CapabilityFlavors[1].Capabilities,
}
Expect(flavors).To(ConsistOf(
gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}},
gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_pxe"}},
))
})

It("does not append duplicate flavors on re-reconcile", func(ctx SpecContext) {
mockSource.images = []ossync.SourceImage{
{
Version: "2254.0.0-baremetal-sci-usi-amd64",
CleanVersion: "2254.0.0",
Architectures: []string{"amd64"},
Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}},
},
}
updater := ossync.ImageUpdater{
Log: GinkgoLogr,
Source: &mockSource,
ImageName: "test",
EnableCapabilities: true,
}
var cpSpec gardencorev1beta1.CloudProfileSpec
Expect(updater.Update(ctx, &cpSpec)).To(Succeed())
Expect(updater.Update(ctx, &cpSpec)).To(Succeed())

versions := cpSpec.MachineImages[0].Versions
var cleanEntry *gardencorev1beta1.MachineImageVersion
for i := range versions {
if versions[i].Version == "2254.0.0" {
cleanEntry = &versions[i]
break
}
}
Expect(cleanEntry).NotTo(BeNil())
Expect(cleanEntry.CapabilityFlavors).To(HaveLen(1))
})

It("does not set CapabilityFlavors when Capabilities is nil", func(ctx SpecContext) {
mockSource.images = []ossync.SourceImage{
{
Version: "2254.0.0-baremetal-amd64",
CleanVersion: "2254.0.0",
Architectures: []string{"amd64"},
},
}
updater := ossync.ImageUpdater{
Log: GinkgoLogr,
Source: &mockSource,
ImageName: "test",
EnableCapabilities: true,
}
var cpSpec gardencorev1beta1.CloudProfileSpec
Expect(updater.Update(ctx, &cpSpec)).To(Succeed())

versions := cpSpec.MachineImages[0].Versions
var cleanEntry *gardencorev1beta1.MachineImageVersion
for i := range versions {
if versions[i].Version == "2254.0.0" {
cleanEntry = &versions[i]
break
}
}
Expect(cleanEntry).NotTo(BeNil())
Expect(cleanEntry.CapabilityFlavors).To(BeEmpty())
})

It("writes both full tag and clean version entries when CleanVersion differs", func(ctx SpecContext) {
mockSource.images = []ossync.SourceImage{
{
Expand Down
41 changes: 28 additions & 13 deletions controllers/garbage_collection.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,10 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image
return err
}

// Track which clean versions still have remaining capability flavors after deletion,
// so we can cascade-delete empty clean version entries from spec.machineImages.
// A version present in this map was a clean version entry; true means it still has flavors.
cleanVersionsWithFlavors := make(map[string]bool)
// Track surviving capability flavors per clean version so the spec.machineImages
// entry can be kept in sync. Nil value means the version was not a clean version entry.
// Non-nil (possibly empty) slice means it was, and holds the remaining capabilities.
survivingFlavors := make(map[string][]gardenerv1beta1.Capabilities)

if cp.Spec.ProviderConfig != nil {
var cfg providercfg.CloudProfileConfig
Expand All @@ -146,11 +146,7 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image
// Legacy flat entry — not a clean version, skip.
continue
}
// Mark as a clean version entry; value indicates whether any flavors remain.
cleanVersionsWithFlavors[v.Version] = len(v.CapabilityFlavors) > 0
if len(v.CapabilityFlavors) == 0 {
continue
}
// Prune stale flavors.
v.CapabilityFlavors = slices.DeleteFunc(v.CapabilityFlavors, func(f providercfg.MachineImageFlavor) bool {
idx := strings.LastIndex(f.Image, ":")
if idx == -1 {
Expand All @@ -159,7 +155,12 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image
_, exists := versionsToDelete[f.Image[idx+1:]]
return exists
})
cleanVersionsWithFlavors[v.Version] = len(v.CapabilityFlavors) > 0
// Record surviving capabilities for this clean version.
caps := make([]gardenerv1beta1.Capabilities, 0, len(v.CapabilityFlavors))
for _, f := range v.CapabilityFlavors {
caps = append(caps, f.Capabilities)
}
survivingFlavors[v.Version] = caps
}
// Remove version entries that have no legacy image ref and no remaining flavors.
cfg.MachineImages[i].Versions = slices.DeleteFunc(cfg.MachineImages[i].Versions, func(mv providercfg.MachineImageVersion) bool {
Expand All @@ -173,7 +174,7 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image
return exists
}
// Clean version entry — delete if all flavors were removed.
return !cleanVersionsWithFlavors[mv.Version]
return len(survivingFlavors[mv.Version]) == 0
})
}
raw, err := json.Marshal(cfg)
Expand All @@ -193,9 +194,23 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image
}
// Cascade-delete clean version entry if all its capability flavors were removed.
// Only entries tracked as clean versions (present in the map) are eligible.
hasRemainingFlavors, isCleanVersion := cleanVersionsWithFlavors[mv.Version]
return isCleanVersion && !hasRemainingFlavors
remaining, isCleanVersion := survivingFlavors[mv.Version]
return isCleanVersion && len(remaining) == 0
})
// Rebuild CapabilityFlavors on surviving clean version entries to match
// what remains in providerConfig after pruning.
for j := range cp.Spec.MachineImages[i].Versions {
mv := &cp.Spec.MachineImages[i].Versions[j]
remaining, isCleanVersion := survivingFlavors[mv.Version]
if !isCleanVersion {
continue
}
flavors := make([]gardenerv1beta1.MachineImageFlavor, 0, len(remaining))
for _, caps := range remaining {
flavors = append(flavors, gardenerv1beta1.MachineImageFlavor{Capabilities: caps})
}
mv.CapabilityFlavors = flavors
}
}

if err := r.Update(ctx, &cp); err != nil {
Expand Down
Loading