Repository: kubesphere/ks-devops
Pull request #542 (2022-04-12 07:26:51)

Add Argo CD application cascade deletion support
<!-- Thanks for sending a pull request! Here are some tips for you:

1. If you want **faster** PR reviews, read how: https://github.com/kubesphere/community/blob/master/developer-guide/development/the-pr-author-guide-to-getting-through-code-review.md
2. In case you want to know how your PR got reviewed, read: https://github.com/kubesphere/community/blob/master/developer-guide/development/code-review-guide.md
3. Here are some coding conventions followed by the KubeSphere community: https://github.com/kubesphere/community/blob/master/developer-guide/development/coding-conventions.md
4. Additional open-source best practice: https://github.com/LinuxSuRen/open-source-best-practice
-->

### What type of PR is this?
<!-- 
Add one of the following kinds:
/kind bug
/kind cleanup
/kind documentation
/kind feature
/kind design
/kind chore

Optionally add one or more of the following kinds if applicable:
/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->
/kind feature

### What this PR does / why we need it:

### Which issue(s) this PR fixes:
<!--
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
_If PR is about `failing-tests or flakes`, please post the related issues/tests in a comment and do not use `Fixes`_*
Please leave it or change # to be None if there is no corresponding issue that exists
-->
Fixes #

### Special notes for reviewers:
<!--
You can use the following command to let the DevOps SIG members help you to review your PR.
/cc @kubesphere/sig-devops 
And please avoid cc any individual.
-->
Please check the following list before waiting reviewers:

- [ ] Already committed the CRD files to [the Helm Chart](https://github.com/kubesphere-sigs/ks-devops-helm-chart/) if you created some new CRDs
- [ ] Already [added the permission](https://github.com/kubesphere/ks-installer/blob/9e063b085a0e43fdb3d0d9e3e7f4149146f14b9c/roles/ks-core/prepare/files/ks-init/role-templates.yaml) for the new API
- [ ] Already added the RBAC markers for the new controllers

### Does this PR introduce a user-facing change??
<!--
If no, just write "None" in the release-note block below.
If yes, a release note is required:
Enter your extended-release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".

Please keep the note be same as your PR title if you believe it should be in the release notes.
-->
```release-note
None
```


ks-ci-bot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubesphere/ks-devops/pull/542#" title="Author self-approved">LinuxSuRen</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubesphere%2Fks-devops).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubesphere/ks-devops/blob/master/OWNERS)~~ [LinuxSuRen]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->

Patch:
diff --git a/controllers/argocd/application_controller.go b/controllers/argocd/application_controller.go
index 1ffb3c5..cd04647 100644
--- a/controllers/argocd/application_controller.go
+++ b/controllers/argocd/application_controller.go
@@ -25,11 +25,15 @@ import (
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/tools/record"
+	"k8s.io/client-go/util/retry"
 	"kubesphere.io/devops/pkg/api/gitops/v1alpha1"
 	"kubesphere.io/devops/pkg/utils/k8sutil"
+	"reflect"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/event"
 	"sigs.k8s.io/controller-runtime/pkg/predicate"
+	"strings"
 )
 
 //+kubebuilder:rbac:groups=gitops.kubesphere.io,resources=applications,verbs=get;list;update
@@ -95,6 +99,7 @@ func (r *ApplicationReconciler) reconcileArgoApplication(app *v1alpha1.Applicati
 
 		if err == nil {
 			k8sutil.RemoveFinalizer(&app.ObjectMeta, v1alpha1.ApplicationFinalizerName)
+			k8sutil.RemoveFinalizer(&app.ObjectMeta, v1alpha1.ArgoCDResourcesFinalizer)
 			err = r.Update(context.TODO(), app)
 		}
 		return
@@ -153,7 +158,20 @@ func (r *ApplicationReconciler) reconcileArgoApplication(app *v1alpha1.Applicati
 			var newArgoApp *unstructured.Unstructured
 			if newArgoApp, err = createUnstructuredApplication(app); err == nil {
 				argoApp.Object["spec"] = newArgoApp.Object["spec"]
-				err = r.Update(ctx, argoApp)
+				argoApp.SetFinalizers(newArgoApp.GetFinalizers())
+				err = retry.RetryOnConflict(retry.DefaultRetry, func() (err error) {
+					latestArgoApp := createBareArgoCDApplicationObject()
+					if err = r.Get(context.Background(), types.NamespacedName{
+						Namespace: argoApp.GetNamespace(),
+						Name:      argoApp.GetName(),
+					}, latestArgoApp); err != nil {
+						return
+					}
+
+					argoApp.SetResourceVersion(latestArgoApp.GetResourceVersion())
+					err = r.Update(ctx, argoApp)
+					return
+				})
 			}
 		}
 	}
@@ -185,6 +203,17 @@ func createUnstructuredApplication(app *v1alpha1.Application) (result *unstructu
 	newArgoApp.SetName(app.GetName())
 	newArgoApp.SetNamespace(app.GetNamespace())
 
+	// copy all potential finalizers
+	finalizers := app.GetFinalizers()
+	targetFinalizers := make([]string, 0)
+	for i := range finalizers {
+		finalizer := finalizers[i]
+		if strings.HasSuffix(finalizer, "argocd.argoproj.io") {
+			targetFinalizers = append(targetFinalizers, finalizer)
+		}
+	}
+	newArgoApp.SetFinalizers(targetFinalizers)
+
 	if err := SetNestedField(newArgoApp.Object, argoApp.Spec, "spec"); err != nil {
 		return nil, err
 	}
@@ -247,13 +276,34 @@ func (r *ApplicationReconciler) GetGroupName() string {
 	return controllerGroupName
 }
 
+type finalizersChangedPredicate struct {
+	predicate.Funcs
+}
+
+// Update implements default UpdateEvent filter for validating finalizers change
+func (finalizersChangedPredicate) Update(e event.UpdateEvent) bool {
+	if e.MetaOld == nil {
+		return false
+	}
+	if e.ObjectOld == nil {
+		return false
+	}
+	if e.ObjectNew == nil {
+		return false
+	}
+	if e.MetaNew == nil {
+		return false
+	}
+	return !reflect.DeepEqual(e.MetaNew.GetFinalizers(), e.MetaOld.GetFinalizers())
+}
+
 // SetupWithManager setups the reconciler with a manager
 // setup the logger, recorder
 func (r *ApplicationReconciler) SetupWithManager(mgr ctrl.Manager) error {
 	r.log = ctrl.Log.WithName(r.GetName())
 	r.recorder = mgr.GetEventRecorderFor(r.GetName())
 	return ctrl.NewControllerManagedBy(mgr).
-		WithEventFilter(predicate.GenerationChangedPredicate{}).
+		WithEventFilter(predicate.Or(predicate.GenerationChangedPredicate{}, finalizersChangedPredicate{})).
 		For(&v1alpha1.Application{}).
 		Complete(r)
 }
diff --git a/pkg/api/gitops/v1alpha1/constants.go b/pkg/api/gitops/v1alpha1/constants.go
index 4ea92a4..861cae9 100644
--- a/pkg/api/gitops/v1alpha1/constants.go
+++ b/pkg/api/gitops/v1alpha1/constants.go
@@ -28,3 +28,6 @@ const (
 
 // ApplicationFinalizerName is the name of PipelineRun finalizer
 const ApplicationFinalizerName = "application." + GroupName
+
+// ArgoCDResourcesFinalizer is the name of Argo CD resource finalizer
+const ArgoCDResourcesFinalizer = "resources-finalizer.argocd.argoproj.io"
diff --git a/pkg/kapis/gitops/v1alpha1/argocd/handler.go b/pkg/kapis/gitops/v1alpha1/argocd/handler.go
index 6d5c1b9..3a51257 100644
--- a/pkg/kapis/gitops/v1alpha1/argocd/handler.go
+++ b/pkg/kapis/gitops/v1alpha1/argocd/handler.go
@@ -28,6 +28,7 @@ import (
 	"kubesphere.io/devops/pkg/config"
 	"kubesphere.io/devops/pkg/kapis/common"
 	"kubesphere.io/devops/pkg/models/resources/v1alpha3"
+	"kubesphere.io/devops/pkg/utils/k8sutil"
 	"net/http"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 )
@@ -171,14 +172,31 @@ func (h *handler) updateOperation(namespace, name string, operation *v1alpha1.Op
 func (h *handler) delApplication(req *restful.Request, res *restful.Response) {
 	namespace := common.GetPathParameter(req, common.NamespacePathParameter)
 	name := common.GetPathParameter(req, pathParameterApplication)
+	cascade := common.GetQueryParameter(req, cascadeQueryParam)
 
+	ctx := context.Background()
 	application := &v1alpha1.Application{}
-	err := h.Get(context.Background(), types.NamespacedName{
+	objectKey := types.NamespacedName{
 		Namespace: namespace,
 		Name:      name,
-	}, application)
+	}
+	err := h.Get(ctx, objectKey, application)
 	if err == nil {
-		err = h.Delete(context.Background(), application)
+		// add the Argo CD resources finalizer if cascade is true
+		if cascade == "true" {
+			if k8sutil.AddFinalizer(&application.ObjectMeta, v1alpha1.ArgoCDResourcesFinalizer) {
+				if err = h.Update(ctx, application); err != nil {
+					common.Response(req, res, application, err)
+					return
+				}
+
+				if err = h.Get(ctx, objectKey, application); err != nil {
+					common.Response(req, res, application, err)
+					return
+				}
+			}
+		}
+		err = h.Delete(ctx, application)
 	}
 	common.Response(req, res, application, err)
 }
diff --git a/pkg/kapis/gitops/v1alpha1/argocd/route.go b/pkg/kapis/gitops/v1alpha1/argocd/route.go
index 05d0e69..d31f697 100644
--- a/pkg/kapis/gitops/v1alpha1/argocd/route.go
+++ b/pkg/kapis/gitops/v1alpha1/argocd/route.go
@@ -29,6 +29,9 @@ var (
 	pathParameterApplication = restful.PathParameter("application", "The application name")
 	syncStatusQueryParam     = restful.QueryParameter("syncStatus", `Filter by sync status. Available values: "Unknown", "Synced" and "OutOfSync"`)
 	healthStatusQueryParam   = restful.QueryParameter("healthStatus", `Filter by health status. Available values: "Unknown", "Progressing", "Healthy", "Suspended", "Degraded" and "Missing"`)
+	cascadeQueryParam        = restful.QueryParameter("cascade",
+		"Delete both the app and its resources, rather than only the application if cascade is true").
+		DefaultValue("false").DataType("bool")
 )
 
 // ApplicationPageResult is the model of page result of Applications.
@@ -105,6 +108,7 @@ func RegisterRoutes(service *restful.WebService, options *common.Options, argoOp
 		To(handler.delApplication).
 		Param(common.NamespacePathParameter).
 		Param(pathParameterApplication).
+		Param(cascadeQueryParam).
 		Doc("Delete a particular application").
 		Returns(http.StatusOK, api.StatusOK, v1alpha1.Application{}))
 


Test patch:
diff --git a/controllers/argocd/application_controller_test.go b/controllers/argocd/application_controller_test.go
index b03b2f6..273651f 100644
--- a/controllers/argocd/application_controller_test.go
+++ b/controllers/argocd/application_controller_test.go
@@ -27,6 +27,8 @@ import (
 	"kubesphere.io/devops/pkg/api/gitops/v1alpha1"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"sigs.k8s.io/controller-runtime/pkg/event"
+	"sigs.k8s.io/controller-runtime/pkg/predicate"
 	"testing"
 )
 
@@ -194,3 +196,96 @@ func TestApplicationReconciler_reconcileArgoApplication(t *testing.T) {
 		})
 	}
 }
+
+func Test_finalizersChangedPredicate_Update(t *testing.T) {
+	type fields struct {
+		Funcs predicate.Funcs
+	}
+	type args struct {
+		e event.UpdateEvent
+	}
+	tests := []struct {
+		name   string
+		fields fields
+		args   args
+		want   bool
+	}{{
+		name: "different finalizers",
+		args: args{
+			e: event.UpdateEvent{
+				MetaOld: &metav1.ObjectMeta{
+					Finalizers: []string{"b"},
+				},
+				ObjectOld: &v1alpha1.Application{},
+				MetaNew: &metav1.ObjectMeta{
+					Finalizers: []string{"a"},
+				},
+				ObjectNew: &v1alpha1.Application{},
+			},
+		},
+		want: true,
+	}, {
+		name: "different order with the finalizers",
+		args: args{
+			e: event.UpdateEvent{
+				MetaOld: &metav1.ObjectMeta{
+					Finalizers: []string{"b", "a"},
+				},
+				ObjectOld: &v1alpha1.Application{},
+				MetaNew: &metav1.ObjectMeta{
+					Finalizers: []string{"a", "b"},
+				},
+				ObjectNew: &v1alpha1.Application{},
+			},
+		},
+		want: true,
+	}, {
+		name: "ObjectOld is nil",
+		args: args{
+			e: event.UpdateEvent{
+				MetaOld:   &metav1.ObjectMeta{},
+				MetaNew:   &metav1.ObjectMeta{},
+				ObjectNew: &v1alpha1.Application{},
+			},
+		},
+		want: false,
+	}, {
+		name: "MetaNew is nil",
+		args: args{
+			e: event.UpdateEvent{
+				ObjectOld: &v1alpha1.Application{},
+				MetaOld:   &metav1.ObjectMeta{},
+				ObjectNew: &v1alpha1.Application{},
+			},
+		},
+		want: false,
+	}, {
+		name: "ObjectNew is nil",
+		args: args{
+			e: event.UpdateEvent{
+				MetaOld:   &metav1.ObjectMeta{},
+				ObjectOld: &v1alpha1.Application{},
+				MetaNew:   &metav1.ObjectMeta{},
+			},
+		},
+		want: false,
+	}, {
+		name: "metaOld is nil",
+		args: args{
+			e: event.UpdateEvent{
+				ObjectOld: &v1alpha1.Application{},
+				MetaNew:   &metav1.ObjectMeta{},
+				ObjectNew: &v1alpha1.Application{},
+			},
+		},
+		want: false,
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			fi := finalizersChangedPredicate{
+				Funcs: tt.fields.Funcs,
+			}
+			assert.Equalf(t, tt.want, fi.Update(tt.args.e), "Update(%v)", tt.args.e)
+		})
+	}
+}
diff --git a/pkg/kapis/gitops/v1alpha1/argocd/route_test.go b/pkg/kapis/gitops/v1alpha1/argocd/route_test.go
index b6b2d28..b59285d 100644
--- a/pkg/kapis/gitops/v1alpha1/argocd/route_test.go
+++ b/pkg/kapis/gitops/v1alpha1/argocd/route_test.go
@@ -192,6 +192,25 @@ func TestAPIs(t *testing.T) {
 			assert.Equal(t, "app", name)
 			assert.Nil(t, err)
 		},
+	}, {
+		name: "delete an application by cascade",
+		request: request{
+			method: http.MethodDelete,
+			uri:    "/namespaces/ns/applications/app?cascade=true",
+		},
+		k8sclient:    fake.NewFakeClientWithScheme(schema, app.DeepCopy()),
+		responseCode: http.StatusOK,
+		verify: func(t *testing.T, body []byte) {
+			list := &unstructured.Unstructured{}
+			err := yaml.Unmarshal(body, list)
+			assert.Nil(t, err)
+
+			name, _, err := unstructured.NestedString(list.Object, "metadata", "name")
+			assert.Equal(t, "app", name)
+			finalizers, _, err := unstructured.NestedSlice(list.Object, "metadata", "finalizers")
+			assert.Equal(t, []interface{}{"resources-finalizer.argocd.argoproj.io"}, finalizers)
+			assert.Nil(t, err)
+		},
 	}, {
 		name: "create an application",
 		request: request{