Repository: kubesphere/ks-devops
Pull request #463 (2022-02-11 02:43:09)

Add Argo CD support to DevOpsProject CRD
<!-- 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:
This is part of #63 

In this PR, I put the `AppProject` into the `DevOpsProject`. So, `DevOpsProject` will be responsible to maintain `AppProject`.

### 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.
-->
This is the temporary image that you can test against this PR.
```
kubespheredev/devops-controller:dev-v3.2.1-35bf471
```

### 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/463#" 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":[]} -->
JohnNiang: /label tide/merge-method-squash
ks-ci-bot: @LinuxSuRen: PR needs rebase.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>
ks-ci-bot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubesphere/ks-devops/pull/463#pullrequestreview-879795674" title="LGTM">JohnNiang</a>*, *<a href="https://github.com/kubesphere/ks-devops/pull/463#" 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/cmd/controller/app/controllers.go b/cmd/controller/app/controllers.go
index 3f7f49b..94a4930 100644
--- a/cmd/controller/app/controllers.go
+++ b/cmd/controller/app/controllers.go
@@ -18,6 +18,7 @@ package app
 
 import (
 	"kubesphere.io/devops/controllers/addon"
+	"kubesphere.io/devops/controllers/argocd"
 	"kubesphere.io/devops/controllers/gitrepository"
 	"kubesphere.io/devops/controllers/jenkins/devopscredential"
 	"kubesphere.io/devops/controllers/jenkins/devopsproject"
@@ -39,9 +40,9 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/manager"
 )
 
-func addControllers(mgr manager.Manager, client k8s.Client, informerFactory informers.InformerFactory, devopsClient devops.Interface, jenkinsCore core.JenkinsCore, s3Client s3.Interface, s *options.DevOpsControllerManagerOptions, stopCh <-chan struct{}) error {
-	kubesphereInformer := informerFactory.KubeSphereSharedInformerFactory()
-
+func addControllers(mgr manager.Manager, client k8s.Client, informerFactory informers.InformerFactory,
+	devopsClient devops.Interface, jenkinsCore core.JenkinsCore, s3Client s3.Interface,
+	s *options.DevOpsControllerManagerOptions, stopCh <-chan struct{}) error {
 	if devopsClient == nil {
 		return errors.New("devopsClient should not be nil")
 	}
@@ -75,8 +76,34 @@ func addControllers(mgr manager.Manager, client k8s.Client, informerFactory info
 	}).SetupWithManager(mgr); err != nil {
 		return err
 	}
+	reconcilers := getAllControllers(mgr, client, informerFactory, devopsClient, jenkinsCore, s3Client, s)
+
+	// Add all controllers into manager.
+	for name, ok := range s.FeatureOptions.GetControllers() {
+		ctrl := reconcilers[name]
+		if ctrl == nil || !ok {
+			klog.V(4).Infof("%s is not going to run due to dependent component disabled.", name)
+			continue
+		}
+
+		if err := ctrl(mgr); err != nil {
+			klog.Error(err, "add controller to manager failed ", name)
+			return err
+		}
+	}
+	return nil
+}
+
+func getAllControllers(mgr manager.Manager, client k8s.Client, informerFactory informers.InformerFactory,
+	devopsClient devops.Interface, jenkinsCore core.JenkinsCore, s3Client s3.Interface,
+	s *options.DevOpsControllerManagerOptions) map[string]func(mgr manager.Manager) error {
+	kubesphereInformer := informerFactory.KubeSphereSharedInformerFactory()
+
+	argocdReconciler := &argocd.Reconciler{
+		Client: mgr.GetClient(),
+	}
 
-	reconcilers := map[string]func(mgr manager.Manager) error{
+	return map[string]func(mgr manager.Manager) error{
 		"gitrepository": func(mgr manager.Manager) error {
 			err := (&gitrepository.Reconciler{
 				Client: mgr.GetClient(),
@@ -145,20 +172,8 @@ func addControllers(mgr manager.Manager, client k8s.Client, informerFactory info
 				kubesphereInformer.Devops().V1alpha1().S2iBinaries(),
 				kubesphereInformer.Devops().V1alpha1().S2iRuns()))
 		},
+		argocdReconciler.GetGroupName(): func(mgr manager.Manager) error {
+			return argocdReconciler.SetupWithManager(mgr)
+		},
 	}
-
-	// Add all controllers into manager.
-	for name, ok := range s.FeatureOptions.GetControllers() {
-		ctrl := reconcilers[name]
-		if ctrl == nil || !ok {
-			klog.V(4).Infof("%s is not going to run due to dependent component disabled.", name)
-			continue
-		}
-
-		if err := ctrl(mgr); err != nil {
-			klog.Error(err, "add controller to manager failed ", name)
-			return err
-		}
-	}
-	return nil
 }
diff --git a/config/crd/bases/devops.kubesphere.io_devopsprojects.yaml b/config/crd/bases/devops.kubesphere.io_devopsprojects.yaml
index 948eb37..1831197 100644
--- a/config/crd/bases/devops.kubesphere.io_devopsprojects.yaml
+++ b/config/crd/bases/devops.kubesphere.io_devopsprojects.yaml
@@ -37,6 +37,246 @@ spec:
             type: object
           spec:
             description: DevOpsProjectSpec defines the desired state of DevOpsProject
+            properties:
+              argo:
+                description: Argo represents the Argo CD specification
+                properties:
+                  clusterResourceBlacklist:
+                    description: ClusterResourceBlacklist contains list of blacklisted
+                      cluster level resources
+                    items:
+                      description: GroupKind specifies a Group and a Kind, but does
+                        not force a version.  This is useful for identifying concepts
+                        during lookup stages without having partially valid types
+                      properties:
+                        group:
+                          type: string
+                        kind:
+                          type: string
+                      required:
+                      - group
+                      - kind
+                      type: object
+                    type: array
+                  clusterResourceWhitelist:
+                    description: ClusterResourceWhitelist contains list of whitelisted
+                      cluster level resources
+                    items:
+                      description: GroupKind specifies a Group and a Kind, but does
+                        not force a version.  This is useful for identifying concepts
+                        during lookup stages without having partially valid types
+                      properties:
+                        group:
+                          type: string
+                        kind:
+                          type: string
+                      required:
+                      - group
+                      - kind
+                      type: object
+                    type: array
+                  description:
+                    description: Description contains optional project description
+                    type: string
+                  destinations:
+                    description: Destinations contains list of destinations available
+                      for deployment
+                    items:
+                      description: ApplicationDestination holds information about
+                        the application's destination
+                      properties:
+                        name:
+                          description: Name is an alternate way of specifying the
+                            target cluster by its symbolic name
+                          type: string
+                        namespace:
+                          description: Namespace specifies the target namespace for
+                            the application's resources. The namespace will only be
+                            set for namespace-scoped resources that have not set a
+                            value for .metadata.namespace
+                          type: string
+                        server:
+                          description: Server specifies the URL of the target cluster
+                            and must be set to the Kubernetes control plane API
+                          type: string
+                      type: object
+                    type: array
+                  namespaceResourceBlacklist:
+                    description: NamespaceResourceBlacklist contains list of blacklisted
+                      namespace level resources
+                    items:
+                      description: GroupKind specifies a Group and a Kind, but does
+                        not force a version.  This is useful for identifying concepts
+                        during lookup stages without having partially valid types
+                      properties:
+                        group:
+                          type: string
+                        kind:
+                          type: string
+                      required:
+                      - group
+                      - kind
+                      type: object
+                    type: array
+                  namespaceResourceWhitelist:
+                    description: NamespaceResourceWhitelist contains list of whitelisted
+                      namespace level resources
+                    items:
+                      description: GroupKind specifies a Group and a Kind, but does
+                        not force a version.  This is useful for identifying concepts
+                        during lookup stages without having partially valid types
+                      properties:
+                        group:
+                          type: string
+                        kind:
+                          type: string
+                      required:
+                      - group
+                      - kind
+                      type: object
+                    type: array
+                  orphanedResources:
+                    description: OrphanedResources specifies if controller should
+                      monitor orphaned resources of apps in this project
+                    properties:
+                      ignore:
+                        description: Ignore contains a list of resources that are
+                          to be excluded from orphaned resources monitoring
+                        items:
+                          description: OrphanedResourceKey is a reference to a resource
+                            to be ignored from
+                          properties:
+                            group:
+                              type: string
+                            kind:
+                              type: string
+                            name:
+                              type: string
+                          type: object
+                        type: array
+                      warn:
+                        description: Warn indicates if warning condition should be
+                          created for apps which have orphaned resources
+                        type: boolean
+                    type: object
+                  roles:
+                    description: Roles are user defined RBAC roles associated with
+                      this project
+                    items:
+                      description: ProjectRole represents a role that has access to
+                        a project
+                      properties:
+                        description:
+                          description: Description is a description of the role
+                          type: string
+                        groups:
+                          description: Groups are a list of OIDC group claims bound
+                            to this role
+                          items:
+                            type: string
+                          type: array
+                        jwtTokens:
+                          description: JWTTokens are a list of generated JWT tokens
+                            bound to this role
+                          items:
+                            description: JWTToken holds the issuedAt and expiresAt
+                              values of a token
+                            properties:
+                              exp:
+                                format: int64
+                                type: integer
+                              iat:
+                                format: int64
+                                type: integer
+                              id:
+                                type: string
+                            required:
+                            - iat
+                            type: object
+                          type: array
+                        name:
+                          description: Name is a name for this role
+                          type: string
+                        policies:
+                          description: Policies Stores a list of casbin formatted
+                            strings that define access policies for the role in the
+                            project
+                          items:
+                            type: string
+                          type: array
+                      required:
+                      - name
+                      type: object
+                    type: array
+                  signatureKeys:
+                    description: SignatureKeys contains a list of PGP key IDs that
+                      commits in Git must be signed with in order to be allowed for
+                      sync
+                    items:
+                      description: SignatureKey is the specification of a key required
+                        to verify commit signatures with
+                      properties:
+                        keyID:
+                          description: The ID of the key in hexadecimal notation
+                          type: string
+                      required:
+                      - keyID
+                      type: object
+                    type: array
+                  sourceRepos:
+                    description: SourceRepos contains list of repository URLs which
+                      can be used for deployment
+                    items:
+                      type: string
+                    type: array
+                  syncWindows:
+                    description: SyncWindows controls when syncs can be run for apps
+                      in this project
+                    items:
+                      description: SyncWindow contains the kind, time, duration and
+                        attributes that are used to assign the syncWindows to apps
+                      properties:
+                        applications:
+                          description: Applications contains a list of applications
+                            that the window will apply to
+                          items:
+                            type: string
+                          type: array
+                        clusters:
+                          description: Clusters contains a list of clusters that the
+                            window will apply to
+                          items:
+                            type: string
+                          type: array
+                        duration:
+                          description: Duration is the amount of time the sync window
+                            will be open
+                          type: string
+                        kind:
+                          description: Kind defines if the window allows or blocks
+                            syncs
+                          type: string
+                        manualSync:
+                          description: ManualSync enables manual syncs when they would
+                            otherwise be blocked
+                          type: boolean
+                        namespaces:
+                          description: Namespaces contains a list of namespaces that
+                            the window will apply to
+                          items:
+                            type: string
+                          type: array
+                        schedule:
+                          description: Schedule is the time the window will begin,
+                            specified in cron format
+                          type: string
+                        timeZone:
+                          description: TimeZone of the sync that will be applied to
+                            the schedule
+                          type: string
+                      type: object
+                    type: array
+                type: object
             type: object
           status:
             description: DevOpsProjectStatus defines the observed state of DevOpsProject
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 9e195f5..a1d2588 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -14,6 +14,15 @@ rules:
   - get
   - list
   - watch
+- apiGroups:
+  - argoproj.io
+  resources:
+  - appprojects
+  verbs:
+  - create
+  - get
+  - list
+  - update
 - apiGroups:
   - argoproj.io
   resources:
@@ -52,6 +61,14 @@ rules:
   - get
   - patch
   - update
+- apiGroups:
+  - devops.kubesphere.io
+  resources:
+  - devopsprojects
+  verbs:
+  - get
+  - list
+  - update
 - apiGroups:
   - devops.kubesphere.io
   resources:
diff --git a/controllers/argocd/project_controller.go b/controllers/argocd/project_controller.go
new file mode 100644
index 0000000..30f971c
--- /dev/null
+++ b/controllers/argocd/project_controller.go
@@ -0,0 +1,190 @@
+/*
+Copyright 2022 The KubeSphere Authors.
+
+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 argocd
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"github.com/go-logr/logr"
+	"html/template"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
+	"kubesphere.io/devops/pkg/utils/k8sutil"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/yaml"
+)
+
+//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=devopsprojects,verbs=get;list;update
+//+kubebuilder:rbac:groups=argoproj.io,resources=appprojects,verbs=get;list;create;update
+
+// Reconciler is the reconciler of the DevOpsProject with Argo AppProject
+type Reconciler struct {
+	client.Client
+	log      logr.Logger
+	recorder record.EventRecorder
+}
+
+// Reconcile makes sure the ArgoAppProject can be maintained which comes from the DevOpsProject
+func (r *Reconciler) Reconcile(req ctrl.Request) (result ctrl.Result, err error) {
+	ctx := context.Background()
+	r.log.Info(fmt.Sprintf("start to reconcile project: %s", req.String()))
+
+	project := &v1alpha3.DevOpsProject{}
+	if err = r.Client.Get(ctx, req.NamespacedName, project); err != nil {
+		err = client.IgnoreNotFound(err)
+		return
+	}
+
+	if argo := project.Spec.Argo; argo != nil {
+		// we only handle the project that have the Argo settings
+		err = r.reconcileArgoProject(project)
+	}
+	return
+}
+
+func (r *Reconciler) reconcileArgoProject(project *v1alpha3.DevOpsProject) (err error) {
+	ctx := context.Background()
+
+	argoAppProject := &unstructured.Unstructured{}
+	argoAppProject.SetKind("AppProject")
+	argoAppProject.SetAPIVersion("argoproj.io/v1alpha1")
+
+	if err = r.Client.Get(ctx, types.NamespacedName{
+		Namespace: project.Name,
+		Name:      project.Name,
+	}, argoAppProject); err != nil {
+		if !apierrors.IsNotFound(err) {
+			return
+		}
+
+		if argoAppProject, err = createUnstructuredObject(project); err != nil {
+			return
+		}
+
+		err = r.Client.Create(ctx, argoAppProject)
+	} else {
+		var newProject *unstructured.Unstructured
+		if newProject, err = createUnstructuredObject(project); err == nil {
+			argoAppProject.Object["spec"] = newProject.Object["spec"]
+			setOwnerRef(argoAppProject, project)
+			err = r.Client.Update(ctx, argoAppProject)
+		}
+	}
+	return
+}
+
+func createUnstructuredObject(project *v1alpha3.DevOpsProject) (result *unstructured.Unstructured, err error) {
+	project = project.DeepCopy()
+	if project.Spec.Argo == nil {
+		err = fmt.Errorf("no argo found from the spec")
+		return
+	}
+
+	var tpl *template.Template
+	if tpl, err = template.New("argo").Parse(argoProjectTemplate); err != nil {
+		return
+	}
+
+	// set some default values
+	argo := project.Spec.Argo
+	if len(argo.SourceRepos) == 0 {
+		argo.SourceRepos = []string{"*"}
+	}
+	if len(argo.Destinations) == 0 {
+		argo.Destinations = []v1alpha3.ApplicationDestination{{
+			Server:    "*",
+			Namespace: "*",
+		}}
+	}
+	if len(argo.ClusterResourceWhitelist) == 0 {
+		argo.ClusterResourceWhitelist = []metav1.GroupKind{{
+			Group: "*",
+			Kind:  "*",
+		}}
+	}
+
+	buffer := new(bytes.Buffer)
+	if err = tpl.Execute(buffer, argo); err == nil {
+		if result, err = GetObjectFromYaml(buffer.String()); err == nil {
+			result.SetName(project.GetName())
+			result.SetNamespace(project.GetName())
+			setOwnerRef(result, project)
+		}
+	}
+	return
+}
+
+func setOwnerRef(object metav1.Object, project *v1alpha3.DevOpsProject) {
+	k8sutil.SetOwnerReference(object, metav1.OwnerReference{
+		Kind:       project.Kind,
+		Name:       project.Name,
+		APIVersion: project.APIVersion,
+		UID:        project.UID,
+	})
+}
+
+const argoProjectTemplate = `apiVersion: argoproj.io/v1alpha1
+kind: AppProject
+spec:
+  description: "{{.Description}}"
+  sourceRepos:
+{{- range .SourceRepos}}
+  - "{{.}}"
+{{- end }}
+  destinations:
+{{range $val := .Destinations}}
+  - namespace: '{{$val.Namespace}}'
+    server: '{{$val.Server}}'
+{{end}}
+  clusterResourceWhitelist:
+{{range $val := .ClusterResourceWhitelist}}
+  - group: '{{$val.Group}}'
+    kind: '{{$val.Kind}}'
+{{end}}
+`
+
+// GetObjectFromYaml returns the Unstructured object from a YAML
+func GetObjectFromYaml(yamlText string) (obj *unstructured.Unstructured, err error) {
+	obj = &unstructured.Unstructured{}
+	err = yaml.Unmarshal([]byte(yamlText), obj)
+	return
+}
+
+// GetName returns the name of this reconciler
+func (r *Reconciler) GetName() string {
+	return "ArgoCDProjectReconciler"
+}
+
+// GetGroupName returns the group name
+func (r *Reconciler) GetGroupName() string {
+	return "argocd"
+}
+
+// SetupWithManager setups the reconciler with a manager
+func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
+	r.log = ctrl.Log.WithName(r.GetName())
+	r.recorder = mgr.GetEventRecorderFor(r.GetName())
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&v1alpha3.DevOpsProject{}).
+		Complete(r)
+}
diff --git a/controllers/core/named.go b/controllers/core/named.go
new file mode 100644
index 0000000..484821a
--- /dev/null
+++ b/controllers/core/named.go
@@ -0,0 +1,30 @@
+/*
+Copyright 2022 The KubeSphere Authors.
+
+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 core
+
+// NamedReconciler is an interface with allow a reconciler has a name
+type NamedReconciler interface {
+	// GetName returns the name of the reconciler
+	GetName() string
+}
+
+// GroupReconciler is an interface with the group name
+// a group means a set of some reconcilers that have a particular feature
+type GroupReconciler interface {
+	// GetGroupName returns the group name
+	GetGroupName() string
+}
diff --git a/pkg/api/devops/v1alpha3/devopsproject_types.go b/pkg/api/devops/v1alpha3/devopsproject_types.go
index 21179b4..db86339 100644
--- a/pkg/api/devops/v1alpha3/devopsproject_types.go
+++ b/pkg/api/devops/v1alpha3/devopsproject_types.go
@@ -23,20 +23,121 @@ import (
 // EDIT THIS FILE!  THIS IS SCAFFOLDING FOR YOU TO OWN!
 // NOTE: json tags are required.  Any new fields you add must have json tags for the fields to be serialized.
 
-const DevOpsProjectFinalizerName = "devopsproject.finalizers.kubesphere.io"
-
 const (
 	ResourceKindDevOpsProject      = "DevOpsProject"
 	ResourceSingularDevOpsProject  = "devopsproject"
 	ResourcePluralDevOpsProject    = "devopsprojects"
 	DevOpsProjectPrefix            = "devopsproject.devops.kubesphere.io/"
+	DevOpsProjectFinalizerName     = "devopsproject.finalizers.kubesphere.io"
 	DevOpeProjectSyncStatusAnnoKey = DevOpsProjectPrefix + "syncstatus"
 	DevOpeProjectSyncTimeAnnoKey   = DevOpsProjectPrefix + "synctime"
-	DevOpeProjectSyncMsgAnnoKey    = DevOpsProjectPrefix + "syncmsg"
 )
 
 // DevOpsProjectSpec defines the desired state of DevOpsProject
 type DevOpsProjectSpec struct {
+	Argo *Argo `json:"argo,omitempty"`
+}
+
+// Argo represents the Argo CD specification
+type Argo struct {
+	// SourceRepos contains list of repository URLs which can be used for deployment
+	SourceRepos []string `json:"sourceRepos,omitempty"`
+	// Destinations contains list of destinations available for deployment
+	Destinations []ApplicationDestination `json:"destinations,omitempty"`
+	// Description contains optional project description
+	Description string `json:"description,omitempty"`
+	// Roles are user defined RBAC roles associated with this project
+	Roles []ProjectRole `json:"roles,omitempty" protobuf:"bytes,4,rep,name=roles"`
+	// ClusterResourceWhitelist contains list of whitelisted cluster level resources
+	ClusterResourceWhitelist []metav1.GroupKind `json:"clusterResourceWhitelist,omitempty"`
+	// NamespaceResourceBlacklist contains list of blacklisted namespace level resources
+	NamespaceResourceBlacklist []metav1.GroupKind `json:"namespaceResourceBlacklist,omitempty"`
+	// OrphanedResources specifies if controller should monitor orphaned resources of apps in this project
+	OrphanedResources *OrphanedResourcesMonitorSettings `json:"orphanedResources,omitempty"`
+	// SyncWindows controls when syncs can be run for apps in this project
+	SyncWindows SyncWindows `json:"syncWindows,omitempty"`
+	// NamespaceResourceWhitelist contains list of whitelisted namespace level resources
+	NamespaceResourceWhitelist []metav1.GroupKind `json:"namespaceResourceWhitelist,omitempty"`
+	// SignatureKeys contains a list of PGP key IDs that commits in Git must be signed with in order to be allowed for sync
+	SignatureKeys []SignatureKey `json:"signatureKeys,omitempty"`
+	// ClusterResourceBlacklist contains list of blacklisted cluster level resources
+	ClusterResourceBlacklist []metav1.GroupKind `json:"clusterResourceBlacklist,omitempty"`
+}
+
+// ApplicationDestination holds information about the application's destination
+type ApplicationDestination struct {
+	// Server specifies the URL of the target cluster and must be set to the Kubernetes control plane API
+	Server string `json:"server,omitempty"`
+	// Namespace specifies the target namespace for the application's resources.
+	// The namespace will only be set for namespace-scoped resources that have not set a value for .metadata.namespace
+	Namespace string `json:"namespace,omitempty"`
+	// Name is an alternate way of specifying the target cluster by its symbolic name
+	Name string `json:"name,omitempty"`
+}
+
+// ProjectRole represents a role that has access to a project
+type ProjectRole struct {
+	// Name is a name for this role
+	Name string `json:"name"`
+	// Description is a description of the role
+	Description string `json:"description,omitempty"`
+	// Policies Stores a list of casbin formatted strings that define access policies for the role in the project
+	Policies []string `json:"policies,omitempty"`
+	// JWTTokens are a list of generated JWT tokens bound to this role
+	JWTTokens []JWTToken `json:"jwtTokens,omitempty"`
+	// Groups are a list of OIDC group claims bound to this role
+	Groups []string `json:"groups,omitempty"`
+}
+
+// JWTToken holds the issuedAt and expiresAt values of a token
+type JWTToken struct {
+	IssuedAt  int64  `json:"iat"`
+	ExpiresAt int64  `json:"exp,omitempty"`
+	ID        string `json:"id,omitempty"`
+}
+
+// OrphanedResourcesMonitorSettings holds settings of orphaned resources monitoring
+type OrphanedResourcesMonitorSettings struct {
+	// Warn indicates if warning condition should be created for apps which have orphaned resources
+	Warn *bool `json:"warn,omitempty"`
+	// Ignore contains a list of resources that are to be excluded from orphaned resources monitoring
+	Ignore []OrphanedResourceKey `json:"ignore,omitempty"`
+}
+
+// OrphanedResourceKey is a reference to a resource to be ignored from
+type OrphanedResourceKey struct {
+	Group string `json:"group,omitempty"`
+	Kind  string `json:"kind,omitempty"`
+	Name  string `json:"name,omitempty"`
+}
+
+// SyncWindows is a collection of sync windows in this project
+type SyncWindows []*SyncWindow
+
+// SyncWindow contains the kind, time, duration and attributes that are used to assign the syncWindows to apps
+type SyncWindow struct {
+	// Kind defines if the window allows or blocks syncs
+	Kind string `json:"kind,omitempty"`
+	// Schedule is the time the window will begin, specified in cron format
+	Schedule string `json:"schedule,omitempty"`
+	// Duration is the amount of time the sync window will be open
+	Duration string `json:"duration,omitempty"`
+	// Applications contains a list of applications that the window will apply to
+	Applications []string `json:"applications,omitempty"`
+	// Namespaces contains a list of namespaces that the window will apply to
+	Namespaces []string `json:"namespaces,omitempty"`
+	// Clusters contains a list of clusters that the window will apply to
+	Clusters []string `json:"clusters,omitempty"`
+	// ManualSync enables manual syncs when they would otherwise be blocked
+	ManualSync bool `json:"manualSync,omitempty"`
+	//TimeZone of the sync that will be applied to the schedule
+	TimeZone string `json:"timeZone,omitempty"`
+}
+
+// SignatureKey is the specification of a key required to verify commit signatures with
+type SignatureKey struct {
+	// The ID of the key in hexadecimal notation
+	KeyID string `json:"keyID"`
 }
 
 // DevOpsProjectStatus defines the observed state of DevOpsProject
diff --git a/pkg/api/devops/v1alpha3/zz_generated.deepcopy.go b/pkg/api/devops/v1alpha3/zz_generated.deepcopy.go
index 85e22ae..6068911 100644
--- a/pkg/api/devops/v1alpha3/zz_generated.deepcopy.go
+++ b/pkg/api/devops/v1alpha3/zz_generated.deepcopy.go
@@ -22,10 +22,99 @@ limitations under the License.
 package v1alpha3
 
 import (
-	"k8s.io/api/core/v1"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1"
 	runtime "k8s.io/apimachinery/pkg/runtime"
 )
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ApplicationDestination) DeepCopyInto(out *ApplicationDestination) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationDestination.
+func (in *ApplicationDestination) DeepCopy() *ApplicationDestination {
+	if in == nil {
+		return nil
+	}
+	out := new(ApplicationDestination)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Argo) DeepCopyInto(out *Argo) {
+	*out = *in
+	if in.SourceRepos != nil {
+		in, out := &in.SourceRepos, &out.SourceRepos
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.Destinations != nil {
+		in, out := &in.Destinations, &out.Destinations
+		*out = make([]ApplicationDestination, len(*in))
+		copy(*out, *in)
+	}
+	if in.Roles != nil {
+		in, out := &in.Roles, &out.Roles
+		*out = make([]ProjectRole, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+	if in.ClusterResourceWhitelist != nil {
+		in, out := &in.ClusterResourceWhitelist, &out.ClusterResourceWhitelist
+		*out = make([]v1.GroupKind, len(*in))
+		copy(*out, *in)
+	}
+	if in.NamespaceResourceBlacklist != nil {
+		in, out := &in.NamespaceResourceBlacklist, &out.NamespaceResourceBlacklist
+		*out = make([]v1.GroupKind, len(*in))
+		copy(*out, *in)
+	}
+	if in.OrphanedResources != nil {
+		in, out := &in.OrphanedResources, &out.OrphanedResources
+		*out = new(OrphanedResourcesMonitorSettings)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.SyncWindows != nil {
+		in, out := &in.SyncWindows, &out.SyncWindows
+		*out = make(SyncWindows, len(*in))
+		for i := range *in {
+			if (*in)[i] != nil {
+				in, out := &(*in)[i], &(*out)[i]
+				*out = new(SyncWindow)
+				(*in).DeepCopyInto(*out)
+			}
+		}
+	}
+	if in.NamespaceResourceWhitelist != nil {
+		in, out := &in.NamespaceResourceWhitelist, &out.NamespaceResourceWhitelist
+		*out = make([]v1.GroupKind, len(*in))
+		copy(*out, *in)
+	}
+	if in.SignatureKeys != nil {
+		in, out := &in.SignatureKeys, &out.SignatureKeys
+		*out = make([]SignatureKey, len(*in))
+		copy(*out, *in)
+	}
+	if in.ClusterResourceBlacklist != nil {
+		in, out := &in.ClusterResourceBlacklist, &out.ClusterResourceBlacklist
+		*out = make([]v1.GroupKind, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Argo.
+func (in *Argo) DeepCopy() *Argo {
+	if in == nil {
+		return nil
+	}
+	out := new(Argo)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *BitbucketServerSource) DeepCopyInto(out *BitbucketServerSource) {
 	*out = *in
@@ -73,7 +162,7 @@ func (in *DevOpsProject) DeepCopyInto(out *DevOpsProject) {
 	*out = *in
 	out.TypeMeta = in.TypeMeta
 	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
-	out.Spec = in.Spec
+	in.Spec.DeepCopyInto(&out.Spec)
 	out.Status = in.Status
 }
 
@@ -130,6 +219,11 @@ func (in *DevOpsProjectList) DeepCopyObject() runtime.Object {
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *DevOpsProjectSpec) DeepCopyInto(out *DevOpsProjectSpec) {
 	*out = *in
+	if in.Argo != nil {
+		in, out := &in.Argo, &out.Argo
+		*out = new(Argo)
+		(*in).DeepCopyInto(*out)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DevOpsProjectSpec.
@@ -312,6 +406,21 @@ func (in *GitlabSource) DeepCopy() *GitlabSource {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *JWTToken) DeepCopyInto(out *JWTToken) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JWTToken.
+func (in *JWTToken) DeepCopy() *JWTToken {
+	if in == nil {
+		return nil
+	}
+	out := new(JWTToken)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *MultiBranchJobTrigger) DeepCopyInto(out *MultiBranchJobTrigger) {
 	*out = *in
@@ -427,6 +536,46 @@ func (in *NoScmPipeline) DeepCopy() *NoScmPipeline {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OrphanedResourceKey) DeepCopyInto(out *OrphanedResourceKey) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OrphanedResourceKey.
+func (in *OrphanedResourceKey) DeepCopy() *OrphanedResourceKey {
+	if in == nil {
+		return nil
+	}
+	out := new(OrphanedResourceKey)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OrphanedResourcesMonitorSettings) DeepCopyInto(out *OrphanedResourcesMonitorSettings) {
+	*out = *in
+	if in.Warn != nil {
+		in, out := &in.Warn, &out.Warn
+		*out = new(bool)
+		**out = **in
+	}
+	if in.Ignore != nil {
+		in, out := &in.Ignore, &out.Ignore
+		*out = make([]OrphanedResourceKey, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OrphanedResourcesMonitorSettings.
+func (in *OrphanedResourcesMonitorSettings) DeepCopy() *OrphanedResourcesMonitorSettings {
+	if in == nil {
+		return nil
+	}
+	out := new(OrphanedResourcesMonitorSettings)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *Parameter) DeepCopyInto(out *Parameter) {
 	*out = *in
@@ -580,7 +729,7 @@ func (in *PipelineRunSpec) DeepCopyInto(out *PipelineRunSpec) {
 	*out = *in
 	if in.PipelineRef != nil {
 		in, out := &in.PipelineRef, &out.PipelineRef
-		*out = new(v1.ObjectReference)
+		*out = new(corev1.ObjectReference)
 		**out = **in
 	}
 	if in.PipelineSpec != nil {
@@ -689,6 +838,36 @@ func (in *PipelineStatus) DeepCopy() *PipelineStatus {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ProjectRole) DeepCopyInto(out *ProjectRole) {
+	*out = *in
+	if in.Policies != nil {
+		in, out := &in.Policies, &out.Policies
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.JWTTokens != nil {
+		in, out := &in.JWTTokens, &out.JWTTokens
+		*out = make([]JWTToken, len(*in))
+		copy(*out, *in)
+	}
+	if in.Groups != nil {
+		in, out := &in.Groups, &out.Groups
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectRole.
+func (in *ProjectRole) DeepCopy() *ProjectRole {
+	if in == nil {
+		return nil
+	}
+	out := new(ProjectRole)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *RemoteTrigger) DeepCopyInto(out *RemoteTrigger) {
 	*out = *in
@@ -719,6 +898,21 @@ func (in *SCM) DeepCopy() *SCM {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SignatureKey) DeepCopyInto(out *SignatureKey) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SignatureKey.
+func (in *SignatureKey) DeepCopy() *SignatureKey {
+	if in == nil {
+		return nil
+	}
+	out := new(SignatureKey)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *SingleSvnSource) DeepCopyInto(out *SingleSvnSource) {
 	*out = *in
@@ -749,6 +943,61 @@ func (in *SvnSource) DeepCopy() *SvnSource {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SyncWindow) DeepCopyInto(out *SyncWindow) {
+	*out = *in
+	if in.Applications != nil {
+		in, out := &in.Applications, &out.Applications
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.Namespaces != nil {
+		in, out := &in.Namespaces, &out.Namespaces
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.Clusters != nil {
+		in, out := &in.Clusters, &out.Clusters
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SyncWindow.
+func (in *SyncWindow) DeepCopy() *SyncWindow {
+	if in == nil {
+		return nil
+	}
+	out := new(SyncWindow)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in SyncWindows) DeepCopyInto(out *SyncWindows) {
+	{
+		in := &in
+		*out = make(SyncWindows, len(*in))
+		for i := range *in {
+			if (*in)[i] != nil {
+				in, out := &(*in)[i], &(*out)[i]
+				*out = new(SyncWindow)
+				(*in).DeepCopyInto(*out)
+			}
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SyncWindows.
+func (in SyncWindows) DeepCopy() SyncWindows {
+	if in == nil {
+		return nil
+	}
+	out := new(SyncWindows)
+	in.DeepCopyInto(out)
+	return *out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *TimerTrigger) DeepCopyInto(out *TimerTrigger) {
 	*out = *in
diff --git a/pkg/utils/k8sutil/k8sutil.go b/pkg/utils/k8sutil/k8sutil.go
index 94d3466..82e0595 100644
--- a/pkg/utils/k8sutil/k8sutil.go
+++ b/pkg/utils/k8sutil/k8sutil.go
@@ -29,3 +29,21 @@ func IsControlledBy(ownerReferences []metav1.OwnerReference, kind string, name s
 	}
 	return false
 }
+
+// SetOwnerReference adds or updates an owner reference to an object
+func SetOwnerReference(object metav1.Object, ownerRef metav1.OwnerReference) {
+	allRefs := object.GetOwnerReferences()
+	if len(allRefs) == 0 {
+		object.SetOwnerReferences([]metav1.OwnerReference{ownerRef})
+	} else {
+		for i, ref := range allRefs {
+			if ref.Name == ownerRef.Name && ref.Kind == ownerRef.Kind &&
+				ref.APIVersion == ownerRef.APIVersion {
+				allRefs[i] = ownerRef
+				return
+			}
+		}
+
+		object.SetOwnerReferences(append(object.GetOwnerReferences(), ownerRef))
+	}
+}


Test patch:
diff --git a/controllers/argocd/project_controller_test.go b/controllers/argocd/project_controller_test.go
new file mode 100644
index 0000000..19750c6
--- /dev/null
+++ b/controllers/argocd/project_controller_test.go
@@ -0,0 +1,185 @@
+/*
+Copyright 2022 The KubeSphere Authors.
+
+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 argocd
+
+import (
+	"context"
+	"github.com/stretchr/testify/assert"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/types"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"testing"
+)
+
+func TestCreateUnstructuredObject(t *testing.T) {
+	project := &v1alpha3.DevOpsProject{}
+	project.SetName("name")
+	project.SetNamespace("namespace")
+
+	// test against the nil situation
+	_, err := createUnstructuredObject(project)
+	assert.NotNil(t, err, "should not do anything if there is no spec.argo")
+
+	var obj *unstructured.Unstructured
+
+	// test against the default value situation
+	p1 := project.DeepCopy()
+	p1.Spec.Argo = &v1alpha3.Argo{}
+	obj, err = createUnstructuredObject(p1)
+	assert.Nil(t, err)
+	result, _, _ := unstructured.NestedStringSlice(obj.Object, "spec", "sourceRepos")
+	assert.Equal(t, []string{"*"}, result)
+	destinations, _, _ := unstructured.NestedSlice(obj.Object, "spec", "destinations")
+	assert.Equal(t, []interface{}{map[string]interface{}{"namespace": "*", "server": "*"}}, destinations)
+	whiteList, _, _ := unstructured.NestedSlice(obj.Object, "spec", "clusterResourceWhitelist")
+	assert.Equal(t, []interface{}{map[string]interface{}{"group": "*", "kind": "*"}}, whiteList)
+
+	// test against a normal case
+	p2 := project.DeepCopy()
+	p2.Spec.Argo = &v1alpha3.Argo{
+		SourceRepos: []string{"repo1", "repo2"},
+		Destinations: []v1alpha3.ApplicationDestination{{
+			Server:    "server1",
+			Namespace: "ns1",
+		}, {
+			Server:    "server2",
+			Namespace: "ns2",
+		}},
+		Description: "description",
+		ClusterResourceWhitelist: []v1.GroupKind{{
+			Group: "group1",
+			Kind:  "kind1",
+		}, {
+			Group: "group2",
+			Kind:  "kind2",
+		}},
+	}
+	obj, err = createUnstructuredObject(p2)
+	assert.Nil(t, err)
+	p2Repos, _, _ := unstructured.NestedStringSlice(obj.Object, "spec", "sourceRepos")
+	assert.Equal(t, []string{"repo1", "repo2"}, p2Repos)
+	p2Desc, _, _ := unstructured.NestedString(obj.Object, "spec", "description")
+	assert.Equal(t, "description", p2Desc)
+	p2Destinations, _, _ := unstructured.NestedSlice(obj.Object, "spec", "destinations")
+	assert.Equal(t, []interface{}{
+		map[string]interface{}{"server": "server1", "namespace": "ns1"},
+		map[string]interface{}{"server": "server2", "namespace": "ns2"}}, p2Destinations)
+	p2ClusterResourceWhitelist, _, _ := unstructured.NestedSlice(obj.Object, "spec", "clusterResourceWhitelist")
+	assert.Equal(t, []interface{}{
+		map[string]interface{}{"group": "group1", "kind": "kind1"},
+		map[string]interface{}{"group": "group2", "kind": "kind2"}}, p2ClusterResourceWhitelist)
+}
+
+func TestBasicInterface(t *testing.T) {
+	nameGetter := &Reconciler{}
+	assert.NotEmpty(t, nameGetter.GetName())
+
+	groupNameGetter := &Reconciler{}
+	assert.NotEmpty(t, groupNameGetter.GetGroupName())
+}
+
+func TestReconcileArgoProject(t *testing.T) {
+	schema, err := v1alpha3.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+
+	project := &v1alpha3.DevOpsProject{}
+	project.SetName("fake")
+
+	appProject := &unstructured.Unstructured{
+		Object: map[string]interface{}{
+			"spec": map[string]interface{}{
+				"description": "description",
+			},
+		},
+	}
+	appProject.SetAPIVersion("argoproj.io/v1alpha1")
+	appProject.SetKind("AppProject")
+	appProject.SetName("fake")
+	appProject.SetNamespace("fake")
+
+	tests := []struct {
+		name    string
+		c       client.Client
+		project func() *v1alpha3.DevOpsProject
+		verify  func(t *testing.T, err error, c client.Client)
+	}{{
+		name: "no argo in the project",
+		c:    fake.NewFakeClientWithScheme(schema),
+		project: func() *v1alpha3.DevOpsProject {
+			return project.DeepCopy()
+		},
+		verify: func(t *testing.T, err error, c client.Client) {
+			assert.NotNil(t, err, "should not found the Argo AppProject")
+		},
+	}, {
+		name: "have an empty Argo",
+		c:    fake.NewFakeClientWithScheme(schema),
+		project: func() *v1alpha3.DevOpsProject {
+			p1 := project.DeepCopy()
+			p1.Spec.Argo = &v1alpha3.Argo{}
+			return p1
+		},
+		verify: func(t *testing.T, err error, c client.Client) {
+			assert.Nil(t, err)
+
+			// make sure the Argo AppProject was created
+			appProject := appProject.DeepCopy()
+
+			err = c.Get(context.Background(), types.NamespacedName{
+				Namespace: "fake",
+				Name:      "fake",
+			}, appProject)
+			assert.Nil(t, err)
+		},
+	}, {
+		name: "update the existing Argo AppProject",
+		c:    fake.NewFakeClientWithScheme(schema, appProject.DeepCopy()),
+		project: func() *v1alpha3.DevOpsProject {
+			p1 := project.DeepCopy()
+			p1.Spec.Argo = &v1alpha3.Argo{
+				Description: "new description",
+			}
+			return p1
+		},
+		verify: func(t *testing.T, err error, c client.Client) {
+			assert.Nil(t, err)
+
+			// make sure the Argo AppProject was updated
+			appProject := appProject.DeepCopy()
+
+			err = c.Get(context.Background(), types.NamespacedName{
+				Namespace: "fake",
+				Name:      "fake",
+			}, appProject)
+			assert.Nil(t, err)
+			desc, _, _ := unstructured.NestedString(appProject.Object, "spec", "description")
+			assert.Equal(t, "new description", desc)
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &Reconciler{
+				Client: tt.c,
+			}
+			err := r.reconcileArgoProject(tt.project())
+			tt.verify(t, err, tt.c)
+		})
+	}
+}
diff --git a/pkg/utils/k8sutil/k8sutil_test.go b/pkg/utils/k8sutil/k8sutil_test.go
index aa54e4c..a8cde6d 100644
--- a/pkg/utils/k8sutil/k8sutil_test.go
+++ b/pkg/utils/k8sutil/k8sutil_test.go
@@ -19,6 +19,7 @@ package k8sutil
 import (
 	"github.com/stretchr/testify/assert"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"testing"
 )
 
@@ -61,3 +62,60 @@ func TestIsControlledBy(t *testing.T) {
 		})
 	}
 }
+
+func TestSetOwnerReference(t *testing.T) {
+	obj := &unstructured.Unstructured{}
+	obj.SetOwnerReferences([]metav1.OwnerReference{{
+		Kind: "fake",
+	}})
+
+	type args struct {
+		object   metav1.Object
+		ownerRef metav1.OwnerReference
+	}
+	tests := []struct {
+		name   string
+		args   args
+		verify func(t *testing.T, object metav1.Object)
+	}{{
+		name: "without owner references",
+		args: args{
+			object: &unstructured.Unstructured{},
+			ownerRef: metav1.OwnerReference{
+				Kind: "kind",
+			},
+		},
+		verify: func(t *testing.T, object metav1.Object) {
+			assert.Equal(t, 1, len(object.GetOwnerReferences()))
+			assert.Equal(t, "kind", object.GetOwnerReferences()[0].Kind)
+		},
+	}, {
+		name: "no matched owner reference",
+		args: args{
+			object: obj.DeepCopy(),
+			ownerRef: metav1.OwnerReference{
+				Kind: "kind",
+			},
+		},
+		verify: func(t *testing.T, object metav1.Object) {
+			assert.Equal(t, 2, len(object.GetOwnerReferences()))
+		},
+	}, {
+		name: "have matched owner reference",
+		args: args{
+			object: obj.DeepCopy(),
+			ownerRef: metav1.OwnerReference{
+				Kind: "fake",
+			},
+		},
+		verify: func(t *testing.T, object metav1.Object) {
+			assert.Equal(t, 1, len(object.GetOwnerReferences()))
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			SetOwnerReference(tt.args.object, tt.args.ownerRef)
+			tt.verify(t, tt.args.object)
+		})
+	}
+}