diff --git a/cmd/main.go b/cmd/main.go index 4433190..dba27ef 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -23,10 +23,12 @@ import ( "flag" "fmt" "os" + "time" // TODO when to update oadp-operator version in go.mod? "github.com/openshift/oadp-operator/api/v1alpha1" velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -113,7 +115,7 @@ func main() { restConfig := ctrl.GetConfigOrDie() - enforcedBackupSpec, enforcedRestoreSpec, err := getEnforcedSpec(restConfig, oadpNamespace) + dpaConfiguration, err := getDPAConfiguration(restConfig, oadpNamespace) if err != nil { setupLog.Error(err, "unable to get enforced spec") os.Exit(1) @@ -151,7 +153,7 @@ func main() { Client: mgr.GetClient(), Scheme: mgr.GetScheme(), OADPNamespace: oadpNamespace, - EnforcedBackupSpec: enforcedBackupSpec, + EnforcedBackupSpec: dpaConfiguration.EnforceBackupSpec, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to setup NonAdminBackup controller with manager") os.Exit(1) @@ -160,7 +162,7 @@ func main() { Client: mgr.GetClient(), Scheme: mgr.GetScheme(), OADPNamespace: oadpNamespace, - EnforcedRestoreSpec: enforcedRestoreSpec, + EnforcedRestoreSpec: dpaConfiguration.EnforceRestoreSpec, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to setup NonAdminRestore controller with manager") os.Exit(1) @@ -174,6 +176,17 @@ func main() { os.Exit(1) } // +kubebuilder:scaffold:builder + if dpaConfiguration.GarbageCollectionPeriod.Duration > 0 { + if err = (&controller.GarbageCollectorReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + OADPNamespace: oadpNamespace, + Frequency: dpaConfiguration.GarbageCollectionPeriod.Duration, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to setup GarbageCollector controller with manager") + os.Exit(1) + } + } if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") @@ -191,32 +204,43 @@ func main() { } } -func getEnforcedSpec(restConfig *rest.Config, oadpNamespace string) (*velerov1.BackupSpec, *velerov1.RestoreSpec, error) { +func getDPAConfiguration(restConfig *rest.Config, oadpNamespace string) (v1alpha1.NonAdmin, error) { + dpaConfiguration := v1alpha1.NonAdmin{ + GarbageCollectionPeriod: &metav1.Duration{ + Duration: 24 * time.Hour, //nolint:revive // 1 day + }, + EnforceBackupSpec: &velerov1.BackupSpec{}, + EnforceRestoreSpec: &velerov1.RestoreSpec{}, + } + dpaClientScheme := runtime.NewScheme() utilruntime.Must(v1alpha1.AddToScheme(dpaClientScheme)) dpaClient, err := client.New(restConfig, client.Options{ Scheme: dpaClientScheme, }) if err != nil { - return nil, nil, err + return dpaConfiguration, err } // TODO we could pass DPA name as env var and do a get call directly. Better? dpaList := &v1alpha1.DataProtectionApplicationList{} - err = dpaClient.List(context.Background(), dpaList) + err = dpaClient.List(context.Background(), dpaList, &client.ListOptions{Namespace: oadpNamespace}) if err != nil { - return nil, nil, err + return dpaConfiguration, err } - enforcedBackupSpec := &velerov1.BackupSpec{} - enforcedRestoreSpec := &velerov1.RestoreSpec{} for _, dpa := range dpaList.Items { - if dpa.Namespace == oadpNamespace { - if dpa.Spec.NonAdmin != nil && dpa.Spec.NonAdmin.EnforceBackupSpec != nil { - enforcedBackupSpec = dpa.Spec.NonAdmin.EnforceBackupSpec + if nonAdmin := dpa.Spec.NonAdmin; nonAdmin != nil { + if nonAdmin.EnforceBackupSpec != nil { + dpaConfiguration.EnforceBackupSpec = nonAdmin.EnforceBackupSpec + } + if nonAdmin.EnforceRestoreSpec != nil { + dpaConfiguration.EnforceRestoreSpec = nonAdmin.EnforceRestoreSpec } - if dpa.Spec.NonAdmin.EnforceRestoreSpec != nil { - enforcedRestoreSpec = dpa.Spec.NonAdmin.EnforceRestoreSpec + if nonAdmin.GarbageCollectionPeriod != nil { + dpaConfiguration.GarbageCollectionPeriod.Duration = nonAdmin.GarbageCollectionPeriod.Duration } + break } } - return enforcedBackupSpec, enforcedRestoreSpec, nil + + return dpaConfiguration, nil } diff --git a/go.mod b/go.mod index a653080..d201f80 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/google/uuid v1.6.0 github.com/onsi/ginkgo/v2 v2.19.0 github.com/onsi/gomega v1.33.1 - github.com/openshift/oadp-operator v1.0.2-0.20241203211837-06081c0bb045 + github.com/openshift/oadp-operator v1.0.2-0.20250205172928-bb0c25a9c6af github.com/stretchr/testify v1.9.0 github.com/vmware-tanzu/velero v1.14.0 k8s.io/api v0.30.5 diff --git a/go.sum b/go.sum index 123be86..b7e17cf 100644 --- a/go.sum +++ b/go.sum @@ -81,8 +81,8 @@ github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= -github.com/openshift/oadp-operator v1.0.2-0.20241203211837-06081c0bb045 h1:Pwh5fEmVgR4DexV47DCCYT+R1tDI/4myXiEh77qKYdg= -github.com/openshift/oadp-operator v1.0.2-0.20241203211837-06081c0bb045/go.mod h1:ndXHIyjyavYVLFIi2EwfvpwUUSwPnjJo//CoyICO4aA= +github.com/openshift/oadp-operator v1.0.2-0.20250205172928-bb0c25a9c6af h1:qYsD1Hu2yt0S5lUcol603NeeSnEtDIEt7mLRzuFTdOo= +github.com/openshift/oadp-operator v1.0.2-0.20250205172928-bb0c25a9c6af/go.mod h1:gNUsBZgNcoS90Z68mNSUgx7pwtOc3THddWJx6uGV14A= github.com/openshift/velero v0.10.2-0.20241211163542-fa8f2486175b h1:ykGzFFul6lhtphKH4V6lWzdIW1oGEtiFfOdS1WyOuNw= github.com/openshift/velero v0.10.2-0.20241211163542-fa8f2486175b/go.mod h1:bbcPBz7mGYVY7ORWbQhD2Yx09e2ZKH2gqS+MQj+zJCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/internal/common/function/function.go b/internal/common/function/function.go index 6db8508..e6921b0 100644 --- a/internal/common/function/function.go +++ b/internal/common/function/function.go @@ -515,15 +515,20 @@ func CheckVeleroBackupMetadata(obj client.Object) bool { return false } - if !checkLabelAnnotationValueIsValid(objLabels, constant.NabOriginNACUUIDLabel) { + if !CheckLabelAnnotationValueIsValid(objLabels, constant.NabOriginNACUUIDLabel) { return false } + return CheckVeleroBackupAnnotations(obj) +} + +// CheckVeleroBackupAnnotations return true if Velero Backup object has required Non Admin annotations, false otherwise +func CheckVeleroBackupAnnotations(obj client.Object) bool { annotations := obj.GetAnnotations() - if !checkLabelAnnotationValueIsValid(annotations, constant.NabOriginNamespaceAnnotation) { + if !CheckLabelAnnotationValueIsValid(annotations, constant.NabOriginNamespaceAnnotation) { return false } - if !checkLabelAnnotationValueIsValid(annotations, constant.NabOriginNameAnnotation) { + if !CheckLabelAnnotationValueIsValid(annotations, constant.NabOriginNameAnnotation) { return false } @@ -545,11 +550,16 @@ func CheckVeleroRestoreMetadata(obj client.Object) bool { return false } + return CheckVeleroRestoreAnnotations(obj) +} + +// CheckVeleroRestoreAnnotations return true if Velero Restore object has required Non Admin annotations, false otherwise +func CheckVeleroRestoreAnnotations(obj client.Object) bool { annotations := obj.GetAnnotations() - if !checkLabelAnnotationValueIsValid(annotations, constant.NarOriginNamespaceAnnotation) { + if !CheckLabelAnnotationValueIsValid(annotations, constant.NarOriginNamespaceAnnotation) { return false } - if !checkLabelAnnotationValueIsValid(annotations, constant.NarOriginNameAnnotation) { + if !CheckLabelAnnotationValueIsValid(annotations, constant.NarOriginNameAnnotation) { return false } @@ -566,20 +576,26 @@ func CheckVeleroBackupStorageLocationMetadata(obj client.Object) bool { return false } - if !checkLabelAnnotationValueIsValid(objLabels, constant.NabslOriginNACUUIDLabel) { + if !CheckLabelAnnotationValueIsValid(objLabels, constant.NabslOriginNACUUIDLabel) { return false } + return CheckVeleroBackupStorageLocationAnnotations(obj) +} + +// CheckVeleroBackupStorageLocationAnnotations return true if Velero BackupStorageLocation object has required Non Admin annotations, false otherwise +func CheckVeleroBackupStorageLocationAnnotations(obj client.Object) bool { annotations := obj.GetAnnotations() - if !checkLabelAnnotationValueIsValid(annotations, constant.NabslOriginNamespaceAnnotation) { + if !CheckLabelAnnotationValueIsValid(annotations, constant.NabslOriginNamespaceAnnotation) { return false } - if !checkLabelAnnotationValueIsValid(annotations, constant.NabslOriginNameAnnotation) { + if !CheckLabelAnnotationValueIsValid(annotations, constant.NabslOriginNameAnnotation) { return false } return true } + func checkLabelValue(objLabels map[string]string, key string, value string) bool { got, exists := objLabels[key] if !exists { @@ -588,7 +604,8 @@ func checkLabelValue(objLabels map[string]string, key string, value string) bool return got == value } -func checkLabelAnnotationValueIsValid(labelsOrAnnotations map[string]string, key string) bool { +// CheckLabelAnnotationValueIsValid return true if key exists among labels/annotations and has a valid length, false otherwise +func CheckLabelAnnotationValueIsValid(labelsOrAnnotations map[string]string, key string) bool { value, exists := labelsOrAnnotations[key] if !exists { return false diff --git a/internal/controller/garbagecollector_controller.go b/internal/controller/garbagecollector_controller.go new file mode 100644 index 0000000..2cb2413 --- /dev/null +++ b/internal/controller/garbagecollector_controller.go @@ -0,0 +1,215 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "time" + + "github.com/go-logr/logr" + velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + nacv1alpha1 "github.com/migtools/oadp-non-admin/api/v1alpha1" + "github.com/migtools/oadp-non-admin/internal/common/constant" + "github.com/migtools/oadp-non-admin/internal/common/function" + "github.com/migtools/oadp-non-admin/internal/source" +) + +// GarbageCollectorReconciler reconciles Velero objects +type GarbageCollectorReconciler struct { + client.Client + Scheme *runtime.Scheme + OADPNamespace string + Frequency time.Duration +} + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (r *GarbageCollectorReconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + labelSelector := client.MatchingLabels{ + constant.OadpLabel: constant.OadpLabelValue, + constant.ManagedByLabel: constant.ManagedByLabelValue, + } + + logger.V(1).Info("Garbage Collector Reconcile start") + // TODO duplication in delete logic + // TODO do deletion in parallel + + secretList := &corev1.SecretList{} + if err := r.List(ctx, secretList, client.InNamespace(r.OADPNamespace), labelSelector); err != nil { + logger.Error(err, "Unable to fetch Secret in OADP namespace") + return ctrl.Result{}, err + } + for _, secret := range secretList.Items { + if !function.CheckLabelAnnotationValueIsValid(secret.GetLabels(), constant.NabslOriginNACUUIDLabel) { + logger.V(1).Info("Secret does not have required label", constant.NameString, secret.Name) + // TODO delete? + continue + } + annotations := secret.GetAnnotations() + if !function.CheckVeleroBackupStorageLocationAnnotations(&secret) { + logger.V(1).Info("Secret does not have required annotations", constant.NameString, secret.Name) + // TODO delete? + continue + } + nabsl := &nacv1alpha1.NonAdminBackupStorageLocation{} + err := r.Get(ctx, types.NamespacedName{ + Name: annotations[constant.NabslOriginNameAnnotation], + Namespace: annotations[constant.NabslOriginNamespaceAnnotation], + }, nabsl) + if err != nil { + if !apierrors.IsNotFound(err) { + logger.Error(err, "Unable to fetch NonAdminBackupStorageLocation") + return ctrl.Result{}, err + } + if err = r.Delete(ctx, &secret); err != nil { + logger.Error(err, "Failed to delete orphan Secret", constant.NameString, secret.Name) + return ctrl.Result{}, err + } + logger.V(1).Info("orphan Secret deleted", constant.NameString, secret.Name) + } + } + + veleroBackupStorageLocationList := &velerov1.BackupStorageLocationList{} + if err := r.List(ctx, veleroBackupStorageLocationList, client.InNamespace(r.OADPNamespace), labelSelector); err != nil { + logger.Error(err, "Unable to fetch BackupStorageLocations in OADP namespace") + return ctrl.Result{}, err + } + for _, backupStorageLocation := range veleroBackupStorageLocationList.Items { + if !function.CheckLabelAnnotationValueIsValid(backupStorageLocation.GetLabels(), constant.NabslOriginNACUUIDLabel) { + logger.V(1).Info("BackupStorageLocation does not have required label", constant.NameString, backupStorageLocation.Name) + // TODO delete? + continue + } + annotations := backupStorageLocation.GetAnnotations() + if !function.CheckVeleroBackupStorageLocationAnnotations(&backupStorageLocation) { + logger.V(1).Info("BackupStorageLocation does not have required annotations", constant.NameString, backupStorageLocation.Name) + // TODO delete? + continue + } + nabsl := &nacv1alpha1.NonAdminBackupStorageLocation{} + err := r.Get(ctx, types.NamespacedName{ + Name: annotations[constant.NabslOriginNameAnnotation], + Namespace: annotations[constant.NabslOriginNamespaceAnnotation], + }, nabsl) + if err != nil { + if !apierrors.IsNotFound(err) { + logger.Error(err, "Unable to fetch NonAdminBackupStorageLocation") + return ctrl.Result{}, err + } + if err = r.Delete(ctx, &backupStorageLocation); err != nil { + logger.Error(err, "Failed to delete orphan BackupStorageLocation", constant.NameString, backupStorageLocation.Name) + return ctrl.Result{}, err + } + logger.V(1).Info("orphan BackupStorageLocation deleted", constant.NameString, backupStorageLocation.Name) + } + } + + veleroBackupList := &velerov1.BackupList{} + if err := r.List(ctx, veleroBackupList, client.InNamespace(r.OADPNamespace), labelSelector); err != nil { + logger.Error(err, "Unable to fetch Backups in OADP namespace") + return ctrl.Result{}, err + } + for _, backup := range veleroBackupList.Items { + if !function.CheckLabelAnnotationValueIsValid(backup.GetLabels(), constant.NabOriginNACUUIDLabel) { + logger.V(1).Info("Backup does not have required label", constant.NameString, backup.Name) + // TODO delete? + continue + } + annotations := backup.GetAnnotations() + if !function.CheckVeleroBackupAnnotations(&backup) { + logger.V(1).Info("Backup does not have required annotations", constant.NameString, backup.Name) + // TODO delete? + continue + } + nab := &nacv1alpha1.NonAdminBackup{} + err := r.Get(ctx, types.NamespacedName{ + Name: annotations[constant.NabOriginNameAnnotation], + Namespace: annotations[constant.NabOriginNamespaceAnnotation], + }, nab) + if err != nil { + if !apierrors.IsNotFound(err) { + logger.Error(err, "Unable to fetch NonAdminBackup") + return ctrl.Result{}, err + } + if err = r.Delete(ctx, &backup); err != nil { + logger.Error(err, "Failed to delete orphan backup", constant.NameString, backup.Name) + return ctrl.Result{}, err + } + logger.V(1).Info("orphan Backup deleted", constant.NameString, backup.Name) + } + } + + veleroRestoreList := &velerov1.RestoreList{} + if err := r.List(ctx, veleroRestoreList, client.InNamespace(r.OADPNamespace), labelSelector); err != nil { + logger.Error(err, "Unable to fetch Restores in OADP namespace") + return ctrl.Result{}, err + } + for _, restore := range veleroRestoreList.Items { + if !function.CheckLabelAnnotationValueIsValid(restore.GetLabels(), constant.NarOriginNACUUIDLabel) { + logger.V(1).Info("Restore does not have required label", constant.NameString, restore.Name) + // TODO delete? + continue + } + annotations := restore.GetAnnotations() + if !function.CheckVeleroRestoreAnnotations(&restore) { + logger.V(1).Info("Restore does not have required annotations", constant.NameString, restore.Name) + // TODO delete? + continue + } + nar := &nacv1alpha1.NonAdminRestore{} + err := r.Get(ctx, types.NamespacedName{ + Name: annotations[constant.NarOriginNameAnnotation], + Namespace: annotations[constant.NarOriginNamespaceAnnotation], + }, nar) + if err != nil { + if !apierrors.IsNotFound(err) { + logger.Error(err, "Unable to fetch NonAdminRestore") + return ctrl.Result{}, err + } + if err = r.Delete(ctx, &restore); err != nil { + logger.Error(err, "Failed to delete orphan Restore", constant.NameString, restore.Name) + return ctrl.Result{}, err + } + logger.V(1).Info("orphan Restore deleted", constant.NameString, restore.Name) + } + } + + logger.V(1).Info("Garbage Collector Reconcile end") + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *GarbageCollectorReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + Named("nonadmingarbagecollector"). + WithLogConstructor(func(_ *reconcile.Request) logr.Logger { + return logr.New(ctrl.Log.GetSink().WithValues("controller", "nonadmingarbagecollector")) + }). + WatchesRawSource(&source.PeriodicalSource{Frequency: r.Frequency}). + Complete(r) +} diff --git a/internal/controller/garbagecollector_controller_test.go b/internal/controller/garbagecollector_controller_test.go new file mode 100644 index 0000000..9f7f20b --- /dev/null +++ b/internal/controller/garbagecollector_controller_test.go @@ -0,0 +1,253 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/migtools/oadp-non-admin/internal/common/constant" +) + +type garbageCollectorFullReconcileScenario struct { + backups int + restores int + orphanBackups int + orphanRestores int + errorLogs int +} + +const fakeUUID = "12345678-4321-1234-4321-123456789abc" + +func buildTestBackup(namespace string, name string, nonAdminNamespace string) *velerov1.Backup { + return &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: map[string]string{ + constant.OadpLabel: constant.OadpLabelValue, + constant.ManagedByLabel: constant.ManagedByLabelValue, + constant.NabOriginNACUUIDLabel: fakeUUID, + }, + Annotations: map[string]string{ + constant.NabOriginNamespaceAnnotation: nonAdminNamespace, + constant.NabOriginNameAnnotation: "non-existent", + }, + }, + Spec: velerov1.BackupSpec{}, + } +} + +func buildTestRestore(namespace string, name string, nonAdminNamespace string) *velerov1.Restore { + return &velerov1.Restore{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: map[string]string{ + constant.OadpLabel: constant.OadpLabelValue, + constant.ManagedByLabel: constant.ManagedByLabelValue, + constant.NarOriginNACUUIDLabel: fakeUUID, + }, + Annotations: map[string]string{ + constant.NarOriginNamespaceAnnotation: nonAdminNamespace, + constant.NarOriginNameAnnotation: "non-existent", + }, + }, + Spec: velerov1.RestoreSpec{}, + } +} + +var _ = ginkgo.Describe("Test full reconcile loop of GarbageCollector Controller", func() { + var ( + ctx context.Context + cancel context.CancelFunc + nonAdminNamespace string + oadpNamespace string + counter int + ) + + ginkgo.BeforeEach(func() { + counter++ + nonAdminNamespace = fmt.Sprintf("test-garbage-collector-reconcile-full-%v", counter) + oadpNamespace = nonAdminNamespace + "-oadp" + }) + + ginkgo.AfterEach(func() { + gomega.Expect(deleteTestNamespaces(ctx, nonAdminNamespace, oadpNamespace)).To(gomega.Succeed()) + + cancel() + + // wait manager shutdown + gomega.Eventually(func() (bool, error) { + logOutput := ginkgo.CurrentSpecReport().CapturedGinkgoWriterOutput + shutdownlog := "INFO Wait completed, proceeding to shutdown the manager" + return strings.Contains(logOutput, shutdownlog) && strings.Count(logOutput, shutdownlog) == 1, nil + }, 5*time.Second, 1*time.Second).Should(gomega.BeTrue()) + }) + + ginkgo.DescribeTable("Reconcile triggered by BackupStorageLocation Create event", + func(scenario garbageCollectorFullReconcileScenario) { + ctx, cancel = context.WithCancel(context.Background()) + + gomega.Expect(createTestNamespaces(ctx, nonAdminNamespace, oadpNamespace)).To(gomega.Succeed()) + + for index := range scenario.backups { + backup := buildTestBackup(oadpNamespace, fmt.Sprintf("test-backup-%v", index), nonAdminNamespace) + backup.Labels = map[string]string{} + backup.Annotations = map[string]string{} + gomega.Expect(k8sClient.Create(ctx, backup)).To(gomega.Succeed()) + } + for index := range scenario.restores { + restore := buildTestRestore(oadpNamespace, fmt.Sprintf("test-restore-%v", index), nonAdminNamespace) + restore.Labels = map[string]string{} + restore.Annotations = map[string]string{} + gomega.Expect(k8sClient.Create(ctx, restore)).To(gomega.Succeed()) + } + for index := range scenario.orphanBackups { + gomega.Expect(k8sClient.Create(ctx, buildTestBackup(oadpNamespace, fmt.Sprintf("test-garbage-collector-backup-%v", index), nonAdminNamespace))).To(gomega.Succeed()) + } + for index := range scenario.orphanRestores { + gomega.Expect(k8sClient.Create(ctx, buildTestRestore(oadpNamespace, fmt.Sprintf("test-garbage-collector-restore-%v", index), nonAdminNamespace))).To(gomega.Succeed()) + } + + backupsInOADPNamespace := &velerov1.BackupList{} + gomega.Expect(k8sClient.List(ctx, backupsInOADPNamespace, client.InNamespace(oadpNamespace))).To(gomega.Succeed()) + gomega.Expect(backupsInOADPNamespace.Items).To(gomega.HaveLen(scenario.backups + scenario.orphanBackups)) + + restoresInOADPNamespace := &velerov1.RestoreList{} + gomega.Expect(k8sClient.List(ctx, restoresInOADPNamespace, client.InNamespace(oadpNamespace))).To(gomega.Succeed()) + gomega.Expect(restoresInOADPNamespace.Items).To(gomega.HaveLen(scenario.restores + scenario.orphanRestores)) + + k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: k8sClient.Scheme(), + Cache: cache.Options{ + DefaultNamespaces: map[string]cache.Config{ + nonAdminNamespace: {}, + oadpNamespace: {}, + }, + }, + }) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + err = (&GarbageCollectorReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + OADPNamespace: oadpNamespace, + Frequency: 2 * time.Second, + }).SetupWithManager(k8sManager) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + go func() { + defer ginkgo.GinkgoRecover() + err = k8sManager.Start(ctx) + gomega.Expect(err).ToNot(gomega.HaveOccurred(), "failed to run manager") + }() + // wait manager start + gomega.Eventually(func() (bool, error) { + logOutput := ginkgo.CurrentSpecReport().CapturedGinkgoWriterOutput + startUpLog := `INFO Starting workers {"controller": "nonadmingarbagecollector", "worker count": 1}` + return strings.Contains(logOutput, startUpLog) && + strings.Count(logOutput, startUpLog) == 1, nil + }, 5*time.Second, 1*time.Second).Should(gomega.BeTrue()) + + go func() { + defer ginkgo.GinkgoRecover() + for index := range 5 { + bsl := &velerov1.BackupStorageLocation{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("test-garbage-collector-bsl-%v", index), + Namespace: oadpNamespace, + Labels: map[string]string{ + constant.OadpLabel: constant.OadpLabelValue, + constant.ManagedByLabel: constant.ManagedByLabelValue, + constant.NabslOriginNACUUIDLabel: fakeUUID, + }, + Annotations: map[string]string{ + constant.NabslOriginNamespaceAnnotation: nonAdminNamespace, + constant.NabslOriginNameAnnotation: "non-existent", + }, + }, + Spec: velerov1.BackupStorageLocationSpec{ + StorageType: velerov1.StorageType{ + ObjectStorage: &velerov1.ObjectStorageLocation{ + Bucket: "example-bucket", + Prefix: "test", + }, + }, + }, + } + gomega.Expect(k8sClient.Create(ctx, bsl)).To(gomega.Succeed()) + time.Sleep(1 * time.Second) + } + }() + + go func() { + defer ginkgo.GinkgoRecover() + for index := range 3 { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("test-garbage-collector-secret-%v", index), + Namespace: oadpNamespace, + Labels: map[string]string{ + constant.OadpLabel: constant.OadpLabelValue, + constant.ManagedByLabel: constant.ManagedByLabelValue, + constant.NabslOriginNACUUIDLabel: fakeUUID, + }, + Annotations: map[string]string{ + constant.NabslOriginNamespaceAnnotation: nonAdminNamespace, + constant.NabslOriginNameAnnotation: "non-existent", + }, + }, + } + gomega.Expect(k8sClient.Create(ctx, secret)).To(gomega.Succeed()) + time.Sleep(1 * time.Second) + } + }() + + time.Sleep(8 * time.Second) + gomega.Expect(strings.Count(ginkgo.CurrentSpecReport().CapturedGinkgoWriterOutput, "orphan Secret deleted")).Should(gomega.Equal(3)) + gomega.Expect(strings.Count(ginkgo.CurrentSpecReport().CapturedGinkgoWriterOutput, "orphan BackupStorageLocation deleted")).Should(gomega.Equal(5)) + gomega.Expect(strings.Count(ginkgo.CurrentSpecReport().CapturedGinkgoWriterOutput, "orphan Backup deleted")).Should(gomega.Equal(scenario.orphanBackups)) + gomega.Expect(strings.Count(ginkgo.CurrentSpecReport().CapturedGinkgoWriterOutput, "orphan Restore deleted")).Should(gomega.Equal(scenario.orphanRestores)) + gomega.Expect(strings.Count(ginkgo.CurrentSpecReport().CapturedGinkgoWriterOutput, "Garbage Collector Reconcile start")).Should(gomega.Equal(5)) + gomega.Expect(strings.Count(ginkgo.CurrentSpecReport().CapturedGinkgoWriterOutput, "ERROR")).Should(gomega.Equal(scenario.errorLogs)) + + gomega.Expect(k8sClient.List(ctx, backupsInOADPNamespace, client.InNamespace(oadpNamespace))).To(gomega.Succeed()) + gomega.Expect(backupsInOADPNamespace.Items).To(gomega.HaveLen(scenario.backups)) + + gomega.Expect(k8sClient.List(ctx, restoresInOADPNamespace, client.InNamespace(oadpNamespace))).To(gomega.Succeed()) + gomega.Expect(restoresInOADPNamespace.Items).To(gomega.HaveLen(scenario.restores)) + }, + ginkgo.Entry("Should delete orphaned Velero resources and then watch them periodically", garbageCollectorFullReconcileScenario{ + backups: 2, + restores: 3, + orphanBackups: 4, + orphanRestores: 1, + }), + ) +}) diff --git a/internal/source/periodical_source.go b/internal/source/periodical_source.go new file mode 100644 index 0000000..9175ca3 --- /dev/null +++ b/internal/source/periodical_source.go @@ -0,0 +1,41 @@ +/* +Copyright 2024. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package source contains all event sources of the project +package source + +import ( + "context" + "time" + + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/util/workqueue" + ctrl "sigs.k8s.io/controller-runtime" +) + +// PeriodicalSource will periodically add an empty object to controller queue +type PeriodicalSource struct { + Frequency time.Duration +} + +// Start periodically adds an empty object to queue +func (p PeriodicalSource) Start(ctx context.Context, q workqueue.RateLimitingInterface) error { //nolint:unparam // object must implement function with this signature + go wait.Until(func() { + q.Add(ctrl.Request{}) + }, p.Frequency, ctx.Done()) + + return nil +}