Repository: kubesphere/ks-devops
Pull request #817 (2022-09-06 11:33:50)

Add Status for FluxApp
<!-- 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?

/kind feature
/kind design

### What this PR does / why we need it:
Watch HelmRelease and Kustomization to Update the FluxApp's status.

Add labels to the Application like below:
```
gitops.kubepshere.io/type=HelmRelease
gitops.kubesphere.io/sync-status=Synced
gitops.kubesphere.io/synced-number=1
```

So we can track App's status in the frontend like this:
![QQ20220906-0](https://user-images.githubusercontent.com/53958238/188625395-b6c6624a-0205-4f96-8b19-a9608946b63f.jpeg)


### 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
- [x] 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: @chengleqi: 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 assign **linuxsuren** after the PR has been reviewed.
You can assign the PR to them by writing `/assign @linuxsuren` in a comment when ready.

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"]} -->
chengleqi: I' will add some labels and annotations to app.

```yaml
apiVersion: gitops.kubesphere.io/v1alpha1
kind: Application
metadata:
  annotations:
    gitops.kubesphere.io/last-revision: 0.1.0+1
  labels:
    gitops.kubepshere.io/type: HelmRelease
    gitops.kubesphere.io/ready-number: 1-1
```

Patch:
diff --git a/Makefile b/Makefile
index d3bfdb9..f791cf2 100644
--- a/Makefile
+++ b/Makefile
@@ -85,7 +85,9 @@ generate-listers:
 
 # Build the docker image of controller-manager
 docker-build-controller:
-	${CONTAINER_CLI} build . -f config/dockerfiles/controller-manager/Dockerfile -t ${CONTROLLER_IMG}
+	${CONTAINER_CLI} build . -f config/dockerfiles/controller-manager/Dockerfile --build-arg GOPROXY=${GOPROXY} -t ${CONTROLLER_IMG}
+build-controller:
+	buildctl build --frontend dockerfile.v0 --local dockerfile=config/dockerfiles/controller-manager/
 
 # Push the docker image of controller-manager
 docker-push-controller:
@@ -98,7 +100,7 @@ run-apiserver:
 	go run cmd/apiserver/apiserver.go
 # Build the docker image of apiserver
 docker-build-apiserver:
-	${CONTAINER_CLI} build . -f config/dockerfiles/apiserver/Dockerfile -t ${APISERVER_IMG}
+	${CONTAINER_CLI} build . -f config/dockerfiles/apiserver/Dockerfile --build-arg GOPROXY=${GOPROXY} -t ${APISERVER_IMG}
 
 # Push the docker image of controller-manager
 docker-push-apiserver:
@@ -109,7 +111,7 @@ docker-build-push-apiserver: docker-build-apiserver docker-push-apiserver
 
 # Build the docker image of apiserver
 docker-build-tools:
-	${CONTAINER_CLI} build . -f config/dockerfiles/tools/Dockerfile -t ${TOOLS_IMG}
+	${CONTAINER_CLI} build . -f config/dockerfiles/tools/Dockerfile --build-arg GOPROXY=${GOPROXY} -t ${TOOLS_IMG}
 
 # Push the docker image of controller-manager
 docker-push-tools:
diff --git a/cmd/controller/app/controllers.go b/cmd/controller/app/controllers.go
index 05a9abe..2190092 100644
--- a/cmd/controller/app/controllers.go
+++ b/cmd/controller/app/controllers.go
@@ -138,6 +138,9 @@ func getAllControllers(mgr manager.Manager, client k8s.Client, informerFactory i
 	fluxcdMultiClusterReconciler := &fluxcd.MultiClusterReconciler{
 		Client: mgr.GetClient(),
 	}
+	fluxcdAppStatusReconciler := &fluxcd.ApplicationStatusReconciler{
+		Client: mgr.GetClient(),
+	}
 
 	return map[string]func(mgr manager.Manager) error{
 		gitRepoReconcilers.GetName(): func(mgr manager.Manager) error {
@@ -235,6 +238,9 @@ func getAllControllers(mgr manager.Manager, client k8s.Client, informerFactory i
 			if err = fluxcdMultiClusterReconciler.SetupWithManager(mgr); err != nil {
 				return
 			}
+			if err = fluxcdAppStatusReconciler.SetupWithManager(mgr); err != nil {
+				return
+			}
 			return fluxcdApplicationReconciler.SetupWithManager(mgr)
 		},
 	}
diff --git a/cmd/controller/app/options/feature.go b/cmd/controller/app/options/feature.go
index 339b30b..540dd0e 100644
--- a/cmd/controller/app/options/feature.go
+++ b/cmd/controller/app/options/feature.go
@@ -26,10 +26,11 @@ import (
 
 // FeatureOptions provide some feature options, such as specifying the controller to be enabled.
 type FeatureOptions struct {
-	Controllers     map[string]bool
-	SystemNamespace string
-	ExternalAddress string
-	ClusterName     string
+	Controllers          map[string]bool
+	SystemNamespace      string
+	ExternalAddress      string
+	ClusterName          string
+	PipelineRunDataStore string
 }
 
 // GetControllers returns the controllers map
@@ -81,6 +82,8 @@ func (o *FeatureOptions) AddFlags(fs *pflag.FlagSet, c *FeatureOptions) {
 		"The system namespace that contains ConfigMap, Secrets e.g.")
 	fs.StringVarP(&o.ExternalAddress, "external-address", "", "", "The external address for the UI")
 	fs.StringVarP(&o.ClusterName, "cluster-name", "", "default", "Current cluster name")
+	fs.StringVarP(&o.PipelineRunDataStore, "pipelinerun-data-store", "", "configmap",
+		"The data store type of the PipelineRun data, could be empty or configmap")
 }
 
 func (o *FeatureOptions) knownControllers() []string {
diff --git a/controllers/fluxcd/constants.go b/controllers/fluxcd/constants.go
index 82979ba..262b47c 100644
--- a/controllers/fluxcd/constants.go
+++ b/controllers/fluxcd/constants.go
@@ -28,6 +28,19 @@ const (
 	Kustomization AppType = "Kustomization"
 )
 
-// DefaultKubeConfigKey is the Default key for kubeconfig
-// https://fluxcd.io/docs/components/helm/helmreleases/#remote-clusters--cluster-api
-const DefaultKubeConfigKey = "value"
+const (
+	// DefaultKubeConfigKey is the Default key for kubeconfig
+	// https://fluxcd.io/docs/components/helm/helmreleases/#remote-clusters--cluster-api
+	DefaultKubeConfigKey = "value"
+
+	// FluxAppTypeKey indicate the Type of this FluxApp
+	// value is one of HelmRelease and Kustomization
+	FluxAppTypeKey = "gitops.kubepshere.io/type"
+
+	// FluxAppReadyNumKey represent how many HelmRelease or Kustomization
+	// are ready in form of readyNumber/totalNumber
+	FluxAppReadyNumKey = "gitops.kubesphere.io/ready-number"
+
+	// FluxAppLastRevision is the revision of the last successfully applied source.
+	FluxAppLastRevision = "gitops.kubesphere.io/last-revision"
+)
diff --git a/controllers/fluxcd/fluxcd-application-status-controller.go b/controllers/fluxcd/fluxcd-application-status-controller.go
new file mode 100644
index 0000000..e7b9115
--- /dev/null
+++ b/controllers/fluxcd/fluxcd-application-status-controller.go
@@ -0,0 +1,186 @@
+/*
+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 fluxcd
+
+import (
+	"context"
+	"fmt"
+	"github.com/go-logr/logr"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/api/meta"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
+	"kubesphere.io/devops/pkg/api/gitops/v1alpha1"
+	helmv2 "kubesphere.io/devops/pkg/external/fluxcd/helm/v2beta1"
+	kusv1 "kubesphere.io/devops/pkg/external/fluxcd/kustomize/v1beta2"
+	apimeta "kubesphere.io/devops/pkg/external/fluxcd/meta"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/handler"
+	"sigs.k8s.io/controller-runtime/pkg/source"
+	"strconv"
+)
+
+//+kubebuilder:rbac:groups=gitops.kubesphere.io,resources=applications,verbs=get;update
+//+kubebuilder:rbac:groups=gitops.kubesphere.io,resources=applications/status,verbs=get;update
+//+kubebuilder:rbac:groups="kustomize.toolkit.fluxcd.io",resources=kustomizations,verbs=get;list;watch
+//+kubebuilder:rbac:groups="helm.toolkit.fluxcd.io",resources=helmreleases,verbs=get;list;watch
+
+// ApplicationStatusReconciler represents a controller to sync the status of
+// FluxApplication (HelmRelease and Kustomization) to Kubesphere GitOps Application
+type ApplicationStatusReconciler struct {
+	client.Client
+	log      logr.Logger
+	recorder record.EventRecorder
+}
+
+// Reconcile is the entrypoint of the controller
+func (r *ApplicationStatusReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) {
+	r.log.Info(fmt.Sprintf("start to reconcile application: %s", req.String()))
+	hr := &helmv2.HelmRelease{}
+	if err = r.Get(ctx, types.NamespacedName{
+		Namespace: req.Namespace,
+		Name:      req.Name,
+	}, hr); err == nil {
+		return r.reconcileHelmRelease(ctx, hr)
+	} else if !apierrors.IsNotFound(err) {
+		return
+	}
+
+	kus := &kusv1.Kustomization{}
+	if err = r.Get(ctx, types.NamespacedName{
+		Namespace: req.Namespace,
+		Name:      req.Name,
+	}, kus); err == nil {
+		return r.reconcileKustomization(ctx, kus)
+	} else if !apierrors.IsNotFound(err) {
+		return
+	}
+	err = nil
+	return
+}
+
+func (r *ApplicationStatusReconciler) reconcileHelmRelease(ctx context.Context, hr *helmv2.HelmRelease) (result ctrl.Result, err error) {
+	appName, appNs := hr.GetLabels()["app.kubernetes.io/managed-by"], hr.GetNamespace()
+	// Only reconcile Kubesphere managed HelmRelease not standalone HelmRelease
+	if appName == "" {
+		return
+	}
+	app := &v1alpha1.Application{}
+	if err = r.Get(ctx, types.NamespacedName{
+		Namespace: appNs,
+		Name:      appName,
+	}, app); err != nil {
+		return
+	}
+
+	readyHRNum, totalHRNum := 0, len(app.Spec.FluxApp.Spec.Config.HelmRelease.Deploy)
+	if app.Status.FluxApp.HelmReleaseStatus == nil {
+		app.Status.FluxApp.HelmReleaseStatus = make(map[string]*helmv2.HelmReleaseStatus, totalHRNum)
+	}
+	app.Status.FluxApp.HelmReleaseStatus[hr.GetAnnotations()["app.kubernetes.io/name"]] = hr.Status.DeepCopy()
+	// Update status
+	if err = r.Status().Update(ctx, app); err != nil {
+		return
+	}
+
+	if app.GetLabels() == nil {
+		app.SetLabels(map[string]string{})
+	}
+	if app.GetAnnotations() == nil {
+		app.SetAnnotations(map[string]string{})
+	}
+	// should aggregate status from all the HelmRelease that FluxApp managed
+	for _, status := range app.Status.FluxApp.HelmReleaseStatus {
+		if meta.IsStatusConditionTrue(status.Conditions, apimeta.ReadyCondition) {
+			readyHRNum++
+		}
+		if app.GetAnnotations()[FluxAppLastRevision] != status.LastAppliedRevision {
+			app.GetAnnotations()[FluxAppLastRevision] = status.LastAppliedRevision
+		}
+	}
+	app.GetLabels()[FluxAppReadyNumKey] = strconv.Itoa(readyHRNum) + "-" + strconv.Itoa(totalHRNum)
+	// TODO: should find a better way to add AppType
+	app.GetLabels()[FluxAppTypeKey] = string(HelmRelease)
+
+	// update label
+	if err = r.Update(ctx, app); err != nil {
+		return
+	}
+	return
+}
+
+func (r *ApplicationStatusReconciler) reconcileKustomization(ctx context.Context, kus *kusv1.Kustomization) (result ctrl.Result, err error) {
+	appName, appNs := kus.GetLabels()["app.kubernetes.io/managed-by"], kus.GetNamespace()
+	// only reconcile Kubesphere managed Kustomization not standalone Kustomization
+	app := &v1alpha1.Application{}
+	if err = r.Get(ctx, types.NamespacedName{
+		Namespace: appNs,
+		Name:      appName,
+	}, app); err != nil {
+		return
+	}
+
+	readyKusNum, totalKusNum := 0, len(app.Spec.FluxApp.Spec.Config.Kustomization)
+	if app.Status.FluxApp.KustomizationStatus == nil {
+		app.Status.FluxApp.KustomizationStatus = make(map[string]*kusv1.KustomizationStatus, totalKusNum)
+	}
+	app.Status.FluxApp.KustomizationStatus[kus.GetAnnotations()["app.kubernetes.io/name"]] = kus.Status.DeepCopy()
+	// Update status
+	if err = r.Status().Update(ctx, app); err != nil {
+		return
+	}
+
+	if app.GetLabels() == nil {
+		app.SetLabels(map[string]string{})
+	}
+	if app.GetAnnotations() == nil {
+		app.SetAnnotations(map[string]string{})
+	}
+	// should aggregate status from all the Kustomization that FluxApp managed
+	for _, status := range app.Status.FluxApp.KustomizationStatus {
+		if meta.IsStatusConditionTrue(status.Conditions, apimeta.ReadyCondition) {
+			readyKusNum++
+		}
+		if app.GetAnnotations()[FluxAppLastRevision] != status.LastAppliedRevision {
+			app.GetAnnotations()[FluxAppLastRevision] = status.LastAppliedRevision
+		}
+	}
+	app.GetLabels()[FluxAppReadyNumKey] = strconv.Itoa(readyKusNum) + "-" + strconv.Itoa(totalKusNum)
+	// TODO: should find a better way to add AppType
+	app.GetLabels()[FluxAppTypeKey] = string(Kustomization)
+	// update label
+	if err = r.Update(ctx, app); err != nil {
+		return
+	}
+	return
+}
+
+// GetName returns the name of this controller
+func (r *ApplicationStatusReconciler) GetName() string {
+	return "FluxCDApplicationStatusController"
+}
+
+// SetupWithManager init the logger, recorder and filters
+func (r *ApplicationStatusReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	r.log = ctrl.Log.WithName(r.GetName())
+	r.recorder = mgr.GetEventRecorderFor(r.GetName())
+	return ctrl.NewControllerManagedBy(mgr).
+		Watches(&source.Kind{Type: &kusv1.Kustomization{}}, &handler.EnqueueRequestForObject{}).
+		For(&helmv2.HelmRelease{}).
+		Complete(r)
+}
diff --git a/controllers/jenkins/pipelinerun/pipelinerun_controller.go b/controllers/jenkins/pipelinerun/pipelinerun_controller.go
index 037c605..31052f3 100644
--- a/controllers/jenkins/pipelinerun/pipelinerun_controller.go
+++ b/controllers/jenkins/pipelinerun/pipelinerun_controller.go
@@ -20,11 +20,13 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"github.com/go-logr/logr"
+	cmstore "kubesphere.io/devops/pkg/store/configmap"
+	storeInter "kubesphere.io/devops/pkg/store/store"
 	"kubesphere.io/devops/pkg/utils/k8sutil"
 	"reflect"
 	"time"
 
-	"github.com/go-logr/logr"
 	"github.com/jenkins-zh/jenkins-client/pkg/core"
 	"github.com/jenkins-zh/jenkins-client/pkg/job"
 	corev1 "k8s.io/api/core/v1"
@@ -47,12 +49,15 @@ const tokenExpireIn time.Duration = 5 * time.Minute
 // Reconciler reconciles a PipelineRun object
 type Reconciler struct {
 	client.Client
-	log          logr.Logger
-	Scheme       *runtime.Scheme
-	DevOpsClient devopsClient.Interface
-	JenkinsCore  core.JenkinsCore
-	TokenIssuer  token.Issuer
-	recorder     record.EventRecorder
+	req                  ctrl.Request
+	ctx                  context.Context
+	log                  logr.Logger
+	Scheme               *runtime.Scheme
+	DevOpsClient         devopsClient.Interface
+	JenkinsCore          core.JenkinsCore
+	TokenIssuer          token.Issuer
+	recorder             record.EventRecorder
+	pipelineRunDataStore string
 }
 
 //+kubebuilder:rbac:groups=devops.kubesphere.io,resources=pipelineruns,verbs=get;list;watch;create;update;patch;delete
@@ -62,6 +67,8 @@ type Reconciler struct {
 // move the current state of the cluster closer to the desired state.
 func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
 	log := r.log.WithValues("PipelineRun", req.NamespacedName)
+	r.ctx = ctx
+	r.req = req
 
 	// get PipelineRun
 	pipelineRun := &v1alpha3.PipelineRun{}
@@ -151,15 +158,8 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 			log.Error(err, "unable to marshal nodes details to JSON")
 			nodeDetailsJSON = []byte("[]")
 		}
-		if pipelineRunCopied.Annotations == nil {
-			pipelineRunCopied.Annotations = make(map[string]string)
-		}
-		pipelineRunCopied.Annotations[v1alpha3.JenkinsPipelineRunStatusAnnoKey] = string(runResultJSON)
-		pipelineRunCopied.Annotations[v1alpha3.JenkinsPipelineRunStagesStatusAnnoKey] = string(nodeDetailsJSON)
 
-		// update labels and annotations
-		if err := r.updateLabelsAndAnnotations(ctx, pipelineRunCopied); err != nil {
-			log.Error(err, "unable to update PipelineRun labels and annotations.")
+		if err = r.storePipelineRunData(string(runResultJSON), string(nodeDetailsJSON), pipelineRunCopied); err != nil {
 			return ctrl.Result{}, err
 		}
 
@@ -225,6 +225,37 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
 	return ctrl.Result{}, nil
 }
 
+func (r *Reconciler) storePipelineRunData(runResultJSON, nodeDetailsJSON string, pipelineRunCopied *v1alpha3.PipelineRun) (err error) {
+	if r.pipelineRunDataStore == "" {
+		if pipelineRunCopied.Annotations == nil {
+			pipelineRunCopied.Annotations = make(map[string]string)
+		}
+		pipelineRunCopied.Annotations[v1alpha3.JenkinsPipelineRunStatusAnnoKey] = runResultJSON
+		pipelineRunCopied.Annotations[v1alpha3.JenkinsPipelineRunStagesStatusAnnoKey] = nodeDetailsJSON
+
+		// update labels and annotations
+		if err = r.updateLabelsAndAnnotations(r.ctx, pipelineRunCopied); err != nil {
+			r.log.Error(err, "unable to update PipelineRun labels and annotations.")
+		}
+	} else if r.pipelineRunDataStore == "configmap" {
+		var cmStore storeInter.ConfigMapStore
+		if cmStore, err = cmstore.NewConfigMapStore(r.ctx, r.req.NamespacedName, r.Client); err == nil {
+			cmStore.SetStages(nodeDetailsJSON)
+			cmStore.SetStatus(runResultJSON)
+			cmStore.SetOwnerReference(v1.OwnerReference{
+				APIVersion: pipelineRunCopied.APIVersion,
+				Kind:       pipelineRunCopied.Kind,
+				Name:       pipelineRunCopied.Name,
+				UID:        pipelineRunCopied.UID,
+			})
+			err = cmStore.Save()
+		}
+	} else {
+		err = fmt.Errorf("unknown pipelineRun data store type: %s", r.pipelineRunDataStore)
+	}
+	return
+}
+
 func (r *Reconciler) hasSamePipelineRun(jobRun *job.PipelineRun, pipeline *v1alpha3.Pipeline) (exists bool, err error) {
 	// check if the run ID exists in the PipelineRun
 	pipelineRuns := &v1alpha3.PipelineRunList{}
@@ -255,22 +286,20 @@ func getSCMRefName(prSpec *v1alpha3.PipelineRunSpec) (string, error) {
 	return branch, nil
 }
 
-func (r *Reconciler) updateLabelsAndAnnotations(ctx context.Context, pr *v1alpha3.PipelineRun) error {
+func (r *Reconciler) updateLabelsAndAnnotations(ctx context.Context, pr *v1alpha3.PipelineRun) (err error) {
 	// get pipeline
 	prToUpdate := v1alpha3.PipelineRun{}
-	err := r.Get(ctx, client.ObjectKey{Namespace: pr.Namespace, Name: pr.Name}, &prToUpdate)
-	if err != nil {
-		return err
-	}
-	if reflect.DeepEqual(pr.Labels, prToUpdate.Labels) && reflect.DeepEqual(pr.Annotations, prToUpdate.Annotations) {
-		return nil
+	if err = r.Get(ctx, client.ObjectKey{Namespace: pr.Namespace, Name: pr.Name}, &prToUpdate); err == nil {
+		if !reflect.DeepEqual(pr.Labels, prToUpdate.Labels) || !reflect.DeepEqual(pr.Annotations, prToUpdate.Annotations) {
+			prToUpdate = *prToUpdate.DeepCopy()
+			prToUpdate.Labels = pr.Labels
+			prToUpdate.Annotations = pr.Annotations
+			// make sure all PipelineRuns have the finalizer
+			k8sutil.AddFinalizer(&prToUpdate.ObjectMeta, v1alpha3.PipelineRunFinalizerName)
+			err = r.Update(ctx, &prToUpdate)
+		}
 	}
-	prToUpdate = *prToUpdate.DeepCopy()
-	prToUpdate.Labels = pr.Labels
-	prToUpdate.Annotations = pr.Annotations
-	// make sure all PipelineRuns have the finalizer
-	k8sutil.AddFinalizer(&prToUpdate.ObjectMeta, v1alpha3.PipelineRunFinalizerName)
-	return r.Update(ctx, &prToUpdate)
+	return
 }
 
 func (r *Reconciler) updateStatus(ctx context.Context, desiredStatus *v1alpha3.PipelineRunStatus, prKey client.ObjectKey) error {
@@ -289,24 +318,25 @@ func (r *Reconciler) updateStatus(ctx context.Context, desiredStatus *v1alpha3.P
 	})
 }
 
-func (r *Reconciler) makePipelineRunOrphan(ctx context.Context, pr *v1alpha3.PipelineRun) error {
+func (r *Reconciler) makePipelineRunOrphan(ctx context.Context, pr *v1alpha3.PipelineRun) (err error) {
 	// make the PipelineRun as orphan
 	prToUpdate := pr.DeepCopy()
 	prToUpdate.LabelAsAnOrphan()
-	if err := r.updateLabelsAndAnnotations(ctx, prToUpdate); err != nil {
-		return err
-	}
-	condition := v1alpha3.Condition{
-		Type:               v1alpha3.ConditionSucceeded,
-		Status:             v1alpha3.ConditionUnknown,
-		Reason:             "SKIPPED",
-		Message:            "skipped to reconcile this PipelineRun due to not found Pipeline reference in PipelineRun.",
-		LastTransitionTime: v1.Now(),
-		LastProbeTime:      v1.Now(),
+	now := v1.Now()
+	if err = r.updateLabelsAndAnnotations(ctx, prToUpdate); err == nil {
+		condition := v1alpha3.Condition{
+			Type:               v1alpha3.ConditionSucceeded,
+			Status:             v1alpha3.ConditionUnknown,
+			Reason:             "SKIPPED",
+			Message:            "skipped to reconcile this PipelineRun due to not found Pipeline reference in PipelineRun.",
+			LastTransitionTime: now,
+			LastProbeTime:      now,
+		}
+		prToUpdate.Status.AddCondition(&condition)
+		prToUpdate.Status.Phase = v1alpha3.Unknown
+		err = r.updateStatus(ctx, &pr.Status, client.ObjectKey{Namespace: pr.Namespace, Name: pr.Name})
 	}
-	prToUpdate.Status.AddCondition(&condition)
-	prToUpdate.Status.Phase = v1alpha3.Unknown
-	return r.updateStatus(ctx, &pr.Status, client.ObjectKey{Namespace: pr.Namespace, Name: pr.Name})
+	return
 }
 
 func (r *Reconciler) getOrCreateJenkinsCore(annotations map[string]string) (*core.JenkinsCore, error) {
diff --git a/pkg/client/devops/jenkins/jenkins.go b/pkg/client/devops/jenkins/jenkins.go
index 90d6dbc..9d2d2f9 100644
--- a/pkg/client/devops/jenkins/jenkins.go
+++ b/pkg/client/devops/jenkins/jenkins.go
@@ -629,6 +629,9 @@ func CreateJenkins(client *http.Client, base string, maxConnection int, auth ...
 		base = base[:len(base)-1]
 	}
 	j.Server = base
+	if maxConnection == 0 {
+		maxConnection = 100
+	}
 	j.Requester = &Requester{Base: base, SslVerify: true, Client: client, connControl: make(chan struct{}, maxConnection)}
 	if j.Requester.Client == nil {
 		j.Requester.Client = http.DefaultClient
diff --git a/pkg/external/fluxcd/helm/v2beta1/condition_types.go b/pkg/external/fluxcd/helm/v2beta1/condition_types.go
new file mode 100644
index 0000000..c0c2095
--- /dev/null
+++ b/pkg/external/fluxcd/helm/v2beta1/condition_types.go
@@ -0,0 +1,98 @@
+/*
+Copyright 2020 The Flux 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 v2beta1
+
+const (
+	// ReleasedCondition represents the status of the last release attempt
+	// (install/upgrade/test) against the latest desired state.
+	ReleasedCondition string = "Released"
+
+	// TestSuccessCondition represents the status of the last test attempt against
+	// the latest desired state.
+	TestSuccessCondition string = "TestSuccess"
+
+	// RemediatedCondition represents the status of the last remediation attempt
+	// (uninstall/rollback) due to a failure of the last release attempt against the
+	// latest desired state.
+	RemediatedCondition string = "Remediated"
+)
+
+const (
+	// InstallSucceededReason represents the fact that the Helm install for the
+	// HelmRelease succeeded.
+	InstallSucceededReason string = "InstallSucceeded"
+
+	// InstallFailedReason represents the fact that the Helm install for the
+	// HelmRelease failed.
+	InstallFailedReason string = "InstallFailed"
+
+	// UpgradeSucceededReason represents the fact that the Helm upgrade for the
+	// HelmRelease succeeded.
+	UpgradeSucceededReason string = "UpgradeSucceeded"
+
+	// UpgradeFailedReason represents the fact that the Helm upgrade for the
+	// HelmRelease failed.
+	UpgradeFailedReason string = "UpgradeFailed"
+
+	// TestSucceededReason represents the fact that the Helm tests for the
+	// HelmRelease succeeded.
+	TestSucceededReason string = "TestSucceeded"
+
+	// TestFailedReason represents the fact that the Helm tests for the HelmRelease
+	// failed.
+	TestFailedReason string = "TestFailed"
+
+	// RollbackSucceededReason represents the fact that the Helm rollback for the
+	// HelmRelease succeeded.
+	RollbackSucceededReason string = "RollbackSucceeded"
+
+	// RollbackFailedReason represents the fact that the Helm test for the
+	// HelmRelease failed.
+	RollbackFailedReason string = "RollbackFailed"
+
+	// UninstallSucceededReason represents the fact that the Helm uninstall for the
+	// HelmRelease succeeded.
+	UninstallSucceededReason string = "UninstallSucceeded"
+
+	// UninstallFailedReason represents the fact that the Helm uninstall for the
+	// HelmRelease failed.
+	UninstallFailedReason string = "UninstallFailed"
+
+	// ArtifactFailedReason represents the fact that the artifact download for the
+	// HelmRelease failed.
+	ArtifactFailedReason string = "ArtifactFailed"
+
+	// InitFailedReason represents the fact that the initialization of the Helm
+	// configuration failed.
+	InitFailedReason string = "InitFailed"
+
+	// GetLastReleaseFailedReason represents the fact that observing the last
+	// release failed.
+	GetLastReleaseFailedReason string = "GetLastReleaseFailed"
+
+	// DependencyNotReadyReason represents the fact that
+	// one of the dependencies is not ready.
+	DependencyNotReadyReason string = "DependencyNotReady"
+
+	// ReconciliationSucceededReason represents the fact that
+	// the reconciliation succeeded.
+	ReconciliationSucceededReason string = "ReconciliationSucceeded"
+
+	// ReconciliationFailedReason represents the fact that
+	// the reconciliation failed.
+	ReconciliationFailedReason string = "ReconciliationFailed"
+)
diff --git a/pkg/external/fluxcd/kustomize/v1beta2/condition_types.go b/pkg/external/fluxcd/kustomize/v1beta2/condition_types.go
new file mode 100644
index 0000000..f1425d4
--- /dev/null
+++ b/pkg/external/fluxcd/kustomize/v1beta2/condition_types.go
@@ -0,0 +1,51 @@
+/*
+Copyright 2021 The Flux 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 v1beta2
+
+const (
+	// HealthyCondition represents the last recorded
+	// health assessment result.
+	HealthyCondition string = "Healthy"
+
+	// PruneFailedReason represents the fact that the
+	// pruning of the Kustomization failed.
+	PruneFailedReason string = "PruneFailed"
+
+	// ArtifactFailedReason represents the fact that the
+	// source artifact download failed.
+	ArtifactFailedReason string = "ArtifactFailed"
+
+	// BuildFailedReason represents the fact that the
+	// kustomize build failed.
+	BuildFailedReason string = "BuildFailed"
+
+	// HealthCheckFailedReason represents the fact that
+	// one of the health checks failed.
+	HealthCheckFailedReason string = "HealthCheckFailed"
+
+	// DependencyNotReadyReason represents the fact that
+	// one of the dependencies is not ready.
+	DependencyNotReadyReason string = "DependencyNotReady"
+
+	// ReconciliationSucceededReason represents the fact that
+	// the reconciliation succeeded.
+	ReconciliationSucceededReason string = "ReconciliationSucceeded"
+
+	// ReconciliationFailedReason represents the fact that
+	// the reconciliation failed.
+	ReconciliationFailedReason string = "ReconciliationFailed"
+)
diff --git a/pkg/external/fluxcd/meta/conditions.go b/pkg/external/fluxcd/meta/conditions.go
new file mode 100644
index 0000000..726890f
--- /dev/null
+++ b/pkg/external/fluxcd/meta/conditions.go
@@ -0,0 +1,115 @@
+/*
+Copyright 2020 The Flux 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 meta
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// These constants define generic Condition types to be used by GitOps Toolkit components.
+//
+// The ReadyCondition SHOULD be implemented by all components' Kubernetes resources to indicate they have been fully
+// reconciled by their respective reconciler. This MAY suffice for simple resources, e.g. a resource that just declares
+// state once and is not expected to receive any updates afterwards.
+//
+// For Kubernetes resources that are expected to receive spec updates over time, take a longer time to reconcile, or
+// deal with more complex logic in which for example a finite error state can be observed, it is RECOMMENDED to
+// implement the StalledCondition and ReconcilingCondition.
+//
+// By doing this, observers making use of kstatus to determine the current state of the resource will have a better
+// experience while they are e.g. waiting for a change to be reconciled, and will be able to stop waiting for a change
+// if a StalledCondition is observed, without having to rely on a timeout.
+//
+// For more information on kstatus, see:
+// https://github.com/kubernetes-sigs/cli-utils/blob/v0.25.0/pkg/kstatus/README.md
+const (
+	// ReadyCondition indicates the resource is ready and fully reconciled.
+	// If the Condition is False, the resource SHOULD be considered to be in the process of reconciling and not a
+	// representation of actual state.
+	ReadyCondition string = "Ready"
+
+	// StalledCondition indicates the reconciliation of the resource has stalled, e.g. because the controller has
+	// encountered an error during the reconcile process or it has made insufficient progress (timeout).
+	// The Condition adheres to an "abnormal-true" polarity pattern, and MUST only be present on the resource if the
+	// Condition is True.
+	// For more information about polarity patterns, see:
+	// https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties
+	StalledCondition string = "Stalled"
+
+	// ReconcilingCondition indicates the controller is currently working on reconciling the latest changes. This MAY be
+	// True for multiple reconciliation attempts, e.g. when an transient error occurred.
+	// The Condition adheres to an "abnormal-true" polarity pattern, and MUST only be present on the resource if the
+	// Condition is True.
+	// For more information about polarity patterns, see:
+	// https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties
+	ReconcilingCondition string = "Reconciling"
+)
+
+// These constants define generic Condition reasons to be used by GitOps Toolkit components.
+//
+// Making use of a generic Reason is RECOMMENDED whenever it can be applied to a Condition in which it provides
+// sufficient context together with the type to summarize the meaning of the Condition cause.
+//
+// Where any of the generic Condition reasons does not suffice, GitOps Toolkit components can introduce new reasons to
+// their API specification, or use an arbitrary PascalCase string when setting the Condition.
+// Declaration of domain common Condition reasons in the API specification is RECOMMENDED, as it eases observations
+// for user and computer.
+//
+// For more information on Condition reason conventions, see:
+// https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties
+const (
+	// SucceededReason indicates a condition or event observed a success, for example when declared desired state
+	// matches actual state, or a performed action succeeded.
+	//
+	// More information about the reason of success MAY be available as additional metadata in an attached message.
+	SucceededReason string = "Succeeded"
+
+	// FailedReason indicates a condition or event observed a failure, for example when declared state does not match
+	// actual state, or a performed action failed.
+	//
+	// More information about the reason of failure MAY be available as additional metadata in an attached message.
+	FailedReason string = "Failed"
+
+	// ProgressingReason indicates a condition or event observed progression, for example when the reconciliation of a
+	// resource or an action has started.
+	//
+	// When this reason is given, other conditions and types MAY no longer be considered as an up-to-date observation.
+	// Producers of the specific condition type or event SHOULD provide more information about the expectations and
+	// precise meaning in their API specification.
+	//
+	// More information about the reason or the current state of the progression MAY be available as additional metadata
+	// in an attached message.
+	ProgressingReason string = "Progressing"
+
+	// SuspendedReason indicates a condition or event has observed a suspension, for
+	// example because a resource has been suspended, or a dependency is.
+	SuspendedReason string = "Suspended"
+)
+
+// ObjectWithConditions describes a Kubernetes resource object with status conditions.
+// +k8s:deepcopy-gen=false
+type ObjectWithConditions interface {
+	// GetConditions returns a slice of metav1.Condition
+	GetConditions() []metav1.Condition
+}
+
+// ObjectWithConditionsSetter describes a Kubernetes resource object with a status conditions setter.
+// +k8s:deepcopy-gen=false
+type ObjectWithConditionsSetter interface {
+	// SetConditions sets the status conditions on the object
+	SetConditions([]metav1.Condition)
+}
diff --git a/pkg/kapis/devops/v1alpha3/pipelinerun/handler.go b/pkg/kapis/devops/v1alpha3/pipelinerun/handler.go
index 1757a6d..90b6052 100644
--- a/pkg/kapis/devops/v1alpha3/pipelinerun/handler.go
+++ b/pkg/kapis/devops/v1alpha3/pipelinerun/handler.go
@@ -22,6 +22,8 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"k8s.io/apimachinery/pkg/types"
+	cmstore "kubesphere.io/devops/pkg/store/configmap"
 	"net/url"
 	"strconv"
 
@@ -165,10 +167,11 @@ func (h *apiHandler) getPipelineRun(request *restful.Request, response *restful.
 func (h *apiHandler) getNodeDetails(request *restful.Request, response *restful.Response) {
 	namespaceName := request.PathParameter("namespace")
 	pipelineRunName := request.PathParameter("pipelinerun")
+	ctx := request.Request.Context()
 
 	// get pipelinerun
 	pr := &v1alpha3.PipelineRun{}
-	if err := h.client.Get(context.Background(), client.ObjectKey{Namespace: namespaceName, Name: pipelineRunName}, pr); err != nil {
+	if err := h.client.Get(ctx, client.ObjectKey{Namespace: namespaceName, Name: pipelineRunName}, pr); err != nil {
 		kapis.HandleError(request, response, err)
 		return
 	}
@@ -176,10 +179,18 @@ func (h *apiHandler) getNodeDetails(request *restful.Request, response *restful.
 	// get stage status
 	stagesJSON, ok := pr.Annotations[v1alpha3.JenkinsPipelineRunStagesStatusAnnoKey]
 	if !ok {
-		// If the stages status dose not exist, set it as an empty array
-		stagesJSON = "[]"
+		if pipelineRunStore, err := cmstore.NewConfigMapStore(ctx, types.NamespacedName{
+			Namespace: namespaceName,
+			Name:      pipelineRunName,
+		}, h.client); err != nil {
+			// If the stages status does not exist, set it as an empty array
+			stagesJSON = "[]"
+		} else {
+			stagesJSON = pipelineRunStore.GetStages()
+		}
 	}
-	stages := []pipelinerun.NodeDetail{}
+
+	var stages []pipelinerun.NodeDetail
 	if err := json.Unmarshal([]byte(stagesJSON), &stages); err != nil {
 		kapis.HandleError(request, response, err)
 		return
diff --git a/pkg/store/configmap/pipelinerun.go b/pkg/store/configmap/pipelinerun.go
new file mode 100644
index 0000000..107c243
--- /dev/null
+++ b/pkg/store/configmap/pipelinerun.go
@@ -0,0 +1,120 @@
+/*
+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 configmap
+
+import (
+	"context"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"kubesphere.io/devops/pkg/store/store"
+	"kubesphere.io/devops/pkg/utils/k8sutil"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// ConfigMapStore represents a key-value store base on ConfigMap
+type ConfigMapStore struct {
+	ctx       context.Context
+	k8sClient client.Client
+
+	cache *v1.ConfigMap
+	owner metav1.OwnerReference
+}
+
+// NewConfigMapStore creates a PipelineRun data store
+func NewConfigMapStore(ctx context.Context, key client.ObjectKey, k8sClient client.Client) (
+	result store.ConfigMapStore, err error) {
+	cm := &v1.ConfigMap{Data: map[string]string{}}
+	cm.Namespace = key.Namespace
+	cm.Name = key.Name
+	if err = k8sClient.Get(ctx, key, cm); err != nil {
+		err = client.IgnoreNotFound(err)
+	}
+
+	result = &ConfigMapStore{
+		cache:     cm,
+		k8sClient: k8sClient,
+		ctx:       ctx,
+	}
+	return
+}
+
+// GetStages returns the stage data
+func (s *ConfigMapStore) GetStages() string {
+	return s.Get(store.DataKeyStage)
+}
+
+// SetStages stores the stage data
+func (s *ConfigMapStore) SetStages(stages string) {
+	s.Set(store.DataKeyStage, stages)
+}
+
+// GetStatus returns the status
+func (s *ConfigMapStore) GetStatus() string {
+	return s.Get(store.DataKeyStatus)
+}
+
+// SetStatus stores the status
+func (s *ConfigMapStore) SetStatus(status string) {
+	s.Set(store.DataKeyStatus, status)
+}
+
+// GetStepLog returns the step log
+func (s *ConfigMapStore) GetStepLog(stage, step int) string {
+	return s.Get(store.StepLogKey(stage, step))
+}
+
+// SetStepLog stores the step log
+func (s *ConfigMapStore) SetStepLog(stage, step int, log string) {
+	s.Set(store.StepLogKey(stage, step), log)
+}
+
+// GetAllLog returns the whole log
+func (s *ConfigMapStore) GetAllLog() string {
+	return s.Get(store.DataKeyAllLog)
+}
+
+// SetAllLog store the whole log
+func (s *ConfigMapStore) SetAllLog(log string) {
+	s.Set(store.DataKeyAllLog, log)
+}
+
+// Get returns the value by a key
+func (s *ConfigMapStore) Get(key string) string {
+	return s.cache.Data[key]
+}
+
+// Set puts a key and value
+func (s *ConfigMapStore) Set(key, value string) {
+	s.cache.Data[key] = value
+}
+
+// Save puts the data into ConfigMap via Kubernetes client
+func (s *ConfigMapStore) Save() (err error) {
+	// it might be a new ConfigMap or not
+	if s.cache.GetResourceVersion() == "" {
+		k8sutil.SetOwnerReference(s.cache, s.owner)
+		err = s.k8sClient.Create(s.ctx, s.cache)
+	} else {
+		err = s.k8sClient.Update(s.ctx, s.cache)
+	}
+	return
+}
+
+// SetOwnerReference set the owner reference
+func (s *ConfigMapStore) SetOwnerReference(owner metav1.OwnerReference) {
+	s.owner = owner
+}
diff --git a/pkg/store/fake/fake_store.go b/pkg/store/fake/fake_store.go
new file mode 100644
index 0000000..865e83c
--- /dev/null
+++ b/pkg/store/fake/fake_store.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 fake
+
+import "kubesphere.io/devops/pkg/store/store"
+
+// FakeStore is a fake store
+type FakeStore struct {
+	data map[string]string
+	err  error
+}
+
+// NewFakeStore creates an instance of the fake store
+func NewFakeStore() *FakeStore {
+	return &FakeStore{
+		data: map[string]string{},
+	}
+}
+
+// WithError sets the error for expect error
+func (s *FakeStore) WithError(err error) *FakeStore {
+	s.err = err
+	return s
+}
+
+// Get is a fake method
+func (s *FakeStore) Get(key string) string {
+	return s.data[key]
+}
+
+// Set is a fake method
+func (s *FakeStore) Set(key, value string) {
+	s.data[key] = value
+}
+
+// Save is a fake method
+func (s *FakeStore) Save() error {
+	return s.err
+}
+
+// GetStages is a fake method
+func (s *FakeStore) GetStages() string {
+	return s.Get(store.DataKeyStage)
+}
+
+// SetStages is fake method
+func (s *FakeStore) SetStages(stages string) {
+	s.Set(store.DataKeyStage, stages)
+}
+
+// GetStatus is a fake method
+func (s *FakeStore) GetStatus() string {
+	return s.Get(store.DataKeyStatus)
+}
+
+// SetStatus is a fake method
+func (s *FakeStore) SetStatus(status string) {
+	s.Set(store.DataKeyStatus, status)
+}
+
+// GetStepLog is a fake method
+func (s *FakeStore) GetStepLog(stage, step int) string {
+	return s.Get(store.StepLogKey(stage, step))
+}
+
+// SetStepLog is a fake method
+func (s *FakeStore) SetStepLog(stage, step int, log string) {
+	s.Set(store.StepLogKey(stage, step), log)
+}
+
+// GetAllLog is a fake method
+func (s *FakeStore) GetAllLog() string {
+	return s.Get(store.DataKeyAllLog)
+}
+
+// SetAllLog is a fake method
+func (s *FakeStore) SetAllLog(log string) {
+	s.data[store.DataKeyAllLog] = log
+}
diff --git a/pkg/store/store/types.go b/pkg/store/store/types.go
new file mode 100644
index 0000000..be27fff
--- /dev/null
+++ b/pkg/store/store/types.go
@@ -0,0 +1,63 @@
+/*
+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 store
+
+import (
+	"fmt"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+const (
+	// DataKeyAllLog is the key of all log
+	DataKeyAllLog = "log-all"
+	// DataKeyStage is the key of stage data
+	DataKeyStage = "stage"
+	// DataKeyStatus is the key of status
+	DataKeyStatus = "status"
+)
+
+// StepLogKey generates a unique key by stage and step number
+func StepLogKey(stage, step int) string {
+	return fmt.Sprintf("log-step-%d-%d", stage, step)
+}
+
+// KeyValueStore represents a key-value store
+type KeyValueStore interface {
+	Get(key string) string
+	Set(key, value string)
+	Save() error
+}
+
+// PipelineRunDataStore represents a PipelineRun data store
+type PipelineRunDataStore interface {
+	KeyValueStore
+
+	GetStages() string
+	SetStages(stages string)
+	GetStatus() string
+	SetStatus(status string)
+	GetStepLog(stage, step int) string
+	SetStepLog(stage, step int, log string)
+	GetAllLog() string
+	SetAllLog(log string)
+}
+
+// ConfigMapStore represents a store base on a ConfigMap
+type ConfigMapStore interface {
+	PipelineRunDataStore
+	SetOwnerReference(owner metav1.OwnerReference)
+}


Test patch:
diff --git a/cmd/controller/app/options/feature_test.go b/cmd/controller/app/options/feature_test.go
index 153a1ba..7cf2cf1 100644
--- a/cmd/controller/app/options/feature_test.go
+++ b/cmd/controller/app/options/feature_test.go
@@ -120,4 +120,5 @@ func TestFeatureOptions(t *testing.T) {
 	assert.NotNil(t, flagSet.Lookup("system-namespace"))
 	assert.NotNil(t, flagSet.Lookup("external-address"))
 	assert.NotNil(t, flagSet.Lookup("cluster-name"))
+	assert.NotNil(t, flagSet.Lookup("pipelinerun-data-store"))
 }
diff --git a/controllers/fluxcd/fluxcd-application-status-controller_test.go b/controllers/fluxcd/fluxcd-application-status-controller_test.go
new file mode 100644
index 0000000..e525dc0
--- /dev/null
+++ b/controllers/fluxcd/fluxcd-application-status-controller_test.go
@@ -0,0 +1,832 @@
+/*
+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 fluxcd
+
+import (
+	"context"
+	"fmt"
+	"github.com/go-logr/logr"
+	"github.com/stretchr/testify/assert"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
+	"kubesphere.io/devops/controllers/core"
+	"kubesphere.io/devops/pkg/api/gitops/v1alpha1"
+	helmv2 "kubesphere.io/devops/pkg/external/fluxcd/helm/v2beta1"
+	kusv1 "kubesphere.io/devops/pkg/external/fluxcd/kustomize/v1beta2"
+	"kubesphere.io/devops/pkg/external/fluxcd/meta"
+	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 TestApplicationStatusReconciler_Reconcile(t *testing.T) {
+	schema, err := v1alpha1.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+
+	err = helmv2.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	err = kusv1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	fluxHelmApp := &v1alpha1.Application{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: "fake-ns",
+			Name:      "fake-app",
+		},
+		Spec: v1alpha1.ApplicationSpec{
+			Kind: "fluxcd",
+			FluxApp: &v1alpha1.FluxApplication{
+				Spec: v1alpha1.FluxApplicationSpec{
+					Source: &v1alpha1.FluxApplicationSource{
+						SourceRef: helmv2.CrossNamespaceObjectReference{
+							APIVersion: "source.toolkit.fluxcd.io/v1beta2",
+							Kind:       "GitRepository",
+							Name:       "fake-repo",
+							Namespace:  "fake-ns",
+						},
+					},
+					Config: &v1alpha1.FluxApplicationConfig{
+						HelmRelease: &v1alpha1.HelmReleaseSpec{
+							Chart: &v1alpha1.HelmChartTemplateSpec{
+								Chart:   "./helm-chart",
+								Version: "0.1.0",
+								Interval: &metav1.Duration{
+									Duration: time.Minute,
+								},
+								ReconcileStrategy: "Revision",
+								ValuesFiles: []string{
+									"./helm-chart/values.yaml",
+								},
+							},
+							Deploy: []*v1alpha1.Deploy{
+								{
+									Destination: v1alpha1.FluxApplicationDestination{
+										KubeConfig: &helmv2.KubeConfig{
+											SecretRef: meta.SecretKeyReference{
+												Name: "aliyun-kubeconfig",
+												Key:  "kubeconfig",
+											},
+										},
+										TargetNamespace: "fake-targetNamespace",
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	fluxKusApp := &v1alpha1.Application{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: "fake-ns",
+			Name:      "fake-app",
+		},
+		Spec: v1alpha1.ApplicationSpec{
+			Kind: "fluxcd",
+			FluxApp: &v1alpha1.FluxApplication{
+				Spec: v1alpha1.FluxApplicationSpec{
+					Source: &v1alpha1.FluxApplicationSource{
+						SourceRef: helmv2.CrossNamespaceObjectReference{
+							APIVersion: "source.toolkit.fluxcd.io/v1beta2",
+							Kind:       "GitRepository",
+							Name:       "fake-repo",
+							Namespace:  "fake-ns",
+						},
+					},
+					Config: &v1alpha1.FluxApplicationConfig{
+						Kustomization: []*v1alpha1.KustomizationSpec{
+							{
+								Destination: v1alpha1.FluxApplicationDestination{
+									KubeConfig: &helmv2.KubeConfig{
+										SecretRef: meta.SecretKeyReference{
+											Name: "aliyun-kubeconfig",
+											Key:  "kubeconfig",
+										},
+									},
+									TargetNamespace: "fake-targetNamespace",
+								},
+								Path:  "kustomization",
+								Prune: true,
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	hr := &helmv2.HelmRelease{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: "fake-ns",
+			Name:      "fake-appabcde",
+			Labels: map[string]string{
+				"app.kubernetes.io/managed-by": "fake-app",
+			},
+			Annotations: map[string]string{
+				"app.kubernetes.io/name": "aliyun-kubeconfig-fake-targetNamespace",
+			},
+		},
+		Spec: helmv2.HelmReleaseSpec{
+			Chart: helmv2.HelmChartTemplate{
+				Spec: helmv2.HelmChartTemplateSpec{
+					SourceRef: helmv2.CrossNamespaceObjectReference{
+						APIVersion: "source.toolkit.fluxcd.io/v1beta2",
+						Kind:       "GitRepository",
+						Name:       "fake-repo",
+						Namespace:  "fake-ns",
+					},
+					Chart:   "./helm-chart",
+					Version: "0.1.0",
+					Interval: &metav1.Duration{
+						Duration: time.Minute,
+					},
+					ReconcileStrategy: "Revision",
+					ValuesFiles: []string{
+						"./helm-chart/values.yaml",
+					},
+				},
+			},
+			Interval: metav1.Duration{},
+			KubeConfig: &helmv2.KubeConfig{
+				SecretRef: meta.SecretKeyReference{
+					Name: "aliyun-kubeconfig",
+					Key:  "kubeconfig",
+				},
+			},
+			TargetNamespace: "fake-targetNamespace",
+		},
+	}
+
+	kus := &kusv1.Kustomization{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: "fake-ns",
+			Name:      "fake-appabcde",
+			Labels: map[string]string{
+				"app.kubernetes.io/managed-by": "fake-app",
+			},
+			Annotations: map[string]string{
+				"app.kubernetes.io/name": "aliyun-kubeconfig-fake-targetNamespace",
+			},
+		},
+		Spec: kusv1.KustomizationSpec{
+			Interval: metav1.Duration{Duration: time.Minute},
+			KubeConfig: &kusv1.KubeConfig{
+				SecretRef: meta.SecretKeyReference{
+					Name: "aliyun-kubeconfig",
+					Key:  "kubeconfig",
+				},
+			},
+			Path:  "kustomization",
+			Prune: true,
+			SourceRef: kusv1.CrossNamespaceSourceReference{
+				APIVersion: "source.toolkit.fluxcd.io/v1beta2",
+				Kind:       "GitRepository",
+				Name:       "fake-repo",
+				Namespace:  "fake-ns",
+			},
+			TargetNamespace: "fake-targetNamespace",
+		},
+	}
+
+	type fields struct {
+		Client client.Client
+	}
+
+	type args struct {
+		req ctrl.Request
+	}
+
+	tests := []struct {
+		name       string
+		fields     fields
+		args       args
+		wantResult ctrl.Result
+		wantErr    assert.ErrorAssertionFunc
+	}{
+		{
+			name: "found a HelmRelease",
+			fields: fields{
+				Client: fake.NewFakeClientWithScheme(schema, hr.DeepCopy(), fluxHelmApp.DeepCopy()),
+			},
+			args: args{
+				req: ctrl.Request{
+					NamespacedName: types.NamespacedName{
+						Namespace: "fake-ns",
+						Name:      "fake-appabcde",
+					},
+				},
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "found a Kustomization",
+			fields: fields{
+				Client: fake.NewFakeClientWithScheme(schema, kus.DeepCopy(), fluxKusApp.DeepCopy()),
+			},
+			args: args{
+				req: ctrl.Request{
+					NamespacedName: types.NamespacedName{
+						Namespace: "fake-ns",
+						Name:      "fake-appabcde",
+					},
+				},
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "not found a HelmRelease neither a Kustomization",
+			fields: fields{
+				Client: fake.NewFakeClientWithScheme(schema),
+			},
+			args: args{
+				req: ctrl.Request{
+					NamespacedName: types.NamespacedName{
+						Namespace: "fake-ns",
+						Name:      "fake-name",
+					},
+				},
+			},
+			wantErr: assert.NoError,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &ApplicationStatusReconciler{
+				Client:   tt.fields.Client,
+				log:      logr.New(log.NullLogSink{}),
+				recorder: &record.FakeRecorder{},
+			}
+			gotResult, err := r.Reconcile(context.Background(), tt.args.req)
+			if tt.wantErr(t, err) {
+				assert.Equal(t, tt.wantResult, gotResult)
+			}
+		})
+	}
+}
+
+func TestApplicationStatusReconciler_reconcileHelmRelease(t *testing.T) {
+	schema, err := v1alpha1.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+
+	err = helmv2.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	fluxHelmApp := &v1alpha1.Application{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: "fake-ns",
+			Name:      "fake-app",
+		},
+		Spec: v1alpha1.ApplicationSpec{
+			Kind: "fluxcd",
+			FluxApp: &v1alpha1.FluxApplication{
+				Spec: v1alpha1.FluxApplicationSpec{
+					Source: &v1alpha1.FluxApplicationSource{
+						SourceRef: helmv2.CrossNamespaceObjectReference{
+							APIVersion: "source.toolkit.fluxcd.io/v1beta2",
+							Kind:       "GitRepository",
+							Name:       "fake-repo",
+							Namespace:  "fake-ns",
+						},
+					},
+					Config: &v1alpha1.FluxApplicationConfig{
+						HelmRelease: &v1alpha1.HelmReleaseSpec{
+							Chart: &v1alpha1.HelmChartTemplateSpec{
+								Chart:   "./helm-chart",
+								Version: "0.1.0",
+								Interval: &metav1.Duration{
+									Duration: time.Minute,
+								},
+								ReconcileStrategy: "Revision",
+								ValuesFiles: []string{
+									"./helm-chart/values.yaml",
+								},
+							},
+							Deploy: []*v1alpha1.Deploy{
+								{
+									Destination: v1alpha1.FluxApplicationDestination{
+										KubeConfig: &helmv2.KubeConfig{
+											SecretRef: meta.SecretKeyReference{
+												Name: "aliyun-kubeconfig",
+												Key:  "kubeconfig",
+											},
+										},
+										TargetNamespace: "fake-targetNamespace",
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	t1, _ := time.Parse("2006-01-02T15:04:05Z", "2022-09-05T13:57:03Z")
+	unKnownHelmRelease := &helmv2.HelmRelease{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: "fake-ns",
+			Name:      "fake-appabcde",
+			Labels: map[string]string{
+				"app.kubernetes.io/managed-by": "fake-app",
+			},
+			Annotations: map[string]string{
+				"app.kubernetes.io/name": "aliyun-kubeconfig-fake-targetNamespace",
+			},
+		},
+		Spec: helmv2.HelmReleaseSpec{
+			Chart: helmv2.HelmChartTemplate{
+				Spec: helmv2.HelmChartTemplateSpec{
+					SourceRef: helmv2.CrossNamespaceObjectReference{
+						APIVersion: "source.toolkit.fluxcd.io/v1beta2",
+						Kind:       "GitRepository",
+						Name:       "fake-repo",
+						Namespace:  "fake-ns",
+					},
+					Chart:   "./helm-chart",
+					Version: "0.1.0",
+					Interval: &metav1.Duration{
+						Duration: time.Minute,
+					},
+					ReconcileStrategy: "Revision",
+					ValuesFiles: []string{
+						"./helm-chart/values.yaml",
+					},
+				},
+			},
+			Interval: metav1.Duration{},
+			KubeConfig: &helmv2.KubeConfig{
+				SecretRef: meta.SecretKeyReference{
+					Name: "aliyun-kubeconfig",
+					Key:  "kubeconfig",
+				},
+			},
+			TargetNamespace: "fake-targetNamespace",
+		},
+		Status: helmv2.HelmReleaseStatus{
+			ObservedGeneration: 1,
+			Conditions: []metav1.Condition{
+				{
+					Type:               meta.ReadyCondition,
+					Status:             "Unknown",
+					ObservedGeneration: 0,
+					LastTransitionTime: metav1.Time{Time: t1},
+					Reason:             meta.ProgressingReason,
+					Message:            "Reconciliation in progress",
+				},
+			},
+			LastAttemptedRevision:       "0.1.0+1",
+			LastAttemptedValuesChecksum: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
+			HelmChart:                   "my-devops-project5s2r8/my-devops-project5s2r8-test-fluxcdk9dbc",
+		},
+	}
+
+	t2, _ := time.Parse("2006-01-02T15:04:05Z", "2022-09-06T05:14:44Z")
+	t3, _ := time.Parse("2006-01-02T15:04:05Z", "2022-09-06T05:14:44Z")
+	readyHelmRelease := &helmv2.HelmRelease{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: "fake-ns",
+			Name:      "fake-appabcde",
+			Labels: map[string]string{
+				"app.kubernetes.io/managed-by": "fake-app",
+			},
+			Annotations: map[string]string{
+				"app.kubernetes.io/name": "aliyun-kubeconfig-fake-targetNamespace",
+			},
+		},
+		Spec: helmv2.HelmReleaseSpec{
+			Chart: helmv2.HelmChartTemplate{
+				Spec: helmv2.HelmChartTemplateSpec{
+					SourceRef: helmv2.CrossNamespaceObjectReference{
+						APIVersion: "source.toolkit.fluxcd.io/v1beta2",
+						Kind:       "GitRepository",
+						Name:       "fake-repo",
+						Namespace:  "fake-ns",
+					},
+					Chart:   "./helm-chart",
+					Version: "0.1.0",
+					Interval: &metav1.Duration{
+						Duration: time.Minute,
+					},
+					ReconcileStrategy: "Revision",
+					ValuesFiles: []string{
+						"./helm-chart/values.yaml",
+					},
+				},
+			},
+			Interval: metav1.Duration{},
+			KubeConfig: &helmv2.KubeConfig{
+				SecretRef: meta.SecretKeyReference{
+					Name: "aliyun-kubeconfig",
+					Key:  "kubeconfig",
+				},
+			},
+			TargetNamespace: "fake-targetNamespace",
+		},
+		Status: helmv2.HelmReleaseStatus{
+			ObservedGeneration: 1,
+			Conditions: []metav1.Condition{
+				{
+					Type:               meta.ReadyCondition,
+					Status:             metav1.ConditionTrue,
+					LastTransitionTime: metav1.Time{Time: t2},
+					Reason:             helmv2.ReconciliationSucceededReason,
+					Message:            "Release reconciliation succeeded",
+				},
+				{
+					Type:               helmv2.ReleasedCondition,
+					Status:             metav1.ConditionTrue,
+					LastTransitionTime: metav1.Time{Time: t3},
+					Reason:             helmv2.InstallSucceededReason,
+					Message:            "Helm install succeeded",
+				},
+			},
+			LastAppliedRevision:         "0.1.0+1",
+			LastAttemptedRevision:       "0.1.0+1",
+			LastAttemptedValuesChecksum: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
+			LastReleaseRevision:         1,
+			HelmChart:                   "my-devops-project5s2r8/my-devops-project5s2r8-test-fluxcd2hbs2",
+		},
+	}
+
+	type fields struct {
+		Client client.Client
+	}
+
+	type args struct {
+		hr *helmv2.HelmRelease
+	}
+
+	tests := []struct {
+		name   string
+		fields fields
+		args   args
+		verify func(t *testing.T, Client client.Client, err error)
+	}{
+		{
+			name: "update Application's status (a Unknown HelmRelease)",
+			fields: fields{
+				Client: fake.NewFakeClientWithScheme(schema, fluxHelmApp.DeepCopy()),
+			},
+			args: args{
+				hr: unKnownHelmRelease.DeepCopy(),
+			},
+			verify: func(t *testing.T, Client client.Client, err error) {
+				assert.Nil(t, err)
+				ctx := context.Background()
+				app := &v1alpha1.Application{}
+				appNS, appName := fluxHelmApp.GetNamespace(), fluxHelmApp.GetName()
+
+				err = Client.Get(ctx, types.NamespacedName{Namespace: appNS, Name: appName}, app)
+				assert.Nil(t, err)
+
+				// status
+				assert.Equal(t, 1, len(app.Status.FluxApp.HelmReleaseStatus))
+				status := app.Status.FluxApp.HelmReleaseStatus["aliyun-kubeconfig-fake-targetNamespace"]
+				assert.Equal(t, 1, int(status.ObservedGeneration))
+				c := status.Conditions[0]
+				assert.Equal(t, meta.ReadyCondition, c.Type)
+				assert.Equal(t, "Unknown", string(c.Status))
+				assert.Equal(t, 0, int(c.ObservedGeneration))
+				assert.True(t, t1.Equal(c.LastTransitionTime.Time))
+				assert.Equal(t, "Progressing", c.Reason)
+				assert.Equal(t, "Reconciliation in progress", c.Message)
+				assert.Equal(t, "0.1.0+1", status.LastAttemptedRevision)
+				assert.Equal(t, "da39a3ee5e6b4b0d3255bfef95601890afd80709", status.LastAttemptedValuesChecksum)
+				assert.Equal(t, "my-devops-project5s2r8/my-devops-project5s2r8-test-fluxcdk9dbc", status.HelmChart)
+
+				// labels
+				assert.Equal(t, string(HelmRelease), app.GetLabels()[FluxAppTypeKey])
+				assert.Equal(t, "0-1", app.GetLabels()[FluxAppReadyNumKey])
+			},
+		},
+		{
+			name: "update Application's status (a Ready HelmRelease)",
+			fields: fields{
+				Client: fake.NewFakeClientWithScheme(schema, fluxHelmApp.DeepCopy()),
+			},
+			args: args{
+				hr: readyHelmRelease.DeepCopy(),
+			},
+			verify: func(t *testing.T, Client client.Client, err error) {
+				assert.Nil(t, err)
+				ctx := context.Background()
+				app := &v1alpha1.Application{}
+				appNS, appName := fluxHelmApp.GetNamespace(), fluxHelmApp.GetName()
+
+				err = Client.Get(ctx, types.NamespacedName{Namespace: appNS, Name: appName}, app)
+				assert.Nil(t, err)
+
+				assert.Equal(t, 1, len(app.Status.FluxApp.HelmReleaseStatus))
+				status := app.Status.FluxApp.HelmReleaseStatus["aliyun-kubeconfig-fake-targetNamespace"]
+				assert.Equal(t, 1, int(status.ObservedGeneration))
+				c1, c2 := status.Conditions[0], status.Conditions[1]
+				assert.Equal(t, meta.ReadyCondition, c1.Type)
+				assert.Equal(t, metav1.ConditionTrue, c1.Status)
+				assert.Equal(t, helmv2.ReconciliationSucceededReason, c1.Reason)
+				assert.Equal(t, "Release reconciliation succeeded", c1.Message)
+				assert.True(t, t2.Equal(c1.LastTransitionTime.Time))
+
+				assert.Equal(t, helmv2.ReleasedCondition, c2.Type)
+				assert.Equal(t, metav1.ConditionTrue, c2.Status)
+				assert.True(t, t3.Equal(c2.LastTransitionTime.Time))
+				assert.Equal(t, helmv2.InstallSucceededReason, c2.Reason)
+				assert.Equal(t, "Helm install succeeded", c2.Message)
+
+				assert.Equal(t, "0.1.0+1", status.LastAppliedRevision)
+				assert.Equal(t, "0.1.0+1", status.LastAttemptedRevision)
+				assert.Equal(t, "da39a3ee5e6b4b0d3255bfef95601890afd80709", status.LastAttemptedValuesChecksum)
+				assert.Equal(t, 1, status.LastReleaseRevision)
+				assert.Equal(t, "my-devops-project5s2r8/my-devops-project5s2r8-test-fluxcd2hbs2", status.HelmChart)
+
+				// labels
+				assert.Equal(t, string(HelmRelease), app.GetLabels()[FluxAppTypeKey])
+				assert.Equal(t, "1-1", app.GetLabels()[FluxAppReadyNumKey])
+			},
+		},
+		{
+			name: "not found a app that manage this HelmRelease",
+			fields: fields{
+				Client: fake.NewFakeClientWithScheme(schema),
+			},
+			args: args{
+				hr: readyHelmRelease,
+			},
+			verify: func(t *testing.T, Client client.Client, err error) {
+				assert.True(t, apierrors.IsNotFound(err))
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := ApplicationStatusReconciler{
+				Client:   tt.fields.Client,
+				log:      logr.New(log.NullLogSink{}),
+				recorder: &record.FakeRecorder{},
+			}
+			_, err := r.reconcileHelmRelease(context.Background(), tt.args.hr)
+			tt.verify(t, tt.fields.Client, err)
+		})
+	}
+}
+
+func TestApplicationStatusReconciler_reconcileKustomization(t *testing.T) {
+	schema, err := v1alpha1.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+
+	err = kusv1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	fluxKusApp := &v1alpha1.Application{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: "fake-ns",
+			Name:      "fake-app",
+		},
+		Spec: v1alpha1.ApplicationSpec{
+			Kind: "fluxcd",
+			FluxApp: &v1alpha1.FluxApplication{
+				Spec: v1alpha1.FluxApplicationSpec{
+					Source: &v1alpha1.FluxApplicationSource{
+						SourceRef: helmv2.CrossNamespaceObjectReference{
+							APIVersion: "source.toolkit.fluxcd.io/v1beta2",
+							Kind:       "GitRepository",
+							Name:       "fake-repo",
+							Namespace:  "fake-ns",
+						},
+					},
+					Config: &v1alpha1.FluxApplicationConfig{
+						Kustomization: []*v1alpha1.KustomizationSpec{
+							{
+								Destination: v1alpha1.FluxApplicationDestination{
+									KubeConfig: &helmv2.KubeConfig{
+										SecretRef: meta.SecretKeyReference{
+											Name: "aliyun-kubeconfig",
+											Key:  "kubeconfig",
+										},
+									},
+									TargetNamespace: "fake-targetNamespace",
+								},
+								Path:  "kustomization",
+								Prune: true,
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	t1, _ := time.Parse("2006-01-02T15:04:05Z", "2022-09-06T07:46:47Z")
+	readyKus := &kusv1.Kustomization{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: "fake-ns",
+			Name:      "fake-appabcde",
+			Labels: map[string]string{
+				"app.kubernetes.io/managed-by": "fake-app",
+			},
+			Annotations: map[string]string{
+				"app.kubernetes.io/name": "aliyun-kubeconfig-fake-targetNamespace",
+			},
+		},
+		Spec: kusv1.KustomizationSpec{
+			SourceRef: kusv1.CrossNamespaceSourceReference{
+				APIVersion: "source.toolkit.fluxcd.io/v1beta2",
+				Kind:       "GitRepository",
+				Name:       "fake-repo",
+				Namespace:  "fake-ns",
+			},
+			KubeConfig: &kusv1.KubeConfig{
+				SecretRef: meta.SecretKeyReference{
+					Name: "aliyun-kubeconfig",
+					Key:  "kubeconfig",
+				},
+			},
+			TargetNamespace: "fake-targetNamespace",
+			Path:            "kustomization",
+			Prune:           true,
+		},
+		Status: kusv1.KustomizationStatus{
+			ObservedGeneration: 1,
+			Conditions: []metav1.Condition{
+				{
+					LastTransitionTime: metav1.Time{Time: t1},
+					Message:            "Applied revision: master/4b8497c8c3dc8c7ad5c9cb66160dbd3bcc1cfd4f",
+					Reason:             kusv1.ReconciliationSucceededReason,
+					Type:               meta.ReadyCondition,
+					Status:             metav1.ConditionTrue,
+				},
+			},
+			LastAppliedRevision:   "master/4b8497c8c3dc8c7ad5c9cb66160dbd3bcc1cfd4f",
+			LastAttemptedRevision: "master/4b8497c8c3dc8c7ad5c9cb66160dbd3bcc1cfd4f",
+			Inventory: &kusv1.ResourceInventory{
+				Entries: []kusv1.ResourceRef{
+					{
+						ID:      "default_nginx-svc__Service",
+						Version: "v1",
+					},
+					{
+						ID:      "default_nginx-deployment_apps_Deployment",
+						Version: "v1",
+					},
+				},
+			},
+		},
+	}
+
+	type fields struct {
+		Client client.Client
+	}
+
+	type args struct {
+		kus *kusv1.Kustomization
+	}
+
+	tests := []struct {
+		name   string
+		fields fields
+		args   args
+		verify func(t *testing.T, Client client.Client, err error)
+	}{
+		{
+			name: "update Application's status (a Kustomization)",
+			fields: fields{
+				Client: fake.NewFakeClientWithScheme(schema, fluxKusApp.DeepCopy()),
+			},
+			args: args{
+				kus: readyKus,
+			},
+			verify: func(t *testing.T, Client client.Client, err error) {
+				assert.Nil(t, err)
+				ctx := context.Background()
+				app := &v1alpha1.Application{}
+				appNS, appName := fluxKusApp.GetNamespace(), fluxKusApp.GetName()
+
+				err = Client.Get(ctx, types.NamespacedName{Namespace: appNS, Name: appName}, app)
+				assert.Nil(t, err)
+
+				// status
+				assert.Equal(t, 1, len(app.Status.FluxApp.KustomizationStatus))
+				status := app.Status.FluxApp.KustomizationStatus["aliyun-kubeconfig-fake-targetNamespace"]
+				assert.Equal(t, 1, int(status.ObservedGeneration))
+
+				assert.Equal(t, 1, int(status.ObservedGeneration))
+				c := status.Conditions[0]
+				assert.True(t, t1.Equal(c.LastTransitionTime.Time))
+				assert.Equal(t, "Applied revision: master/4b8497c8c3dc8c7ad5c9cb66160dbd3bcc1cfd4f", c.Message)
+				assert.Equal(t, kusv1.ReconciliationSucceededReason, c.Reason)
+				assert.Equal(t, meta.ReadyCondition, c.Type)
+				assert.Equal(t, metav1.ConditionTrue, c.Status)
+				assert.Equal(t, "master/4b8497c8c3dc8c7ad5c9cb66160dbd3bcc1cfd4f", status.LastAppliedRevision)
+				assert.Equal(t, "master/4b8497c8c3dc8c7ad5c9cb66160dbd3bcc1cfd4f", status.LastAttemptedRevision)
+				assert.Equal(t, "default_nginx-svc__Service", status.Inventory.Entries[0].ID)
+				assert.Equal(t, "v1", status.Inventory.Entries[0].Version)
+				assert.Equal(t, "default_nginx-deployment_apps_Deployment", status.Inventory.Entries[1].ID)
+				assert.Equal(t, "v1", status.Inventory.Entries[1].Version)
+
+				// labels
+				assert.Equal(t, string(Kustomization), app.GetLabels()[FluxAppTypeKey])
+				assert.Equal(t, "1-1", app.GetLabels()[FluxAppReadyNumKey])
+			},
+		},
+		{
+			name: "not found a app that manage this Kustomization",
+			fields: fields{
+				Client: fake.NewFakeClientWithScheme(schema),
+			},
+			args: args{
+				kus: readyKus,
+			},
+			verify: func(t *testing.T, Client client.Client, err error) {
+				assert.True(t, apierrors.IsNotFound(err))
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := ApplicationStatusReconciler{
+				Client:   tt.fields.Client,
+				log:      logr.New(log.NullLogSink{}),
+				recorder: &record.FakeRecorder{},
+			}
+			_, err := r.reconcileKustomization(context.Background(), tt.args.kus)
+			tt.verify(t, tt.fields.Client, err)
+		})
+	}
+}
+
+func TestApplicationStatusReconciler_GetName(t *testing.T) {
+	t.Run("get ApplicationStatusReconciler name", func(t *testing.T) {
+
+		r := &ApplicationStatusReconciler{}
+		assert.Equal(t, "FluxCDApplicationStatusController", r.GetName())
+	})
+}
+
+func TestApplicationStatusReconciler_SetupWithManager(t *testing.T) {
+	schema, err := v1alpha1.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+
+	err = helmv2.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	err = kusv1.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	type fields struct {
+		Client   client.Client
+		log      logr.Logger
+		recorder record.EventRecorder
+	}
+	type args struct {
+		mgr ctrl.Manager
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		args    args
+		wantErr assert.ErrorAssertionFunc
+	}{{
+		name: "normal",
+		args: args{
+			mgr: &core.FakeManager{
+				Scheme: schema,
+				Client: fake.NewFakeClientWithScheme(schema),
+			},
+		},
+		wantErr: core.NoErrors,
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &ApplicationStatusReconciler{
+				Client:   tt.fields.Client,
+				log:      tt.fields.log,
+				recorder: tt.fields.recorder,
+			}
+			tt.wantErr(t, r.SetupWithManager(tt.args.mgr), fmt.Sprintf("SetupWithManager(%v)", tt.args.mgr))
+		})
+	}
+}
diff --git a/controllers/jenkins/pipelinerun/pipelinerun_controller_test.go b/controllers/jenkins/pipelinerun/pipelinerun_controller_test.go
index 7fc8869..2adf58a 100644
--- a/controllers/jenkins/pipelinerun/pipelinerun_controller_test.go
+++ b/controllers/jenkins/pipelinerun/pipelinerun_controller_test.go
@@ -17,21 +17,30 @@ limitations under the License.
 package pipelinerun
 
 import (
-	"reflect"
-	"testing"
-
+	"context"
+	"fmt"
+	"github.com/go-logr/logr"
 	"github.com/jenkins-zh/jenkins-client/pkg/core"
 	"github.com/jenkins-zh/jenkins-client/pkg/job"
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
 	"github.com/stretchr/testify/assert"
-	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/apiserver/pkg/authentication/user"
+	"k8s.io/client-go/tools/record"
+	ctrlCore "kubesphere.io/devops/controllers/core"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
 	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
 	"kubesphere.io/devops/pkg/client/clientset/versioned/scheme"
 	"kubesphere.io/devops/pkg/jwt/token"
+	"reflect"
+	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/log"
+	"testing"
 	// nolint
 	// The fakeclient will undeprecated starting with v0.7.0
 	// Reference:
@@ -136,7 +145,7 @@ var _ = Describe("TestReconciler_hasSamePipelineRun", func() {
 
 	BeforeEach(func() {
 		genernalPipeline = &v1alpha3.Pipeline{
-			ObjectMeta: v1.ObjectMeta{
+			ObjectMeta: metav1.ObjectMeta{
 				Name:      "general-pipeline",
 				Namespace: "default",
 			},
@@ -145,7 +154,7 @@ var _ = Describe("TestReconciler_hasSamePipelineRun", func() {
 			},
 		}
 		multiBranchPipeline = &v1alpha3.Pipeline{
-			ObjectMeta: v1.ObjectMeta{
+			ObjectMeta: metav1.ObjectMeta{
 				Name:      "multi-branch-pipeline",
 				Namespace: "default",
 			},
@@ -155,7 +164,7 @@ var _ = Describe("TestReconciler_hasSamePipelineRun", func() {
 		}
 
 		generalPipelineRun = &v1alpha3.PipelineRun{
-			ObjectMeta: v1.ObjectMeta{
+			ObjectMeta: metav1.ObjectMeta{
 				Name:      "pipeline-run-2",
 				Namespace: "default",
 				Annotations: map[string]string{
@@ -168,7 +177,7 @@ var _ = Describe("TestReconciler_hasSamePipelineRun", func() {
 		}
 
 		multiBranchPipelineRun = &v1alpha3.PipelineRun{
-			ObjectMeta: v1.ObjectMeta{
+			ObjectMeta: metav1.ObjectMeta{
 				Name:      "pipeline-run-1",
 				Namespace: "default",
 				Annotations: map[string]string{
@@ -358,3 +367,152 @@ func TestReconciler_getOrCreateJenkinsCoreIfHasCreator(t *testing.T) {
 		})
 	}
 }
+
+func TestPipelineRunReconciler_SetupWithManager(t *testing.T) {
+	schema, err := v1alpha3.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+	err = v1alpha1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	type fields struct {
+		Client   client.Client
+		recorder record.EventRecorder
+	}
+	type args struct {
+		mgr controllerruntime.Manager
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		args    args
+		wantErr assert.ErrorAssertionFunc
+	}{{
+		name: "normal",
+		args: args{
+			mgr: &ctrlCore.FakeManager{
+				Client: fake.NewFakeClientWithScheme(schema),
+				Scheme: schema,
+			},
+		},
+		wantErr: ctrlCore.NoErrors,
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &Reconciler{
+				Client:   tt.fields.Client,
+				log:      logr.Logger{},
+				recorder: tt.fields.recorder,
+			}
+			tt.wantErr(t, r.SetupWithManager(tt.args.mgr), fmt.Sprintf("SetupWithManager(%v)", tt.args.mgr))
+		})
+	}
+}
+
+func TestPipelineRunReconcile(t *testing.T) {
+	schema, err := v1alpha3.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+	err = v1alpha1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	pipelineRun := v1alpha3.PipelineRun{}
+	pipelineRun.SetName("name")
+	pipelineRun.SetNamespace("ns")
+
+	now := metav1.Now()
+	completedPipelineRun := pipelineRun.DeepCopy()
+	completedPipelineRun.Status.CompletionTime = &now
+
+	normalPipeline := pipelineRun.DeepCopy()
+	normalPipeline.Spec.PipelineRef = &v1.ObjectReference{
+		Namespace: "ns",
+		Name:      "pipeline",
+	}
+
+	defaultReq := ctrl.Request{
+		NamespacedName: types.NamespacedName{
+			Namespace: "ns", Name: "name",
+		},
+	}
+
+	tests := []struct {
+		name      string
+		k8sclient client.Client
+		request   ctrl.Request
+		wantErr   bool
+	}{{
+		name:      "not found",
+		k8sclient: fake.NewClientBuilder().WithScheme(schema).Build(),
+		request:   defaultReq,
+		wantErr:   false,
+	}, {
+		name:      "orphan",
+		k8sclient: fake.NewClientBuilder().WithScheme(schema).WithObjects(pipelineRun.DeepCopy()).Build(),
+		request:   defaultReq,
+		wantErr:   false,
+	}, {
+		name:      "completed PipelineRun",
+		k8sclient: fake.NewClientBuilder().WithScheme(schema).WithObjects(completedPipelineRun.DeepCopy()).Build(),
+		request:   defaultReq,
+		wantErr:   false,
+	}, {
+		name:      "no corresponding Pipeline",
+		k8sclient: fake.NewClientBuilder().WithScheme(schema).WithObjects(normalPipeline.DeepCopy()).Build(),
+		request:   defaultReq,
+		wantErr:   false,
+	}}
+	for i, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := &Reconciler{
+				Client: tt.k8sclient,
+				log:    logr.New(log.NullLogSink{}),
+			}
+			_, err = r.Reconcile(context.Background(), tt.request)
+			if tt.wantErr {
+				assert.NotNil(t, err, "failed in [%d]", i)
+			} else {
+				assert.Nil(t, err, "failed in [%d]", i)
+			}
+		})
+	}
+}
+
+func TestStorePipelineRunData(t *testing.T) {
+	schema, err := v1alpha3.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+	err = v1alpha1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	pipelineRun := v1alpha3.PipelineRun{}
+	pipelineRun.SetName("name")
+	pipelineRun.SetNamespace("ns")
+
+	r := &Reconciler{
+		Client:               fake.NewClientBuilder().WithScheme(schema).WithObjects(pipelineRun.DeepCopy()).Build(),
+		log:                  logr.New(log.NullLogSink{}),
+		pipelineRunDataStore: "fake",
+	}
+	assert.NotNil(t, r.storePipelineRunData("", "", pipelineRun.DeepCopy()))
+
+	r = &Reconciler{
+		Client: fake.NewClientBuilder().WithScheme(schema).WithObjects(pipelineRun.DeepCopy()).Build(),
+		log:    logr.New(log.NullLogSink{}),
+		req: ctrl.Request{
+			NamespacedName: types.NamespacedName{Name: "name", Namespace: "ns"},
+		},
+		pipelineRunDataStore: "configmap",
+	}
+	assert.Nil(t, r.storePipelineRunData("", "", pipelineRun.DeepCopy()))
+
+	r = &Reconciler{
+		Client:               fake.NewClientBuilder().WithScheme(schema).WithObjects(pipelineRun.DeepCopy()).Build(),
+		log:                  logr.New(log.NullLogSink{}),
+		pipelineRunDataStore: "",
+	}
+	assert.Nil(t, r.storePipelineRunData("", "", pipelineRun.DeepCopy()))
+}
diff --git a/pkg/kapis/devops/v1alpha3/pipelinerun/handler_test.go b/pkg/kapis/devops/v1alpha3/pipelinerun/handler_test.go
index e302d1d..8ce8c18 100644
--- a/pkg/kapis/devops/v1alpha3/pipelinerun/handler_test.go
+++ b/pkg/kapis/devops/v1alpha3/pipelinerun/handler_test.go
@@ -20,7 +20,8 @@ import (
 	"context"
 	"encoding/json"
 	"io"
-	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apiserver/pkg/authentication/user"
 	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
 	"kubesphere.io/devops/pkg/apiserver/request"
@@ -42,7 +43,7 @@ func TestApis(t *testing.T) {
 	assert.Nil(t, err)
 
 	RegisterRoutes(wsWithGroup, fakedevops.NewFakeDevops(nil), fake.NewFakeClientWithScheme(schema, &v1alpha3.Pipeline{
-		ObjectMeta: v1.ObjectMeta{
+		ObjectMeta: metav1.ObjectMeta{
 			Name:      "fake",
 			Namespace: "fake",
 		},
@@ -125,3 +126,54 @@ func TestApis(t *testing.T) {
 		})
 	}
 }
+
+func TestGetNodeDetails(t *testing.T) {
+	schema, err := v1alpha3.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	pipelineRun := &v1alpha3.PipelineRun{}
+	pipelineRun.SetName("pr1")
+	pipelineRun.SetNamespace("ns")
+
+	cm := &v1.ConfigMap{Data: map[string]string{}}
+	cm.SetName(pipelineRun.GetName())
+	cm.SetNamespace(pipelineRun.GetNamespace())
+	cm.Data["stage"] = `[{"id":"id","steps":[{"approvable":true}]}]`
+
+	handler := &apiHandler{
+		apiHandlerOption: apiHandlerOption{
+			client: fake.NewClientBuilder().WithScheme(schema).
+				WithObjects(pipelineRun.DeepCopy()).
+				WithObjects(cm.DeepCopy()).Build(),
+		},
+	}
+
+	recorder := httptest.NewRecorder()
+	req := restful.NewRequest(&http.Request{
+		Header: map[string][]string{
+			"Accept": {"*/*"},
+		},
+	})
+	restful.DefaultResponseContentType(restful.MIME_JSON)
+	req.PathParameters()["namespace"] = "ns"
+	req.PathParameters()["pipelinerun"] = "pr1"
+	resp := restful.NewResponse(recorder)
+	handler.getNodeDetails(req, resp)
+
+	body, err := io.ReadAll(recorder.Body)
+	assert.Nil(t, err)
+	assert.Equal(t, `[
+ {
+  "id": "id",
+  "startTime": null,
+  "steps": [
+   {
+    "startTime": null,
+    "approvable": true
+   }
+  ]
+ }
+]`, string(body))
+}
diff --git a/pkg/store/configmap/pipelinerun_test.go b/pkg/store/configmap/pipelinerun_test.go
new file mode 100644
index 0000000..0573b3e
--- /dev/null
+++ b/pkg/store/configmap/pipelinerun_test.go
@@ -0,0 +1,59 @@
+/*
+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 configmap
+
+import (
+	"context"
+	"github.com/stretchr/testify/assert"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"kubesphere.io/devops/pkg/store/store"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"testing"
+)
+
+func TestConfigMapStore(t *testing.T) {
+	var err error
+	var cmStore store.ConfigMapStore
+	cmStore, err = NewConfigMapStore(context.Background(), types.NamespacedName{
+		Namespace: "ns",
+		Name:      "name",
+	}, fake.NewClientBuilder().Build())
+	assert.NotNil(t, cmStore)
+	assert.Nil(t, err)
+
+	cmStore.SetOwnerReference(v1.OwnerReference{})
+	assert.Nil(t, cmStore.Save())
+
+	assert.Empty(t, cmStore.GetStages())
+	cmStore.SetStages("stages")
+	assert.Equal(t, "stages", cmStore.GetStages())
+
+	assert.Empty(t, cmStore.GetStatus())
+	cmStore.SetStatus("status")
+	assert.Equal(t, "status", cmStore.GetStatus())
+
+	assert.Empty(t, cmStore.GetStepLog(1, 2))
+	cmStore.SetStepLog(1, 2, "step")
+	assert.Equal(t, "step", cmStore.GetStepLog(1, 2))
+
+	assert.Empty(t, cmStore.GetAllLog())
+	cmStore.SetAllLog("log")
+	assert.Equal(t, "log", cmStore.GetAllLog())
+
+	assert.Nil(t, cmStore.Save())
+}
diff --git a/pkg/store/fake/fake_core_test.go b/pkg/store/fake/fake_core_test.go
new file mode 100644
index 0000000..382140f
--- /dev/null
+++ b/pkg/store/fake/fake_core_test.go
@@ -0,0 +1,51 @@
+/*
+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 fake
+
+import (
+	"errors"
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func TestFakeStore(t *testing.T) {
+	store := NewFakeStore()
+	assert.NotNil(t, store)
+
+	assert.Empty(t, store.Get("fake"))
+	store.Set("fake", "fake")
+	assert.Equal(t, "fake", store.Get("fake"))
+
+	assert.Empty(t, store.GetAllLog())
+	store.SetAllLog("log")
+	assert.Equal(t, "log", store.GetAllLog())
+
+	assert.Empty(t, store.GetStages())
+	store.SetStages("stages")
+	assert.Equal(t, "stages", store.GetStages())
+
+	assert.Empty(t, store.GetStatus())
+	store.SetStatus("status")
+	assert.Equal(t, "status", store.GetStatus())
+
+	assert.Empty(t, store.GetStepLog(1, 1))
+	store.SetStepLog(1, 1, "step")
+	assert.Equal(t, "step", store.GetStepLog(1, 1))
+
+	assert.Nil(t, store.Save())
+	assert.NotNil(t, store.WithError(errors.New("fake")).Save())
+}
diff --git a/pkg/store/store/types_test.go b/pkg/store/store/types_test.go
new file mode 100644
index 0000000..1bf85e1
--- /dev/null
+++ b/pkg/store/store/types_test.go
@@ -0,0 +1,45 @@
+/*
+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 store
+
+import "testing"
+
+func TestStepLogKey(t *testing.T) {
+	type args struct {
+		stage int
+		step  int
+	}
+	tests := []struct {
+		name string
+		args args
+		want string
+	}{{
+		name: "normal",
+		args: args{
+			stage: 1,
+			step:  2,
+		},
+		want: "log-step-1-2",
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := StepLogKey(tt.args.stage, tt.args.step); got != tt.want {
+				t.Errorf("StepLogKey() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}