@@ -190,13 +190,11 @@ func (r *Reconciler) reconcileGarbageCollection(ctx context.Context, mcp *v1alph
190190 }
191191 }
192192
193- if len (versionsToDelete ) > 0 {
194- if err := r .deleteVersions (ctx , mcp .Name , updates .ImageName , versionsToDelete ); err != nil {
195- if apierrors .IsInvalid (err ) {
196- continue
197- }
198- return r .failWithStatusUpdate (ctx , mcp , fmt .Errorf ("failed to delete image versions: %w" , err ))
193+ if err := r .deleteVersions (ctx , mcp .Name , updates .ImageName , versionsToDelete ); err != nil {
194+ if apierrors .IsInvalid (err ) {
195+ continue
199196 }
197+ return r .failWithStatusUpdate (ctx , mcp , fmt .Errorf ("failed to delete image versions: %w" , err ))
200198 }
201199 }
202200
@@ -209,15 +207,11 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image
209207 return err
210208 }
211209
212- for i := range cp .Spec .MachineImages {
213- if cp .Spec .MachineImages [i ].Name != imageName {
214- continue
215- }
216- cp .Spec .MachineImages [i ].Versions = slices .DeleteFunc (cp .Spec .MachineImages [i ].Versions , func (mv gardenerv1beta1.MachineImageVersion ) bool {
217- _ , exists := versionsToDelete [mv .Version ]
218- return exists
219- })
220- }
210+ // Track which clean versions still have remaining capability flavors after deletion,
211+ // so we can cascade-delete empty clean version entries from spec.machineImages.
212+ // A version present in this map was a clean version entry; true means it still has flavors.
213+ cleanVersionsWithFlavors := make (map [string ]bool )
214+
221215 if cp .Spec .ProviderConfig != nil {
222216 var cfg providercfg.CloudProfileConfig
223217 if err := json .Unmarshal (cp .Spec .ProviderConfig .Raw , & cfg ); err != nil {
@@ -227,14 +221,40 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image
227221 if cfg .MachineImages [i ].Name != imageName {
228222 continue
229223 }
224+ for j := range cfg .MachineImages [i ].Versions {
225+ v := & cfg .MachineImages [i ].Versions [j ]
226+ if v .Image != "" {
227+ // Legacy flat entry — not a clean version, skip.
228+ continue
229+ }
230+ // Mark as a clean version entry; value indicates whether any flavors remain.
231+ cleanVersionsWithFlavors [v .Version ] = len (v .CapabilityFlavors ) > 0
232+ if len (v .CapabilityFlavors ) == 0 {
233+ continue
234+ }
235+ v .CapabilityFlavors = slices .DeleteFunc (v .CapabilityFlavors , func (f providercfg.MachineImageFlavor ) bool {
236+ idx := strings .LastIndex (f .Image , ":" )
237+ if idx == - 1 {
238+ return false
239+ }
240+ _ , exists := versionsToDelete [f .Image [idx + 1 :]]
241+ return exists
242+ })
243+ cleanVersionsWithFlavors [v .Version ] = len (v .CapabilityFlavors ) > 0
244+ }
245+ // Remove version entries that have no legacy image ref and no remaining flavors.
230246 cfg .MachineImages [i ].Versions = slices .DeleteFunc (cfg .MachineImages [i ].Versions , func (mv providercfg.MachineImageVersion ) bool {
231- idx := strings .LastIndex (mv .Image , ":" )
232- if idx == - 1 {
233- return false
247+ if mv .Image != "" {
248+ // Legacy flat entry — delete if its tag is in versionsToDelete.
249+ idx := strings .LastIndex (mv .Image , ":" )
250+ if idx == - 1 {
251+ return false
252+ }
253+ _ , exists := versionsToDelete [mv .Image [idx + 1 :]]
254+ return exists
234255 }
235- version := mv .Image [idx + 1 :]
236- _ , exists := versionsToDelete [version ]
237- return exists
256+ // Clean version entry — delete if all flavors were removed.
257+ return ! cleanVersionsWithFlavors [mv .Version ]
238258 })
239259 }
240260 raw , err := json .Marshal (cfg )
@@ -243,6 +263,22 @@ func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, image
243263 }
244264 cp .Spec .ProviderConfig .Raw = raw
245265 }
266+
267+ for i := range cp .Spec .MachineImages {
268+ if cp .Spec .MachineImages [i ].Name != imageName {
269+ continue
270+ }
271+ cp .Spec .MachineImages [i ].Versions = slices .DeleteFunc (cp .Spec .MachineImages [i ].Versions , func (mv gardenerv1beta1.MachineImageVersion ) bool {
272+ if _ , exists := versionsToDelete [mv .Version ]; exists {
273+ return true
274+ }
275+ // Cascade-delete clean version entry if all its capability flavors were removed.
276+ // Only entries tracked as clean versions (present in the map) are eligible.
277+ hasRemainingFlavors , isCleanVersion := cleanVersionsWithFlavors [mv .Version ]
278+ return isCleanVersion && ! hasRemainingFlavors
279+ })
280+ }
281+
246282 if err := r .Update (ctx , & cp ); err != nil {
247283 return err
248284 }
@@ -271,6 +307,39 @@ func (r *Reconciler) getReferencedVersions(ctx context.Context, cloudProfileName
271307 }
272308 }
273309
310+ // For any clean version referenced by a Shoot, also protect the raw OCI tags
311+ // that back it via capabilityFlavors — otherwise GC would delete the images
312+ // that the clean version depends on.
313+ if len (referenced ) > 0 {
314+ var cp gardenerv1beta1.CloudProfile
315+ if err := r .Get (ctx , types.NamespacedName {Name : cloudProfileName }, & cp ); err != nil {
316+ return nil , fmt .Errorf ("failed to get CloudProfile: %w" , err )
317+ }
318+ if cp .Spec .ProviderConfig != nil {
319+ var cfg providercfg.CloudProfileConfig
320+ if err := json .Unmarshal (cp .Spec .ProviderConfig .Raw , & cfg ); err != nil {
321+ return nil , fmt .Errorf ("failed to unmarshal ProviderConfig: %w" , err )
322+ }
323+ for _ , img := range cfg .MachineImages {
324+ if img .Name != imageName {
325+ continue
326+ }
327+ for _ , v := range img .Versions {
328+ if _ , isReferenced := referenced [v .Version ]; ! isReferenced {
329+ continue
330+ }
331+ for _ , flavor := range v .CapabilityFlavors {
332+ idx := strings .LastIndex (flavor .Image , ":" )
333+ if idx == - 1 {
334+ continue
335+ }
336+ referenced [flavor .Image [idx + 1 :]] = struct {}{}
337+ }
338+ }
339+ }
340+ }
341+ }
342+
274343 return referenced , nil
275344}
276345
0 commit comments