Repository: kubesphere/ks-devops
Pull request #703 (2022-06-30 02:04:51)

Support the basic image updater feature base on Argo
<!-- 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
-->

### What this PR does / why we need it:

```
kubespheredev/devops-controller:dev--@sha256:8b2c0471b912356b7e91f9bbbb2d0eefd5676e90b5c557dd07e03639988e398f
```

### Which issue(s) this PR fixes:
<!--
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
_If PR is about `failing-tests or flakes`, please post the related issues/tests in a comment and do not use `Fixes`_*
Please leave it or change # to be None if there is no corresponding issue that exists
-->
Fixes #

### Special notes for reviewers:
<!--
You can use the following command to let the DevOps SIG members help you to review your PR.
/cc @kubesphere/sig-devops 
And please avoid cc any individual.
-->
Please check the following list before waiting reviewers:

- [ ] Already committed the CRD files to [the Helm Chart](https://github.com/kubesphere-sigs/ks-devops-helm-chart/) if you created some new CRDs
- [ ] Already [added the permission](https://github.com/kubesphere/ks-installer/blob/9e063b085a0e43fdb3d0d9e3e7f4149146f14b9c/roles/ks-core/prepare/files/ks-init/role-templates.yaml) for the new API
- [ ] Already added the RBAC markers for the new controllers

### Does this PR introduce a user-facing change??
<!--
If no, just write "None" in the release-note block below.
If yes, a release note is required:
Enter your extended-release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".

Please keep the note be same as your PR title if you believe it should be in the release notes.
-->
```release-note
None
```


ks-ci-bot: @LinuxSuRen: Adding the "do-not-merge/release-note-label-needed" label because no release-note block was detected, please follow our [release note process](https://git.k8s.io/community/contributors/guide/release-notes.md) to remove it.

<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 **NOT APPROVED**

This pull-request has been approved by:
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please ask for approval from **linuxsuren** after the PR has been reviewed.

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

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

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

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":["linuxsuren"]} -->
codecov[bot]: # [Codecov](https://codecov.io/gh/kubesphere/ks-devops/pull/703?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) Report
> Merging [#703](https://codecov.io/gh/kubesphere/ks-devops/pull/703?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) (8c2ec0c) into [master](https://codecov.io/gh/kubesphere/ks-devops/commit/459b8622003dcb01f0e95dea71e14f6c26c6f10b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) (459b862) will **increase** coverage by `0.02%`.
> The diff coverage is `88.88%`.

```diff
@@            Coverage Diff             @@
##           master     #703      +/-   ##
==========================================
+ Coverage   77.62%   77.64%   +0.02%     
==========================================
  Files         154      156       +2     
  Lines       23559    23604      +45     
==========================================
+ Hits        18287    18327      +40     
- Misses       5041     5046       +5     
  Partials      231      231              
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `77.64% <88.88%> (+0.02%)` | :arrow_up: |

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/703?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) | Coverage Δ | |
|---|---|---|
| [controllers/argocd/image-updater-controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/703/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvYXJnb2NkL2ltYWdlLXVwZGF0ZXItY29udHJvbGxlci5nbw==) | `88.63% <88.63%> (ø)` | |
| [pkg/api/gitops/v1alpha1/image-updater.go](https://codecov.io/gh/kubesphere/ks-devops/pull/703/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2FwaS9naXRvcHMvdjFhbHBoYTEvaW1hZ2UtdXBkYXRlci5nbw==) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/kubesphere/ks-devops/pull/703?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/703?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere). Last update [459b862...8c2ec0c](https://codecov.io/gh/kubesphere/ks-devops/pull/703?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).


Patch:
diff --git a/.licenserc.yaml b/.licenserc.yaml
index 62ca471..dda27e1 100644
--- a/.licenserc.yaml
+++ b/.licenserc.yaml
@@ -48,5 +48,6 @@ header:
     - 'pkg/client/devops/jenkins/job.go'
     - 'pkg/client/devops/jenkins/request.go'
     - 'pkg/client/devops/jenkins/utils.go'
+    - '**/*.json'
 
   comment: on-failure
diff --git a/cmd/controller/app/controllers.go b/cmd/controller/app/controllers.go
index b51b6c7..f508da1 100644
--- a/cmd/controller/app/controllers.go
+++ b/cmd/controller/app/controllers.go
@@ -112,6 +112,9 @@ func getAllControllers(mgr manager.Manager, client k8s.Client, informerFactory i
 		Client:        mgr.GetClient(),
 		ArgoNamespace: s.ArgoCDOption.Namespace,
 	}
+	argcdImageUpdaterReconciler := &argocd.ImageUpdaterReconciler{
+		Client: mgr.GetClient(),
+	}
 	gitRepoReconcilers := gitrepository.GetReconcilers(mgr.GetClient())
 
 	return map[string]func(mgr manager.Manager) error{
@@ -177,5 +180,8 @@ func getAllControllers(mgr manager.Manager, client k8s.Client, informerFactory i
 			}
 			return argocdAppReconciler.SetupWithManager(mgr)
 		},
+		argcdImageUpdaterReconciler.GetGroupName() + "-image-updater": func(mgr manager.Manager) error {
+			return argcdImageUpdaterReconciler.SetupWithManager(mgr)
+		},
 	}
 }
diff --git a/config/crd/bases/devops.kubesphere.io_clustersteptemplates.yaml b/config/crd/bases/devops.kubesphere.io_clustersteptemplates.yaml
index 0b4ae33..5351cd9 100644
--- a/config/crd/bases/devops.kubesphere.io_clustersteptemplates.yaml
+++ b/config/crd/bases/devops.kubesphere.io_clustersteptemplates.yaml
@@ -40,6 +40,7 @@ spec:
                 type: string
               parameters:
                 items:
+                  description: ParameterInStep is the parameter which used in a step
                   properties:
                     defaultValue:
                       type: string
@@ -56,6 +57,7 @@ spec:
               runtime:
                 type: string
               secret:
+                description: SecretInStep is the secret which used in a step
                 properties:
                   mapping:
                     additionalProperties:
diff --git a/config/crd/bases/gitops.kubesphere.io_imageupdaters.yaml b/config/crd/bases/gitops.kubesphere.io_imageupdaters.yaml
new file mode 100644
index 0000000..7729c6b
--- /dev/null
+++ b/config/crd/bases/gitops.kubesphere.io_imageupdaters.yaml
@@ -0,0 +1,110 @@
+
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.6.2
+  creationTimestamp: null
+  name: imageupdaters.gitops.kubesphere.io
+spec:
+  group: gitops.kubesphere.io
+  names:
+    kind: ImageUpdater
+    listKind: ImageUpdaterList
+    plural: imageupdaters
+    singular: imageupdater
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - jsonPath: .spec.kind
+      name: Kind
+      type: string
+    name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: ImageUpdater represents an image updating request
+        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: ImageUpdaterSpec is the specification of the image updater
+            properties:
+              argo:
+                description: ArgoImageUpdater is the specification of the Argo image
+                  updater
+                properties:
+                  allowTags:
+                    additionalProperties:
+                      type: string
+                    type: object
+                  app:
+                    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
+                  ignoreTags:
+                    additionalProperties:
+                      type: string
+                    type: object
+                  platforms:
+                    additionalProperties:
+                      type: string
+                    type: object
+                  secrets:
+                    additionalProperties:
+                      type: string
+                    type: object
+                  updateStrategy:
+                    additionalProperties:
+                      type: string
+                    type: object
+                  write:
+                    default: built-in
+                    description: WriteMethod is an alias of string that represents
+                      the write back method of Argo CD Image updater
+                    enum:
+                    - built-in
+                    - git
+                    type: string
+                required:
+                - app
+                type: object
+              images:
+                items:
+                  type: string
+                type: array
+              kind:
+                default: argocd
+                enum:
+                - argocd
+                - fluxcd
+                type: string
+            type: object
+        required:
+        - spec
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 4ae43f3..d12f08b 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -308,3 +308,11 @@ rules:
   verbs:
   - get
   - update
+- apiGroups:
+  - gitops.kubesphere.io
+  resources:
+  - imageupdaters
+  verbs:
+  - get
+  - list
+  - watch
diff --git a/config/samples/gitops_v1alpha1_application.yaml b/config/samples/gitops/application.yaml
similarity index 60%
rename from config/samples/gitops_v1alpha1_application.yaml
rename to config/samples/gitops/application.yaml
index 41c7596..ad6eebf 100644
--- a/config/samples/gitops_v1alpha1_application.yaml
+++ b/config/samples/gitops/application.yaml
@@ -2,14 +2,16 @@ apiVersion: gitops.kubesphere.io/v1alpha1
 kind: Application
 metadata:
   name: app
+  annotations:
+    argocd-image-updater.argoproj.io/image-list: ghcr.io/linuxsuren-bot/open-podcasts-ui
 spec:
   argoApp:
     spec:
-      project: default
+      project: testddmtg
       source:
-        repoURL: https://gitlab.com/LinuxSuRen1/argocd
+        repoURL: https://github.com/linuxsuren-bot/open-podcasts/
         targetRevision: HEAD
-        path: test
+        path: config/default
         directory:
           recurse: true
       destination:
diff --git a/config/samples/gitops/image-updater.yaml b/config/samples/gitops/image-updater.yaml
new file mode 100644
index 0000000..9c2f83c
--- /dev/null
+++ b/config/samples/gitops/image-updater.yaml
@@ -0,0 +1,15 @@
+apiVersion: gitops.kubesphere.io/v1alpha1
+kind: ImageUpdater
+metadata:
+  name: updater
+  namespace: testddmtg
+spec:
+  # default value of kind is argocd
+#  kind: argocd
+  images:
+    - ghcr.io/linuxsuren-bot/open-podcasts-ui
+    - nginx
+    - alpine
+  argo:
+    app:
+      name: app
diff --git a/controllers/argocd/argocd-application-status-controller.go b/controllers/argocd/argocd-application-status-controller.go
index a5d9d8c..5d925d5 100644
--- a/controllers/argocd/argocd-application-status-controller.go
+++ b/controllers/argocd/argocd-application-status-controller.go
@@ -32,6 +32,7 @@ import (
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/predicate"
+	"strings"
 )
 
 //+kubebuilder:rbac:groups=gitops.kubesphere.io,resources=applications,verbs=get;update
@@ -97,13 +98,19 @@ func (r *ApplicationStatusReconciler) Reconcile(req ctrl.Request) (result ctrl.R
 				app.Spec.ArgoApp.Operation = nil
 			}
 
-			// update labels
-			if err = r.Update(ctx, app); err != nil {
-				return
+			var argoS *argoStatus
+			if argoS, err = parseArgoStatus(statusData); err == nil {
+				if app.Annotations == nil {
+					app.Annotations = map[string]string{}
+				}
+				app.Annotations[v1alpha1.AnnoKeyImages] = strings.Join(argoS.Summary.Images, ",")
 			}
 
-			app.Status.ArgoApp = string(statusData)
-			err = r.Status().Update(ctx, app)
+			// update labels
+			if err = r.Update(ctx, app); err == nil {
+				app.Status.ArgoApp = string(statusData)
+				err = r.Status().Update(ctx, app)
+			}
 		}
 	}
 	return
@@ -138,6 +145,21 @@ func createBareArgoCDApplicationObject() *unstructured.Unstructured {
 	return argoApp
 }
 
+// we can add more fields when need it
+type argoStatus struct {
+	Summary argoStatusSummary `json:"summary"`
+}
+
+type argoStatusSummary struct {
+	Images []string `json:"images"`
+}
+
+func parseArgoStatus(data []byte) (status *argoStatus, err error) {
+	status = &argoStatus{}
+	err = json.Unmarshal(data, status)
+	return
+}
+
 // SetupWithManager init the logger, recorder and filters
 func (r *ApplicationStatusReconciler) SetupWithManager(mgr ctrl.Manager) error {
 	argoApp := createBareArgoCDApplicationObject()
diff --git a/controllers/argocd/data/argo-status-without-summary.json b/controllers/argocd/data/argo-status-without-summary.json
new file mode 100644
index 0000000..eb97e78
--- /dev/null
+++ b/controllers/argocd/data/argo-status-without-summary.json
@@ -0,0 +1,7 @@
+{
+  "health": {
+    "status": "Healthy"
+  },
+  "reconciledAt": "2022-06-30T06:48:05Z",
+  "sourceType": "Kustomize"
+}
diff --git a/controllers/argocd/data/argo-status.json b/controllers/argocd/data/argo-status.json
new file mode 100644
index 0000000..2f37cda
--- /dev/null
+++ b/controllers/argocd/data/argo-status.json
@@ -0,0 +1,35 @@
+{
+  "health": {
+    "status": "Healthy"
+  },
+  "reconciledAt": "2022-06-30T06:48:05Z",
+  "sourceType": "Kustomize",
+  "summary": {
+    "images": [
+      "ghcr.io/linuxsuren-bot/open-podcasts-ui:v1.0.2",
+      "ghcr.io/linuxsuren-bot/open-podcasts:v1.0.0",
+      "ghcr.io/opensource-f2f/kube-rbac-proxy:v0.8.0",
+      "ghcr.io/opensource-f2f/open-podcasts-apiserver:dev"
+    ]
+  },
+  "sync": {
+    "comparedTo": {
+      "destination": {
+        "name": "in-cluster",
+        "namespace": "argocd"
+      },
+      "source": {
+        "kustomize": {
+          "images": [
+            "ghcr.io/linuxsuren-bot/open-podcasts-ui:v1.0.2"
+          ]
+        },
+        "path": "config/default",
+        "repoURL": "https://github.com/linuxsuren-bot/open-podcasts/",
+        "targetRevision": "HEAD"
+      }
+    },
+    "revision": "1d4fcf1f56f5dbe2ae65abb001ffc304f1d58070",
+    "status": "Synced"
+  }
+}
diff --git a/controllers/argocd/image-updater-controller.go b/controllers/argocd/image-updater-controller.go
new file mode 100644
index 0000000..59c1b70
--- /dev/null
+++ b/controllers/argocd/image-updater-controller.go
@@ -0,0 +1,151 @@
+/*
+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"
+	"fmt"
+	"github.com/go-logr/logr"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
+	"kubesphere.io/devops/pkg/api/gitops/v1alpha1"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"strings"
+	"time"
+)
+
+//+kubebuilder:rbac:groups=gitops.kubesphere.io,resources=applications,verbs=get;list;update
+//+kubebuilder:rbac:groups=gitops.kubesphere.io,resources=imageupdaters,verbs=get;list;watch
+//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch
+
+// ImageUpdaterReconciler is the reconciler of the ImageUpdater
+type ImageUpdaterReconciler struct {
+	client.Client
+	log      logr.Logger
+	recorder record.EventRecorder
+}
+
+// Reconcile makes sure the Application has the expected annotations
+func (r *ImageUpdaterReconciler) Reconcile(req ctrl.Request) (result ctrl.Result, err error) {
+	ctx := context.Background()
+	r.log.Info(fmt.Sprintf("start to reconcile imageUpdater: %s", req.String()))
+
+	updater := &v1alpha1.ImageUpdater{}
+	if err = r.Get(ctx, req.NamespacedName, updater); err != nil {
+		err = client.IgnoreNotFound(err)
+		return
+	}
+
+	// skip if kind is not argocd
+	if updater.Spec.Kind != "argocd" {
+		r.log.V(7).Info(fmt.Sprintf("skip %s due to the spec.kind value is not argocd", req.String()))
+		return
+	}
+
+	argo := updater.Spec.Argo
+	if argo == nil {
+		r.log.V(7).Info(fmt.Sprintf("skip %s due to the Argo is nil", req.String()))
+		return
+	}
+
+	appNs := req.Namespace
+	appName := argo.App.Name
+	if appName == "" {
+		r.recorder.Eventf(updater, corev1.EventTypeWarning, "Missing", "application name is required")
+		return
+	}
+
+	app := &v1alpha1.Application{}
+	if err = r.Get(ctx, types.NamespacedName{
+		Namespace: appNs,
+		Name:      appName,
+	}, app); err != nil {
+		result = ctrl.Result{RequeueAfter: time.Minute}
+		return
+	}
+
+	if app.Annotations == nil {
+		app.Annotations = map[string]string{}
+	}
+	setImagePreference(argo, app.Annotations)
+
+	updateImageList(updater.Spec.Images, app.Annotations)
+	err = r.Update(ctx, app)
+	return
+}
+
+func setImagePreference(argo *v1alpha1.ArgoImageUpdater, annotations map[string]string) {
+	// set write method
+	if argo.Write.GetValue() != "" {
+		annotations["argocd-image-updater.argoproj.io/write-back-method"] = argo.Write.GetValue()
+	}
+
+	for name, secret := range argo.Secrets {
+		annotations[fmt.Sprintf("argocd-image-updater.argoproj.io/%s.pull-secret", name)] =
+			fmt.Sprintf("pullsecret:%s", secret)
+	}
+
+	for name, tagReg := range argo.AllowTags {
+		annotations[fmt.Sprintf("argocd-image-updater.argoproj.io/%s.allow-tags", name)] = fmt.Sprintf("regexp:%s", tagReg)
+	}
+
+	for name, strategy := range argo.UpdateStrategy {
+		annotations[fmt.Sprintf("argocd-image-updater.argoproj.io/%s.update-strategy", name)] = strategy
+	}
+
+	for name, platform := range argo.Platforms {
+		annotations[fmt.Sprintf("argocd-image-updater.argoproj.io/%s.platforms", name)] = platform
+	}
+
+	for name, ignoreTag := range argo.IgnoreTags {
+		annotations[fmt.Sprintf("argocd-image-updater.argoproj.io/%s.ignore-tags", name)] = ignoreTag
+	}
+}
+
+func updateImageList(images []string, annotations map[string]string) {
+	if len(images) == 0 {
+		if _, ok := annotations["argocd-image-updater.argoproj.io/image-list"]; !ok {
+			return
+		}
+	}
+	if len(images) == 0 {
+		delete(annotations, "argocd-image-updater.argoproj.io/image-list")
+	} else {
+		annotations["argocd-image-updater.argoproj.io/image-list"] = strings.Join(images, ",")
+	}
+}
+
+// GetName returns the name of this controller
+func (r *ImageUpdaterReconciler) GetName() string {
+	return "ImageUpdaterController"
+}
+
+// GetGroupName returns the group name of this controller
+func (r *ImageUpdaterReconciler) GetGroupName() string {
+	return controllerGroupName
+}
+
+// SetupWithManager setups the log and recorder
+func (r *ImageUpdaterReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	r.log = ctrl.Log.WithName(r.GetName())
+	r.recorder = mgr.GetEventRecorderFor(r.GetName())
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&v1alpha1.ImageUpdater{}).
+		Complete(r)
+}
diff --git a/docs/image-automation.md b/docs/image-automation.md
new file mode 100644
index 0000000..61f1a2e
--- /dev/null
+++ b/docs/image-automation.md
@@ -0,0 +1,105 @@
+## Background
+
+Technically, GitOps does not have a strong relationship with CI (Continous Integration). 
+It even does not care about where those images come from. The core concept of GitOps is 
+having a Git repository that always contains declarative descriptions of the infrastructure 
+currently desired in the target environment and an automated process to make the 
+environment match the described state in the repository.
+
+But people usually want to update the images in some environments if there are some specific images changed.
+The biggest challenge might be the git repository part. A Kubernetes-based Application might be 
+installed from different formats, such as Helm charts, Kustomization or others.
+
+## Solution
+This document is going to explain all these details and try to provide a solution and design for it.
+
+[Argo CD][https://github.com/argoproj] and [Flux CD](https://github.com/fluxcd) are two very popular GitOps
+solutions. They all have their way to update the images automatically. See also the following projects:
+
+* [Argo CD Image Updater](https://github.com/argoproj-labs/argocd-image-updater)
+  * Not ready for the production environment yet
+* [Image Reflector Controller](https://github.com/fluxcd/image-reflector-controller) and [Image Automation Controller](https://github.com/fluxcd/image-automation-controller)
+
+Both of them adopt `Apache 2.0` as the open-source License. So, we can integrate them into this project.
+
+For the Flux CD, its solution will watch the changes of container images, 
+then update the images according to some kind of marker expressions. For example,
+add something like this `image: nginx # {"$imagepolicy": "flux-system:nginx"}` to a Deployment or StatefulSet. See also [more details](https://fluxcd.io/docs/guides/image-update/).
+
+But Argo CD will focus on the Application level. It will watch all the changes of the container 
+images that are annotated in the Applications.
+
+Since `v3.3.0`, KubeSphere DevOps already integrated Argo CD as the GitOps Engine. And we have 
+a plan [to integrate Flux CD](https://github.com/kubesphere/community/blob/master/sig-advocacy-and-outreach/ospp-2022/ks-devops-fluxcd-integrations_zh-CN.md) as well. So, we need to figure out a way to provide a 
+consistent way to help users to use it easily.
+
+Providing a CRD (Custom Resource Definition) as the abstraction of the Image update process is a good idea. We could have different implementations of the CRD, but the end-users don't need 
+to know the implementation details.
+
+## Design
+Below is a sample of the CRD.
+
+```yaml
+apiVersion: gitops.kubesphere.io/v1alpha1
+kind: ImageUpdater
+metadata:
+  name: demo
+spec:
+  ## 
+  images:
+  - nginx:^0.1
+  - myalias=some/image
+  kind: argocd | fluxcd
+  argocd:
+    app:
+      name: demo
+      namespace: demo
+    write: built-in | git
+    update-strategy:
+      nginx: semver
+      myalias: latest
+    allow-tags:
+      nginx: any
+    ignore-tags:
+      tomcat: "*-dev"
+    platforms:
+      nginx: linux/amd64
+    secret:
+      nginx: docker-hub-secret
+  fluxcd:
+    interval: 1m
+    secret: docker-hub-secret
+    policy:
+      semver:
+        range: 5.0.x
+    sourceRef:
+      kind: GitRepository
+      name: podinfo
+    git:
+      checkout:
+        ref:
+          branch: main
+      commit:
+        author:
+          email: fluxcdbot@users.noreply.github.com
+          name: fluxcdbot
+        messageTemplate: '{{range .Updated.Images}}{{println .}}{{end}}'
+      push:
+        branch: main
+    update:
+      path: ./clusters/my-cluster
+      strategy: Setters
+```
+
+Provide one or more controllers to update the images accoding to `spec.engine`. Basically, the controllers will be a 
+bridge between the CRD and the actual implementation.
+
+APIs are required for the image automation update feature.
+
+| API | Description |
+|---|---|
+| POST `/namespaces/{namespace}/imageupdaters` | Create an image update configuration |
+| GET `/namespaces/{namespace}/imageupdaters/{imageupdater}` | Returns the details of a specific imageUpdater |
+| GET `/namespaces/{namespace}/imageupdaters` | Return the list of imageUpdaters |
+| PUT `/namespaces/{namespace}/imageupdaters/{imageupdater}` | Update a specific imageUpdater |
+| DELETE `/namespaces/{namespace}/imageupdaters/{imageupdater}` | Delete a specific imageUpater |
diff --git a/go.mod b/go.mod
index a3cd0d6..d63dbf4 100644
--- a/go.mod
+++ b/go.mod
@@ -59,6 +59,7 @@ replace (
 	github.com/golang/example => github.com/golang/example v0.0.0-20170904185048-46695d81d1fa
 	github.com/googleapis/gnostic => github.com/googleapis/gnostic v0.4.0
 	github.com/kubesphere/sonargo => github.com/kubesphere/sonargo v0.0.2
+	golang.org/x/text => golang.org/x/text v0.3.7
 	k8s.io/api => k8s.io/api v0.18.6
 	k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.18.6
 	k8s.io/apimachinery => k8s.io/apimachinery v0.18.6
diff --git a/go.sum b/go.sum
index 41c7557..1194dcb 100644
--- a/go.sum
+++ b/go.sum
@@ -736,14 +736,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
diff --git a/pkg/api/gitops/v1alpha1/constants.go b/pkg/api/gitops/v1alpha1/constants.go
index 861cae9..4b4fc36 100644
--- a/pkg/api/gitops/v1alpha1/constants.go
+++ b/pkg/api/gitops/v1alpha1/constants.go
@@ -26,6 +26,11 @@ const (
 	AppNameLabelKey            = GroupName + "/application-name"
 )
 
+const (
+	// AnnoKeyImages is the key for the image list
+	AnnoKeyImages = GroupName + "/images"
+)
+
 // ApplicationFinalizerName is the name of PipelineRun finalizer
 const ApplicationFinalizerName = "application." + GroupName
 
diff --git a/pkg/api/gitops/v1alpha1/image-updater.go b/pkg/api/gitops/v1alpha1/image-updater.go
new file mode 100644
index 0000000..22b10aa
--- /dev/null
+++ b/pkg/api/gitops/v1alpha1/image-updater.go
@@ -0,0 +1,93 @@
+/*
+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 v1alpha1
+
+import (
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// ImageUpdaterSpec is the specification of the image updater
+type ImageUpdaterSpec struct {
+	// +kubebuilder:default:=argocd
+	// +kubebuilder:validation:Enum=argocd;fluxcd
+	Kind   string            `json:"kind,omitempty"`
+	Images []string          `json:"images,omitempty"`
+	Argo   *ArgoImageUpdater `json:"argo,omitempty"`
+}
+
+// ArgoImageUpdater is the specification of the Argo image updater
+type ArgoImageUpdater struct {
+	App v1.LocalObjectReference `json:"app"`
+	// +kubebuilder:default:=built-in
+	// +kubebuilder:validation:Enum=built-in;git
+	Write          WriteMethod       `json:"write,omitempty"`
+	UpdateStrategy map[string]string `json:"updateStrategy,omitempty"`
+	AllowTags      map[string]string `json:"allowTags,omitempty"`
+	IgnoreTags     map[string]string `json:"ignoreTags,omitempty"`
+	Platforms      map[string]string `json:"platforms,omitempty"`
+	Secrets        map[string]string `json:"secrets,omitempty"`
+}
+
+// WriteMethod is an alias of string that represents the write back method of Argo CD Image updater
+type WriteMethod string
+
+const (
+	// WriteMethodBuiltIn is the default method that only effect in cluster
+	WriteMethodBuiltIn WriteMethod = "built-in"
+	// WriteMethodGit indicates the changes will be written back to git repository
+	WriteMethodGit WriteMethod = "git"
+)
+
+// GetValue returns the value that could be recognized by Argo CD Image Updater
+func (w WriteMethod) GetValue() string {
+	switch w {
+	case WriteMethodBuiltIn:
+		return "argocd"
+	case WriteMethodGit:
+		return "git"
+	}
+	return ""
+}
+
+// +genclient
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +k8s:openapi-gen=true
+// +kubebuilder:printcolumn:name="Kind",type=string,JSONPath=`.spec.kind`
+
+// ImageUpdater represents an image updating request
+type ImageUpdater struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec ImageUpdaterSpec `json:"spec"`
+}
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+
+// ImageUpdaterList represents a set of the applications
+type ImageUpdaterList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []ImageUpdater `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&ImageUpdater{}, &ImageUpdaterList{})
+}
diff --git a/pkg/api/gitops/v1alpha1/zz_generated.deepcopy.go b/pkg/api/gitops/v1alpha1/zz_generated.deepcopy.go
index 9e8d866..c483f20 100644
--- a/pkg/api/gitops/v1alpha1/zz_generated.deepcopy.go
+++ b/pkg/api/gitops/v1alpha1/zz_generated.deepcopy.go
@@ -390,6 +390,57 @@ func (in *ArgoApplicationSpec) DeepCopy() *ArgoApplicationSpec {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ArgoImageUpdater) DeepCopyInto(out *ArgoImageUpdater) {
+	*out = *in
+	out.App = in.App
+	if in.UpdateStrategy != nil {
+		in, out := &in.UpdateStrategy, &out.UpdateStrategy
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
+	if in.AllowTags != nil {
+		in, out := &in.AllowTags, &out.AllowTags
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
+	if in.IgnoreTags != nil {
+		in, out := &in.IgnoreTags, &out.IgnoreTags
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
+	if in.Platforms != nil {
+		in, out := &in.Platforms, &out.Platforms
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
+	if in.Secrets != nil {
+		in, out := &in.Secrets, &out.Secrets
+		*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 ArgoImageUpdater.
+func (in *ArgoImageUpdater) DeepCopy() *ArgoImageUpdater {
+	if in == nil {
+		return nil
+	}
+	out := new(ArgoImageUpdater)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *Backoff) DeepCopyInto(out *Backoff) {
 	*out = *in
@@ -480,6 +531,89 @@ func (in *HelmParameter) DeepCopy() *HelmParameter {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ImageUpdater) DeepCopyInto(out *ImageUpdater) {
+	*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 ImageUpdater.
+func (in *ImageUpdater) DeepCopy() *ImageUpdater {
+	if in == nil {
+		return nil
+	}
+	out := new(ImageUpdater)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ImageUpdater) 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 *ImageUpdaterList) DeepCopyInto(out *ImageUpdaterList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]ImageUpdater, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageUpdaterList.
+func (in *ImageUpdaterList) DeepCopy() *ImageUpdaterList {
+	if in == nil {
+		return nil
+	}
+	out := new(ImageUpdaterList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ImageUpdaterList) 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 *ImageUpdaterSpec) DeepCopyInto(out *ImageUpdaterSpec) {
+	*out = *in
+	if in.Images != nil {
+		in, out := &in.Images, &out.Images
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.Argo != nil {
+		in, out := &in.Argo, &out.Argo
+		*out = new(ArgoImageUpdater)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageUpdaterSpec.
+func (in *ImageUpdaterSpec) DeepCopy() *ImageUpdaterSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(ImageUpdaterSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *Info) DeepCopyInto(out *Info) {
 	*out = *in


Test patch:
diff --git a/controllers/argocd/argocd-application-status-controller_test.go b/controllers/argocd/argocd-application-status-controller_test.go
index 1b56ab6..f02e6a2 100644
--- a/controllers/argocd/argocd-application-status-controller_test.go
+++ b/controllers/argocd/argocd-application-status-controller_test.go
@@ -17,8 +17,10 @@ limitations under the License.
 package argocd
 
 import (
+	"context"
 	"fmt"
 	"github.com/stretchr/testify/assert"
+	"io/ioutil"
 	v1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/types"
@@ -115,6 +117,7 @@ func TestArgoCDApplicationStatusReconciler_Reconcile(t *testing.T) {
 	defaultApp := &v1alpha1.Application{}
 	defaultApp.SetName("name")
 	defaultApp.SetNamespace("ns")
+	defaultApp.Spec.ArgoApp = &v1alpha1.ArgoApplication{}
 
 	defaultArgoCDApp := &unstructured.Unstructured{}
 	defaultArgoCDApp.SetKind("Application")
@@ -122,6 +125,23 @@ func TestArgoCDApplicationStatusReconciler_Reconcile(t *testing.T) {
 	defaultArgoCDApp.SetName("name")
 	defaultArgoCDApp.SetNamespace("ns")
 
+	appWithStatus := defaultArgoCDApp.DeepCopy()
+	appWithStatus.SetLabels(map[string]string{
+		v1alpha1.AppNamespaceLabelKey: "ns",
+		v1alpha1.AppNameLabelKey:      "name",
+	})
+	_ = unstructured.SetNestedMap(appWithStatus.Object, map[string]interface{}{
+		"summary": map[string]interface{}{
+			"images": []interface{}{"nginx"},
+		},
+		"sync": map[string]interface{}{
+			"status": "ready",
+		},
+		"health": map[string]interface{}{
+			"status": "ready",
+		},
+	}, "status")
+
 	type fields struct {
 		Client client.Client
 	}
@@ -185,6 +205,52 @@ func TestArgoCDApplicationStatusReconciler_Reconcile(t *testing.T) {
 		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
 			return true
 		},
+	}, {
+		name: "have status from argo application",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, appWithStatus, defaultApp),
+		},
+		args: args{
+			req: controllerruntime.Request{
+				NamespacedName: types.NamespacedName{
+					Namespace: "ns",
+					Name:      "name",
+				},
+			},
+		},
+		wantResult: controllerruntime.Result{},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+
+			c := i[1].(client.Client)
+			app := &v1alpha1.Application{}
+			err = c.Get(context.Background(), types.NamespacedName{
+				Namespace: "ns",
+				Name:      "name",
+			}, app)
+			assert.Nil(t, err)
+
+			assert.Equal(t, "nginx", app.Annotations[v1alpha1.AnnoKeyImages])
+			return true
+		},
+	}, {
+		name: "cannot found inner app",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, appWithStatus),
+		},
+		args: args{
+			req: controllerruntime.Request{
+				NamespacedName: types.NamespacedName{
+					Namespace: "ns",
+					Name:      "name",
+				},
+			},
+		},
+		wantResult: controllerruntime.Result{},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
 	}}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -194,10 +260,54 @@ func TestArgoCDApplicationStatusReconciler_Reconcile(t *testing.T) {
 				recorder: &record.FakeRecorder{},
 			}
 			gotResult, err := r.Reconcile(tt.args.req)
-			if !tt.wantErr(t, err, fmt.Sprintf("Reconcile(%v)", tt.args.req)) {
+			if !tt.wantErr(t, err, fmt.Sprintf("Reconcile(%v)", tt.args.req), tt.fields.Client) {
 				return
 			}
 			assert.Equalf(t, tt.wantResult, gotResult, "Reconcile(%v)", tt.args.req)
 		})
 	}
 }
+
+func Test_parseArgoStatus(t *testing.T) {
+	type args struct {
+		dataFile string
+	}
+	tests := []struct {
+		name       string
+		args       args
+		wantStatus *argoStatus
+		wantErr    assert.ErrorAssertionFunc
+	}{{
+		name: "normal",
+		args: args{dataFile: "data/argo-status.json"},
+		wantStatus: &argoStatus{argoStatusSummary{
+			Images: []string{"ghcr.io/linuxsuren-bot/open-podcasts-ui:v1.0.2",
+				"ghcr.io/linuxsuren-bot/open-podcasts:v1.0.0",
+				"ghcr.io/opensource-f2f/kube-rbac-proxy:v0.8.0",
+				"ghcr.io/opensource-f2f/open-podcasts-apiserver:dev"}}},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}, {
+		name:       "no summary",
+		args:       args{dataFile: "data/argo-status-without-summary.json"},
+		wantStatus: &argoStatus{},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			data, err := ioutil.ReadFile(tt.args.dataFile)
+			assert.Nil(t, err)
+
+			gotStatus, err := parseArgoStatus(data)
+			if !tt.wantErr(t, err, fmt.Sprintf("parseArgoStatus(%v)", tt.args.dataFile)) {
+				return
+			}
+			assert.Equalf(t, tt.wantStatus, gotStatus, "parseArgoStatus(%v)", tt.args.dataFile)
+		})
+	}
+}
diff --git a/controllers/argocd/image-updater-controller_test.go b/controllers/argocd/image-updater-controller_test.go
new file mode 100644
index 0000000..5529fb1
--- /dev/null
+++ b/controllers/argocd/image-updater-controller_test.go
@@ -0,0 +1,351 @@
+/*
+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"
+	"fmt"
+	"github.com/stretchr/testify/assert"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
+	"kubesphere.io/devops/pkg/api/gitops/v1alpha1"
+	controllerruntime "sigs.k8s.io/controller-runtime"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"sigs.k8s.io/controller-runtime/pkg/log"
+	"testing"
+	"time"
+)
+
+func Test_updateImageList(t *testing.T) {
+	type args struct {
+		images      []string
+		annotations map[string]string
+	}
+	tests := []struct {
+		name   string
+		args   args
+		verify func(*testing.T, map[string]string)
+	}{{
+		name: "empty images and annotations",
+		args: args{
+			images:      []string{},
+			annotations: map[string]string{},
+		},
+		verify: func(t *testing.T, m map[string]string) {
+			assert.Equal(t, map[string]string{}, m)
+		},
+	}, {
+		name: "clean the image list in the annotations",
+		args: args{
+			images: nil,
+			annotations: map[string]string{
+				"argocd-image-updater.argoproj.io/image-list": "nginx",
+			},
+		},
+		verify: func(t *testing.T, m map[string]string) {
+			assert.Equal(t, map[string]string{}, m)
+		},
+	}, {
+		name: "have different items",
+		args: args{
+			images: []string{"nginx", "alpine"},
+			annotations: map[string]string{
+				"argocd-image-updater.argoproj.io/image-list": "a, b",
+			},
+		},
+		verify: func(t *testing.T, m map[string]string) {
+			assert.Equal(t, map[string]string{
+				"argocd-image-updater.argoproj.io/image-list": "nginx,alpine",
+			}, m)
+		},
+	}, {
+		name: "put an image to the empty annotations",
+		args: args{
+			images:      []string{"nginx"},
+			annotations: map[string]string{},
+		},
+		verify: func(t *testing.T, m map[string]string) {
+			assert.Equal(t, map[string]string{
+				"argocd-image-updater.argoproj.io/image-list": "nginx",
+			}, m)
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			updateImageList(tt.args.images, tt.args.annotations)
+			tt.verify(t, tt.args.annotations)
+		})
+	}
+}
+
+func TestReconcile(t *testing.T) {
+	schema, err := v1alpha1.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	app := &v1alpha1.Application{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "app",
+			Namespace: "fake",
+		},
+	}
+
+	updater := &v1alpha1.ImageUpdater{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "updater",
+			Namespace: "fake",
+		},
+		Spec: v1alpha1.ImageUpdaterSpec{
+			Kind:   "argocd",
+			Images: []string{"nginx"},
+			Argo: &v1alpha1.ArgoImageUpdater{
+				App:   v1.LocalObjectReference{Name: "app"},
+				Write: v1alpha1.WriteMethodBuiltIn,
+			},
+		},
+	}
+	notArgoKindUpdater := updater.DeepCopy()
+	notArgoKindUpdater.Spec.Kind = "fake"
+	noArgoSettingUpdater := updater.DeepCopy()
+	noArgoSettingUpdater.Spec.Argo = nil
+	emptyAppName := updater.DeepCopy()
+	emptyAppName.Spec.Argo.App.Name = ""
+	invalidWriteMethod := updater.DeepCopy()
+	invalidWriteMethod.Spec.Argo.Write = "invalid"
+	withSecretUpdater := updater.DeepCopy()
+	withSecretUpdater.Spec.Argo.Secrets = map[string]string{
+		"nginx": "ns/secret",
+	}
+
+	defaultReq := ctrl.Request{
+		NamespacedName: types.NamespacedName{
+			Namespace: "fake",
+			Name:      "updater",
+		},
+	}
+
+	type fields struct {
+		Client client.Client
+	}
+	type args struct {
+		req controllerruntime.Request
+	}
+	tests := []struct {
+		name       string
+		fields     fields
+		args       args
+		wantResult controllerruntime.Result
+		wantErr    assert.ErrorAssertionFunc
+	}{{
+		name: "no imageUpdaters found",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, app.DeepCopy(), updater.DeepCopy()),
+		},
+		args: args{
+			req: ctrl.Request{
+				NamespacedName: types.NamespacedName{
+					Namespace: "fake",
+					Name:      "fake",
+				},
+			},
+		},
+		wantResult: ctrl.Result{},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}, {
+		name: "kind is not argocd",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, notArgoKindUpdater.DeepCopy()),
+		},
+		args: args{
+			req: defaultReq,
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			return true
+		},
+		wantResult: ctrl.Result{},
+	}, {
+		name: "argo setting is nil",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, noArgoSettingUpdater),
+		},
+		args: args{req: defaultReq},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			return true
+		},
+		wantResult: ctrl.Result{},
+	}, {
+		name: "argo app name is empty",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, emptyAppName),
+		},
+		args: args{req: defaultReq},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			return true
+		},
+		wantResult: ctrl.Result{},
+	}, {
+		name: "cannot found app",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, updater),
+		},
+		args: args{req: defaultReq},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			return true
+		},
+		wantResult: ctrl.Result{RequeueAfter: time.Minute},
+	}, {
+		name: "normal case",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, updater, app),
+		},
+		args: args{req: defaultReq},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			c := i[1].(client.Client)
+			assert.Nil(t, err)
+
+			resultApp := &v1alpha1.Application{}
+			err = c.Get(context.Background(), types.NamespacedName{
+				Namespace: "fake",
+				Name:      "app",
+			}, resultApp)
+			assert.Nil(t, err)
+			assert.Equal(t, map[string]string{
+				"argocd-image-updater.argoproj.io/image-list":        "nginx",
+				"argocd-image-updater.argoproj.io/write-back-method": "argocd",
+			}, resultApp.Annotations)
+
+			return true
+		},
+		wantResult: ctrl.Result{},
+	}, {
+		name: "invalid write method",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, invalidWriteMethod, app),
+		},
+		args: args{req: defaultReq},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			c := i[1].(client.Client)
+			assert.Nil(t, err)
+
+			resultApp := &v1alpha1.Application{}
+			err = c.Get(context.Background(), types.NamespacedName{
+				Namespace: "fake",
+				Name:      "app",
+			}, resultApp)
+			assert.Nil(t, err)
+			assert.Equal(t, map[string]string{
+				"argocd-image-updater.argoproj.io/image-list": "nginx",
+			}, resultApp.Annotations)
+
+			return true
+		},
+		wantResult: ctrl.Result{},
+	}, {
+		name: "with image secret",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, withSecretUpdater, app),
+		},
+		args: args{req: defaultReq},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			c := i[1].(client.Client)
+			assert.Nil(t, err)
+
+			resultApp := &v1alpha1.Application{}
+			err = c.Get(context.Background(), types.NamespacedName{
+				Namespace: "fake",
+				Name:      "app",
+			}, resultApp)
+			assert.Nil(t, err)
+			assert.Equal(t, map[string]string{
+				"argocd-image-updater.argoproj.io/image-list":        "nginx",
+				"argocd-image-updater.argoproj.io/write-back-method": "argocd",
+				"argocd-image-updater.argoproj.io/nginx.pull-secret": "pullsecret:ns/secret",
+			}, resultApp.Annotations)
+
+			return true
+		},
+		wantResult: ctrl.Result{},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &ImageUpdaterReconciler{
+				Client:   tt.fields.Client,
+				log:      log.NullLogger{},
+				recorder: &record.FakeRecorder{},
+			}
+			gotResult, err := r.Reconcile(tt.args.req)
+			if !tt.wantErr(t, err, fmt.Sprintf("Reconcile(%v)", tt.args.req), tt.fields.Client) {
+				return
+			}
+			assert.Equalf(t, tt.wantResult, gotResult, "Reconcile(%v)", tt.args.req)
+		})
+	}
+}
+
+func Test_setImagePreference(t *testing.T) {
+	type args struct {
+		argo        *v1alpha1.ArgoImageUpdater
+		annotations map[string]string
+	}
+	tests := []struct {
+		name   string
+		args   args
+		verify func(*testing.T, map[string]string)
+	}{{
+		name: "normal case",
+		args: args{
+			argo: &v1alpha1.ArgoImageUpdater{
+				Secrets: map[string]string{
+					"nginx": "ns/name",
+				},
+				AllowTags: map[string]string{
+					"nginx": "v1.1.1",
+				},
+				IgnoreTags: map[string]string{
+					"nginx": "latest",
+				},
+				UpdateStrategy: map[string]string{
+					"nginx": "semver",
+				},
+				Platforms: map[string]string{
+					"nginx": "linux/amd64",
+				},
+			},
+			annotations: map[string]string{},
+		},
+		verify: func(t *testing.T, m map[string]string) {
+			assert.Equal(t, "pullsecret:ns/name", m["argocd-image-updater.argoproj.io/nginx.pull-secret"])
+			assert.Equal(t, "v1.1.1", m["argocd-image-updater.argoproj.io/nginx.allow-tags"])
+			assert.Equal(t, "latest", m["argocd-image-updater.argoproj.io/nginx.ignore-tags"])
+			assert.Equal(t, "semver", m["argocd-image-updater.argoproj.io/nginx.update-strategy"])
+			assert.Equal(t, "linux/amd64", m["argocd-image-updater.argoproj.io/nginx.platforms"])
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			setImagePreference(tt.args.argo, tt.args.annotations)
+		})
+	}
+}
diff --git a/controllers/argocd/interface_test.go b/controllers/argocd/interface_test.go
index d8df440..b826dba 100644
--- a/controllers/argocd/interface_test.go
+++ b/controllers/argocd/interface_test.go
@@ -61,6 +61,18 @@ func TestInterfaceImplement(t *testing.T) {
 			NamedReconciler: &GitRepositoryController{},
 			GroupReconciler: &GitRepositoryController{},
 		},
+	}, {
+		name: "ImageUpdaterController",
+		instance: interInstance{
+			NamedReconciler: &ImageUpdaterReconciler{},
+			GroupReconciler: &ImageUpdaterReconciler{},
+		},
+	}, {
+		name: "ArgoCDApplicationStatusController",
+		instance: interInstance{
+			NamedReconciler: &ApplicationStatusReconciler{},
+			GroupReconciler: &ApplicationStatusReconciler{},
+		},
 	}}
 	for i := range tests {
 		tt := tests[i]
diff --git a/pkg/api/gitops/v1alpha1/image-updater_test.go b/pkg/api/gitops/v1alpha1/image-updater_test.go
new file mode 100644
index 0000000..a1b47c7
--- /dev/null
+++ b/pkg/api/gitops/v1alpha1/image-updater_test.go
@@ -0,0 +1,47 @@
+/*
+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 v1alpha1
+
+import (
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func TestWriteMethod_GetValue(t *testing.T) {
+	tests := []struct {
+		name string
+		w    WriteMethod
+		want string
+	}{{
+		name: "built-in",
+		w:    WriteMethodBuiltIn,
+		want: "argocd",
+	}, {
+		name: "git",
+		w:    WriteMethodGit,
+		want: "git",
+	}, {
+		name: "invalid",
+		w:    WriteMethod("invalid"),
+		want: "",
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equalf(t, tt.want, tt.w.GetValue(), "GetValue()")
+		})
+	}
+}
diff --git a/test/api/README.md b/test/api/README.md
new file mode 100644
index 0000000..af16cce
--- /dev/null
+++ b/test/api/README.md
@@ -0,0 +1,5 @@
+Please run the following command to start the API testing in the current directory.
+
+```shell
+atest run -p '*.yaml'
+```
\ No newline at end of file