Repository: kubesphere/ks-devops
Pull request #451 (2022-01-25 08:12:35)

Support to install addon with 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 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:
See the details from the document.

### 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.
-->
```
None
```

### 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
Support to install addon with CRD
```


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

This pull-request has been approved by: *<a href="https://github.com/kubesphere/ks-devops/pull/451#" 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":[]} -->
codecov[bot]: # [Codecov](https://codecov.io/gh/kubesphere/ks-devops/pull/451?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) Report
> Merging [#451](https://codecov.io/gh/kubesphere/ks-devops/pull/451?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) (593d6ce) into [master](https://codecov.io/gh/kubesphere/ks-devops/commit/6cb96cf0debdc4fc2fcc8af60ab324e5bf6c2102?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) (6cb96cf) will **decrease** coverage by `0.04%`.
> The diff coverage is `58.01%`.

[![Impacted file tree graph](https://codecov.io/gh/kubesphere/ks-devops/pull/451/graphs/tree.svg?width=650&height=150&src=pr&token=qSK4RBbyNg&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere)](https://codecov.io/gh/kubesphere/ks-devops/pull/451?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere)

```diff
@@            Coverage Diff             @@
##           master     #451      +/-   ##
==========================================
- Coverage   76.98%   76.94%   -0.05%     
==========================================
  Files         111      117       +6     
  Lines       22094    22224     +130     
==========================================
+ Hits        17009    17100      +91     
- Misses       4909     4944      +35     
- Partials      176      180       +4     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `76.94% <58.01%> (-0.05%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/kubesphere/ks-devops/pull/451?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) | Coverage Δ | |
|---|---|---|
| [...lers/jenkins/pipelinerun/pipelinerun\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/451/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvamVua2lucy9waXBlbGluZXJ1bi9waXBlbGluZXJ1bl9jb250cm9sbGVyLmdv) | `16.04% <0.00%> (+0.29%)` | :arrow_up: |
| [controllers/addon/addon\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/451/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvYWRkb24vYWRkb25fY29udHJvbGxlci5nbw==) | `43.83% <43.83%> (ø)` | |
| [controllers/addon/operator\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/451/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvYWRkb24vb3BlcmF0b3JfY29udHJvbGxlci5nbw==) | `71.42% <71.42%> (ø)` | |
| [pkg/api/devops/v1alpha1/addon.go](https://codecov.io/gh/kubesphere/ks-devops/pull/451/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2FwaS9kZXZvcHMvdjFhbHBoYTEvYWRkb24uZ28=) | `100.00% <100.00%> (ø)` | |
| [pkg/api/devops/v1alpha1/addon\_strategy.go](https://codecov.io/gh/kubesphere/ks-devops/pull/451/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2FwaS9kZXZvcHMvdjFhbHBoYTEvYWRkb25fc3RyYXRlZ3kuZ28=) | `100.00% <100.00%> (ø)` | |
| [pkg/utils/k8sutil/objectmeta.go](https://codecov.io/gh/kubesphere/ks-devops/pull/451/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL3V0aWxzL2s4c3V0aWwvb2JqZWN0bWV0YS5nbw==) | `100.00% <100.00%> (ø)` | |
| [pkg/utils/sliceutil/sliceutils.go](https://codecov.io/gh/kubesphere/ks-devops/pull/451/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL3V0aWxzL3NsaWNldXRpbC9zbGljZXV0aWxzLmdv) | `100.00% <100.00%> (ø)` | |
| [...rs/devopscredential/devopscredential\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/451/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvZGV2b3BzY3JlZGVudGlhbC9kZXZvcHNjcmVkZW50aWFsX2NvbnRyb2xsZXIuZ28=) | `33.10% <0.00%> (-3.38%)` | :arrow_down: |
| [pkg/utils/k8sutil/k8sutil.go](https://codecov.io/gh/kubesphere/ks-devops/pull/451/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL3V0aWxzL2s4c3V0aWwvazhzdXRpbC5nbw==) | `100.00% <0.00%> (ø)` | |
| ... and [2 more](https://codecov.io/gh/kubesphere/ks-devops/pull/451/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/kubesphere/ks-devops/pull/451?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/kubesphere/ks-devops/pull/451?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere). Last update [6cb96cf...593d6ce](https://codecov.io/gh/kubesphere/ks-devops/pull/451?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere).

LinuxSuRen: /cc @kubesphere/sig-devops 
JohnNiang: /label tide/merge-method-squash
ks-ci-bot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubesphere/ks-devops/pull/451#pullrequestreview-874206983" title="LGTM">JohnNiang</a>*, *<a href="https://github.com/kubesphere/ks-devops/pull/451#" 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/.golangci.yml b/.golangci.yml
index 4da87a2..ef73f0f 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -104,7 +104,6 @@ run:
     - pkg/utils/reflectutils/deep.go
     - pkg/models/auth/token.go
     - pkg/utils/hash.go
-    - pkg/utils/sliceutil/sliceutils.go
     - pkg/apiserver/authentication/options/authenticate_options.go
     - pkg/client/cache/redis.go
     - pkg/models/devops/s2ibinary_handler.go
diff --git a/Makefile b/Makefile
index e958627..4ba4a72 100644
--- a/Makefile
+++ b/Makefile
@@ -52,6 +52,9 @@ deploy: manifests
 manifests: controller-gen
 	$(CONTROLLER_GEN) $(CRD_OPTIONS) rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=config/crd/bases
 
+install-crd:
+	kubectl apply -f config/crd/bases
+
 # Run go fmt against code
 fmt:
 	go fmt ./...
diff --git a/cmd/controller/app/controllers.go b/cmd/controller/app/controllers.go
index 0349938..1850217 100644
--- a/cmd/controller/app/controllers.go
+++ b/cmd/controller/app/controllers.go
@@ -17,6 +17,7 @@ limitations under the License.
 package app
 
 import (
+	"kubesphere.io/devops/controllers/addon"
 	"kubesphere.io/devops/controllers/gitrepository"
 	"kubesphere.io/devops/pkg/jwt/token"
 	"kubesphere.io/devops/pkg/server/errors"
@@ -88,6 +89,17 @@ func addControllers(mgr manager.Manager, client k8s.Client, informerFactory info
 			}
 			return err
 		},
+		"addon": func(mgr manager.Manager) error {
+			err := (&addon.OperatorCRDReconciler{
+				Client: mgr.GetClient(),
+			}).SetupWithManager(mgr)
+			if err == nil {
+				err = (&addon.Reconciler{
+					Client: mgr.GetClient(),
+				}).SetupWithManager(mgr)
+			}
+			return err
+		},
 		"jenkinsconfig": func(mgr manager.Manager) error {
 			return mgr.Add(config.NewController(&config.ControllerOptions{
 				LimitRangeClient:    client.Kubernetes().CoreV1(),
diff --git a/cmd/controller/app/server.go b/cmd/controller/app/server.go
index 5341089..7772ab5 100644
--- a/cmd/controller/app/server.go
+++ b/cmd/controller/app/server.go
@@ -18,8 +18,8 @@ package app
 
 import (
 	"fmt"
-
 	"github.com/jenkins-zh/jenkins-client/pkg/core"
+	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	utilerrors "k8s.io/apimachinery/pkg/util/errors"
 	"kubesphere.io/devops/cmd/controller/app/options"
 	"kubesphere.io/devops/pkg/apis"
@@ -166,6 +166,7 @@ func Run(s *options.DevOpsControllerManagerOptions, stopCh <-chan struct{}) erro
 		klog.Fatalf("unable to set up overall controller manager: %v", err)
 	}
 	apis.AddToScheme(mgr.GetScheme())
+	_ = apiextensions.AddToScheme(mgr.GetScheme())
 
 	// Init s3 client
 	var s3Client s3.Interface
diff --git a/config/crd/bases/devops.kubesphere.io_addons.yaml b/config/crd/bases/devops.kubesphere.io_addons.yaml
new file mode 100644
index 0000000..73567ff
--- /dev/null
+++ b/config/crd/bases/devops.kubesphere.io_addons.yaml
@@ -0,0 +1,77 @@
+
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.6.2
+  creationTimestamp: null
+  name: addons.devops.kubesphere.io
+spec:
+  group: devops.kubesphere.io
+  names:
+    kind: Addon
+    listKind: AddonList
+    plural: addons
+    singular: addon
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - description: The version of target addon
+      jsonPath: .spec.version
+      name: Version
+      type: string
+    name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: Addon represents a plugin (addon) of ks-devops
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: AddonSpec is the specification of an Addon
+            properties:
+              externalAddress:
+                type: string
+              parameters:
+                additionalProperties:
+                  type: string
+                type: object
+              strategy:
+                description: LocalObjectReference contains enough information to let
+                  you locate the referenced object inside the same namespace.
+                properties:
+                  name:
+                    description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+                      TODO: Add other useful fields. apiVersion, kind, uid?'
+                    type: string
+                type: object
+              version:
+                type: string
+            type: object
+          status:
+            description: AddonStatus represents the status of an addon
+            properties:
+              phase:
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
diff --git a/config/crd/bases/devops.kubesphere.io_addonstrategies.yaml b/config/crd/bases/devops.kubesphere.io_addonstrategies.yaml
new file mode 100644
index 0000000..05cdb26
--- /dev/null
+++ b/config/crd/bases/devops.kubesphere.io_addonstrategies.yaml
@@ -0,0 +1,182 @@
+
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.6.2
+  creationTimestamp: null
+  name: addonstrategies.devops.kubesphere.io
+spec:
+  group: devops.kubesphere.io
+  names:
+    kind: AddonStrategy
+    listKind: AddonStrategyList
+    plural: addonstrategies
+    singular: addonstrategy
+  scope: Cluster
+  versions:
+  - name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: AddonStrategy represents an addonStrategy
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: AddStrategySpec is the specification of an AddonStrategy
+            properties:
+              available:
+                type: boolean
+              helmRepo:
+                type: string
+              operator:
+                description: 'ObjectReference contains enough information to let you
+                  inspect or modify the referred object. --- New uses of this type
+                  are discouraged because of difficulty describing its usage when
+                  embedded in APIs.  1. Ignored fields.  It includes many fields which
+                  are not generally honored.  For instance, ResourceVersion and FieldPath
+                  are both very rarely valid in actual usage.  2. Invalid usage help.  It
+                  is impossible to add specific help for individual usage.  In most
+                  embedded usages, there are particular     restrictions like, "must
+                  refer only to types A and B" or "UID not honored" or "name must
+                  be restricted".     Those cannot be well described when embedded.  3.
+                  Inconsistent validation.  Because the usages are different, the
+                  validation rules are different by usage, which makes it hard for
+                  users to predict what will happen.  4. The fields are both imprecise
+                  and overly precise.  Kind is not a precise mapping to a URL. This
+                  can produce ambiguity     during interpretation and require a REST
+                  mapping.  In most cases, the dependency is on the group,resource
+                  tuple     and the version of the actual struct is irrelevant.  5.
+                  We cannot easily change it.  Because this type is embedded in many
+                  locations, updates to this type     will affect numerous schemas.  Don''t
+                  make new APIs embed an underspecified API type they do not control.
+                  Instead of using this type, create a locally provided and used type
+                  that is well-focused on your reference. For example, ServiceReferences
+                  for admission registration: https://github.com/kubernetes/api/blob/release-1.17/admissionregistration/v1/types.go#L533
+                  .'
+                properties:
+                  apiVersion:
+                    description: API version of the referent.
+                    type: string
+                  fieldPath:
+                    description: 'If referring to a piece of an object instead of
+                      an entire object, this string should contain a valid JSON/Go
+                      field access statement, such as desiredState.manifest.containers[2].
+                      For example, if the object reference is to a container within
+                      a pod, this would take on a value like: "spec.containers{name}"
+                      (where "name" refers to the name of the container that triggered
+                      the event) or if no container name is specified "spec.containers[2]"
+                      (container with index 2 in this pod). This syntax is chosen
+                      only to have some well-defined way of referencing a part of
+                      an object. TODO: this design is not final and this field is
+                      subject to change in the future.'
+                    type: string
+                  kind:
+                    description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+                    type: string
+                  name:
+                    description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names'
+                    type: string
+                  namespace:
+                    description: 'Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/'
+                    type: string
+                  resourceVersion:
+                    description: 'Specific resourceVersion to which this reference
+                      is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency'
+                    type: string
+                  uid:
+                    description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids'
+                    type: string
+                type: object
+              parameters:
+                additionalProperties:
+                  type: string
+                type: object
+              simpleOperator:
+                description: 'ObjectReference contains enough information to let you
+                  inspect or modify the referred object. --- New uses of this type
+                  are discouraged because of difficulty describing its usage when
+                  embedded in APIs.  1. Ignored fields.  It includes many fields which
+                  are not generally honored.  For instance, ResourceVersion and FieldPath
+                  are both very rarely valid in actual usage.  2. Invalid usage help.  It
+                  is impossible to add specific help for individual usage.  In most
+                  embedded usages, there are particular     restrictions like, "must
+                  refer only to types A and B" or "UID not honored" or "name must
+                  be restricted".     Those cannot be well described when embedded.  3.
+                  Inconsistent validation.  Because the usages are different, the
+                  validation rules are different by usage, which makes it hard for
+                  users to predict what will happen.  4. The fields are both imprecise
+                  and overly precise.  Kind is not a precise mapping to a URL. This
+                  can produce ambiguity     during interpretation and require a REST
+                  mapping.  In most cases, the dependency is on the group,resource
+                  tuple     and the version of the actual struct is irrelevant.  5.
+                  We cannot easily change it.  Because this type is embedded in many
+                  locations, updates to this type     will affect numerous schemas.  Don''t
+                  make new APIs embed an underspecified API type they do not control.
+                  Instead of using this type, create a locally provided and used type
+                  that is well-focused on your reference. For example, ServiceReferences
+                  for admission registration: https://github.com/kubernetes/api/blob/release-1.17/admissionregistration/v1/types.go#L533
+                  .'
+                properties:
+                  apiVersion:
+                    description: API version of the referent.
+                    type: string
+                  fieldPath:
+                    description: 'If referring to a piece of an object instead of
+                      an entire object, this string should contain a valid JSON/Go
+                      field access statement, such as desiredState.manifest.containers[2].
+                      For example, if the object reference is to a container within
+                      a pod, this would take on a value like: "spec.containers{name}"
+                      (where "name" refers to the name of the container that triggered
+                      the event) or if no container name is specified "spec.containers[2]"
+                      (container with index 2 in this pod). This syntax is chosen
+                      only to have some well-defined way of referencing a part of
+                      an object. TODO: this design is not final and this field is
+                      subject to change in the future.'
+                    type: string
+                  kind:
+                    description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+                    type: string
+                  name:
+                    description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names'
+                    type: string
+                  namespace:
+                    description: 'Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/'
+                    type: string
+                  resourceVersion:
+                    description: 'Specific resourceVersion to which this reference
+                      is made, if any. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency'
+                    type: string
+                  uid:
+                    description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids'
+                    type: string
+                type: object
+              template:
+                type: string
+              type:
+                description: AddonInstallStrategy represents the addon installation
+                  strategy
+                type: string
+              yaml:
+                type: string
+            type: object
+        type: object
+    served: true
+    storage: true
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
diff --git a/config/rbac/fake_editor_role.yaml b/config/rbac/fake_editor_role.yaml
deleted file mode 100644
index c83abf0..0000000
--- a/config/rbac/fake_editor_role.yaml
+++ /dev/null
@@ -1,24 +0,0 @@
-# permissions for end users to edit fakes.
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  name: fake-editor-role
-rules:
-- apiGroups:
-  - devops.kubesphere.io
-  resources:
-  - fakes
-  verbs:
-  - create
-  - delete
-  - get
-  - list
-  - patch
-  - update
-  - watch
-- apiGroups:
-  - devops.kubesphere.io
-  resources:
-  - fakes/status
-  verbs:
-  - get
diff --git a/config/rbac/fake_viewer_role.yaml b/config/rbac/fake_viewer_role.yaml
deleted file mode 100644
index e49cb95..0000000
--- a/config/rbac/fake_viewer_role.yaml
+++ /dev/null
@@ -1,20 +0,0 @@
-# permissions for end users to view fakes.
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
-  name: fake-viewer-role
-rules:
-- apiGroups:
-  - devops.kubesphere.io
-  resources:
-  - fakes
-  verbs:
-  - get
-  - list
-  - watch
-- apiGroups:
-  - devops.kubesphere.io
-  resources:
-  - fakes/status
-  verbs:
-  - get
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 728900a..ed81fd4 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -7,24 +7,30 @@ metadata:
   name: manager-role
 rules:
 - apiGroups:
-  - devops.kubesphere.io
+  - apiextensions.k8s.io
   resources:
-  - fakes
+  - customresourcedefinitions
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - argoproj.io
+  resources:
+  - argocds
   verbs:
   - create
   - delete
   - get
-  - list
-  - patch
   - update
-  - watch
 - apiGroups:
   - devops.kubesphere.io
   resources:
-  - fakes/status
+  - addonStrategies
   verbs:
+  - create
+  - delete
   - get
-  - patch
   - update
 - apiGroups:
   - devops.kubesphere.io
@@ -75,6 +81,15 @@ rules:
   - get
   - patch
   - update
+- apiGroups:
+  - devops.kubesphere.io
+  resources:
+  - releasercontrollers
+  verbs:
+  - create
+  - delete
+  - get
+  - update
 - apiGroups:
   - devops.kubesphere.io
   resources:
diff --git a/config/samples/addon/argocd.yaml b/config/samples/addon/argocd.yaml
new file mode 100644
index 0000000..f04aa19
--- /dev/null
+++ b/config/samples/addon/argocd.yaml
@@ -0,0 +1,8 @@
+apiVersion: devops.kubesphere.io/v1alpha1
+kind: Addon
+metadata:
+  name: argocd
+spec:
+  version: v2.2.3
+  strategy:
+    name: simple-operator-argocd
diff --git a/config/samples/addon/argocd_simple_operator.yaml b/config/samples/addon/argocd_simple_operator.yaml
new file mode 100644
index 0000000..729a7e8
--- /dev/null
+++ b/config/samples/addon/argocd_simple_operator.yaml
@@ -0,0 +1,14 @@
+apiVersion: devops.kubesphere.io/v1alpha1
+kind: AddonStrategy
+metadata:
+  name: simple-operator-argocd
+spec:
+  type: simple-operator
+  simpleOperator:
+    apiVersion: argoproj.io/v1alpha1
+    kind: ArgoCD
+  template: |
+    apiVersion: argoproj.io/v1alpha1
+    kind: ArgoCD
+    spec:
+      version: {{.Spec.Version}}
diff --git a/config/samples/addon/ks_releaser.yaml b/config/samples/addon/ks_releaser.yaml
new file mode 100644
index 0000000..140ee17
--- /dev/null
+++ b/config/samples/addon/ks_releaser.yaml
@@ -0,0 +1,8 @@
+apiVersion: devops.kubesphere.io/v1alpha1
+kind: Addon
+metadata:
+  name: ks-releaser
+spec:
+  version: v0.0.14
+  strategy:
+    name: simple-operator-releasercontroller
diff --git a/config/samples/addon/ks_releaser_simple_operator.yaml b/config/samples/addon/ks_releaser_simple_operator.yaml
new file mode 100644
index 0000000..9b0b338
--- /dev/null
+++ b/config/samples/addon/ks_releaser_simple_operator.yaml
@@ -0,0 +1,18 @@
+apiVersion: devops.kubesphere.io/v1alpha1
+kind: AddonStrategy
+metadata:
+  name: ks-releaser-simple-operator
+spec:
+  type: simple-operator
+  simpleOperator:
+    apiVersion: devops.kubesphere.io/v1alpha1
+    kind: ReleaserController
+  parameters:
+    image: "ghcr.io/kubesphere-sigs/ks-releaser"
+  template: |
+    apiVersion: devops.kubesphere.io/v1alpha1
+    kind: ReleaserController
+    spec:
+      image: {{.Spec.Parameters.image}}
+      version: {{.Spec.Version}}
+      webhook: false
diff --git a/controllers/addon/addon_controller.go b/controllers/addon/addon_controller.go
new file mode 100644
index 0000000..aec9086
--- /dev/null
+++ b/controllers/addon/addon_controller.go
@@ -0,0 +1,178 @@
+/*
+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 addon
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"github.com/go-logr/logr"
+	"html/template"
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"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/v1alpha1"
+	"kubesphere.io/devops/pkg/utils/k8sutil"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/yaml"
+)
+
+// Reconciler takes the responsible for addon lifecycle
+type Reconciler struct {
+	client.Client
+	log      logr.Logger
+	recorder record.EventRecorder
+}
+
+const defaultNamespace = "default"
+
+// Reconcile responsible for addon lifecycle
+func (r *Reconciler) Reconcile(req ctrl.Request) (result ctrl.Result, err error) {
+	ctx := context.Background()
+	r.log.Info(fmt.Sprintf("start to reconcile addon: %s", req.String()))
+
+	addon := &v1alpha1.Addon{}
+	if err = r.Client.Get(ctx, req.NamespacedName, addon); err != nil {
+		err = client.IgnoreNotFound(err)
+		return
+	}
+
+	if beingDeleting(addon) {
+		return r.cleanup(addon)
+	}
+
+	err = r.addonHandle(ctx, addon)
+	return
+}
+
+func beingDeleting(addon *v1alpha1.Addon) bool {
+	return addon != nil && !addon.DeletionTimestamp.IsZero()
+}
+
+func (r *Reconciler) cleanup(addon *v1alpha1.Addon) (result ctrl.Result, err error) {
+	var obj *unstructured.Unstructured
+	if obj, _, err = r.findTemplateInstance(context.Background(), addon); err != nil && !apierrors.IsNotFound(err) {
+		return
+	}
+
+	if err = r.Client.Delete(context.Background(), obj); err == nil {
+		k8sutil.RemoveFinalizer(&addon.ObjectMeta, v1alpha1.AddonFinalizerName)
+		err = r.Client.Update(context.Background(), addon)
+	}
+	return
+}
+
+const (
+	// EventReasonMissing represents the reason because of missing something
+	EventReasonMissing = "Missing"
+)
+
+func (r *Reconciler) addonHandle(ctx context.Context, addon *v1alpha1.Addon) (err error) {
+	var tpl string
+	var obj *unstructured.Unstructured
+	if obj, tpl, err = r.findTemplateInstance(ctx, addon); err != nil {
+		if apierrors.IsNotFound(err) {
+			err = r.Client.Create(ctx, obj)
+		}
+	} else {
+		resVersion := obj.GetResourceVersion()
+
+		if err = yaml.Unmarshal([]byte(tpl), obj); err != nil {
+			err = fmt.Errorf("failed parse template to addon, error is %v", err)
+			return
+		}
+		obj.SetResourceVersion(resVersion)
+		obj.SetName(addon.Name)
+		obj.SetNamespace(defaultNamespace)
+		err = r.Client.Update(ctx, obj)
+	}
+
+	// add finalizer
+	if err == nil {
+		k8sutil.AddFinalizer(&addon.ObjectMeta, v1alpha1.AddonFinalizerName)
+		err = r.Update(ctx, addon)
+	}
+	return
+}
+
+func (r *Reconciler) findTemplateInstance(ctx context.Context, addon *v1alpha1.Addon) (instance *unstructured.Unstructured, tpl string, err error) {
+	strategy := addon.Spec.Strategy
+
+	addonStrategy := &v1alpha1.AddonStrategy{}
+	if err = r.Client.Get(ctx, types.NamespacedName{Name: strategy.Name}, addonStrategy); err != nil {
+		r.recorder.Eventf(addon, corev1.EventTypeWarning, EventReasonMissing, "failed to get AddonStrategy with: %s", strategy.Name)
+		return
+	}
+
+	if !r.supportedStrategy(addonStrategy) {
+		err = fmt.Errorf("not supported addon strategy: %s", strategy.Name)
+		return
+	}
+
+	if addonStrategy.Spec.Template == "" {
+		r.recorder.Eventf(addon, corev1.EventTypeWarning, EventReasonMissing, "no template found from %s", strategy.Name)
+		err = fmt.Errorf("no template found from %s", strategy.Name)
+		return
+	}
+
+	if tpl, err = getTemplate(addonStrategy.Spec.Template, addon); err != nil {
+		err = fmt.Errorf("failed to render template: %s, error: %v", addonStrategy.Spec.Template, err)
+		return
+	}
+
+	instance = &unstructured.Unstructured{}
+	if err = yaml.Unmarshal([]byte(tpl), instance); err != nil {
+		err = fmt.Errorf("failed parse template to addon, error is %v", err)
+		return
+	}
+	instance.SetName(addon.Name)
+	instance.SetNamespace(defaultNamespace)
+
+	err = r.Client.Get(ctx, types.NamespacedName{Name: addon.Name, Namespace: defaultNamespace}, instance)
+	return
+}
+
+func getTemplate(tpl string, addon *v1alpha1.Addon) (result string, err error) {
+	var addonTpl *template.Template
+	if addonTpl, err = template.New("addon").Parse(tpl); err == nil {
+		buf := bytes.NewBuffer([]byte{})
+		if err = addonTpl.Execute(buf, addon); err == nil {
+			result = buf.String()
+		}
+	}
+	return
+}
+
+func (r *Reconciler) supportedStrategy(strategy *v1alpha1.AddonStrategy) bool {
+	// TODO support more types in the future
+	if strategy != nil {
+		return strategy.Spec.Type == v1alpha1.AddonInstallStrategySimpleOperator
+	}
+	return false
+}
+
+// SetupWithManager set the reconcilers
+func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
+	r.log = ctrl.Log.WithName("AddonReconciler")
+	r.recorder = mgr.GetEventRecorderFor("addon-controller")
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&v1alpha1.Addon{}).
+		Complete(r)
+}
diff --git a/controllers/addon/operator_controller.go b/controllers/addon/operator_controller.go
new file mode 100644
index 0000000..968d3d0
--- /dev/null
+++ b/controllers/addon/operator_controller.go
@@ -0,0 +1,119 @@
+/*
+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 addon
+
+import (
+	"context"
+	"fmt"
+	"github.com/go-logr/logr"
+	v1 "k8s.io/api/core/v1"
+	apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"strings"
+
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+//+kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get;list;watch
+//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=addonStrategies,verbs=get;delete;create;update
+//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=releasercontrollers,verbs=get;delete;create;update
+//+kubebuilder:rbac:groups=argoproj.io,resources=argocds,verbs=get;delete;create;update
+
+// OperatorCRDReconciler watches those CRDs which belong to Operator
+type OperatorCRDReconciler struct {
+	client.Client
+	log logr.Logger
+}
+
+// TODO make this to be configurable
+var supportedOperators = []string{"ReleaserController", "ArgoCD"}
+
+// Reconcile manages addonStrategy according to the CRDs of operators
+func (r *OperatorCRDReconciler) Reconcile(req ctrl.Request) (result ctrl.Result, err error) {
+	ctx := context.Background()
+
+	crd := &apiextensions.CustomResourceDefinition{}
+	if err = r.Client.Get(ctx, req.NamespacedName, crd); err != nil {
+		err = client.IgnoreNotFound(err)
+		return
+	}
+
+	spec := crd.Spec
+	err = r.operatorsHandle(spec.Names.Kind, spec.Versions[0].Name)
+	return
+}
+
+func (r *OperatorCRDReconciler) operatorsHandle(name string, version string) (err error) {
+	ctx := context.Background()
+	r.log.Info(fmt.Sprintf("start to reconcile: %s-%s", name, version))
+	if !operatorSupport(name) {
+		r.log.V(8).Info(fmt.Sprintf("not support %s as an operator", name))
+		return
+	}
+
+	strategyName := getStrategyName(name, string(v1alpha1.AddonInstallStrategySimpleOperator))
+
+	strategy := &v1alpha1.AddonStrategy{}
+	if err = r.Client.Get(ctx, types.NamespacedName{
+		Name: strategyName,
+	}, strategy); err != nil {
+		if apierrors.IsNotFound(err) {
+			// create addonStrategy
+			strategy.Name = strategyName
+			strategy.Spec = v1alpha1.AddStrategySpec{
+				Type: v1alpha1.AddonInstallStrategySimpleOperator,
+				SimpleOperator: v1.ObjectReference{
+					APIVersion: version,
+					Kind:       name,
+				},
+			}
+
+			err = r.Client.Create(ctx, strategy)
+			return
+		}
+	} else {
+		strategy.Spec.Available = true
+		strategy.Spec.SimpleOperator.APIVersion = version
+		err = r.Client.Update(ctx, strategy)
+	}
+	return
+}
+
+func operatorSupport(name string) (support bool) {
+	for _, operatorKind := range supportedOperators {
+		if name == operatorKind {
+			support = true
+			break
+		}
+	}
+	return
+}
+
+func getStrategyName(operatorName, kind string) string {
+	return strings.ToLower(fmt.Sprintf("%s-%s", kind, operatorName))
+}
+
+// SetupWithManager set the reconcilers
+func (r *OperatorCRDReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	r.log = ctrl.Log.WithName("OperatorCRDReconciler")
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&apiextensions.CustomResourceDefinition{}).
+		Complete(r)
+}
diff --git a/controllers/fake_controller.go b/controllers/fake_controller.go
deleted file mode 100644
index 8e57138..0000000
--- a/controllers/fake_controller.go
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
-
-
-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 controllers
-
-import (
-	"context"
-
-	"github.com/go-logr/logr"
-	"k8s.io/apimachinery/pkg/runtime"
-	ctrl "sigs.k8s.io/controller-runtime"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-
-	devopsv1alpha3 "kubesphere.io/devops/pkg/api/devops/v1alpha3"
-)
-
-// FakeReconciler reconciles a Fake object
-type FakeReconciler struct {
-	client.Client
-	Log    logr.Logger
-	Scheme *runtime.Scheme
-}
-
-// +kubebuilder:rbac:groups=devops.kubesphere.io,resources=fakes,verbs=get;list;watch;create;update;patch;delete
-// +kubebuilder:rbac:groups=devops.kubesphere.io,resources=fakes/status,verbs=get;update;patch
-
-func (r *FakeReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
-	_ = context.Background()
-	_ = r.Log.WithValues("fake", req.NamespacedName)
-
-	// your logic here
-
-	return ctrl.Result{}, nil
-}
-
-func (r *FakeReconciler) SetupWithManager(mgr ctrl.Manager) error {
-	return ctrl.NewControllerManagedBy(mgr).
-		For(&devopsv1alpha3.Fake{}).
-		Complete(r)
-}
diff --git a/controllers/jenkinsconfig/README.md b/controllers/jenkins/config/README.md
similarity index 100%
rename from controllers/jenkinsconfig/README.md
rename to controllers/jenkins/config/README.md
diff --git a/controllers/jenkins/pipelinerun/pipelinerun_controller.go b/controllers/jenkins/pipelinerun/pipelinerun_controller.go
index 3617bc1..6eb838c 100644
--- a/controllers/jenkins/pipelinerun/pipelinerun_controller.go
+++ b/controllers/jenkins/pipelinerun/pipelinerun_controller.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"kubesphere.io/devops/pkg/utils/k8sutil"
 	"reflect"
 	"time"
 
@@ -36,7 +37,6 @@ import (
 	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
 	devopsClient "kubesphere.io/devops/pkg/client/devops"
 	"kubesphere.io/devops/pkg/jwt/token"
-	"kubesphere.io/devops/pkg/utils/sliceutil"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 )
@@ -82,9 +82,7 @@ func (r *Reconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
 			klog.V(4).Infof("failed to delete Jenkins job history from PipelineRun: %s/%s, error: %v",
 				pipelineRunCopied.Namespace, pipelineRunCopied.Name, err)
 		} else {
-			pipelineRunCopied.ObjectMeta.Finalizers = sliceutil.RemoveString(pipelineRunCopied.ObjectMeta.Finalizers, func(item string) bool {
-				return item == v1alpha3.PipelineRunFinalizerName
-			})
+			k8sutil.RemoveFinalizer(&pipelineRunCopied.ObjectMeta, v1alpha3.PipelineRunFinalizerName)
 			err = r.Update(context.TODO(), pipelineRunCopied)
 		}
 		return ctrl.Result{}, err
@@ -272,9 +270,7 @@ func (r *Reconciler) updateLabelsAndAnnotations(ctx context.Context, pr *v1alpha
 	prToUpdate.Labels = pr.Labels
 	prToUpdate.Annotations = pr.Annotations
 	// make sure all PipelineRuns have the finalizer
-	if !sliceutil.HasString(prToUpdate.ObjectMeta.Finalizers, v1alpha3.PipelineRunFinalizerName) {
-		prToUpdate.ObjectMeta.Finalizers = append(prToUpdate.ObjectMeta.Finalizers, v1alpha3.PipelineRunFinalizerName)
-	}
+	k8sutil.AddFinalizer(&prToUpdate.ObjectMeta, v1alpha3.PipelineRunFinalizerName)
 	return r.Update(ctx, &prToUpdate)
 }
 
diff --git a/docs/README.md b/docs/README.md
index ee46303..1a429bc 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -6,4 +6,5 @@ This is the right place if you want to know more details about `ks-devops`.
 * [projects](projects.md)
 * [e2e](e2e.md)
 * [Swagger Support](swagger.md)
+* [Addon management](addon.md)
 * [Pipeline Template Design](pipeline-template.md)
diff --git a/docs/addon.md b/docs/addon.md
new file mode 100644
index 0000000..b7ef036
--- /dev/null
+++ b/docs/addon.md
@@ -0,0 +1,57 @@
+Addon means an extension of ks-devops. For intance: [Jenkins](http://jenkins.io/), [Argo CD](https://github.com/argoproj/argo-cd/),
+[SonarQube](https://www.sonarqube.org/), [ks-releaser](https://github.com/kubesphere-sigs/ks-releaser/), etc.
+
+There are two CRDs:
+
+* `Addon`, it represents a component
+* `AddonStrategy`, it describes how to install an addon.
+
+## Supported addons
+
+| Name                                                           | Description                                                               |
+|----------------------------------------------------------------|---------------------------------------------------------------------------|
+| [ks-releaser](https://github.com/kubesphere-sigs/ks-releaser/) | Help to release a project which especially has multiple git repositories. |
+| [Argo CD](https://github.com/argoproj/argo-cd/)                | Declarative continuous deployment for Kubernetes.                         |
+
+## How to use?
+
+The `Addon Controller` is optional, please add the flag `--enabled-controllers addon=true` into the controller command line.
+For instance:
+
+```yaml
+spec:
+  containers:
+    - args
+      - --enabled-controllers
+      - addon=true
+      image: ghcr.io/kubesphere/devops-controller
+```
+
+then, create the `AddonStrategy`: `simple-operator-argocd` and `ks-releaser-simple-operator`. You can find the YAML files from [here](../config/samples/addon).
+
+install operator, for instance:
+
+```shell
+kubectl apply -f https://github.com/kubesphere-sigs/ks-releaser-operator/releases/download/v0.0.2/install.yaml
+```
+
+finally, you can install `ks-releaser` by adding the following resource:
+
+```yaml
+apiVersion: devops.kubesphere.io/v1alpha1
+kind: Addon
+metadata:
+  name: ks-releaser
+spec:
+  version: v0.0.14
+  strategy:
+    name: simple-operator-releasercontroller
+```
+
+## Support more?
+
+Want to support more addons? It would be easy if you can find it from the [operator hub](https://operatorhub.io/).
+
+> Restriction:
+> * Require install desired operator manually.
+> * [Hard code](../controllers/addon/operator_controller.go) about the supported addons
diff --git a/go.mod b/go.mod
index b1775e7..d1870ee 100644
--- a/go.mod
+++ b/go.mod
@@ -51,6 +51,7 @@ require (
 	k8s.io/klog/v2 v2.4.0
 	k8s.io/kube-openapi v0.0.0-20210527164424-3c818078ee3d
 	sigs.k8s.io/controller-runtime v0.6.3
+	sigs.k8s.io/yaml v1.2.0
 )
 
 replace (
diff --git a/pkg/api/devops/v1alpha1/addon.go b/pkg/api/devops/v1alpha1/addon.go
new file mode 100644
index 0000000..5ef293a
--- /dev/null
+++ b/pkg/api/devops/v1alpha1/addon.go
@@ -0,0 +1,50 @@
+package v1alpha1
+
+import (
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// AddonSpec is the specification of an Addon
+type AddonSpec struct {
+	ExternalAddress string                  `json:"externalAddress,omitempty"`
+	Version         string                  `json:"version,omitempty"`
+	Strategy        v1.LocalObjectReference `json:"strategy,omitempty"`
+	Parameters      map[string]string       `json:"parameters,omitempty"`
+}
+
+// AddonStatus represents the status of an addon
+type AddonStatus struct {
+	Phase string `json:"phase,omitempty"`
+}
+
+// +genclient
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+// +kubebuilder:printcolumn:name="Version",type=string,JSONPath=`.spec.version`,description="The version of target addon"
+// +kubebuilder:object:root=true
+// +k8s:openapi-gen=true
+
+// Addon represents a plugin (addon) of ks-devops
+type Addon struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   AddonSpec   `json:"spec,omitempty"`
+	Status AddonStatus `json:"status,omitempty"`
+}
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+
+// AddonList contains a list of AddonStrategy
+type AddonList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []Addon `json:"items"`
+}
+
+// AddonFinalizerName is the name of Addone finalizer
+const AddonFinalizerName = "addon.finalizers.kubesphere.io"
+
+func init() {
+	SchemeBuilder.Register(&Addon{}, &AddonList{})
+}
diff --git a/pkg/api/devops/v1alpha1/addon_strategy.go b/pkg/api/devops/v1alpha1/addon_strategy.go
new file mode 100644
index 0000000..85e8d47
--- /dev/null
+++ b/pkg/api/devops/v1alpha1/addon_strategy.go
@@ -0,0 +1,69 @@
+package v1alpha1
+
+import (
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// AddStrategySpec is the specification of an AddonStrategy
+type AddStrategySpec struct {
+	Available      bool                 `json:"available,omitempty"`
+	Type           AddonInstallStrategy `json:"type,omitempty"`
+	YAML           string               `json:"yaml,omitempty"`
+	Operator       v1.ObjectReference   `json:"operator,omitempty"`
+	SimpleOperator v1.ObjectReference   `json:"simpleOperator,omitempty"`
+	HelmRepo       string               `json:"helmRepo,omitempty"`
+	Template       string               `json:"template,omitempty"`
+	Parameters     map[string]string    `json:"parameters,omitempty"`
+}
+
+// AddonInstallStrategy represents the addon installation strategy
+type AddonInstallStrategy string
+
+const (
+	// AddonInstallStrategySimple represents a single YAML file to install addon
+	AddonInstallStrategySimple AddonInstallStrategy = "simple"
+	// AddonInstallStrategyHelm represents to install via helm
+	AddonInstallStrategyHelm AddonInstallStrategy = "helm"
+	// AddonInstallStrategyOperator represents the full operator feature
+	AddonInstallStrategyOperator AddonInstallStrategy = "operator"
+	// AddonInstallStrategySimpleOperator represents a single Operator image to install addon
+	AddonInstallStrategySimpleOperator AddonInstallStrategy = "simple-operator"
+)
+
+// IsValid checks if this is valid
+func (a AddonInstallStrategy) IsValid() bool {
+	switch a {
+	case AddonInstallStrategySimple, AddonInstallStrategyHelm,
+		AddonInstallStrategyOperator, AddonInstallStrategySimpleOperator:
+		return true
+	default:
+		return false
+	}
+}
+
+// +genclient:nonNamespaced
+// +kubebuilder:resource:scope="Cluster"
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+// +k8s:openapi-gen=true
+
+// AddonStrategy represents an addonStrategy
+type AddonStrategy struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec AddStrategySpec `json:"spec,omitempty"`
+}
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+
+// AddonStrategyList contains a list of AddonStrategy
+type AddonStrategyList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []AddonStrategy `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&AddonStrategy{}, &AddonStrategyList{})
+}
diff --git a/pkg/api/devops/v1alpha1/zz_generated.deepcopy.go b/pkg/api/devops/v1alpha1/zz_generated.deepcopy.go
index 5c04c74..fccee50 100644
--- a/pkg/api/devops/v1alpha1/zz_generated.deepcopy.go
+++ b/pkg/api/devops/v1alpha1/zz_generated.deepcopy.go
@@ -26,6 +26,185 @@ import (
 	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 *AddStrategySpec) DeepCopyInto(out *AddStrategySpec) {
+	*out = *in
+	out.Operator = in.Operator
+	out.SimpleOperator = in.SimpleOperator
+	if in.Parameters != nil {
+		in, out := &in.Parameters, &out.Parameters
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AddStrategySpec.
+func (in *AddStrategySpec) DeepCopy() *AddStrategySpec {
+	if in == nil {
+		return nil
+	}
+	out := new(AddStrategySpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Addon) DeepCopyInto(out *Addon) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	out.Status = in.Status
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Addon.
+func (in *Addon) DeepCopy() *Addon {
+	if in == nil {
+		return nil
+	}
+	out := new(Addon)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *Addon) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AddonList) DeepCopyInto(out *AddonList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]Addon, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AddonList.
+func (in *AddonList) DeepCopy() *AddonList {
+	if in == nil {
+		return nil
+	}
+	out := new(AddonList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *AddonList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AddonSpec) DeepCopyInto(out *AddonSpec) {
+	*out = *in
+	out.Strategy = in.Strategy
+	if in.Parameters != nil {
+		in, out := &in.Parameters, &out.Parameters
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AddonSpec.
+func (in *AddonSpec) DeepCopy() *AddonSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(AddonSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AddonStatus) DeepCopyInto(out *AddonStatus) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AddonStatus.
+func (in *AddonStatus) DeepCopy() *AddonStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(AddonStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AddonStrategy) DeepCopyInto(out *AddonStrategy) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AddonStrategy.
+func (in *AddonStrategy) DeepCopy() *AddonStrategy {
+	if in == nil {
+		return nil
+	}
+	out := new(AddonStrategy)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *AddonStrategy) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AddonStrategyList) DeepCopyInto(out *AddonStrategyList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]AddonStrategy, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AddonStrategyList.
+func (in *AddonStrategyList) DeepCopy() *AddonStrategyList {
+	if in == nil {
+		return nil
+	}
+	out := new(AddonStrategyList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *AddonStrategyList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *AuthConfig) DeepCopyInto(out *AuthConfig) {
 	*out = *in
diff --git a/pkg/utils/k8sutil/objectmeta.go b/pkg/utils/k8sutil/objectmeta.go
new file mode 100644
index 0000000..19f5b7c
--- /dev/null
+++ b/pkg/utils/k8sutil/objectmeta.go
@@ -0,0 +1,16 @@
+package k8sutil
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"kubesphere.io/devops/pkg/utils/sliceutil"
+)
+
+// AddFinalizer adds an finalizer
+func AddFinalizer(objectMeta *metav1.ObjectMeta, finalizer string) {
+	objectMeta.Finalizers = sliceutil.AddToSlice(finalizer, objectMeta.Finalizers)
+}
+
+// RemoveFinalizer removes an finalizer
+func RemoveFinalizer(objectMeta *metav1.ObjectMeta, finalizer string) {
+	objectMeta.Finalizers = sliceutil.RemoveString(objectMeta.Finalizers, sliceutil.SameItem(finalizer))
+}
diff --git a/pkg/utils/sliceutil/sliceutils.go b/pkg/utils/sliceutil/sliceutils.go
index 7c49dd1..6d11ff1 100644
--- a/pkg/utils/sliceutil/sliceutils.go
+++ b/pkg/utils/sliceutil/sliceutils.go
@@ -16,6 +16,7 @@ limitations under the License.
 
 package sliceutil
 
+// RemoveString removes an item from a slice with a custom function
 func RemoveString(slice []string, remove func(item string) bool) []string {
 	for i := 0; i < len(slice); i++ {
 		if remove(slice[i]) {
@@ -26,6 +27,14 @@ func RemoveString(slice []string, remove func(item string) bool) []string {
 	return slice
 }
 
+// SameItem returns a function to check if the item is same to
+func SameItem(target string) func(item string) bool {
+	return func(item string) bool {
+		return target == item
+	}
+}
+
+// HasString checks if there is a same string existing in a slice
 func HasString(slice []string, str string) bool {
 	for _, s := range slice {
 		if s == str {
@@ -34,3 +43,11 @@ func HasString(slice []string, str string) bool {
 	}
 	return false
 }
+
+// AddToSlice adds an item to a slice without duplicated
+func AddToSlice(item string, array []string) []string {
+	if !HasString(array, item) {
+		array = append(array, item)
+	}
+	return array
+}


Test patch:
diff --git a/controllers/addon/addon_controller_test.go b/controllers/addon/addon_controller_test.go
new file mode 100644
index 0000000..9e8d70e
--- /dev/null
+++ b/controllers/addon/addon_controller_test.go
@@ -0,0 +1,269 @@
+package addon
+
+import (
+	"context"
+	"fmt"
+	"github.com/go-logr/logr"
+	"github.com/stretchr/testify/assert"
+	v1 "k8s.io/api/core/v1"
+	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/v1alpha1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"testing"
+)
+
+func TestReconciler_supportedStrategy(t *testing.T) {
+	type args struct {
+		strategy *v1alpha1.AddonStrategy
+	}
+	tests := []struct {
+		name string
+		args args
+		want bool
+	}{{
+		name: "argument is nil",
+		want: false,
+	}, {
+		name: "empty object",
+		args: args{strategy: &v1alpha1.AddonStrategy{}},
+		want: false,
+	}, {
+		name: "fake type",
+		args: args{strategy: &v1alpha1.AddonStrategy{
+			Spec: v1alpha1.AddStrategySpec{Type: "fake"},
+		}},
+		want: false,
+	}, {
+		name: "simple-operator",
+		args: args{strategy: &v1alpha1.AddonStrategy{
+			Spec: v1alpha1.AddStrategySpec{Type: "simple-operator"},
+		}},
+		want: true,
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &Reconciler{}
+			assert.Equalf(t, tt.want, r.supportedStrategy(tt.args.strategy), "supportedStrategy(%v)", tt.args.strategy)
+		})
+	}
+}
+
+func Test_getTemplate(t *testing.T) {
+	type args struct {
+		tpl   string
+		addon *v1alpha1.Addon
+	}
+	tests := []struct {
+		name       string
+		args       args
+		wantResult string
+		wantErr    assert.ErrorAssertionFunc
+	}{{
+		name: "addon is nil",
+		args: args{
+			tpl: "this is a template",
+		},
+		wantResult: "this is a template",
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			return false
+		},
+	}, {
+		name: "addon with version",
+		args: args{
+			tpl:   "{{.spec.version}}",
+			addon: &v1alpha1.Addon{Spec: v1alpha1.AddonSpec{Version: "v1alpha1"}},
+		},
+		wantResult: "v1alpha1",
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			return false
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotResult, err := getTemplate(tt.args.tpl, tt.args.addon)
+			if !tt.wantErr(t, err, fmt.Sprintf("getTemplate(%v, %v)", tt.args.tpl, tt.args.addon)) {
+				return
+			}
+			assert.Equalf(t, tt.wantResult, gotResult, "getTemplate(%v, %v)", tt.args.tpl, tt.args.addon)
+		})
+	}
+}
+
+func TestReconciler_addonHandle(t *testing.T) {
+	schema, err := v1alpha1.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+
+	strategy := &v1alpha1.AddonStrategy{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "simple-operator",
+		},
+		Spec: v1alpha1.AddStrategySpec{
+			Available:      true,
+			Type:           v1alpha1.AddonInstallStrategySimpleOperator,
+			SimpleOperator: v1.ObjectReference{},
+			Template: `apiVersion: devops.kubesphere.io/v1alpha1
+kind: ReleaserController
+spec:
+  image: "ghcr.io/kubesphere-sigs/ks-releaser"
+  version: {{.Spec.Version}}
+  webhook: false`,
+		},
+	}
+	addon := &v1alpha1.Addon{
+		ObjectMeta: metav1.ObjectMeta{Name: "ks-releaser", Namespace: "default"},
+		Spec: v1alpha1.AddonSpec{
+			Version: "v0.0.1",
+			Strategy: v1.LocalObjectReference{
+				Name: "simple-operator",
+			},
+		},
+	}
+
+	type fields struct {
+		Client client.Client
+	}
+	type args struct {
+		ctx   context.Context
+		addon *v1alpha1.Addon
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		args    args
+		wantErr assert.ErrorAssertionFunc
+		verify  func(t *testing.T, Client client.Client)
+	}{{
+		name: "normal case",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, strategy.DeepCopy(), addon.DeepCopy()),
+		},
+		args: args{
+			ctx:   context.TODO(),
+			addon: addon.DeepCopy(),
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			return err == nil
+		},
+		verify: func(t *testing.T, c client.Client) {
+			var err error
+			addon := &v1alpha1.Addon{}
+			err = c.Get(context.Background(), types.NamespacedName{Name: "ks-releaser", Namespace: "default"}, addon)
+			assert.Nil(t, err)
+			assert.ElementsMatch(t, []string{v1alpha1.AddonFinalizerName}, addon.Finalizers)
+
+			obj := &unstructured.Unstructured{}
+			obj.SetKind("ReleaserController")
+			obj.SetAPIVersion("devops.kubesphere.io/v1alpha1")
+
+			err = c.Get(context.Background(), types.NamespacedName{
+				Namespace: "default",
+				Name:      "ks-releaser",
+			}, obj)
+			assert.Nil(t, err)
+			assert.Equal(t, "ks-releaser", obj.GetName())
+
+			var image string
+			var ok bool
+			image, ok, err = unstructured.NestedString(obj.Object, "spec", "image")
+			assert.Nil(t, err)
+			assert.True(t, ok)
+			assert.Equal(t, "ghcr.io/kubesphere-sigs/ks-releaser", image)
+		},
+	}, {
+		name: "update existing addon",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, strategy.DeepCopy(), &unstructured.Unstructured{Object: map[string]interface{}{
+				"apiVersion": "devops.kubesphere.io/v1alpha1",
+				"kind":       "ReleaserController",
+				"metadata": map[string]interface{}{
+					"name":      "ks-releaser",
+					"namespace": "default",
+				},
+			}}),
+		},
+		args: args{
+			ctx: context.TODO(),
+			addon: &v1alpha1.Addon{
+				ObjectMeta: metav1.ObjectMeta{Name: "ks-releaser"},
+				Spec: v1alpha1.AddonSpec{
+					Version: "v0.0.1",
+					Strategy: v1.LocalObjectReference{
+						Name: "simple-operator",
+					},
+				}},
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			return err == nil
+		},
+		verify: func(t *testing.T, c client.Client) {
+			obj := &unstructured.Unstructured{}
+			obj.SetKind("ReleaserController")
+			obj.SetAPIVersion("devops.kubesphere.io/v1alpha1")
+
+			err := c.Get(context.Background(), types.NamespacedName{
+				Namespace: "default",
+				Name:      "ks-releaser",
+			}, obj)
+			assert.Nil(t, err)
+			assert.Equal(t, "ks-releaser", obj.GetName())
+
+			var image string
+			var ok bool
+			image, ok, err = unstructured.NestedString(obj.Object, "spec", "image")
+			assert.Nil(t, err)
+			assert.True(t, ok)
+			assert.Equal(t, "ghcr.io/kubesphere-sigs/ks-releaser", image)
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &Reconciler{
+				Client:   tt.fields.Client,
+				log:      logr.Discard(),
+				recorder: record.NewFakeRecorder(100),
+			}
+			tt.wantErr(t, r.addonHandle(tt.args.ctx, tt.args.addon), fmt.Sprintf("addonHandle(%v, %v)", tt.args.ctx, tt.args.addon))
+			if tt.verify != nil {
+				tt.verify(t, r.Client)
+			}
+		})
+	}
+}
+
+func Test_beingDeleting(t *testing.T) {
+	nowTime := metav1.Now()
+
+	type args struct {
+		addon *v1alpha1.Addon
+	}
+	tests := []struct {
+		name string
+		args args
+		want bool
+	}{{
+		name: "empty struct",
+		args: args{
+			addon: &v1alpha1.Addon{},
+		},
+		want: false,
+	}, {
+		name: "has deletionTimestamp",
+		args: args{
+			addon: &v1alpha1.Addon{
+				ObjectMeta: metav1.ObjectMeta{
+					DeletionTimestamp: &nowTime,
+				},
+			},
+		},
+		want: true,
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equalf(t, tt.want, beingDeleting(tt.args.addon), "beingDeleting(%v)", tt.args.addon)
+		})
+	}
+}
diff --git a/controllers/addon/operator_controller_test.go b/controllers/addon/operator_controller_test.go
new file mode 100644
index 0000000..01e75b2
--- /dev/null
+++ b/controllers/addon/operator_controller_test.go
@@ -0,0 +1,169 @@
+package addon
+
+import (
+	"context"
+	"github.com/go-logr/logr"
+	"github.com/stretchr/testify/assert"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"testing"
+)
+
+func Test_getStrategyName(t *testing.T) {
+	type args struct {
+		operatorName string
+		kind         string
+	}
+	tests := []struct {
+		name string
+		args args
+		want string
+	}{{
+		name: "have upper case",
+		args: args{
+			operatorName: "Name",
+			kind:         "Kind",
+		},
+		want: "kind-name",
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := getStrategyName(tt.args.operatorName, tt.args.kind); got != tt.want {
+				t.Errorf("getStrategyName() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestOperatorCRDReconciler_operatorsHandle(t *testing.T) {
+	schema, err := v1alpha1.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+
+	type fields struct {
+		Client client.Client
+		log    logr.Logger
+	}
+	type args struct {
+		name    string
+		version string
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		args    args
+		wantErr bool
+		verify  func(t *testing.T, c client.Client)
+	}{{
+		name: "not support operator",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema),
+			log:    logr.Discard(),
+		},
+		args: args{
+			name:    "fake",
+			version: "devops.kubesphere.io/v1alpha1",
+		},
+		wantErr: false,
+		verify:  func(t *testing.T, c client.Client) {},
+	}, {
+		name: "normal case",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema),
+			log:    logr.Discard(),
+		},
+		args: args{
+			name:    "ReleaserController",
+			version: "devops.kubesphere.io/v1alpha1",
+		},
+		wantErr: false,
+		verify: func(t *testing.T, c client.Client) {
+			result := &v1alpha1.AddonStrategy{}
+			err := c.Get(context.TODO(), types.NamespacedName{
+				Name: "simple-operator-releasercontroller",
+			}, result)
+			assert.Nil(t, err)
+			assert.NotNil(t, result)
+			assert.Equal(t, v1alpha1.AddonInstallStrategy("simple-operator"), result.Spec.Type)
+			assert.Equal(t, "ReleaserController", result.Spec.SimpleOperator.Kind)
+			assert.Equal(t, "devops.kubesphere.io/v1alpha1", result.Spec.SimpleOperator.APIVersion)
+		},
+	}, {
+		name: "update the existing",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, &v1alpha1.AddonStrategy{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "simple-operator-releasercontroller",
+				},
+				Spec: v1alpha1.AddStrategySpec{
+					Type: v1alpha1.AddonInstallStrategySimpleOperator,
+					SimpleOperator: v1.ObjectReference{
+						Kind:       "ReleaserController",
+						APIVersion: "devops.kubesphere.io/v1",
+					},
+				},
+			}),
+			log: logr.Discard(),
+		},
+		args: args{
+			name:    "ReleaserController",
+			version: "devops.kubesphere.io/v1alpha1",
+		},
+		wantErr: false,
+		verify: func(t *testing.T, c client.Client) {
+			result := &v1alpha1.AddonStrategy{}
+			err := c.Get(context.TODO(), types.NamespacedName{
+				Name: "simple-operator-releasercontroller",
+			}, result)
+			assert.Nil(t, err)
+			assert.NotNil(t, result)
+			assert.True(t, result.Spec.Available)
+			assert.Equal(t, v1alpha1.AddonInstallStrategy("simple-operator"), result.Spec.Type)
+			assert.Equal(t, "ReleaserController", result.Spec.SimpleOperator.Kind)
+			assert.Equal(t, "devops.kubesphere.io/v1alpha1", result.Spec.SimpleOperator.APIVersion)
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &OperatorCRDReconciler{
+				Client: tt.fields.Client,
+				log:    tt.fields.log,
+			}
+			if err := r.operatorsHandle(tt.args.name, tt.args.version); (err != nil) != tt.wantErr {
+				t.Errorf("operatorsHandle() error = %v, wantErr %v", err, tt.wantErr)
+			}
+			tt.verify(t, tt.fields.Client)
+		})
+	}
+}
+
+func Test_operatorSupport(t *testing.T) {
+	type args struct {
+		name string
+	}
+	tests := []struct {
+		name        string
+		args        args
+		wantSupport bool
+	}{{
+		name: "supported: ReleaserController",
+		args: args{
+			name: "ReleaserController",
+		},
+		wantSupport: true,
+	}, {
+		name: "supported: fake",
+		args: args{
+			name: "fake",
+		},
+		wantSupport: false,
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equalf(t, tt.wantSupport, operatorSupport(tt.args.name), "operatorSupport(%v)", tt.args.name)
+		})
+	}
+}
diff --git a/pkg/api/devops/v1alpha1/addon_strategy_test.go b/pkg/api/devops/v1alpha1/addon_strategy_test.go
new file mode 100644
index 0000000..14624a2
--- /dev/null
+++ b/pkg/api/devops/v1alpha1/addon_strategy_test.go
@@ -0,0 +1,39 @@
+package v1alpha1
+
+import (
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func TestAddonInstallStrategy_IsValid(t *testing.T) {
+	tests := []struct {
+		name string
+		a    AddonInstallStrategy
+		want bool
+	}{{
+		name: "normal case - simple",
+		a:    AddonInstallStrategySimple,
+		want: true,
+	}, {
+		name: "normal case - helm",
+		a:    AddonInstallStrategyHelm,
+		want: true,
+	}, {
+		name: "normal case - operator",
+		a:    AddonInstallStrategyOperator,
+		want: true,
+	}, {
+		name: "normal case - simple-operator",
+		a:    AddonInstallStrategySimpleOperator,
+		want: true,
+	}, {
+		name: "a fake strategy",
+		a:    AddonInstallStrategy("fake"),
+		want: false,
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equalf(t, tt.want, tt.a.IsValid(), "IsValid()")
+		})
+	}
+}
diff --git a/pkg/utils/k8sutil/k8sutil_test.go b/pkg/utils/k8sutil/k8sutil_test.go
new file mode 100644
index 0000000..a6fe4db
--- /dev/null
+++ b/pkg/utils/k8sutil/k8sutil_test.go
@@ -0,0 +1,47 @@
+package k8sutil
+
+import (
+	"github.com/stretchr/testify/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"testing"
+)
+
+func TestIsControlledBy(t *testing.T) {
+	type args struct {
+		ownerReferences []metav1.OwnerReference
+		kind            string
+		name            string
+	}
+	tests := []struct {
+		name string
+		args args
+		want bool
+	}{{
+		name: "normal case, has the reference",
+		args: args{
+			ownerReferences: []metav1.OwnerReference{{
+				Kind: "kind",
+				Name: "name",
+			}},
+			kind: "kind",
+			name: "name",
+		},
+		want: true,
+	}, {
+		name: "not have the reference",
+		args: args{
+			ownerReferences: []metav1.OwnerReference{{
+				Kind: "kind",
+				Name: "name",
+			}},
+			kind: "kind",
+			name: "fake",
+		},
+		want: false,
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equalf(t, tt.want, IsControlledBy(tt.args.ownerReferences, tt.args.kind, tt.args.name), "IsControlledBy(%v, %v, %v)", tt.args.ownerReferences, tt.args.kind, tt.args.name)
+		})
+	}
+}
diff --git a/pkg/utils/k8sutil/objectmeta_test.go b/pkg/utils/k8sutil/objectmeta_test.go
new file mode 100644
index 0000000..b570a15
--- /dev/null
+++ b/pkg/utils/k8sutil/objectmeta_test.go
@@ -0,0 +1,72 @@
+package k8sutil
+
+import (
+	"github.com/stretchr/testify/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"testing"
+)
+
+type demoCR struct {
+	metav1.ObjectMeta
+}
+
+func TestAddFinalizer(t *testing.T) {
+	demo := &demoCR{}
+
+	type args struct {
+		objectMeta *metav1.ObjectMeta
+		finalizer  string
+	}
+	tests := []struct {
+		name   string
+		args   args
+		verify func(t *testing.T, meta *metav1.ObjectMeta)
+	}{{
+		name: "normal case",
+		args: args{
+			objectMeta: &demo.ObjectMeta,
+			finalizer:  "abc",
+		},
+		verify: func(t *testing.T, meta *metav1.ObjectMeta) {
+			assert.ElementsMatch(t, []string{"abc"}, meta.Finalizers)
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			AddFinalizer(tt.args.objectMeta, tt.args.finalizer)
+			tt.verify(t, tt.args.objectMeta)
+		})
+	}
+}
+
+func TestRemoveFinalizer(t *testing.T) {
+	demo := &demoCR{
+		ObjectMeta: metav1.ObjectMeta{
+			Finalizers: []string{"abc", "def"},
+		}}
+
+	type args struct {
+		objectMeta *metav1.ObjectMeta
+		finalizer  string
+	}
+	tests := []struct {
+		name   string
+		args   args
+		verify func(t *testing.T, meta *metav1.ObjectMeta)
+	}{{
+		name: "normal case",
+		args: args{
+			objectMeta: &demo.ObjectMeta,
+			finalizer:  "def",
+		},
+		verify: func(t *testing.T, meta *metav1.ObjectMeta) {
+			assert.ElementsMatch(t, []string{"abc"}, meta.Finalizers)
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			RemoveFinalizer(tt.args.objectMeta, tt.args.finalizer)
+			tt.verify(t, tt.args.objectMeta)
+		})
+	}
+}
diff --git a/pkg/utils/sliceutil/sliceutils_test.go b/pkg/utils/sliceutil/sliceutils_test.go
new file mode 100644
index 0000000..ca63e4a
--- /dev/null
+++ b/pkg/utils/sliceutil/sliceutils_test.go
@@ -0,0 +1,115 @@
+package sliceutil
+
+import (
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func TestAddToSlice(t *testing.T) {
+	type args struct {
+		item  string
+		array []string
+	}
+	tests := []struct {
+		name   string
+		args   args
+		verify func(t *testing.T, array []string)
+	}{{
+		name: "no existing",
+		args: args{
+			item:  "a",
+			array: []string{"b"},
+		},
+		verify: func(t *testing.T, array []string) {
+			assert.ElementsMatch(t, []string{"a", "b"}, array)
+		},
+	}, {
+		name: "existing",
+		args: args{
+			item:  "a",
+			array: []string{"b", "a"},
+		},
+		verify: func(t *testing.T, array []string) {
+			assert.ElementsMatch(t, []string{"a", "b"}, array)
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			tt.args.array = AddToSlice(tt.args.item, tt.args.array)
+			tt.verify(t, tt.args.array)
+		})
+	}
+}
+
+func TestRemoveString(t *testing.T) {
+	type args struct {
+		slice  []string
+		remove func(item string) bool
+	}
+	tests := []struct {
+		name string
+		args args
+		want []string
+	}{{
+		name: "remove a not exist item",
+		args: args{
+			slice: []string{"a", "b"},
+			remove: func(item string) bool {
+				return item == "c"
+			},
+		},
+		want: []string{"a", "b"},
+	}, {
+		name: "remove the exit item",
+		args: args{
+			slice: []string{"a", "b"},
+			remove: func(item string) bool {
+				return item == "b"
+			},
+		},
+		want: []string{"a"},
+	}, {
+		name: "remove the exit item with the function",
+		args: args{
+			slice:  []string{"a", "b"},
+			remove: SameItem("b"),
+		},
+		want: []string{"a"},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equalf(t, tt.want, RemoveString(tt.args.slice, tt.args.remove), "RemoveString(%v, %v)", tt.args.slice, tt.args.remove)
+		})
+	}
+}
+
+func TestHasString(t *testing.T) {
+	type args struct {
+		slice []string
+		str   string
+	}
+	tests := []struct {
+		name string
+		args args
+		want bool
+	}{{
+		name: "has item",
+		args: args{
+			slice: []string{"a", "b"},
+			str:   "a",
+		},
+		want: true,
+	}, {
+		name: "not have item",
+		args: args{
+			slice: []string{"a", "b"},
+			str:   "c",
+		},
+		want: false,
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equalf(t, tt.want, HasString(tt.args.slice, tt.args.str), "HasString(%v, %v)", tt.args.slice, tt.args.str)
+		})
+	}
+}