Repository: kubesphere/ks-devops
Pull request #825 (2022-09-19 08:57:22)

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


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/825?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) Report
> Merging [#825](https://codecov.io/gh/kubesphere/ks-devops/pull/825?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) (0a42ac7) into [master](https://codecov.io/gh/kubesphere/ks-devops/commit/bbc478537766569eeff79fe31e9e1f0cbca32654?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) (bbc4785) will **decrease** coverage by `0.70%`.
> The diff coverage is `59.64%`.

```diff
@@            Coverage Diff             @@
##           master     #825      +/-   ##
==========================================
- Coverage   56.47%   55.76%   -0.71%     
==========================================
  Files         184      188       +4     
  Lines       10964    15089    +4125     
==========================================
+ Hits         6192     8415    +2223     
- Misses       4406     6295    +1889     
- Partials      366      379      +13     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `55.76% <59.64%> (-0.71%)` | :arrow_down: |

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

| [Impacted Files](https://codecov.io/gh/kubesphere/ks-devops/pull/825?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) | Coverage Δ | |
|---|---|---|
| [...lers/jenkins/pipelinerun/pipelinerun\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/825/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvamVua2lucy9waXBlbGluZXJ1bi9waXBlbGluZXJ1bl9jb250cm9sbGVyLmdv) | `17.52% <0.00%> (+1.38%)` | :arrow_up: |
| [pkg/kapis/generic/generic.go](https://codecov.io/gh/kubesphere/ks-devops/pull/825/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2thcGlzL2dlbmVyaWMvZ2VuZXJpYy5nbw==) | `31.25% <31.25%> (ø)` | |
| [pkg/kapis/devops/v1alpha3/pipelinerun/handler.go](https://codecov.io/gh/kubesphere/ks-devops/pull/825/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2thcGlzL2Rldm9wcy92MWFscGhhMy9waXBlbGluZXJ1bi9oYW5kbGVyLmdv) | `54.43% <66.66%> (+8.28%)` | :arrow_up: |
| [cmd/controller/app/options/feature.go](https://codecov.io/gh/kubesphere/ks-devops/pull/825/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y21kL2NvbnRyb2xsZXIvYXBwL29wdGlvbnMvZmVhdHVyZS5nbw==) | `100.00% <100.00%> (ø)` | |
| [pkg/store/configmap/pipelinerun.go](https://codecov.io/gh/kubesphere/ks-devops/pull/825/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL3N0b3JlL2NvbmZpZ21hcC9waXBlbGluZXJ1bi5nbw==) | `100.00% <100.00%> (ø)` | |
| [pkg/store/fake/fake\_store.go](https://codecov.io/gh/kubesphere/ks-devops/pull/825/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL3N0b3JlL2Zha2UvZmFrZV9zdG9yZS5nbw==) | `100.00% <100.00%> (ø)` | |
| [pkg/store/store/types.go](https://codecov.io/gh/kubesphere/ks-devops/pull/825/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL3N0b3JlL3N0b3JlL3R5cGVzLmdv) | `100.00% <100.00%> (ø)` | |
| [pkg/apiserver/runtime/runtime.go](https://codecov.io/gh/kubesphere/ks-devops/pull/825/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2FwaXNlcnZlci9ydW50aW1lL3J1bnRpbWUuZ28=) | `46.42% <0.00%> (-9.83%)` | :arrow_down: |
| [...ontrollers/jenkins/pipeline/pipeline\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/825/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvamVua2lucy9waXBlbGluZS9waXBlbGluZV9jb250cm9sbGVyLmdv) | `36.31% <0.00%> (-6.87%)` | :arrow_down: |
| [...g/kapis/devops/v1alpha3/pipelinerun/listhandler.go](https://codecov.io/gh/kubesphere/ks-devops/pull/825/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2thcGlzL2Rldm9wcy92MWFscGhhMy9waXBlbGluZXJ1bi9saXN0aGFuZGxlci5nbw==) | `61.53% <0.00%> (-5.13%)` | :arrow_down: |
| ... and [181 more](https://codecov.io/gh/kubesphere/ks-devops/pull/825/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere)


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/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/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/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/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)
+			}
+		})
+	}
+}