Repository: kubesphere/ks-devops
Pull request #724 (2022-07-11 09:19:12)

Avoid using Jenkins API to convert Jenkinsfile format
<!-- 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 api-change

### 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
-->
Related https://github.com/kubesphere/ks-devops/issues/713

### Special notes for reviewers:
<!--
You can use the following command to let the DevOps SIG members help you to review your PR.
/cc @kubesphere/sig-devops 
And please avoid cc any individual.
-->
Please check the following list before waiting reviewers:

- [ ] Already committed the CRD files to [the Helm Chart](https://github.com/kubesphere-sigs/ks-devops-helm-chart/) if you created some new CRDs
- [ ] Already [added the permission](https://github.com/kubesphere/ks-installer/blob/9e063b085a0e43fdb3d0d9e3e7f4149146f14b9c/roles/ks-core/prepare/files/ks-init/role-templates.yaml) for the new API
- [ ] Already added the RBAC markers for the new controllers

### Does this PR introduce a user-facing change??
<!--
If no, just write "None" in the release-note block below.
If yes, a release note is required:
Enter your extended-release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".

Please keep the note be same as your PR title if you believe it should be in the release notes.
-->
```release-note
None
```


ks-ci-bot: [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/724?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) Report
> Merging [#724](https://codecov.io/gh/kubesphere/ks-devops/pull/724?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) (bb0ef9d) into [master](https://codecov.io/gh/kubesphere/ks-devops/commit/bfe9d3610ba2bc2837dc874d0b25b9c2f711b096?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) (bfe9d36) will **increase** coverage by `0.26%`.
> The diff coverage is `78.64%`.

```diff
@@            Coverage Diff             @@
##           master     #724      +/-   ##
==========================================
+ Coverage   77.77%   78.03%   +0.26%     
==========================================
  Files         156      157       +1     
  Lines       23626    23729     +103     
==========================================
+ Hits        18374    18518     +144     
+ Misses       5019     4973      -46     
- Partials      233      238       +5     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `78.03% <78.64%> (+0.26%)` | :arrow_up: |

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

| [Impacted Files](https://codecov.io/gh/kubesphere/ks-devops/pull/724?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) | Coverage Δ | |
|---|---|---|
| [pkg/api/devops/v1alpha3/pipeline\_types.go](https://codecov.io/gh/kubesphere/ks-devops/pull/724/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2FwaS9kZXZvcHMvdjFhbHBoYTMvcGlwZWxpbmVfdHlwZXMuZ28=) | `100.00% <ø> (ø)` | |
| [pkg/kapis/devops/v1alpha3/handler.go](https://codecov.io/gh/kubesphere/ks-devops/pull/724/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2thcGlzL2Rldm9wcy92MWFscGhhMy9oYW5kbGVyLmdv) | `64.02% <66.66%> (+0.17%)` | :arrow_up: |
| [controllers/jenkins/pipeline/json\_converter.go](https://codecov.io/gh/kubesphere/ks-devops/pull/724/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvamVua2lucy9waXBlbGluZS9qc29uX2NvbnZlcnRlci5nbw==) | `72.30% <72.30%> (ø)` | |
| [pkg/kapis/devops/v1alpha3/register.go](https://codecov.io/gh/kubesphere/ks-devops/pull/724/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2thcGlzL2Rldm9wcy92MWFscGhhMy9yZWdpc3Rlci5nbw==) | `100.00% <100.00%> (ø)` | |
| [pkg/models/devops/devops.go](https://codecov.io/gh/kubesphere/ks-devops/pull/724/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL21vZGVscy9kZXZvcHMvZGV2b3BzLmdv) | `24.41% <100.00%> (+5.07%)` | :arrow_up: |
| [...ns/devopscredential/devopscredential\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/724/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvamVua2lucy9kZXZvcHNjcmVkZW50aWFsL2Rldm9wc2NyZWRlbnRpYWxfY29udHJvbGxlci5nbw==) | `36.73% <0.00%> (+3.40%)` | :arrow_up: |
| [controllers/jenkins/pipeline/metadata\_converter.go](https://codecov.io/gh/kubesphere/ks-devops/pull/724/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvamVua2lucy9waXBlbGluZS9tZXRhZGF0YV9jb252ZXJ0ZXIuZ28=) | `100.00% <0.00%> (+90.56%)` | :arrow_up: |

------

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


Patch:
diff --git a/cmd/controller/app/controllers.go b/cmd/controller/app/controllers.go
index f508da1..f291538 100644
--- a/cmd/controller/app/controllers.go
+++ b/cmd/controller/app/controllers.go
@@ -74,7 +74,7 @@ func addControllers(mgr manager.Manager, client k8s.Client, informerFactory info
 	}).SetupWithManager(mgr); err != nil {
 		return err
 	}
-	reconcilers := getAllControllers(mgr, client, informerFactory, devopsClient, s)
+	reconcilers := getAllControllers(mgr, client, informerFactory, devopsClient, s, jenkinsCore)
 
 	// Add all controllers into manager.
 	for name, ok := range s.FeatureOptions.GetControllers() {
@@ -93,7 +93,7 @@ func addControllers(mgr manager.Manager, client k8s.Client, informerFactory info
 }
 
 func getAllControllers(mgr manager.Manager, client k8s.Client, informerFactory informers.InformerFactory,
-	devopsClient devops.Interface, s *options.DevOpsControllerManagerOptions) map[string]func(mgr manager.Manager) error {
+	devopsClient devops.Interface, s *options.DevOpsControllerManagerOptions, jenkinsCore core.JenkinsCore) map[string]func(mgr manager.Manager) error {
 
 	argocdReconciler := &argocd.Reconciler{
 		Client:        mgr.GetClient(),
@@ -163,6 +163,16 @@ func getAllControllers(mgr manager.Manager, client k8s.Client, informerFactory i
 					informerFactory.KubernetesSharedInformerFactory().Core().V1().Namespaces(),
 					informerFactory.KubeSphereSharedInformerFactory().Devops().V1alpha3().Pipelines()))
 			}
+
+			tokenIssuer := token.NewTokenIssuer(s.JWTOptions.Secret, s.JWTOptions.MaximumClockSkew)
+			if err == nil {
+				jenkinsfileReconciler := &jenkinspipeline.JenkinsfileReconciler{
+					Client:      mgr.GetClient(),
+					TokenIssuer: tokenIssuer,
+					JenkinsCore: jenkinsCore,
+				}
+				err = jenkinsfileReconciler.SetupWithManager(mgr)
+			}
 			return err
 		},
 		argocdReconciler.GetGroupName(): func(mgr manager.Manager) (err error) {
diff --git a/controllers/jenkins/pipeline/constants.go b/controllers/jenkins/pipeline/constants.go
new file mode 100644
index 0000000..bdd040a
--- /dev/null
+++ b/controllers/jenkins/pipeline/constants.go
@@ -0,0 +1,22 @@
+/*
+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 pipeline
+
+const (
+	// ControllerGroupName is the group name of a set of controllers
+	ControllerGroupName = "jenkins"
+)
diff --git a/controllers/jenkins/pipeline/json_converter.go b/controllers/jenkins/pipeline/json_converter.go
new file mode 100644
index 0000000..a74442d
--- /dev/null
+++ b/controllers/jenkins/pipeline/json_converter.go
@@ -0,0 +1,158 @@
+/*
+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 pipeline
+
+import (
+	"context"
+	"fmt"
+	"github.com/go-logr/logr"
+	"github.com/jenkins-zh/jenkins-client/pkg/core"
+	"k8s.io/apiserver/pkg/authentication/user"
+	"k8s.io/client-go/tools/record"
+	v1alpha3 "kubesphere.io/devops/pkg/api/devops/v1alpha3"
+	"kubesphere.io/devops/pkg/jwt/token"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"time"
+)
+
+// tokenExpireIn indicates that the temporary token issued by controller will be expired in some time.
+const tokenExpireIn time.Duration = 5 * time.Minute
+
+//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=pipelines,verbs=get;list;update;patch;watch
+//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch
+
+// JenkinsfileReconciler will convert between JSON and Jenkinsfile (as groovy) formats
+type JenkinsfileReconciler struct {
+	log      logr.Logger
+	recorder record.EventRecorder
+
+	client.Client
+	JenkinsCore core.JenkinsCore
+	TokenIssuer token.Issuer
+}
+
+// Reconcile is the main entrypoint of this controller
+func (r *JenkinsfileReconciler) Reconcile(req ctrl.Request) (result ctrl.Result, err error) {
+	ctx := context.Background()
+
+	pip := &v1alpha3.Pipeline{}
+	if err = r.Get(ctx, req.NamespacedName, pip); err != nil {
+		err = client.IgnoreNotFound(err)
+		return
+	}
+
+	if pip.Spec.Type != v1alpha3.NoScmPipelineType || pip.Spec.Pipeline == nil {
+		return
+	}
+
+	// set up the Jenkins client
+	var c *core.JenkinsCore
+	if c, err = r.getOrCreateJenkinsCore(map[string]string{
+		v1alpha3.PipelineRunCreatorAnnoKey: "admin",
+	}); err != nil {
+		err = fmt.Errorf("failed to create Jenkins client, error: %v", err)
+		return
+	}
+	c.RoundTripper = r.JenkinsCore.RoundTripper
+	coreClient := core.Client{JenkinsCore: *c}
+
+	editMode := pip.Annotations[v1alpha3.PipelineJenkinsfileEditModeAnnoKey]
+	switch editMode {
+	case "":
+		result, err = r.reconcileJenkinsfileEditMode(pip, coreClient)
+	case v1alpha3.PipelineJenkinsfileEditModeJSON:
+		result, err = r.reconcileJSONEditMode(pip, coreClient)
+	default:
+		r.log.Info(fmt.Sprintf("invalid edit mode: %s", editMode))
+		return
+	}
+	return
+}
+
+func (r *JenkinsfileReconciler) reconcileJenkinsfileEditMode(pip *v1alpha3.Pipeline, coreClient core.Client) (
+	result ctrl.Result, err error) {
+
+	jenkinsfile := pip.Spec.Pipeline.Jenkinsfile
+
+	var toJSONResult core.GenericResult
+	if toJSONResult, err = coreClient.ToJSON(jenkinsfile); err != nil || toJSONResult.GetStatus() != "success" {
+		err = fmt.Errorf("failed to convert Jenkinsfile to JSON format, error: %v", err)
+		return
+	}
+
+	if pip.Annotations == nil {
+		pip.Annotations = map[string]string{}
+	}
+	pip.Annotations[v1alpha3.PipelineJenkinsfileValueAnnoKey] = toJSONResult.GetResult()
+	err = r.Update(context.Background(), pip)
+	return
+}
+
+func (r *JenkinsfileReconciler) reconcileJSONEditMode(pip *v1alpha3.Pipeline, coreClient core.Client) (
+	result ctrl.Result, err error) {
+	var jsonData string
+	if jsonData = pip.Annotations[v1alpha3.PipelineJenkinsfileValueAnnoKey]; jsonData != "" {
+		var toResult core.GenericResult
+		if toResult, err = coreClient.ToJenkinsfile(jsonData); err != nil || toResult.GetStatus() != "success" {
+			err = fmt.Errorf("failed to convert JSON format to Jenkinsfile, error: %v", err)
+			return
+		}
+
+		pip.Annotations[v1alpha3.PipelineJenkinsfileEditModeAnnoKey] = ""
+		pip.Spec.Pipeline.Jenkinsfile = toResult.GetResult()
+		err = r.Update(context.Background(), pip)
+	}
+	return
+}
+
+// GetName returns the name of this controller
+func (r *JenkinsfileReconciler) GetName() string {
+	return "JenkinsfileController"
+}
+
+// GetGroupName returns the group name of this controller
+func (r *JenkinsfileReconciler) GetGroupName() string {
+	return ControllerGroupName
+}
+
+func (r *JenkinsfileReconciler) getOrCreateJenkinsCore(annotations map[string]string) (*core.JenkinsCore, error) {
+	creator, ok := annotations[v1alpha3.PipelineRunCreatorAnnoKey]
+	if !ok || creator == "" {
+		return &r.JenkinsCore, nil
+	}
+	// create a new JenkinsCore for current creator
+	accessToken, err := r.TokenIssuer.IssueTo(&user.DefaultInfo{Name: creator}, token.AccessToken, tokenExpireIn)
+	if err != nil {
+		return nil, fmt.Errorf("failed to issue access token for creator %s, error was %v", creator, err)
+	}
+	jenkinsCore := &core.JenkinsCore{
+		URL:      r.JenkinsCore.URL,
+		UserName: creator,
+		Token:    accessToken,
+	}
+	return jenkinsCore, nil
+}
+
+// SetupWithManager setups the log and recorder
+func (r *JenkinsfileReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	r.log = ctrl.Log.WithName(r.GetName())
+	r.recorder = mgr.GetEventRecorderFor(r.GetName())
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&v1alpha3.Pipeline{}).
+		Complete(r)
+}
diff --git a/go.mod b/go.mod
index d63dbf4..660bab3 100644
--- a/go.mod
+++ b/go.mod
@@ -25,7 +25,7 @@ require (
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/h2non/gock v1.0.9
 	github.com/jenkins-x/go-scm v1.11.4
-	github.com/jenkins-zh/jenkins-client v0.0.9
+	github.com/jenkins-zh/jenkins-client v0.0.10-0.20220706065616-22f8c7675234
 	github.com/kubesphere/sonargo v0.0.2
 	github.com/onsi/ginkgo v1.16.4
 	github.com/onsi/gomega v1.15.0
diff --git a/go.sum b/go.sum
index 1194dcb..e08511f 100644
--- a/go.sum
+++ b/go.sum
@@ -330,8 +330,8 @@ github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NH
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
 github.com/jenkins-zh/jenkins-cli v0.0.32/go.mod h1:uE1mH9PNITrg0sugv6HXuM/CSddg0zxXoYu3w57I3JY=
-github.com/jenkins-zh/jenkins-client v0.0.9 h1:yqWtjLifYWbVxR+wFdExh/cHSQ54i/Cdco2MYkc/5vI=
-github.com/jenkins-zh/jenkins-client v0.0.9/go.mod h1:ICBk7OOoTafVP//f/VfKZ34c0ff8vJwVnOsF9btiMYU=
+github.com/jenkins-zh/jenkins-client v0.0.10-0.20220706065616-22f8c7675234 h1:gO2Ca6t/V7l7SD9H6mAy6UkcHVT0Ey3sZ6o0jIKPUHg=
+github.com/jenkins-zh/jenkins-client v0.0.10-0.20220706065616-22f8c7675234/go.mod h1:ICBk7OOoTafVP//f/VfKZ34c0ff8vJwVnOsF9btiMYU=
 github.com/jenkins-zh/jenkins-formulas v0.0.5/go.mod h1:zS8fm8u5L6FcjZM0QznXsLV9T2UtSVK+hT6Sm76iUZ4=
 github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
 github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
diff --git a/pkg/api/devops/v1alpha3/pipeline_types.go b/pkg/api/devops/v1alpha3/pipeline_types.go
index e8c8df2..74019af 100644
--- a/pkg/api/devops/v1alpha3/pipeline_types.go
+++ b/pkg/api/devops/v1alpha3/pipeline_types.go
@@ -41,13 +41,18 @@ const (
 	PipelineJenkinsBranchesAnnoKey = PipelinePrefix + "jenkins-branches"
 	// PipelineRequestToSyncRunsAnnoKey is the annotation key of requesting to synchronize PipelineRun after a dedicated time.
 	PipelineRequestToSyncRunsAnnoKey = PipelinePrefix + "request-to-sync-pipelineruns"
+	// PipelineJenkinsfileValueAnnoKey is the annotation key of the Jenkinsfile content
+	PipelineJenkinsfileValueAnnoKey = PipelinePrefix + "jenkinsfile"
+	// PipelineJenkinsfileEditModeAnnoKey is the annotation key of the Jenkinsfile edit mode
+	PipelineJenkinsfileEditModeAnnoKey = PipelinePrefix + "jenkinsfile.edit.mode"
+
+	// PipelineJenkinsfileEditModeJSON indicates the Jenkinsfile editing mode is JSON
+	PipelineJenkinsfileEditModeJSON = "json"
 )
 
 // PipelineSpec defines the desired state of Pipeline
 type PipelineSpec struct {
-	// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
-	// Important: Run "make" to regenerate code after modifying this file
-	Type                string               `json:"type" description:"type of devops pipeline, in scm or no scm"`
+	Type                PipelineType         `json:"type" description:"type of devops pipeline, in scm or no scm"`
 	Pipeline            *NoScmPipeline       `json:"pipeline,omitempty" description:"no scm pipeline structs"`
 	MultiBranchPipeline *MultiBranchPipeline `json:"multi_branch_pipeline,omitempty" description:"in scm pipeline structs"`
 }
@@ -91,9 +96,12 @@ func (p *Pipeline) IsMultiBranch() bool {
 	return p.Spec.Type == MultiBranchPipelineType
 }
 
+// PipelineType is an alias of string that represents the type of Pipelines
+type PipelineType string
+
 const (
-	NoScmPipelineType       = "pipeline"
-	MultiBranchPipelineType = "multi-branch-pipeline"
+	NoScmPipelineType       PipelineType = "pipeline"
+	MultiBranchPipelineType PipelineType = "multi-branch-pipeline"
 )
 
 const (
diff --git a/pkg/kapis/devops/v1alpha3/handler.go b/pkg/kapis/devops/v1alpha3/handler.go
index db1235a..91dea78 100644
--- a/pkg/kapis/devops/v1alpha3/handler.go
+++ b/pkg/kapis/devops/v1alpha3/handler.go
@@ -204,6 +204,30 @@ func (h *devopsHandler) UpdatePipeline(request *restful.Request, response *restf
 	}
 }
 
+// GenericPayload represents a generic HTTP request payload data structure
+type GenericPayload struct {
+	Data string `json:"data"`
+}
+
+func (h *devopsHandler) UpdateJenkinsfile(request *restful.Request, response *restful.Response) {
+	projectName := request.PathParameter("devops")
+	pipelineName := request.PathParameter("pipeline")
+	mode := request.QueryParameter("mode")
+
+	var err error
+	payload := &GenericPayload{}
+	if err = request.ReadEntity(payload); err != nil {
+		kapis.HandleBadRequest(response, request, err)
+		return
+	}
+
+	var client devops.DevopsOperator
+	if client, err = h.getDevOps(request); err == nil {
+		err = client.UpdateJenkinsfile(projectName, pipelineName, mode, payload.Data)
+	}
+	errorHandle(request, response, nil, err)
+}
+
 func (h *devopsHandler) DeletePipeline(request *restful.Request, response *restful.Response) {
 	devops := request.PathParameter("devops")
 	pipeline := request.PathParameter("pipeline")
diff --git a/pkg/kapis/devops/v1alpha3/register.go b/pkg/kapis/devops/v1alpha3/register.go
index 3c564af..1406179 100644
--- a/pkg/kapis/devops/v1alpha3/register.go
+++ b/pkg/kapis/devops/v1alpha3/register.go
@@ -171,6 +171,16 @@ func registerRoutersForPipelines(handler *devopsHandler, ws *restful.WebService)
 		Returns(http.StatusOK, api.StatusOK, v1alpha3.Pipeline{}).
 		Metadata(restfulspec.KeyOpenAPITags, []string{constants.DevOpsProjectTag}))
 
+	ws.Route(ws.PUT("/devops/{devops}/pipelines/{pipeline}/jenkinsfile").
+		To(handler.UpdateJenkinsfile).
+		Param(ws.PathParameter("devops", "project name")).
+		Param(ws.PathParameter("pipeline", "pipeline name")).
+		Param(ws.QueryParameter("mode", "the mode(json or raw) that you expect to update the Jenkinsfile")).
+		Reads(&GenericPayload{}, "The Jenkinsfile content should be in the 'data' field").
+		Doc("Update the Jenkinsfile of a Pipeline").
+		Returns(http.StatusOK, api.StatusOK, v1alpha3.Pipeline{}).
+		Metadata(restfulspec.KeyOpenAPITags, []string{constants.DevOpsProjectTag}))
+
 	ws.Route(ws.DELETE("/devops/{devops}/pipelines/{pipeline}").
 		To(handler.DeletePipeline).
 		Param(ws.PathParameter("devops", "project name")).
diff --git a/pkg/models/devops/devops.go b/pkg/models/devops/devops.go
index 0d271d9..cffd89f 100644
--- a/pkg/models/devops/devops.go
+++ b/pkg/models/devops/devops.go
@@ -65,6 +65,7 @@ type DevopsOperator interface {
 	DeletePipelineObj(projectName string, pipelineName string) error
 	UpdatePipelineObj(projectName string, pipeline *v1alpha3.Pipeline) (*v1alpha3.Pipeline, error)
 	ListPipelineObj(projectName string, query *query.Query) (api.ListResult, error)
+	UpdateJenkinsfile(projectName, pipelineName, mode, jenkinsfile string) error
 
 	CreateCredentialObj(projectName string, s *v1.Secret) (*v1.Secret, error)
 	GetCredentialObj(projectName string, secretName string) (*v1.Secret, error)
@@ -291,6 +292,11 @@ func (d devopsOperator) UpdatePipelineObj(projectName string, pipeline *v1alpha3
 	// trying to avoid the error of `Operation cannot be fulfilled on` by getting the latest resourceVersion
 	if latestPipe, err := d.ksclient.DevopsV1alpha3().Pipelines(ns).Get(d.context, name, metav1.GetOptions{}); err == nil {
 		pipeline.ResourceVersion = latestPipe.ResourceVersion
+
+		// avoid update the Jenkinsfile in this API, see also UpdateJenkinsfile
+		if pipeline.Spec.Pipeline != nil && latestPipe.Spec.Pipeline != nil {
+			pipeline.Spec.Pipeline.Jenkinsfile = latestPipe.Spec.Pipeline.Jenkinsfile
+		}
 	} else {
 		return nil, fmt.Errorf("cannot found pipeline %s/%s, error: %v", ns, name, err)
 	}
@@ -298,6 +304,30 @@ func (d devopsOperator) UpdatePipelineObj(projectName string, pipeline *v1alpha3
 	return d.ksclient.DevopsV1alpha3().Pipelines(ns).Update(d.context, pipeline, metav1.UpdateOptions{})
 }
 
+// UpdateJenkinsfile updates the Jenkinsfile value with specific edit mode
+func (d devopsOperator) UpdateJenkinsfile(projectName, pipelineName, mode, jenkinsfile string) (err error) {
+	var pipeline *devopsv1alpha3.Pipeline
+	if pipeline, err = d.ksclient.DevopsV1alpha3().Pipelines(projectName).Get(d.context, pipelineName, metav1.GetOptions{}); err != nil {
+		return
+	}
+
+	if pipeline.Annotations == nil {
+		pipeline.Annotations = map[string]string{}
+	}
+	switch mode {
+	case devopsv1alpha3.PipelineJenkinsfileEditModeJSON:
+		pipeline.Annotations[devopsv1alpha3.PipelineJenkinsfileEditModeAnnoKey] = devopsv1alpha3.PipelineJenkinsfileEditModeJSON
+		pipeline.Annotations[devopsv1alpha3.PipelineJenkinsfileValueAnnoKey] = jenkinsfile
+	default:
+		pipeline.Annotations[devopsv1alpha3.PipelineJenkinsfileEditModeAnnoKey] = ""
+		if pipeline.Spec.Pipeline != nil {
+			pipeline.Spec.Pipeline.Jenkinsfile = jenkinsfile
+		}
+	}
+	_, err = d.ksclient.DevopsV1alpha3().Pipelines(projectName).Update(d.context, pipeline, metav1.UpdateOptions{})
+	return
+}
+
 func (d devopsOperator) ListPipelineObj(projectName string, query *query.Query) (api.ListResult, error) {
 	project, err := d.ksclient.DevopsV1alpha3().DevOpsProjects().Get(d.context, projectName, metav1.GetOptions{})
 	if err != nil {


Test patch:
diff --git a/controllers/jenkins/pipeline/interface_test.go b/controllers/jenkins/pipeline/interface_test.go
new file mode 100644
index 0000000..b253055
--- /dev/null
+++ b/controllers/jenkins/pipeline/interface_test.go
@@ -0,0 +1,50 @@
+/*
+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 pipeline
+
+import (
+	"github.com/stretchr/testify/assert"
+	"kubesphere.io/devops/controllers/core"
+	"testing"
+)
+
+func TestInterfaceImplement(t *testing.T) {
+	type interInstance struct {
+		NamedReconciler core.NamedReconciler
+		GroupReconciler core.GroupReconciler
+	}
+
+	tests := []struct {
+		name     string
+		instance interInstance
+	}{{
+		name: "JenkinsfileReconciler",
+		instance: interInstance{
+			NamedReconciler: &JenkinsfileReconciler{},
+			GroupReconciler: &JenkinsfileReconciler{},
+		},
+	}}
+	for i := range tests {
+		tt := tests[i]
+		t.Run(tt.name, func(t *testing.T) {
+			assert.NotNil(t, tt.instance.NamedReconciler)
+			assert.NotEmpty(t, tt.instance.NamedReconciler.GetName())
+			assert.NotNil(t, tt.instance.GroupReconciler)
+			assert.NotEmpty(t, tt.instance.GroupReconciler.GetGroupName())
+		})
+	}
+}
diff --git a/controllers/jenkins/pipeline/json_converter_test.go b/controllers/jenkins/pipeline/json_converter_test.go
new file mode 100644
index 0000000..85fdbdf
--- /dev/null
+++ b/controllers/jenkins/pipeline/json_converter_test.go
@@ -0,0 +1,219 @@
+/*
+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 pipeline
+
+import (
+	"context"
+	"fmt"
+	"github.com/go-logr/logr"
+	"github.com/golang/mock/gomock"
+	"github.com/jenkins-zh/jenkins-client/pkg/core"
+	"github.com/jenkins-zh/jenkins-client/pkg/mock/mhttp"
+	"github.com/stretchr/testify/assert"
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
+	"kubesphere.io/devops/pkg/jwt/token"
+	controllerruntime "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"
+)
+
+func TestJenkinsfileReconciler_Reconcile(t *testing.T) {
+	schema, err := v1alpha3.SchemeBuilder.Register().Build()
+	assert.Nil(t, err)
+	err = v1.SchemeBuilder.AddToScheme(schema)
+	assert.Nil(t, err)
+
+	defaultReq := controllerruntime.Request{
+		NamespacedName: types.NamespacedName{
+			Namespace: "ns",
+			Name:      "name",
+		},
+	}
+
+	pip := &v1alpha3.Pipeline{}
+	pip.SetNamespace("ns")
+	pip.SetName("name")
+	pip.Annotations = map[string]string{}
+	pip.Spec.Type = v1alpha3.NoScmPipelineType
+	pip.Spec.Pipeline = &v1alpha3.NoScmPipeline{
+		Jenkinsfile: `jenkinsfile`,
+	}
+
+	jsonEditModePip := pip.DeepCopy()
+	jsonEditModePip.Annotations[v1alpha3.PipelineJenkinsfileEditModeAnnoKey] = "json"
+	jsonEditModePip.Annotations[v1alpha3.PipelineJenkinsfileValueAnnoKey] = `json`
+
+	invalidEditMode := pip.DeepCopy()
+	invalidEditMode.Annotations[v1alpha3.PipelineJenkinsfileEditModeAnnoKey] = "invalid"
+
+	irregularPip := pip.DeepCopy()
+	irregularPip.Spec.Type = ""
+
+	type fields struct {
+		Client      client.Client
+		log         logr.Logger
+		recorder    record.EventRecorder
+		JenkinsCore core.JenkinsCore
+		TokenIssuer token.Issuer
+	}
+	type args struct {
+		req controllerruntime.Request
+	}
+	tests := []struct {
+		name       string
+		fields     fields
+		args       args
+		prepare    func(t *testing.T, c *core.JenkinsCore)
+		verify     func(t *testing.T, Client client.Client)
+		wantResult controllerruntime.Result
+		wantErr    assert.ErrorAssertionFunc
+	}{{
+		name: "not found",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema),
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+		wantResult: controllerruntime.Result{},
+	}, {
+		name: "invalid edit mode",
+		fields: fields{
+			Client:      fake.NewFakeClientWithScheme(schema, invalidEditMode),
+			JenkinsCore: core.JenkinsCore{},
+			log:         log.NullLogger{},
+			TokenIssuer: &token.FakeIssuer{},
+		},
+		args: args{
+			req: defaultReq,
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}, {
+		name: "irregular pipeline, and jenkinsfile edit mode",
+		fields: fields{
+			Client:      fake.NewFakeClientWithScheme(schema, irregularPip),
+			JenkinsCore: core.JenkinsCore{},
+			log:         log.NullLogger{},
+		},
+		args: args{
+			req: defaultReq,
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}, {
+		name: "a regular pipeline with jenkinsfile edit mode",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, pip),
+			JenkinsCore: core.JenkinsCore{
+				URL: "http://localhost",
+			},
+			log:         log.NullLogger{},
+			TokenIssuer: &token.FakeIssuer{},
+		},
+		args: args{
+			req: defaultReq,
+		},
+		prepare: func(t *testing.T, c *core.JenkinsCore) {
+			ctrl := gomock.NewController(t)
+			roundTripper := mhttp.NewMockRoundTripper(ctrl)
+			c.RoundTripper = roundTripper
+
+			core.PrepareForToJSON(roundTripper, "http://localhost", "", "")
+		},
+		verify: func(t *testing.T, Client client.Client) {
+			pip := &v1alpha3.Pipeline{}
+			err := Client.Get(context.Background(), types.NamespacedName{
+				Namespace: "ns",
+				Name:      "name",
+			}, pip)
+			assert.Nil(t, err)
+			assert.Equal(t, `{"a":"b"}`, pip.Annotations[v1alpha3.PipelineJenkinsfileValueAnnoKey])
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}, {
+		name: "a regular pipeline with JSON edit mode",
+		fields: fields{
+			Client: fake.NewFakeClientWithScheme(schema, jsonEditModePip),
+			JenkinsCore: core.JenkinsCore{
+				URL: "http://localhost",
+			},
+			log:         log.NullLogger{},
+			TokenIssuer: &token.FakeIssuer{},
+		},
+		args: args{
+			req: defaultReq,
+		},
+		prepare: func(t *testing.T, c *core.JenkinsCore) {
+			ctrl := gomock.NewController(t)
+			roundTripper := mhttp.NewMockRoundTripper(ctrl)
+			c.RoundTripper = roundTripper
+
+			core.PrepareForToJenkinsfile(roundTripper, "http://localhost", "", "")
+		},
+		verify: func(t *testing.T, Client client.Client) {
+			pip := &v1alpha3.Pipeline{}
+			err := Client.Get(context.Background(), types.NamespacedName{
+				Namespace: "ns",
+				Name:      "name",
+			}, pip)
+			assert.Nil(t, err)
+			assert.Equal(t, "json", pip.Annotations[v1alpha3.PipelineJenkinsfileValueAnnoKey])
+			assert.Equal(t, "", pip.Annotations[v1alpha3.PipelineJenkinsfileEditModeAnnoKey])
+			assert.Equal(t, "jenkinsfile", pip.Spec.Pipeline.Jenkinsfile)
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.prepare != nil {
+				tt.prepare(t, &tt.fields.JenkinsCore)
+			}
+			r := &JenkinsfileReconciler{
+				Client:      tt.fields.Client,
+				log:         tt.fields.log,
+				recorder:    tt.fields.recorder,
+				JenkinsCore: tt.fields.JenkinsCore,
+				TokenIssuer: tt.fields.TokenIssuer,
+			}
+			gotResult, err := r.Reconcile(tt.args.req)
+			if !tt.wantErr(t, err, fmt.Sprintf("Reconcile(%v)", tt.args.req)) {
+				return
+			}
+			assert.Equalf(t, tt.wantResult, gotResult, "Reconcile(%v)", tt.args.req)
+			if tt.verify != nil {
+				tt.verify(t, tt.fields.Client)
+			}
+		})
+	}
+}
diff --git a/controllers/jenkins/pipeline/metadata_converter_test.go b/controllers/jenkins/pipeline/metadata_converter_test.go
index a35b883..9a108e2 100644
--- a/controllers/jenkins/pipeline/metadata_converter_test.go
+++ b/controllers/jenkins/pipeline/metadata_converter_test.go
@@ -17,8 +17,10 @@ limitations under the License.
 package pipeline
 
 import (
+	"kubesphere.io/devops/pkg/models/pipeline"
 	"reflect"
 	"testing"
+	"time"
 
 	"github.com/jenkins-zh/jenkins-client/pkg/job"
 )
@@ -152,3 +154,188 @@ func Test_convertParameterDefinitions(t *testing.T) {
 		})
 	}
 }
+
+func Test_convertCauses(t *testing.T) {
+	type args struct {
+		jobCauses []job.Cause
+	}
+	tests := []struct {
+		name string
+		args args
+		want []pipeline.Cause
+	}{{
+		name: "normal",
+		args: args{
+			jobCauses: []job.Cause{{
+				"shortDescription": "shortDescription",
+			}},
+		},
+		want: []pipeline.Cause{{
+			ShortDescription: "shortDescription",
+		}},
+	}, {
+		name: "parameter and result is nil",
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := convertCauses(tt.args.jobCauses); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("convertCauses() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func Test_convertBranches(t *testing.T) {
+	type args struct {
+		jobBranches []job.PipelineBranch
+	}
+	tests := []struct {
+		name string
+		args args
+		want []pipeline.Branch
+	}{{
+		name: "parameter and result is nil",
+		want: []pipeline.Branch{},
+	}, {
+		name: "normal",
+		args: args{
+			jobBranches: []job.PipelineBranch{{
+				BluePipelineItem: job.BluePipelineItem{
+					Name:        "name",
+					DisplayName: "displayName",
+					Disabled:    true,
+				},
+				BlueRunnableItem: job.BlueRunnableItem{
+					WeatherScore: 100,
+				},
+			}},
+		},
+		want: []pipeline.Branch{{
+			Name:         "name",
+			RawName:      "displayName",
+			WeatherScore: 100,
+			Disabled:     true,
+		}},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := convertBranches(tt.args.jobBranches); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("convertBranches() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func Test_convertLatestRun(t *testing.T) {
+	now := job.Time{
+		Time: time.Now(),
+	}
+	var durationInMillis int64
+
+	type args struct {
+		jobLatestRun *job.PipelineRunSummary
+	}
+	tests := []struct {
+		name string
+		args args
+		want *pipeline.LatestRun
+	}{{
+		name: "parameter and result is nil",
+	}, {
+		name: "normal",
+		args: args{
+			jobLatestRun: &job.PipelineRunSummary{
+				BlueItemRun: job.BlueItemRun{
+					DurationInMillis: &durationInMillis,
+					EndTime:          now,
+					StartTime:        now,
+					ID:               "id",
+					Name:             "name",
+					Result:           "result",
+					State:            "state",
+				},
+			},
+		},
+		want: &pipeline.LatestRun{
+			EndTime:          now,
+			DurationInMillis: &durationInMillis,
+			StartTime:        now,
+			ID:               "id",
+			Name:             "name",
+			Result:           "result",
+			State:            "state",
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := convertLatestRun(tt.args.jobLatestRun); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("convertLatestRun() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func Test_convertPipeline(t *testing.T) {
+	var durationInMillis int64
+
+	type args struct {
+		jobPipeline *job.Pipeline
+	}
+	tests := []struct {
+		name string
+		args args
+		want *pipeline.Metadata
+	}{{
+		name: "normal",
+		args: args{
+			jobPipeline: &job.Pipeline{
+				BlueMultiBranchPipeline: job.BlueMultiBranchPipeline{
+					BluePipelineItem: job.BluePipelineItem{
+						Name:     "name",
+						Disabled: true,
+					},
+					BlueRunnableItem: job.BlueRunnableItem{
+						WeatherScore:              100,
+						EstimatedDurationInMillis: durationInMillis,
+					},
+					BlueContainerItem: job.BlueContainerItem{
+						NumberOfPipelines: 100,
+						NumberOfFolders:   100,
+					},
+					BlueMultiBranchItem: job.BlueMultiBranchItem{
+						BranchNames:                    []string{"master"},
+						NumberOfFailingBranches:        100,
+						NumberOfSuccessfulBranches:     100,
+						NumberOfSuccessfulPullRequests: 100,
+						TotalNumberOfBranches:          100,
+						TotalNumberOfPullRequests:      100,
+					},
+					ScriptPath: "Jenkinsfile",
+				},
+			},
+		},
+		want: &pipeline.Metadata{
+			WeatherScore:                   100,
+			EstimatedDurationInMillis:      durationInMillis,
+			Name:                           "name",
+			Disabled:                       true,
+			NumberOfPipelines:              100,
+			NumberOfFolders:                100,
+			TotalNumberOfBranches:          100,
+			NumberOfSuccessfulBranches:     100,
+			TotalNumberOfPullRequests:      100,
+			NumberOfFailingBranches:        100,
+			NumberOfSuccessfulPullRequests: 100,
+			BranchNames:                    []string{"master"},
+			ScriptPath:                     "Jenkinsfile",
+			Parameters:                     []job.ParameterDefinition{},
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := convertPipeline(tt.args.jobPipeline); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("convertPipeline() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/pkg/kapis/devops/v1alpha3/register_test.go b/pkg/kapis/devops/v1alpha3/register_test.go
index 1439302..56590fd 100644
--- a/pkg/kapis/devops/v1alpha3/register_test.go
+++ b/pkg/kapis/devops/v1alpha3/register_test.go
@@ -159,6 +159,12 @@ func TestAPIsExist(t *testing.T) {
 			method: http.MethodDelete,
 			uri:    "/workspaces/fake/devops/fake",
 		},
+	}, {
+		name: "update jenkinsfile",
+		args: args{
+			method: http.MethodPut,
+			uri:    "/devops/fake/pipelines/fake/jenkinsfile",
+		},
 	}}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
diff --git a/pkg/models/devops/devops_test.go b/pkg/models/devops/devops_test.go
index 7172816..1f4eb60 100644
--- a/pkg/models/devops/devops_test.go
+++ b/pkg/models/devops/devops_test.go
@@ -18,6 +18,8 @@ package devops
 
 import (
 	"context"
+	"fmt"
+	"k8s.io/client-go/kubernetes"
 	"net/http"
 	"testing"
 
@@ -325,3 +327,199 @@ func Test_devopsOperator_GetDevOpsProject(t *testing.T) {
 		})
 	}
 }
+
+func Test_devopsOperator_UpdateJenkinsfile(t *testing.T) {
+	pipeline := &v1alpha3.Pipeline{}
+	pipeline.SetNamespace("ns")
+	pipeline.SetName("fake")
+	pipeline.Spec.Pipeline = &v1alpha3.NoScmPipeline{}
+
+	type fields struct {
+		devopsClient devops.Interface
+		k8sclient    kubernetes.Interface
+		ksclient     versioned.Interface
+		context      context.Context
+	}
+	type args struct {
+		projectName  string
+		pipelineName string
+		mode         string
+		jenkinsfile  string
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		args    args
+		wantErr assert.ErrorAssertionFunc
+		verify  func(t *testing.T, ksclient versioned.Interface)
+	}{{
+		name: "not found pipeline",
+		fields: fields{
+			ksclient: fakeclientset.NewSimpleClientset(),
+		},
+		args: args{
+			projectName:  "ns",
+			pipelineName: "fake",
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.NotNil(t, err)
+			return true
+		},
+	}, {
+		name: "json mode",
+		fields: fields{
+			ksclient: fakeclientset.NewSimpleClientset(pipeline.DeepCopy()),
+		},
+		args: args{
+			projectName:  "ns",
+			pipelineName: "fake",
+			mode:         "json",
+			jenkinsfile:  "json-format-jenkinsfile",
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+		verify: func(t *testing.T, ksclient versioned.Interface) {
+			pip, err := ksclient.DevopsV1alpha3().Pipelines("ns").Get(context.Background(), "fake", metav1.GetOptions{})
+			assert.Nil(t, err)
+			assert.Equal(t, "json-format-jenkinsfile", pip.Annotations[v1alpha3.PipelineJenkinsfileValueAnnoKey])
+			assert.Equal(t, "json", pip.Annotations[v1alpha3.PipelineJenkinsfileEditModeAnnoKey])
+		},
+	}, {
+		name: "mode value is empty",
+		fields: fields{
+			ksclient: fakeclientset.NewSimpleClientset(pipeline.DeepCopy()),
+		},
+		args: args{
+			projectName:  "ns",
+			pipelineName: "fake",
+			mode:         "",
+			jenkinsfile:  "jenkinsfile",
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+		verify: func(t *testing.T, ksclient versioned.Interface) {
+			pip, err := ksclient.DevopsV1alpha3().Pipelines("ns").Get(context.Background(), "fake", metav1.GetOptions{})
+			assert.Nil(t, err)
+			assert.Equal(t, "jenkinsfile", pip.Spec.Pipeline.Jenkinsfile)
+			assert.Equal(t, "", pip.Annotations[v1alpha3.PipelineJenkinsfileEditModeAnnoKey])
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			d := devopsOperator{
+				devopsClient: tt.fields.devopsClient,
+				k8sclient:    tt.fields.k8sclient,
+				ksclient:     tt.fields.ksclient,
+				context:      tt.fields.context,
+			}
+			tt.wantErr(t, d.UpdateJenkinsfile(tt.args.projectName, tt.args.pipelineName, tt.args.mode, tt.args.jenkinsfile), fmt.Sprintf("UpdateJenkinsfile(%v, %v, %v, %v)", tt.args.projectName, tt.args.pipelineName, tt.args.mode, tt.args.jenkinsfile))
+			if tt.verify != nil {
+				tt.verify(t, tt.fields.ksclient)
+			}
+		})
+	}
+}
+
+func Test_devopsOperator_UpdatePipelineObj(t *testing.T) {
+	pip := &v1alpha3.Pipeline{}
+	pip.SetName("fake")
+	pip.SetNamespace("ns")
+
+	pipWithJenkinsfile := pip.DeepCopy()
+	pipWithJenkinsfile.Spec.Pipeline = &v1alpha3.NoScmPipeline{}
+
+	project := &v1alpha3.DevOpsProject{}
+	project.SetName("ns")
+	project.Status.AdminNamespace = "ns"
+
+	type fields struct {
+		devopsClient devops.Interface
+		k8sclient    kubernetes.Interface
+		ksclient     versioned.Interface
+		context      context.Context
+	}
+	type args struct {
+		projectName string
+		pipeline    *v1alpha3.Pipeline
+	}
+	tests := []struct {
+		name    string
+		fields  fields
+		args    args
+		want    *v1alpha3.Pipeline
+		wantErr assert.ErrorAssertionFunc
+	}{{
+		name: "not found project",
+		fields: fields{
+			ksclient: fakeclientset.NewSimpleClientset(),
+		},
+		args: args{
+			projectName: "ns",
+			pipeline:    pip.DeepCopy(),
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.NotNil(t, err)
+			return true
+		},
+	}, {
+		name: "not found pipeline",
+		fields: fields{
+			ksclient: fakeclientset.NewSimpleClientset(project.DeepCopy()),
+		},
+		args: args{
+			projectName: "ns",
+			pipeline:    pip.DeepCopy(),
+		},
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.NotNil(t, err)
+			return true
+		},
+	}, {
+		name: "without jenkinsfile",
+		fields: fields{
+			ksclient: fakeclientset.NewSimpleClientset(project.DeepCopy(), pip.DeepCopy()),
+		},
+		args: args{
+			projectName: "ns",
+			pipeline:    pip.DeepCopy(),
+		},
+		want: pip.DeepCopy(),
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}, {
+		name: "normal case, with jenkinsfile",
+		fields: fields{
+			ksclient: fakeclientset.NewSimpleClientset(project.DeepCopy(), pipWithJenkinsfile.DeepCopy()),
+		},
+		args: args{
+			projectName: "ns",
+			pipeline:    pipWithJenkinsfile.DeepCopy(),
+		},
+		want: pipWithJenkinsfile.DeepCopy(),
+		wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
+			assert.Nil(t, err)
+			return true
+		},
+	}}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			d := devopsOperator{
+				devopsClient: tt.fields.devopsClient,
+				k8sclient:    tt.fields.k8sclient,
+				ksclient:     tt.fields.ksclient,
+				context:      tt.fields.context,
+			}
+			got, err := d.UpdatePipelineObj(tt.args.projectName, tt.args.pipeline)
+			if !tt.wantErr(t, err, fmt.Sprintf("UpdatePipelineObj(%v, %v)", tt.args.projectName, tt.args.pipeline)) {
+				return
+			}
+			assert.Equalf(t, tt.want, got, "UpdatePipelineObj(%v, %v)", tt.args.projectName, tt.args.pipeline)
+		})
+	}
+}