diff --git a/controllers/nstemplatetierrevisioncleanup/nstemplatetier_revision_cleanup_controller.go b/controllers/nstemplatetierrevisioncleanup/nstemplatetier_revision_cleanup_controller.go new file mode 100644 index 000000000..8330f0752 --- /dev/null +++ b/controllers/nstemplatetierrevisioncleanup/nstemplatetier_revision_cleanup_controller.go @@ -0,0 +1,150 @@ +package nstemplatetierrevisioncleanup + +import ( + "context" + "fmt" + "time" + + "sigs.k8s.io/controller-runtime/pkg/log" + + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/host-operator/controllers/nstemplatetier" + "github.com/redhat-cop/operator-utils/pkg/util" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + ctrl "sigs.k8s.io/controller-runtime" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +const minTTRAge = 30 * time.Second + +// SetupWithManager sets up the controller with the Manager. +func (r *Reconciler) SetupWithManager(mgr manager.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&toolchainv1alpha1.TierTemplateRevision{}). + Complete(r) +} + +type Reconciler struct { + Client runtimeclient.Client +} + +func (r *Reconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + // fetch the TTR + ttr := &toolchainv1alpha1.TierTemplateRevision{} + if err := r.Client.Get(ctx, request.NamespacedName, ttr); err != nil { + if errors.IsNotFound(err) { + logger.Info("TierTemplateRevision not found") + return reconcile.Result{}, nil + } + + // Error reading the object - requeue the request. + return reconcile.Result{}, fmt.Errorf("unable to get the current TierTemplateRevision: %w", err) + } + + // if the TTR has deletion time stamp, return, no need to do anything as its already marked for deleteion + if util.IsBeingDeleted(ttr) { + logger.Info("TierTemplateRevision already marked for deletion") + return reconcile.Result{}, nil + } + //there is no point in fetching the NStemplateTier, if the TTR is just created, + // as it is a new TTR created due to changes in NSTemplate Tier, + // and the references are still being updated to nstemplatetier + //get the tier template revision creation time stamp and the duration + timeSinceCreation := time.Since(ttr.GetCreationTimestamp().Time) + + //the ttr age should be greater than 30 seconds + if timeSinceCreation < minTTRAge { + requeueAfter := minTTRAge - timeSinceCreation + logger.Info("the TierTemplateRevision is not old enough", "requeue-after", requeueAfter) + return reconcile.Result{RequeueAfter: requeueAfter}, nil + } + + //check if there is tier-name label available + tierName, ok := ttr.GetLabels()[toolchainv1alpha1.TierLabelKey] + if !ok { + //if tier-name label is not found we should delete the TTR + return reconcile.Result{}, r.deleteTTR(ctx, ttr) + } + // fetch the related NSTemplateTier tier + tier := &toolchainv1alpha1.NSTemplateTier{} + if err := r.Client.Get(ctx, types.NamespacedName{ + Namespace: ttr.GetNamespace(), + Name: tierName, + }, tier); err != nil { + if errors.IsNotFound(err) { + // if there is no related nstemplate tier, we can delete the TTR directly + return reconcile.Result{}, r.deleteTTR(ctx, ttr) + } + // Error reading the object - requeue the request. + return reconcile.Result{}, fmt.Errorf("unable to get the current NSTemplateTier: %w", err) + } + + // let's make sure that we delete the TTR only if it's not used + isTTrUnused, err := r.verifyUnusedTTR(ctx, tier, ttr) + if err != nil { + return reconcile.Result{}, err + } + + // Delete the unused revision + if isTTrUnused { + return reconcile.Result{}, r.deleteTTR(ctx, ttr) + } + + return reconcile.Result{}, nil +} + +// verifyUnusedTTR function verifies that the TTR is not used (returns true if it's NOT used). +// this is done by: +// - checking the NSTemplateTier.status.revisions field, if the TTR is referenced there or not +// - checking if all Spaces are up-to-date. In case there are some outdated space, we could risk that the TTR is still being used +func (r *Reconciler) verifyUnusedTTR(ctx context.Context, nsTmplTier *toolchainv1alpha1.NSTemplateTier, + rev *toolchainv1alpha1.TierTemplateRevision) (bool, error) { + + logger := log.FromContext(ctx) + //check if the ttr name is present status.revisions + for _, ttStatusRev := range nsTmplTier.Status.Revisions { + if ttStatusRev == rev.Name { + logger.Info("the TierTemplateRevision is still being referenced in NSTemplateTier.Status.Revisions") + return false, nil + } + } + + // get the outdated matching label to list outdated spaces + matchOutdated, err := nstemplatetier.OutdatedTierSelector(nsTmplTier) + if err != nil { + return false, err + + } + // look-up all spaces associated with the NSTemplateTier which are outdated + spaces := &toolchainv1alpha1.SpaceList{} + if err := r.Client.List(ctx, spaces, runtimeclient.InNamespace(nsTmplTier.Namespace), + matchOutdated, runtimeclient.Limit(1)); err != nil { + return false, err + } + + //If there has been an update on nstemplatetier, it might be in a process to update all the spaces. + // so we need to check that there should be no outdated spaces. + if len(spaces.Items) > 0 { + logger.Info("there are still some spaces which are outdated") + return false, nil + } + return true, nil +} + +// deleteTTR function delete the TTR +func (r *Reconciler) deleteTTR(ctx context.Context, ttr *toolchainv1alpha1.TierTemplateRevision) error { + if err := r.Client.Delete(ctx, ttr); err != nil { + if errors.IsNotFound(err) { + return nil // was already deleted + } + return fmt.Errorf("unable to delete the current Tier Template Revision %s: %w", ttr.Name, err) + } + log.FromContext(ctx).Info("The TierTemplateRevision has been deleted") + return nil +} diff --git a/controllers/nstemplatetierrevisioncleanup/nstemplatetier_revision_cleanup_controller_test.go b/controllers/nstemplatetierrevisioncleanup/nstemplatetier_revision_cleanup_controller_test.go new file mode 100644 index 000000000..0dd7ac422 --- /dev/null +++ b/controllers/nstemplatetierrevisioncleanup/nstemplatetier_revision_cleanup_controller_test.go @@ -0,0 +1,289 @@ +package nstemplatetierrevisioncleanup_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/codeready-toolchain/api/api/v1alpha1" + toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1" + "github.com/codeready-toolchain/host-operator/controllers/nstemplatetierrevisioncleanup" + "github.com/codeready-toolchain/host-operator/pkg/apis" + tiertest "github.com/codeready-toolchain/host-operator/test/nstemplatetier" + "github.com/codeready-toolchain/host-operator/test/tiertemplaterevision" + "github.com/codeready-toolchain/toolchain-common/pkg/test" + spacetest "github.com/codeready-toolchain/toolchain-common/pkg/test/space" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/kubectl/pkg/scheme" + controllerruntime "sigs.k8s.io/controller-runtime" + runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +func TestTTRDeletionReconcile(t *testing.T) { + oldCreationTime := metav1.NewTime(time.Now().Add(-time.Minute)) + nsTemplateTier := tiertest.Base1nsTier(t, tiertest.CurrentBase1nsTemplates, tiertest.WithStatusRevisions()) + ttrName := nsTemplateTier.Spec.ClusterResources.TemplateRef + "-ttrcr" + ttr := newTTR(*nsTemplateTier, ttrName, oldCreationTime) + s := newSpace(nsTemplateTier) + t.Run("TTR Deleted Successfully", func(t *testing.T) { + //given + r, req, cl := prepareReconcile(t, ttr.Name, ttr, s, nsTemplateTier) + //when + res, err := r.Reconcile(context.TODO(), req) + //then + require.NoError(t, err) + require.Equal(t, controllerruntime.Result{}, res) + tiertemplaterevision.AssertThatTTRs(t, cl, nsTemplateTier.GetNamespace()).DoNotExist() + }) + + t.Run("TTR has deletion time Stamp", func(t *testing.T) { + //given + ttr.DeletionTimestamp = &metav1.Time{ + Time: time.Now(), + } + controllerutil.AddFinalizer(ttr, v1alpha1.FinalizerName) + r, req, cl := prepareReconcile(t, ttr.Name, ttr, s, nsTemplateTier) + //when + res, err := r.Reconcile(context.TODO(), req) + //then + require.NoError(t, err) + require.Equal(t, controllerruntime.Result{}, res) + tiertemplaterevision.AssertThatTTRs(t, cl, nsTemplateTier.GetNamespace()).ExistFor(nsTemplateTier.Name) + }) + + t.Run("the creation timestamp is less than 30 sec", func(t *testing.T) { + // given + ttr := newTTR(*nsTemplateTier, ttrName, metav1.NewTime(time.Now().Add(-29*time.Second))) + r, req, cl := prepareReconcile(t, ttr.Name, ttr, s, nsTemplateTier) + + // when + res, err := r.Reconcile(context.TODO(), req) + + // then + require.NoError(t, err) + require.LessOrEqual(t, res.RequeueAfter, time.Second) + tiertemplaterevision.AssertThatTTRs(t, cl, nsTemplateTier.GetNamespace()).ExistFor(nsTemplateTier.Name) + + }) + t.Run("ttr is still being referenced in status.revisions", func(t *testing.T) { + // given + ttr := newTTR(*nsTemplateTier, nsTemplateTier.Spec.ClusterResources.TemplateRef+"-ttr", oldCreationTime) + r, req, cl := prepareReconcile(t, ttr.Name, ttr, s, nsTemplateTier) + + // when + res, err := r.Reconcile(context.TODO(), req) + + // then + require.NoError(t, err) + require.Equal(t, controllerruntime.Result{}, res) + tiertemplaterevision.AssertThatTTRs(t, cl, nsTemplateTier.GetNamespace()).ExistFor(nsTemplateTier.Name) + + }) + + t.Run("spaces are still being updated", func(t *testing.T) { + // given + nsTemplateTier := tiertest.Base1nsTier(t, tiertest.CurrentBase1nsTemplates, tiertest.WithStatusRevisions()) + nsTemplateTier.Status.Revisions = map[string]string{ + "base1ns-code-123456new": "base1ns-code-123456new-ttr", + "base1ns-clusterresources-123456new": "base1ns-clusterresources-123456new-ttr", + } + ttr := newTTR(*nsTemplateTier, ttrName, oldCreationTime) + r, req, cl := prepareReconcile(t, ttr.Name, ttr, s, nsTemplateTier) + + // when + res, err := r.Reconcile(context.TODO(), req) + + // then + require.NoError(t, err) + require.Equal(t, controllerruntime.Result{}, res) + tiertemplaterevision.AssertThatTTRs(t, cl, nsTemplateTier.GetNamespace()).ExistFor(nsTemplateTier.Name) + }) + + t.Run("Failure", func(t *testing.T) { + deleteErr := "unable to delete the current Tier Template Revision base1ns-clusterresources-123456new-ttrcr: some error cannot delete" + nsTemplateTier := tiertest.Base1nsTier(t, tiertest.CurrentBase1nsTemplates, tiertest.WithStatusRevisions()) + ttr := newTTR(*nsTemplateTier, ttrName, oldCreationTime) + s := newSpace(nsTemplateTier) + t.Run("Error while deleting the TTR", func(t *testing.T) { + // given + ttr := newTTR(*nsTemplateTier, ttrName, oldCreationTime) + s := newSpace(nsTemplateTier) + r, req, cl := prepareReconcile(t, ttr.Name, ttr, s, nsTemplateTier) + cl.MockDelete = func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.DeleteOption) error { + return fmt.Errorf("some error cannot delete") + } + // when + res, err := r.Reconcile(context.TODO(), req) + + // then + require.EqualError(t, err, deleteErr) + require.Equal(t, controllerruntime.Result{}, res) + tiertemplaterevision.AssertThatTTRs(t, cl, nsTemplateTier.GetNamespace()).ExistFor(nsTemplateTier.Name) + }) + + t.Run("Is Not Found Error-already deleted, while deleting the TTR", func(t *testing.T) { + // given + ttr := newTTR(*nsTemplateTier, ttrName, oldCreationTime) + s := newSpace(nsTemplateTier) + r, req, cl := prepareReconcile(t, ttr.Name, ttr, s, nsTemplateTier) + cl.MockDelete = func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.DeleteOption) error { + return errors.NewNotFound(schema.GroupResource{}, ttr.Name) + } + + // when + res, err := r.Reconcile(context.TODO(), req) + + // then + require.NoError(t, err) + require.Equal(t, controllerruntime.Result{}, res) + }) + t.Run("error while getting revision", func(t *testing.T) { + r, req, cl := prepareReconcile(t, ttr.Name, ttr, s, nsTemplateTier) + cl.MockGet = func(ctx context.Context, key runtimeclient.ObjectKey, obj runtimeclient.Object, opts ...runtimeclient.GetOption) error { + return fmt.Errorf("some error cannot get") + } + // when + res, err := r.Reconcile(context.TODO(), req) + + // then + require.EqualError(t, err, "unable to get the current TierTemplateRevision: some error cannot get") + require.Equal(t, controllerruntime.Result{}, res) + tiertemplaterevision.AssertThatTTRs(t, cl, nsTemplateTier.GetNamespace()).ExistFor(nsTemplateTier.Name) + }) + + t.Run("error while getting NSTemplate Tier", func(t *testing.T) { + r, req, cl := prepareReconcile(t, ttr.Name, ttr, nsTemplateTier) + + cl.MockGet = func(ctx context.Context, key types.NamespacedName, obj runtimeclient.Object, opts ...runtimeclient.GetOption) error { + if _, ok := obj.(*toolchainv1alpha1.NSTemplateTier); ok { + return fmt.Errorf("mock error") + } + return cl.Client.Get(ctx, key, obj, opts...) + } + //when + _, err := r.Reconcile(context.TODO(), req) + //then + require.EqualError(t, err, "unable to get the current NSTemplateTier: mock error") + tiertemplaterevision.AssertThatTTRs(t, cl, nsTemplateTier.GetNamespace()).ExistFor(nsTemplateTier.Name) + + }) + t.Run("error while listing outdated spaces", func(t *testing.T) { + r, req, cl := prepareReconcile(t, ttr.Name, ttr, s, nsTemplateTier) + cl.MockList = func(ctx context.Context, list runtimeclient.ObjectList, opts ...runtimeclient.ListOption) error { + return fmt.Errorf("some error") + } + // when + res, err := r.Reconcile(context.TODO(), req) + + // then + require.EqualError(t, err, "some error") + require.Equal(t, controllerruntime.Result{}, res) + tiertemplaterevision.AssertThatTTRs(t, cl, nsTemplateTier.GetNamespace()).ExistFor(nsTemplateTier.Name) + }) + + t.Run("revision not found", func(t *testing.T) { + r, req, _ := prepareReconcile(t, "base") + //when + res, err := r.Reconcile(context.TODO(), req) + //then + require.NoError(t, err) + require.Equal(t, controllerruntime.Result{}, res) + + }) + t.Run("NSTemplate Tier not found - ttr gets deleted", func(t *testing.T) { + //given + r, req, cl := prepareReconcile(t, ttr.Name, ttr) + //when + res, err := r.Reconcile(context.TODO(), req) + //then + require.NoError(t, err) + require.Equal(t, controllerruntime.Result{}, res) + tiertemplaterevision.AssertThatTTRs(t, cl, nsTemplateTier.GetNamespace()).DoNotExist() + + }) + + t.Run("NSTemplate Tier not found, but error deleting ttr", func(t *testing.T) { + r, req, cl := prepareReconcile(t, ttr.Name, ttr) + cl.MockDelete = func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.DeleteOption) error { + return fmt.Errorf("some error cannot delete") + } + //when + res, err := r.Reconcile(context.TODO(), req) + //then + require.EqualError(t, err, deleteErr) + require.Equal(t, controllerruntime.Result{}, res) + tiertemplaterevision.AssertThatTTRs(t, cl, nsTemplateTier.GetNamespace()).ExistFor(nsTemplateTier.Name) + }) + t.Run("tier label not found - ttr gets deleted", func(t *testing.T) { + ttr := newTTR(*nsTemplateTier, ttrName, oldCreationTime) + ttr.Labels = map[string]string{} + r, req, cl := prepareReconcile(t, ttr.Name, ttr) + //when + res, err := r.Reconcile(context.TODO(), req) + //then + require.NoError(t, err) + require.Equal(t, controllerruntime.Result{}, res) + tiertemplaterevision.AssertThatTTRs(t, cl, nsTemplateTier.GetNamespace()).DoNotExist() + }) + + t.Run("tier label not found but error deleting ttr", func(t *testing.T) { + ttr := newTTR(*nsTemplateTier, ttrName, oldCreationTime) + ttr.Labels = map[string]string{} + r, req, cl := prepareReconcile(t, ttr.Name, ttr) + cl.MockDelete = func(ctx context.Context, obj runtimeclient.Object, opts ...runtimeclient.DeleteOption) error { + return fmt.Errorf("some error cannot delete") + } + //when + res, err := r.Reconcile(context.TODO(), req) + //then + require.EqualError(t, err, deleteErr) + require.Equal(t, controllerruntime.Result{}, res) + tiertemplaterevision.AssertThatTTRs(t, cl, nsTemplateTier.GetNamespace()).ExistFor(nsTemplateTier.Name) + }) + + }) + +} + +func prepareReconcile(t *testing.T, name string, initObjs ...runtimeclient.Object) (*nstemplatetierrevisioncleanup.Reconciler, reconcile.Request, *test.FakeClient) { + s := scheme.Scheme + err := apis.AddToScheme(s) + require.NoError(t, err) + cl := test.NewFakeClient(t, initObjs...) + r := &nstemplatetierrevisioncleanup.Reconciler{ + Client: cl, + } + return r, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: name, + Namespace: test.HostOperatorNs, + }, + }, cl +} +func newTTR(nsTTier toolchainv1alpha1.NSTemplateTier, name string, crtime metav1.Time) *toolchainv1alpha1.TierTemplateRevision { + labels := map[string]string{ + toolchainv1alpha1.TierLabelKey: nsTTier.Name, + } + ttr := &toolchainv1alpha1.TierTemplateRevision{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: test.HostOperatorNs, + Name: name, + Labels: labels, + CreationTimestamp: crtime, + }, + } + return ttr +} + +func newSpace(nsTTier *toolchainv1alpha1.NSTemplateTier) *toolchainv1alpha1.Space { + testSpace := spacetest.NewSpace(test.HostOperatorNs, "oddity1", + spacetest.WithTierNameAndHashLabelFor(nsTTier)) + return testSpace +} diff --git a/main.go b/main.go index cd8326cc1..fa1af4caa 100644 --- a/main.go +++ b/main.go @@ -6,14 +6,16 @@ import ( "net/http" "os" goruntime "runtime" + "time" + "sigs.k8s.io/controller-runtime/pkg/cache" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - "time" "github.com/codeready-toolchain/host-operator/controllers/deactivation" "github.com/codeready-toolchain/host-operator/controllers/masteruserrecord" "github.com/codeready-toolchain/host-operator/controllers/notification" "github.com/codeready-toolchain/host-operator/controllers/nstemplatetier" + "github.com/codeready-toolchain/host-operator/controllers/nstemplatetierrevisioncleanup" "github.com/codeready-toolchain/host-operator/controllers/socialevent" "github.com/codeready-toolchain/host-operator/controllers/space" "github.com/codeready-toolchain/host-operator/controllers/spacebindingcleanup" @@ -279,6 +281,12 @@ func main() { // nolint:gocyclo setupLog.Error(err, "unable to create controller", "controller", "NSTemplateTier") os.Exit(1) } + if err = (&nstemplatetierrevisioncleanup.Reconciler{ + Client: mgr.GetClient(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "NSTemplatTierRevisionCleanup") + os.Exit(1) + } if err := (&toolchainconfig.Reconciler{ Client: mgr.GetClient(), GetMembersFunc: commoncluster.GetMemberClusters,