diff --git a/.gitignore b/.gitignore index ed86553..17f74a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ .DS_Store -build/ +build/.claude/ \ No newline at end of file diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSES/CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/REUSE.toml b/REUSE.toml index 36a6efc..cd9f98d 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -32,3 +32,11 @@ path = [ ] SPDX-FileCopyrightText = "2020 The Kubernetes Authors" SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = [ + "build/*", + ".claude/*", +] +SPDX-FileCopyrightText = "NOASSERTION" +SPDX-License-Identifier = "CC0-1.0" diff --git a/api/v1alpha1/managedcloudprofile.go b/api/v1alpha1/managedcloudprofile.go index d1672a5..b01264b 100644 --- a/api/v1alpha1/managedcloudprofile.go +++ b/api/v1alpha1/managedcloudprofile.go @@ -97,6 +97,20 @@ type MachineImageUpdate struct { // ImagesName is the name of the image to maintain automatically ImageName string `json:"imageName"` + // GarbageCollection contains configuration for automated garbage collection + // +optional + GarbageCollection *GarbageCollectionConfig `json:"garbageCollection,omitempty"` +} + +type GarbageCollectionConfig struct { + // Enabled toggles garbage collection for this image. + // +optional + Enabled bool `json:"enabled,omitempty"` + // MaxAge defines the maximum age for images to keep. Images older than + // now - MaxAge are eligible for deletion. + // +optional + // +kubebuilder:validation:XValidation:rule="duration(self) >= duration('0s')",message="maxAge must not be negative" + MaxAge metav1.Duration `json:"maxAge,omitempty"` } type MachineImageUpdateSource struct { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 5a4e2a9..f73ee13 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -86,11 +86,32 @@ func (in *CloudProfileSpec) DeepCopy() *CloudProfileSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GarbageCollectionConfig) DeepCopyInto(out *GarbageCollectionConfig) { + *out = *in + out.MaxAge = in.MaxAge +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GarbageCollectionConfig. +func (in *GarbageCollectionConfig) DeepCopy() *GarbageCollectionConfig { + if in == nil { + return nil + } + out := new(GarbageCollectionConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MachineImageUpdate) DeepCopyInto(out *MachineImageUpdate) { *out = *in in.Source.DeepCopyInto(&out.Source) in.Provider.DeepCopyInto(&out.Provider) + if in.GarbageCollection != nil { + in, out := &in.GarbageCollection, &out.GarbageCollection + *out = new(GarbageCollectionConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineImageUpdate. diff --git a/cloudprofilesync/imageupdater.go b/cloudprofilesync/imageupdater.go index 59d85b4..a93e206 100644 --- a/cloudprofilesync/imageupdater.go +++ b/cloudprofilesync/imageupdater.go @@ -47,7 +47,7 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou iu.Log.Info("checked source", "image", iu.ImageName) sourceImages = filterImages(iu.Log, sourceImages) // Images from a source arrive in no guaranteed order. A changed order - // in the source images may lead to a chenged order in the CloudProfile, + // in the source images may lead to a changed order in the CloudProfile, // causing unnecesscary reconciliations. slices.SortFunc(sourceImages, func(a, b SourceImage) int { return cmp.Compare(a.Version, b.Version) diff --git a/cloudprofilesync/provider_test.go b/cloudprofilesync/provider_test.go index 8c9cad3..f566924 100644 --- a/cloudprofilesync/provider_test.go +++ b/cloudprofilesync/provider_test.go @@ -10,7 +10,6 @@ import ( "github.com/ironcore-dev/gardener-extension-provider-ironcore-metal/pkg/apis/metal/v1alpha1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "k8s.io/utils/ptr" "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync" ) @@ -51,16 +50,19 @@ var _ = Describe("IroncoreProvider", func() { Expect(json.Unmarshal(cpSpec.ProviderConfig.Raw, &providerConfig)).To(Succeed()) Expect(providerConfig.MachineImages).To(HaveLen(1)) Expect(providerConfig.MachineImages[0].Name).To(Equal("test")) + + amd64 := "amd64" + arm64 := "arm64" Expect(providerConfig.MachineImages[0].Versions).To(ConsistOf([]v1alpha1.MachineImageVersion{ { Version: "v1.0.0", Image: "registry.io/repo:v1.0.0", - Architecture: ptr.To("amd64"), + Architecture: &amd64, }, { Version: "v1.0.0", Image: "registry.io/repo:v1.0.0", - Architecture: ptr.To("arm64"), + Architecture: &arm64, }, })) }) diff --git a/cloudprofilesync/source.go b/cloudprofilesync/source.go index f9af796..5d8f9d3 100644 --- a/cloudprofilesync/source.go +++ b/cloudprofilesync/source.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "strings" + "time" "golang.org/x/sync/semaphore" "oras.land/oras-go/v2/registry/remote" @@ -23,6 +24,7 @@ type Result[T any] struct { type SourceImage struct { Version string Architectures []string + CreatedAt time.Time } type Source interface { @@ -38,7 +40,7 @@ type OCIParams struct { Registry string `json:"registry"` Repository string `json:"repository"` Username string `json:"username"` - Password string `json:"password"` + Password string `json:"password"` //nolint:gosec,nolintlint Parallel int64 `json:"parallel"` } @@ -104,10 +106,21 @@ func (o *OCI) GetVersions(ctx context.Context) ([]SourceImage, error) { out <- Result[SourceImage]{err: errors.New("architecture annotation not found in descriptor")} return } + created := time.Time{} + if s, ok := manifest.Annotations["org.opencontainers.image.created"]; ok { + if t, err := time.Parse(time.RFC3339, s); err == nil { + created = t + } + } else if s, ok := manifest.Annotations["created"]; ok { + if t, err := time.Parse(time.RFC3339, s); err == nil { + created = t + } + } out <- Result[SourceImage]{ value: SourceImage{ Version: strings.ReplaceAll(tag, "_", "+"), // Follow the helm convention Architectures: []string{arch}, + CreatedAt: created, }, } }() diff --git a/controllers/managedcloudprofile_controller.go b/controllers/managedcloudprofile_controller.go index 2c46a64..b6249a3 100644 --- a/controllers/managedcloudprofile_controller.go +++ b/controllers/managedcloudprofile_controller.go @@ -5,13 +5,16 @@ package controllers import ( "context" + "encoding/json" "errors" "fmt" "slices" + "strings" "time" gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" "github.com/go-logr/logr" + providercfg "github.com/ironcore-dev/gardener-extension-provider-ironcore-metal/pkg/apis/metal/v1alpha1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -28,8 +31,21 @@ const ( CloudProfileAppliedConditionType string = "CloudProfileApplied" ) +// OCISourceFactory defines an interface for creating OCI sources. +type OCISourceFactory interface { + Create(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) +} + +// DefaultOCISourceFactory is the default implementation of OCISourceFactory. +type DefaultOCISourceFactory struct{} + +func (f *DefaultOCISourceFactory) Create(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) { + return cloudprofilesync.NewOCI(params, insecure) +} + type Reconciler struct { client.Client + OCISourceFactory OCISourceFactory } func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { @@ -39,11 +55,23 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu return ctrl.Result{}, client.IgnoreNotFound(err) } + if err := r.reconcileCloudProfile(ctx, log, &mcp); err != nil { + return ctrl.Result{}, err + } + + log.Info("reconciled ManagedCloudProfile") + if err := r.reconcileGarbageCollection(ctx, log, &mcp); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil +} + +func (r *Reconciler) reconcileCloudProfile(ctx context.Context, log logr.Logger, mcp *v1alpha1.ManagedCloudProfile) error { var cloudProfile gardenerv1beta1.CloudProfile cloudProfile.Name = mcp.Name op, err := controllerutil.CreateOrPatch(ctx, r.Client, &cloudProfile, func() error { - err := controllerutil.SetControllerReference(&mcp, &cloudProfile, r.Scheme()) + err := controllerutil.SetControllerReference(mcp, &cloudProfile, r.Scheme()) if err != nil { return err } @@ -56,35 +84,194 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu return errors.Join(errs...) }) if err != nil { - if err := r.patchStatusAndCondition(ctx, &mcp, v1alpha1.FailedReconcileStatus, metav1.Condition{ + if err := r.patchStatusAndCondition(ctx, mcp, v1alpha1.FailedReconcileStatus, metav1.Condition{ Type: CloudProfileAppliedConditionType, Status: metav1.ConditionFalse, ObservedGeneration: mcp.Generation, Reason: "ApplyFailed", Message: fmt.Sprintf("Failed to apply CloudProfile: %s", err), }); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to patch ManagedCloudProfile status: %w", err) + return fmt.Errorf("failed to patch ManagedCloudProfile status: %w", err) } if apierrors.IsInvalid(err) { log.Error(err, "tried to apply invalid CloudProfile") - return ctrl.Result{}, nil + return nil } - return ctrl.Result{}, fmt.Errorf("failed to create or patch CloudProfile: %w", err) + return fmt.Errorf("failed to create or patch CloudProfile: %w", err) } log.Info("applied cloud profile", "op", op) if op != controllerutil.OperationResultNone { - if err := r.patchStatusAndCondition(ctx, &mcp, v1alpha1.SucceededReconcileStatus, metav1.Condition{ + if err := r.patchStatusAndCondition(ctx, mcp, v1alpha1.SucceededReconcileStatus, metav1.Condition{ Type: CloudProfileAppliedConditionType, Status: metav1.ConditionTrue, ObservedGeneration: mcp.Generation, Reason: "Applied", Message: "Generated CloudProfile applied successfully", }); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to patch ManagedCloudProfile status: %w", err) + return fmt.Errorf("failed to patch ManagedCloudProfile status: %w", err) } } - log.Info("reconciled ManagedCloudProfile") - return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil + return nil +} + +func (r *Reconciler) reconcileGarbageCollection(ctx context.Context, log logr.Logger, mcp *v1alpha1.ManagedCloudProfile) error { + for _, updates := range mcp.Spec.MachineImageUpdates { + if updates.GarbageCollection == nil || !updates.GarbageCollection.Enabled { + continue + } + if updates.GarbageCollection.MaxAge.Duration < 0 { + return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("invalid garbage collection maxAge: %s", updates.GarbageCollection.MaxAge.String())) + } + if updates.Source.OCI == nil { + continue + } + var source cloudprofilesync.Source + password, err := r.getCredential(ctx, updates.Source.OCI.Password) + if err != nil { + return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to get credential for garbage collection: %w", err)) + } + src, err := r.OCISourceFactory.Create(cloudprofilesync.OCIParams{ + Registry: updates.Source.OCI.Registry, + Repository: updates.Source.OCI.Repository, + Username: updates.Source.OCI.Username, + Password: string(password), + Parallel: 1, + }, updates.Source.OCI.Insecure) + if err != nil { + return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to initialize OCI source for garbage collection: %w", err)) + } + source = src + + versions, err := source.GetVersions(ctx) + if err != nil { + return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to list source versions for garbage collection: %w", err)) + } + + referencedVersions, err := r.getReferencedVersions(ctx, mcp.Name, updates.ImageName) + if err != nil { + return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to determine referenced versions for garbage collection: %w", err)) + } + + cutoff := time.Now().Add(-updates.GarbageCollection.MaxAge.Duration) + versionsToDelete := make(map[string]struct{}) + for _, v := range versions { + if v.CreatedAt.IsZero() { + continue + } + if _, isReferenced := referencedVersions[v.Version]; isReferenced { + continue + } + if v.CreatedAt.Before(cutoff) { + versionsToDelete[v.Version] = struct{}{} + } + } + + if len(versionsToDelete) > 0 { + if err := r.deleteVersions(ctx, mcp.Name, updates.ImageName, versionsToDelete); err != nil { + if apierrors.IsInvalid(err) { + log.V(1).Info("garbage collection validation failed, skipping", "image", updates.ImageName) + continue + } + return r.failWithStatusUpdate(ctx, mcp, fmt.Errorf("failed to delete image versions: %w", err)) + } + for v := range versionsToDelete { + log.Info("deleted image version from CloudProfile", "image", updates.ImageName, "version", v) + } + } + } + + return nil +} + +func (r *Reconciler) deleteVersions(ctx context.Context, cloudProfileName, imageName string, versionsToDelete map[string]struct{}) error { + var cp gardenerv1beta1.CloudProfile + if err := r.Get(ctx, types.NamespacedName{Name: cloudProfileName}, &cp); err != nil { + return err + } + + for i := range cp.Spec.MachineImages { + if cp.Spec.MachineImages[i].Name != imageName { + continue + } + cp.Spec.MachineImages[i].Versions = slices.DeleteFunc(cp.Spec.MachineImages[i].Versions, func(mv gardenerv1beta1.MachineImageVersion) bool { + _, exists := versionsToDelete[mv.Version] + return exists + }) + } + if cp.Spec.ProviderConfig != nil { + var cfg providercfg.CloudProfileConfig + if err := json.Unmarshal(cp.Spec.ProviderConfig.Raw, &cfg); err != nil { + return fmt.Errorf("failed to unmarshal ProviderConfig: %w", err) + } + for i := range cfg.MachineImages { + if cfg.MachineImages[i].Name != imageName { + continue + } + cfg.MachineImages[i].Versions = slices.DeleteFunc(cfg.MachineImages[i].Versions, func(mv providercfg.MachineImageVersion) bool { + idx := strings.LastIndex(mv.Image, ":") + if idx == -1 { + return false + } + version := mv.Image[idx+1:] + _, exists := versionsToDelete[version] + return exists + }) + } + raw, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to marshal ProviderConfig: %w", err) + } + cp.Spec.ProviderConfig.Raw = raw + } + + return r.Update(ctx, &cp) +} + +func (r *Reconciler) getReferencedVersions(ctx context.Context, cloudProfileName, imageName string) (map[string]struct{}, error) { + referenced := make(map[string]struct{}) + + var cp gardenerv1beta1.CloudProfile + if err := r.Get(ctx, types.NamespacedName{Name: cloudProfileName}, &cp); err != nil { + return nil, fmt.Errorf("failed to get CloudProfile: %w", err) + } + if cp.Spec.ProviderConfig != nil { + var cfg providercfg.CloudProfileConfig + if err := json.Unmarshal(cp.Spec.ProviderConfig.Raw, &cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal ProviderConfig: %w", err) + } + for _, img := range cfg.MachineImages { + if img.Name != imageName { + continue + } + for _, v := range img.Versions { + if idx := strings.LastIndex(v.Image, ":"); idx != -1 { + version := v.Image[idx+1:] + referenced[version] = struct{}{} + } + } + } + } + + shootList := &gardenerv1beta1.ShootList{} + if err := r.List(ctx, shootList, client.InNamespace(metav1.NamespaceAll)); err != nil { + return nil, fmt.Errorf("failed to list Shoots: %w", err) + } + for _, shoot := range shootList.Items { + if shoot.Spec.CloudProfile == nil || shoot.Spec.CloudProfile.Name != cloudProfileName { + continue + } + + for _, worker := range shoot.Spec.Provider.Workers { + if worker.Machine.Image == nil || worker.Machine.Image.Name != imageName { + continue + } + if worker.Machine.Image.Version != nil { + referenced[*worker.Machine.Image.Version] = struct{}{} + } + } + } + + return referenced, nil } func (r *Reconciler) updateMachineImages(ctx context.Context, log logr.Logger, update v1alpha1.MachineImageUpdate, cpSpec *gardenerv1beta1.CloudProfileSpec) error { @@ -95,7 +282,7 @@ func (r *Reconciler) updateMachineImages(ctx context.Context, log logr.Logger, u if err != nil { return err } - oci, err := cloudprofilesync.NewOCI(cloudprofilesync.OCIParams{ + src, err := r.OCISourceFactory.Create(cloudprofilesync.OCIParams{ Registry: update.Source.OCI.Registry, Repository: update.Source.OCI.Repository, Username: update.Source.OCI.Username, @@ -105,7 +292,7 @@ func (r *Reconciler) updateMachineImages(ctx context.Context, log logr.Logger, u if err != nil { return fmt.Errorf("failed to initialize oci source: %w", err) } - source = oci + source = src default: return errors.New("no machine images source configured") } @@ -174,25 +361,41 @@ func applyCondition(conditions []metav1.Condition, cond metav1.Condition) []meta } func CloudProfileSpecToGardener(spec *v1alpha1.CloudProfileSpec) gardenerv1beta1.CloudProfileSpec { - cpy := spec.DeepCopy() + cpu := spec.DeepCopy() return gardenerv1beta1.CloudProfileSpec{ - CABundle: cpy.CABundle, - Kubernetes: cpy.Kubernetes, - MachineImages: cpy.MachineImages, - MachineTypes: cpy.MachineTypes, - ProviderConfig: cpy.ProviderConfig, - Regions: cpy.Regions, - SeedSelector: cpy.SeedSelector, - Type: cpy.Type, - VolumeTypes: cpy.VolumeTypes, - Bastion: cpy.Bastion, - Limits: cpy.Limits, - MachineCapabilities: cpy.MachineCapabilities, + CABundle: cpu.CABundle, + Kubernetes: cpu.Kubernetes, + MachineImages: cpu.MachineImages, + MachineTypes: cpu.MachineTypes, + ProviderConfig: cpu.ProviderConfig, + Regions: cpu.Regions, + SeedSelector: cpu.SeedSelector, + Type: cpu.Type, + VolumeTypes: cpu.VolumeTypes, + Bastion: cpu.Bastion, + Limits: cpu.Limits, + MachineCapabilities: cpu.MachineCapabilities, } } +func (r *Reconciler) failWithStatusUpdate(ctx context.Context, mcp *v1alpha1.ManagedCloudProfile, returnErr error) error { + if patchErr := r.patchStatusAndCondition(ctx, mcp, v1alpha1.FailedReconcileStatus, metav1.Condition{ + Type: CloudProfileAppliedConditionType, + Status: metav1.ConditionFalse, + ObservedGeneration: mcp.Generation, + Reason: "GarbageCollectionFailed", + Message: returnErr.Error(), + }); patchErr != nil { + return fmt.Errorf("failed to patch ManagedCloudProfile status: %w (original error: %w)", patchErr, returnErr) + } + return returnErr +} + // SetupWithManager attaches the controller to the given manager. func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + if r.OCISourceFactory == nil { + r.OCISourceFactory = &DefaultOCISourceFactory{} + } return ctrl.NewControllerManagedBy(mgr). For(&v1alpha1.ManagedCloudProfile{}). Owns(&gardenerv1beta1.CloudProfile{}). diff --git a/controllers/managedcloudprofile_controller_test.go b/controllers/managedcloudprofile_controller_test.go index 79a29e0..f2911d3 100644 --- a/controllers/managedcloudprofile_controller_test.go +++ b/controllers/managedcloudprofile_controller_test.go @@ -4,41 +4,101 @@ package controllers_test import ( - gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" + "context" + "encoding/json" + "errors" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" + gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + providercfg "github.com/ironcore-dev/gardener-extension-provider-ironcore-metal/pkg/apis/metal/v1alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/cobaltcore-dev/cloud-profile-sync/api/v1alpha1" + "github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync" "github.com/cobaltcore-dev/cloud-profile-sync/controllers" ) -var _ = Describe("The ManagedCloudProfile reconciler", func() { +// fakeSource used to simulate GC list failures in tests +type fakeSource struct{} + +func (f *fakeSource) GetVersions(ctx context.Context) ([]cloudprofilesync.SourceImage, error) { + return nil, errors.New("simulated list error") +} + +// mockOCIFactory implements controllers.OCISourceFactory for testing +type mockOCIFactory struct { + createFunc func(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) +} + +func (m *mockOCIFactory) Create(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) { + return m.createFunc(params, insecure) +} +var _ = Describe("The ManagedCloudProfile reconciler", func() { + amd64 := "amd64" + version := "1.0.0" AfterEach(func(ctx SpecContext) { var mcpList v1alpha1.ManagedCloudProfileList + Expect(k8sClient.List(ctx, &mcpList)).To(Succeed()) + for _, mcp := range mcpList.Items { + Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) + } + + var cpList gardenerv1beta1.CloudProfileList + Expect(k8sClient.List(ctx, &cpList)).To(Succeed()) + for _, cp := range cpList.Items { + Expect(k8sClient.Delete(ctx, &cp)).To(Succeed()) + } + + var shootList gardenerv1beta1.ShootList + Expect(k8sClient.List(ctx, &shootList)).To(Succeed()) + for _, shoot := range shootList.Items { + Expect(k8sClient.Delete(ctx, &shoot)).To(Succeed()) + } + + var secList corev1.SecretList + Expect(k8sClient.List(ctx, &secList)).To(Succeed()) + for _, sec := range secList.Items { + if sec.Namespace == metav1.NamespaceDefault && sec.Name == "oci" { + Expect(k8sClient.Delete(ctx, &sec)).To(Succeed()) + } + } + Eventually(func(g Gomega) int { - g.Expect(k8sClient.List(ctx, &mcpList)).To(Succeed()) - return len(mcpList.Items) + var updated v1alpha1.ManagedCloudProfileList + g.Expect(k8sClient.List(ctx, &updated)).To(Succeed()) + return len(updated.Items) }).Should(Equal(0)) - var cloudprofiles gardenerv1beta1.CloudProfileList + Eventually(func(g Gomega) int { - g.Expect(k8sClient.List(ctx, &cloudprofiles)).To(Succeed()) - return len(cloudprofiles.Items) + var updated gardenerv1beta1.ShootList + g.Expect(k8sClient.List(ctx, &updated)).To(Succeed()) + return len(updated.Items) }).Should(Equal(0)) - var secrets corev1.SecretList + Eventually(func(g Gomega) int { - g.Expect(k8sClient.List(ctx, &secrets)).To(Succeed()) - return len(secrets.Items) + var updated corev1.SecretList + g.Expect(k8sClient.List(ctx, &updated)).To(Succeed()) + count := 0 + for _, sec := range updated.Items { + if sec.Namespace == metav1.NamespaceDefault && sec.Name == "oci" { + count++ + } + } + return count }).Should(Equal(0)) }) It("should copy the spec of a ManagedCloudProfile to the respective CloudProfile", func(ctx SpecContext) { var mcp v1alpha1.ManagedCloudProfile mcp.Name = "test-mcp" + usable := true mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ Regions: []gardenerv1beta1.Region{{Name: "foo"}}, MachineImages: []gardenerv1beta1.MachineImage{ @@ -57,8 +117,8 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { MachineTypes: []gardenerv1beta1.MachineType{ { Name: "baz", - Architecture: ptr.To("amd64"), - Usable: ptr.To(true), + Architecture: &amd64, + Usable: &usable, }, }, } @@ -107,13 +167,14 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { It("invokes the image updater based on an image source", func(ctx SpecContext) { var mcp v1alpha1.ManagedCloudProfile mcp.Name = "test-oci" + usable := true mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ Regions: []gardenerv1beta1.Region{{Name: "foo"}}, MachineTypes: []gardenerv1beta1.MachineType{ { Name: "baz", - Architecture: ptr.To("amd64"), - Usable: ptr.To(true), + Architecture: &amd64, + Usable: &usable, }, }, } @@ -214,4 +275,552 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() { Expect(k8sClient.Delete(ctx, &secret)).To(Succeed()) }) + It("deletes old machine image versions not referenced by any Shoot", func(ctx SpecContext) { + var mcp v1alpha1.ManagedCloudProfile + mcp.Name = "test-gc" + mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + } + mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ + { + ImageName: "gc-image", + Source: v1alpha1.MachineImageUpdateSource{ + OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + Registry: registryAddr, + Repository: "repo", + Insecure: true, + }, + }, + GarbageCollection: &v1alpha1.GarbageCollectionConfig{ + Enabled: true, + MaxAge: metav1.Duration{Duration: 0}, + }, + }, + } + Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) + + Eventually(func(g Gomega) v1alpha1.ReconcileStatus { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) + return mcp.Status.Status + }).Should(Equal(v1alpha1.SucceededReconcileStatus)) + + var cloudProfile gardenerv1beta1.CloudProfile + cloudProfile.Name = mcp.Name + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile)).To(Succeed()) + + Eventually(func(g Gomega) int { + freshProfile := gardenerv1beta1.CloudProfile{} + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &freshProfile)).To(Succeed()) + if len(freshProfile.Spec.MachineImages) == 0 { + return 0 + } + for _, img := range freshProfile.Spec.MachineImages { + if img.Name == "gc-image" { + return len(img.Versions) + } + } + return 0 + }, "10s").Should(Equal(0)) + + Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) + }) + + It("preserves old machine image versions referenced by Shoot worker pools", func(ctx SpecContext) { + var cloudProfile gardenerv1beta1.CloudProfile + cloudProfile.Name = "test-gc-preserve" + cloudProfile.Spec.Regions = []gardenerv1beta1.Region{{Name: "foo"}} + cloudProfile.Spec.MachineTypes = []gardenerv1beta1.MachineType{{Name: "baz"}} + cloudProfile.Spec.MachineImages = []gardenerv1beta1.MachineImage{ + { + Name: "preserve-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "2.0.0"}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "3.0.0"}, Architectures: []string{"amd64"}}, + }, + }, + } + + var cfg providercfg.CloudProfileConfig + cfg.MachineImages = []providercfg.MachineImages{ + { + Name: "preserve-image", + Versions: []providercfg.MachineImageVersion{ + {Image: "repo/preserve-image:1.0.0"}, + }, + }, + } + raw, err := json.Marshal(cfg) + Expect(err).To(Succeed()) + cloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: raw} + Expect(k8sClient.Create(ctx, &cloudProfile)).To(Succeed()) + + var shoot gardenerv1beta1.Shoot + shoot.Name = "test-shoot-preserve" + shoot.Namespace = metav1.NamespaceDefault + shoot.Spec.CloudProfile = &gardenerv1beta1.CloudProfileReference{Name: cloudProfile.Name} + shoot.Spec.Provider.Workers = []gardenerv1beta1.Worker{ + { + Name: "worker1", + Machine: gardenerv1beta1.Machine{ + Image: &gardenerv1beta1.ShootMachineImage{ + Name: "preserve-image", + Version: &version, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, &shoot)).To(Succeed()) + + var mcp v1alpha1.ManagedCloudProfile + mcp.Name = "test-gc-preserve" + mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "preserve-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "2.0.0"}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "3.0.0"}, Architectures: []string{"amd64"}}, + }, + }, + }, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + } + mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ + { + ImageName: "preserve-image", + Source: v1alpha1.MachineImageUpdateSource{ + OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + Registry: registryAddr, + Repository: "repo", + Insecure: true, + }, + }, + GarbageCollection: &v1alpha1.GarbageCollectionConfig{ + Enabled: true, + MaxAge: metav1.Duration{Duration: 0}, + }, + }, + } + Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) + + Eventually(func(g Gomega) v1alpha1.ReconcileStatus { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) + return mcp.Status.Status + }).Should(Equal(v1alpha1.SucceededReconcileStatus)) + + Eventually(func(g Gomega) []string { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile)).To(Succeed()) + if len(cloudProfile.Spec.MachineImages) == 0 { + return []string{} + } + versions := []string{} + for _, v := range cloudProfile.Spec.MachineImages[0].Versions { + versions = append(versions, v.Version) + } + return versions + }).Should(ContainElement("1.0.0")) + + Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) + Expect(k8sClient.Delete(ctx, &cloudProfile)).To(Succeed()) + Expect(k8sClient.Delete(ctx, &shoot)).To(Succeed()) + }) + + It("preserves machine image versions referenced by Shoot workers", func(ctx SpecContext) { + var cloudProfile gardenerv1beta1.CloudProfile + cloudProfile.Name = "test-gc-shoot-preserve" + cloudProfile.Spec.Regions = []gardenerv1beta1.Region{{Name: "foo"}} + cloudProfile.Spec.MachineTypes = []gardenerv1beta1.MachineType{{Name: "baz"}} + cloudProfile.Spec.MachineImages = []gardenerv1beta1.MachineImage{ + { + Name: "shoot-preserve-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.1+abc"}, Architectures: []string{"amd64"}}, + }, + }, + } + Expect(k8sClient.Create(ctx, &cloudProfile)).To(Succeed()) + + var shoot gardenerv1beta1.Shoot + shoot.Name = "test-shoot" + shoot.Namespace = metav1.NamespaceDefault + shoot.Spec.CloudProfile = &gardenerv1beta1.CloudProfileReference{Name: cloudProfile.Name} + shoot.Spec.Provider.Workers = []gardenerv1beta1.Worker{ + { + Name: "worker1", + Machine: gardenerv1beta1.Machine{ + Image: &gardenerv1beta1.ShootMachineImage{ + Name: "shoot-preserve-image", + Version: &version, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, &shoot)).To(Succeed()) + + var mcp v1alpha1.ManagedCloudProfile + mcp.Name = "test-gc-shoot-preserve" + mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "shoot-preserve-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.1+abc"}, Architectures: []string{"amd64"}}, + }, + }, + }, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + } + mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ + { + ImageName: "shoot-preserve-image", + Source: v1alpha1.MachineImageUpdateSource{ + OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + Registry: registryAddr, + Repository: "repo", + Insecure: true, + }, + }, + GarbageCollection: &v1alpha1.GarbageCollectionConfig{ + Enabled: true, + MaxAge: metav1.Duration{Duration: 0}, + }, + }, + } + Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) + + Eventually(func(g Gomega) v1alpha1.ReconcileStatus { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) + return mcp.Status.Status + }).Should(Equal(v1alpha1.SucceededReconcileStatus)) + + Eventually(func(g Gomega) []string { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile)).To(Succeed()) + if len(cloudProfile.Spec.MachineImages) == 0 { + return []string{} + } + versions := []string{} + for _, v := range cloudProfile.Spec.MachineImages[0].Versions { + versions = append(versions, v.Version) + } + return versions + }).Should(And( + ContainElement("1.0.0"), + Not(ContainElement("1.0.1+abc")), + )) + + Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) + Expect(k8sClient.Delete(ctx, &cloudProfile)).To(Succeed()) + Expect(k8sClient.Delete(ctx, &shoot)).To(Succeed()) + }) + + It("handles missing credential for GC OCI source", func(ctx SpecContext) { + var mcp v1alpha1.ManagedCloudProfile + mcp.Name = "test-gc-cred-error" + mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + } + mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ + { + ImageName: "test-image", + Source: v1alpha1.MachineImageUpdateSource{ + OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + Registry: registryAddr, + Repository: "repo", + Insecure: true, + Password: v1alpha1.SecretReference{ + Name: "nonexistent-secret", + Namespace: metav1.NamespaceDefault, + Key: "password", + }, + }, + }, + GarbageCollection: &v1alpha1.GarbageCollectionConfig{ + Enabled: true, + MaxAge: metav1.Duration{Duration: 3600000000000}, + }, + }, + } + Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) + + Eventually(func(g Gomega) v1alpha1.ReconcileStatus { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) + return mcp.Status.Status + }).Should(Equal(v1alpha1.FailedReconcileStatus)) + + Expect(mcp.Status.Conditions).To(ContainElement(SatisfyAll( + HaveField("Type", controllers.CloudProfileAppliedConditionType), + HaveField("Status", metav1.ConditionFalse), + HaveField("Message", ContainSubstring("failed to get secret")), + ))) + + Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) + }) + + It("handles invalid OCI registry for GC", func(ctx SpecContext) { + var mcp v1alpha1.ManagedCloudProfile + mcp.Name = "test-gc-invalid-registry" + mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + } + mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ + { + ImageName: "test-image", + Source: v1alpha1.MachineImageUpdateSource{ + OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + Registry: "invalid://registry", + Repository: "repo", + Insecure: true, + }, + }, + GarbageCollection: &v1alpha1.GarbageCollectionConfig{ + Enabled: true, + MaxAge: metav1.Duration{Duration: 3600000000000}, + }, + }, + } + Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) + + Eventually(func(g Gomega) v1alpha1.ReconcileStatus { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) + return mcp.Status.Status + }).Should(Equal(v1alpha1.FailedReconcileStatus)) + + Expect(mcp.Status.Conditions).To(ContainElement(SatisfyAll( + HaveField("Type", controllers.CloudProfileAppliedConditionType), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", "ApplyFailed"), + HaveField("Message", ContainSubstring("failed to initialize oci source")), + ))) + + Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) + }) + + It("reports failure when GC version listing errors occur", func(ctx SpecContext) { + old := reconciler.OCISourceFactory + defer func() { reconciler.OCISourceFactory = old }() + reconciler.OCISourceFactory = &mockOCIFactory{ + createFunc: func(params cloudprofilesync.OCIParams, insecure bool) (cloudprofilesync.Source, error) { + return &fakeSource{}, nil + }, + } + + var mcp v1alpha1.ManagedCloudProfile + mcp.Name = "test-gc-list-error" + mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + } + mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ + { + ImageName: "test-image", + Source: v1alpha1.MachineImageUpdateSource{ + OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + Registry: registryAddr, + Repository: "repo", + Insecure: true, + }, + }, + GarbageCollection: &v1alpha1.GarbageCollectionConfig{ + Enabled: true, + MaxAge: metav1.Duration{Duration: 3600000000000}, + }, + }, + } + Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) + + Eventually(func(g Gomega) v1alpha1.ReconcileStatus { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) + return mcp.Status.Status + }).Should(Equal(v1alpha1.FailedReconcileStatus)) + + Expect(mcp.Status.Conditions).To(ContainElement(SatisfyAll( + HaveField("Type", controllers.CloudProfileAppliedConditionType), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", "ApplyFailed"), + HaveField("Message", ContainSubstring("failed to retrieve images version")), + ))) + + Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) + }) + + It("skips GC when no source is configured", func(ctx SpecContext) { + var mcp v1alpha1.ManagedCloudProfile + mcp.Name = "test-gc-no-source" + mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "test-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, + }, + }, + }, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + } + Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) + + var cp gardenerv1beta1.CloudProfile + Eventually(func(g Gomega) error { + return k8sClient.Get(ctx, client.ObjectKey{Name: mcp.Name}, &cp) + }).Should(Succeed()) + + Expect(k8sClient.Get(ctx, client.ObjectKey{Name: mcp.Name}, &cp)).To(Succeed()) + Expect(cp.Spec.MachineImages).To(HaveLen(1)) + Expect(cp.Spec.MachineImages[0].Versions).To(HaveLen(1)) + + Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) + }) + + It("reports failure when CloudProfile is already owned by another controller", func(ctx SpecContext) { + var cloudProfile gardenerv1beta1.CloudProfile + cloudProfile.Name = "test-owned" + usable := true + cloudProfile.Spec = gardenerv1beta1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "existing-region"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "existing-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, + }, + }, + }, + MachineTypes: []gardenerv1beta1.MachineType{ + {Name: "existing-type", Architecture: &amd64, Usable: &usable}, + }, + } + cloudProfile.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "Dummy", + Name: "dummy-owner", + UID: "dummy-uid", + }, + } + Expect(k8sClient.Create(ctx, &cloudProfile)).To(Succeed()) + + var mcp v1alpha1.ManagedCloudProfile + mcp.Name = "test-owned" + mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + } + Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) + + Eventually(func(g Gomega) v1alpha1.ReconcileStatus { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) + return mcp.Status.Status + }).Should(Equal(v1alpha1.FailedReconcileStatus)) + Expect(mcp.Status.Conditions).To(ContainElement(SatisfyAll( + HaveField("Type", controllers.CloudProfileAppliedConditionType), + HaveField("Status", metav1.ConditionFalse), + ))) + + Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) + Expect(k8sClient.Delete(ctx, &cloudProfile)).To(Succeed()) + }) + + It("updates ProviderConfig when garbage collecting machine image versions", func(ctx SpecContext) { + var cloudProfile gardenerv1beta1.CloudProfile + cloudProfile.Name = "test-gc-provider-config" + cloudProfile.Spec.Regions = []gardenerv1beta1.Region{{Name: "foo"}} + cloudProfile.Spec.MachineTypes = []gardenerv1beta1.MachineType{{Name: "baz"}} + cloudProfile.Spec.MachineImages = []gardenerv1beta1.MachineImage{ + { + Name: "provider-config-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.1+abc"}, Architectures: []string{"amd64"}}, + }, + }, + } + + var cfg providercfg.CloudProfileConfig + cfg.MachineImages = []providercfg.MachineImages{ + { + Name: "provider-config-image", + Versions: []providercfg.MachineImageVersion{ + {Image: "repo/provider-config-image:1.0.0"}, + {Image: "repo/provider-config-image:1.0.1+abc"}, + }, + }, + } + raw, err := json.Marshal(cfg) + Expect(err).To(Succeed()) + cloudProfile.Spec.ProviderConfig = &runtime.RawExtension{Raw: raw} + Expect(k8sClient.Create(ctx, &cloudProfile)).To(Succeed()) + + var mcp v1alpha1.ManagedCloudProfile + mcp.Name = "test-gc-provider-config" + mcp.Spec.CloudProfile = v1alpha1.CloudProfileSpec{ + Regions: []gardenerv1beta1.Region{{Name: "foo"}}, + MachineImages: []gardenerv1beta1.MachineImage{ + { + Name: "provider-config-image", + Versions: []gardenerv1beta1.MachineImageVersion{ + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.0"}, Architectures: []string{"amd64"}}, + {ExpirableVersion: gardenerv1beta1.ExpirableVersion{Version: "1.0.1+abc"}, Architectures: []string{"amd64"}}, + }, + }, + }, + MachineTypes: []gardenerv1beta1.MachineType{{Name: "baz"}}, + } + mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{ + { + ImageName: "provider-config-image", + Source: v1alpha1.MachineImageUpdateSource{ + OCI: &v1alpha1.MachineImageUpdateSourceOCI{ + Registry: registryAddr, + Repository: "repo", + Insecure: true, + }, + }, + GarbageCollection: &v1alpha1.GarbageCollectionConfig{ + Enabled: true, + MaxAge: metav1.Duration{Duration: 0}, + }, + }, + } + Expect(k8sClient.Create(ctx, &mcp)).To(Succeed()) + + Eventually(func(g Gomega) v1alpha1.ReconcileStatus { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&mcp), &mcp)).To(Succeed()) + return mcp.Status.Status + }).Should(Equal(v1alpha1.SucceededReconcileStatus)) + + Eventually(func(g Gomega) []string { + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&cloudProfile), &cloudProfile)).To(Succeed()) + if cloudProfile.Spec.ProviderConfig == nil { + return []string{} + } + var updatedCfg providercfg.CloudProfileConfig + if err := json.Unmarshal(cloudProfile.Spec.ProviderConfig.Raw, &updatedCfg); err != nil { + return []string{} + } + for _, img := range updatedCfg.MachineImages { + if img.Name == "provider-config-image" { + images := make([]string, len(img.Versions)) + for i, v := range img.Versions { + images[i] = v.Image + } + return images + } + } + return []string{} + }).Should(BeEmpty()) + + Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed()) + Expect(k8sClient.Delete(ctx, &cloudProfile)).To(Succeed()) + }) + }) diff --git a/controllers/suite_test.go b/controllers/suite_test.go index c0451b3..c9af20f 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -45,6 +45,7 @@ var ( testEnv *envtest.Environment reg *registry.Registry stop context.CancelFunc + reconciler *controllers.Reconciler ) func TestControllers(t *testing.T) { @@ -77,9 +78,10 @@ var _ = BeforeSuite(func(ctx SpecContext) { }) Expect(err).To(Succeed()) - err = (&controllers.Reconciler{ + reconciler = &controllers.Reconciler{ Client: k8sManager.GetClient(), - }).SetupWithManager(k8sManager) + } + err = reconciler.SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) stopCtx, cancel := context.WithCancel(ctrl.SetupSignalHandler()) @@ -129,6 +131,7 @@ var _ = BeforeSuite(func(ctx SpecContext) { }, Annotations: map[string]string{ "architecture": "amd64", + "created": time.Now().Add(-24 * time.Hour).Format(time.RFC3339), }, } indexBlob, err := json.Marshal(index) diff --git a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml index 7cc0252..7546e34 100644 --- a/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml +++ b/crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.18.0 + controller-gen.kubebuilder.io/version: v0.20.0 name: managedcloudprofiles.cloudprofilesync.cobaltcore.dev spec: group: cloudprofilesync.cobaltcore.dev @@ -431,7 +431,7 @@ spec: properties: matchExpressions: description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. + requirements. The requirements are ANDead. items: description: |- A label selector requirement is a selector that contains values, a key, and an operator that @@ -468,7 +468,7 @@ spec: description: |- matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. + operator is "In", and the values array contains only "value". The requirements are ANDead. type: object providerTypes: description: Providers is optional and can be used by restricting @@ -521,6 +521,20 @@ spec: information to automate machine images. items: properties: + garbageCollection: + description: GarbageCollection contains configuration for automated + garbage collection + properties: + enabled: + description: Enabled toggles garbage collection for this + image. + type: boolean + maxAge: + description: |- + MaxAge defines the maximum age for images to keep. Images older than + now MaxAge are eligible for deletion. + type: string + type: object imageName: description: ImagesName is the name of the image to maintain automatically diff --git a/crd/core.gardener.cloud_cloudprofiles.yaml b/crd/core.gardener.cloud_cloudprofiles.yaml index 54f93b8..c68e8c6 100644 --- a/crd/core.gardener.cloud_cloudprofiles.yaml +++ b/crd/core.gardener.cloud_cloudprofiles.yaml @@ -414,7 +414,7 @@ spec: properties: matchExpressions: description: matchExpressions is a list of label selector requirements. - The requirements are ANDed. + The requirements are ANDead. items: description: |- A label selector requirement is a selector that contains values, a key, and an operator that @@ -451,7 +451,7 @@ spec: description: |- matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. + operator is "In", and the values array contains only "value". The requirements are ANDead. type: object providerTypes: description: Providers is optional and can be used by restricting diff --git a/crd/core.gardener.cloud_shoots.yaml b/crd/core.gardener.cloud_shoots.yaml new file mode 100644 index 0000000..da58e2b --- /dev/null +++ b/crd/core.gardener.cloud_shoots.yaml @@ -0,0 +1,75 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: shoots.core.gardener.cloud +spec: + group: core.gardener.cloud + versions: + - name: v1beta1 + served: true + storage: true + schema: + openAPIV3Schema: + description: Shoot is the schema for the shoots API. + type: object + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Spec defines the desired state of the Shoot. + type: object + properties: + cloudProfile: + description: Reference to the CloudProfile used by the Shoot. + type: object + properties: + name: + description: Name of the CloudProfile. + type: string + provider: + description: Provider-specific configuration. + type: object + properties: + workers: + description: Worker pools for the Shoot. + type: array + items: + type: object + properties: + machine: + description: Machine configuration for a worker pool. + type: object + properties: + image: + description: Image configuration for worker nodes. + type: object + properties: + name: + description: Machine image name. + type: string + version: + description: Machine image version. + type: string + status: + description: Status contains the current status of the Shoot. + type: object + scope: Namespaced + names: + plural: shoots + singular: shoot + kind: Shoot \ No newline at end of file