Repository: kubesphere/ks-devops
Pull request #468 (2022-02-17 09:14:54)

Provide RESTful APIs for ClusterTemplate
### What type of PR is this?

/kind feature
/kind api-change

### What this PR does / why we need it:

Provide ClusterTemplate related APIs:

- Query ClusterTemplates
    ```bash
    /v1alpha1/clustertemplates?sortBy=name&ascending=true&page=10
    ```
    > This query API cannot support too many query condition currently. It only supports some general query condition, like page, sortBy, and so on.

- Render ClusterTemplate
    ```bash
    /v1alpha1/clustertemplates/fake-template/render
    ```
    > The render result is included in the Template's annotation, and cannot support to pass parameters to render complex Template.

### Which issue(s) this PR fixes:

This PR fixes part of issue #454 and prepares for https://github.com/kubesphere/console/issues/2946.

### Special notes for reviewers:

### Does this PR introduce a user-facing change??

```release-note
Provide RESTful APIs for ClusterTemplate
```

/cc @kubesphere/sig-devops 

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

This pull-request has been approved by: *<a href="https://github.com/kubesphere/ks-devops/pull/468#" title="Author self-approved">JohnNiang</a>*
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"]} -->
JohnNiang: /label tide/merge-method-squash
codecov[bot]: # [Codecov](https://codecov.io/gh/kubesphere/ks-devops/pull/468?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) Report
> Merging [#468](https://codecov.io/gh/kubesphere/ks-devops/pull/468?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) (b12a8c2) into [master](https://codecov.io/gh/kubesphere/ks-devops/commit/d54a80ebd16c0df7b4698869babb17e41fec681e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) (d54a80e) will **decrease** coverage by `0.04%`.
> The diff coverage is `64.81%`.

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

```diff
@@            Coverage Diff             @@
##           master     #468      +/-   ##
==========================================
- Coverage   76.89%   76.85%   -0.05%     
==========================================
  Files         127      129       +2     
  Lines       22518    22567      +49     
==========================================
+ Hits        17316    17343      +27     
- Misses       5015     5033      +18     
- Partials      187      191       +4     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `76.85% <64.81%> (-0.05%)` | :arrow_down: |

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

| [Impacted Files](https://codecov.io/gh/kubesphere/ks-devops/pull/468?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere) | Coverage Δ | |
|---|---|---|
| [pkg/kapis/devops/v1alpha1/clustertemplate/route.go](https://codecov.io/gh/kubesphere/ks-devops/pull/468/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2thcGlzL2Rldm9wcy92MWFscGhhMS9jbHVzdGVydGVtcGxhdGUvcm91dGUuZ28=) | `0.00% <0.00%> (ø)` | |
| [pkg/kapis/devops/v1alpha1/template/route.go](https://codecov.io/gh/kubesphere/ks-devops/pull/468/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2thcGlzL2Rldm9wcy92MWFscGhhMS90ZW1wbGF0ZS9yb3V0ZS5nbw==) | `0.00% <0.00%> (ø)` | |
| [pkg/kapis/devops/v1alpha1/template/handler.go](https://codecov.io/gh/kubesphere/ks-devops/pull/468/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2thcGlzL2Rldm9wcy92MWFscGhhMS90ZW1wbGF0ZS9oYW5kbGVyLmdv) | `87.80% <80.00%> (+1.31%)` | :arrow_up: |
| [...g/kapis/devops/v1alpha1/clustertemplate/handler.go](https://codecov.io/gh/kubesphere/ks-devops/pull/468/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2thcGlzL2Rldm9wcy92MWFscGhhMS9jbHVzdGVydGVtcGxhdGUvaGFuZGxlci5nbw==) | `84.37% <84.37%> (ø)` | |
| [pkg/kapis/devops/v1alpha1/register.go](https://codecov.io/gh/kubesphere/ks-devops/pull/468/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-cGtnL2thcGlzL2Rldm9wcy92MWFscGhhMS9yZWdpc3Rlci5nbw==) | `100.00% <100.00%> (ø)` | |
| [controllers/s2irun/s2irun\_controller.go](https://codecov.io/gh/kubesphere/ks-devops/pull/468/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubesphere#diff-Y29udHJvbGxlcnMvczJpcnVuL3MyaXJ1bl9jb250cm9sbGVyLmdv) | `33.57% <0.00%> (-3.65%)` | :arrow_down: |

------

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

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

This pull-request has been approved by: *<a href="https://github.com/kubesphere/ks-devops/pull/468#" title="Author self-approved">JohnNiang</a>*, *<a href="https://github.com/kubesphere/ks-devops/pull/468#pullrequestreview-888133634" title="Approved">LinuxSuRen</a>*

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

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

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

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

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->

Patch:
diff --git a/config/samples/devops_v1alpha1_clustertemplate.yaml b/config/samples/devops_v1alpha1_clustertemplate.yaml
index c7ff466..6603c5e 100644
--- a/config/samples/devops_v1alpha1_clustertemplate.yaml
+++ b/config/samples/devops_v1alpha1_clustertemplate.yaml
@@ -3,4 +3,61 @@ kind: ClusterTemplate
 metadata:
   name: clustertemplate-sample
 spec:
-  # TODO(user): Add fields here
+  parameters:
+    - name: gitCloneURL
+      description: What is your repository URL you want to clone?
+      type: string # ignorable
+      validation:
+        expression: "matches()"
+        message: "Please input a correct URL."
+    - name: revision
+      description: Which revision do you want to clone from?
+      default: "main" # Valid JSON value
+    - name: buildOnly
+      description: Do we really need build stage only?
+      default: false # Valid JSON value
+      type: bool
+    - name: matrix
+      description: Matrix versions of gradle
+      type: string-array
+      default: ["6.9.1-jdk11", "7.0.0-jdk11", "7.3.3-jdk11"]
+
+  template: | # Go template
+    pipeline {
+        agent {
+            kubernetes {
+            inheritFrom 'gradle'
+                containerTemplate {
+                    name 'gradle'
+                    image 'gradle:7.3.3-jdk11'
+                }
+            }
+        }
+        stages {
+            stage('Checkout') {
+                steps {
+                    checkout poll: false, scm: [$class: 'GitSCM', branches: [[name: '*/master']], extensions: [[$class: 'CloneOption', depth: 1, noTags: true, reference: '', shallow: true], [$class: 'SubmoduleOption', depth: 1, disableSubmodules: false, parentCredentials: false, recursiveSubmodules: true, reference: '', shallow: true, trackingSubmodules: false]], userRemoteConfigs: [[url: '{{ .params.gitCloneURL }}']]]
+                }
+            }
+            {{if not .buildOnly}}
+            stage('Gradle Check') {
+                steps {
+                    container('gradle') {
+                        sh 'gradle check'
+                    }
+                }
+            }
+            stage('Gradle Build') {
+                steps {
+                    container('gradle') {
+                        sh 'gradle build -x test'
+                    }
+                }
+            }
+            stage('Archive Assets') {
+                steps {
+                    archiveArtifacts '**/build/libs/*.jar'
+                }
+            }
+        }
+    }
\ No newline at end of file
diff --git a/pkg/api/devops/v1alpha1/clustertemplate_types.go b/pkg/api/devops/v1alpha1/clustertemplate_types.go
index 7efe9b8..59bdcd2 100644
--- a/pkg/api/devops/v1alpha1/clustertemplate_types.go
+++ b/pkg/api/devops/v1alpha1/clustertemplate_types.go
@@ -24,6 +24,8 @@ import (
 //+kubebuilder:subresource:status
 //+kubebuilder:resource:scope=Cluster
 
+var _ TemplateObject = &ClusterTemplate{}
+
 // ClusterTemplate is the Schema for the clustertemplates API
 type ClusterTemplate struct {
 	metav1.TypeMeta   `json:",inline"`
@@ -33,6 +35,11 @@ type ClusterTemplate struct {
 	Status TemplateStatus `json:"status,omitempty"`
 }
 
+// TemplateSpec returns Spec field of ClusterTemplate.
+func (template *ClusterTemplate) TemplateSpec() TemplateSpec {
+	return template.Spec
+}
+
 //+kubebuilder:object:root=true
 
 // ClusterTemplateList contains a list of ClusterTemplate
diff --git a/pkg/api/devops/v1alpha1/openapi_generated.go b/pkg/api/devops/v1alpha1/openapi_generated.go
index d24f4ab..87f32d4 100644
--- a/pkg/api/devops/v1alpha1/openapi_generated.go
+++ b/pkg/api/devops/v1alpha1/openapi_generated.go
@@ -473,7 +473,7 @@ func schema_k8sio_api_core_v1_AzureDiskVolumeSource(ref common.ReferenceCallback
 					},
 					"cachingMode": {
 						SchemaProps: spec.SchemaProps{
-							Description: "Host Caching mode: None, Read Only, Read Write.",
+							Description: "Host Caching mode: None, Read Only, Read WriteEntityOrError.",
 							Type:        []string{"string"},
 							Format:      "",
 						},
diff --git a/pkg/api/devops/v1alpha1/template_interface.go b/pkg/api/devops/v1alpha1/template_interface.go
new file mode 100644
index 0000000..d85990d
--- /dev/null
+++ b/pkg/api/devops/v1alpha1/template_interface.go
@@ -0,0 +1,29 @@
+// Copyright 2022 KubeSphere Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+package v1alpha1
+
+import (
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+)
+
+// TemplateObject is implemented by Template and ClusterTemplate.
+type TemplateObject interface {
+	v1.Object
+	runtime.Object
+	// TemplateSpec returns TemplateSpec.
+	TemplateSpec() TemplateSpec
+}
diff --git a/pkg/api/devops/v1alpha1/template_types.go b/pkg/api/devops/v1alpha1/template_types.go
index 74f5400..5aeae97 100644
--- a/pkg/api/devops/v1alpha1/template_types.go
+++ b/pkg/api/devops/v1alpha1/template_types.go
@@ -21,6 +21,8 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
+var _ TemplateObject = &Template{}
+
 // TemplateSpec defines the desired state of Template
 type TemplateSpec struct {
 	// Parameters are used to configure template.
@@ -80,6 +82,11 @@ type Template struct {
 	Status TemplateStatus `json:"status,omitempty"`
 }
 
+// TemplateSpec returns specification of Template.
+func (template *Template) TemplateSpec() TemplateSpec {
+	return template.Spec
+}
+
 //+kubebuilder:object:root=true
 
 // TemplateList contains a list of Template
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 01aea06..6f1add0 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -21,15 +21,16 @@ const (
 	CreatorAnnotationKey = "kubesphere.io/creator"
 	WorkspaceLabelKey    = "kubesphere.io/workspace"
 
-	DevOpsProjectLabelKey = "kubesphere.io/devopsproject"
-	DevOpsCredentialTag   = "DevOps Credential"
-	DevOpsPipelineTag     = "DevOps Pipeline"
-	DevOpsWebhookTag      = "DevOps Webhook"
-	DevOpsJenkinsfileTag  = "DevOps Jenkinsfile"
-	DevOpsScmTag          = "DevOps Scm"
-	DevOpsJenkinsTag      = "Jenkins"
-	DevOpsProjectTag      = "DevOps Project"
-	DevOpsTemplateTag     = "DevOps Template"
+	DevOpsProjectLabelKey    = "kubesphere.io/devopsproject"
+	DevOpsCredentialTag      = "DevOps Credential"
+	DevOpsPipelineTag        = "DevOps Pipeline"
+	DevOpsWebhookTag         = "DevOps Webhook"
+	DevOpsJenkinsfileTag     = "DevOps Jenkinsfile"
+	DevOpsScmTag             = "DevOps Scm"
+	DevOpsJenkinsTag         = "Jenkins"
+	DevOpsProjectTag         = "DevOps Project"
+	DevOpsTemplateTag        = "DevOps Template"
+	DevOpsClusterTemplateTag = "DevOps Cluster Template"
 )
 
 // K8SToken is the context key of k8s token
diff --git a/pkg/kapis/devops/v1alpha1/register.go b/pkg/kapis/devops/v1alpha1/register.go
index 7a06453..7381f57 100644
--- a/pkg/kapis/devops/v1alpha1/register.go
+++ b/pkg/kapis/devops/v1alpha1/register.go
@@ -26,9 +26,10 @@ import (
 
 // AddToContainer adds web services into web service container.
 func AddToContainer(container *restful.Container, options *common.Options) []*restful.WebService {
-	var services []*restful.WebService
-	services = append(services, runtime.NewWebService(v1alpha1.GroupVersion))
-	services = append(services, runtime.NewWebServiceWithoutGroup(v1alpha1.GroupVersion))
+	services := []*restful.WebService{
+		runtime.NewWebService(v1alpha1.GroupVersion),
+		runtime.NewWebServiceWithoutGroup(v1alpha1.GroupVersion),
+	}
 	for _, service := range services {
 		template.RegisterRoutes(service, options)
 		argocd.RegisterRoutes(service, options)
diff --git a/pkg/kapis/devops/v1alpha1/template/cluster_template_handler.go b/pkg/kapis/devops/v1alpha1/template/cluster_template_handler.go
new file mode 100644
index 0000000..24d8e72
--- /dev/null
+++ b/pkg/kapis/devops/v1alpha1/template/cluster_template_handler.go
@@ -0,0 +1,74 @@
+// Copyright 2022 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 template
+
+import (
+	"context"
+	"github.com/emicklei/go-restful"
+	"k8s.io/apimachinery/pkg/runtime"
+	"kubesphere.io/devops/pkg/api"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
+	"kubesphere.io/devops/pkg/apiserver/query"
+	"kubesphere.io/devops/pkg/kapis"
+	"kubesphere.io/devops/pkg/models/resources/v1alpha3"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+func (h *handler) handleQueryClusterTemplates(request *restful.Request, response *restful.Response) {
+	commonQuery := query.ParseQueryParameter(request)
+	kapis.ResponseWriter{Response: response}.WriteEntityOrError(h.queryClusterTemplates(commonQuery))
+}
+
+func (h *handler) handleRenderClusterTemplate(request *restful.Request, response *restful.Response) {
+	templateName := request.PathParameter(ClusterTemplatePathParameter.Data().Name)
+	kapis.ResponseWriter{Response: response}.WriteEntityOrError(h.renderClusterTemplate(templateName))
+}
+
+func (h *handler) queryClusterTemplates(commonQuery *query.Query) (*api.ListResult, error) {
+	templateList := &v1alpha1.ClusterTemplateList{}
+	if err := h.genericClient.List(context.Background(),
+		templateList,
+		client.MatchingLabelsSelector{
+			Selector: commonQuery.Selector(),
+		}); err != nil {
+		return nil, err
+	}
+	return v1alpha3.ToListResult(clusterTemplatesToObjects(templateList.Items), commonQuery, nil), nil
+}
+
+func (h *handler) getClusterTemplate(templateName string) (*v1alpha1.ClusterTemplate, error) {
+	template := &v1alpha1.ClusterTemplate{}
+	if err := h.genericClient.Get(context.Background(), client.ObjectKey{Name: templateName}, template); err != nil {
+		return nil, err
+	}
+	return template, nil
+}
+
+func (h *handler) renderClusterTemplate(templateName string) (v1alpha1.TemplateObject, error) {
+	template, err := h.getClusterTemplate(templateName)
+	if err != nil {
+		return nil, err
+	}
+	return render(template), nil
+}
+
+func clusterTemplatesToObjects(templates []v1alpha1.ClusterTemplate) []runtime.Object {
+	var objects []runtime.Object
+	for i := range templates {
+		objects = append(objects, &templates[i])
+	}
+	return objects
+}
diff --git a/pkg/kapis/devops/v1alpha1/template/handler.go b/pkg/kapis/devops/v1alpha1/template/handler.go
index 6de8805..6436bf1 100644
--- a/pkg/kapis/devops/v1alpha1/template/handler.go
+++ b/pkg/kapis/devops/v1alpha1/template/handler.go
@@ -16,16 +16,7 @@
 package template
 
 import (
-	"context"
-	"github.com/emicklei/go-restful"
-	"k8s.io/apimachinery/pkg/runtime"
-	"kubesphere.io/devops/pkg/api"
-	"kubesphere.io/devops/pkg/api/devops"
-	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
-	"kubesphere.io/devops/pkg/apiserver/query"
-	"kubesphere.io/devops/pkg/kapis"
 	kapisv1alpha1 "kubesphere.io/devops/pkg/kapis/devops/v1alpha1/common"
-	"kubesphere.io/devops/pkg/models/resources/v1alpha3"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
@@ -38,74 +29,3 @@ func newHandler(options *kapisv1alpha1.Options) *handler {
 		genericClient: options.GenericClient,
 	}
 }
-
-func (h *handler) handleQuery(request *restful.Request, response *restful.Response) {
-	if data, err := h.queryTemplate(request); err != nil {
-		kapis.HandleError(request, response, err)
-	} else {
-		_ = response.WriteEntity(data)
-	}
-}
-
-func (h *handler) queryTemplate(request *restful.Request) (*api.ListResult, error) {
-	devopsName := request.PathParameter(kapisv1alpha1.DevopsPathParameter.Data().Name)
-	queryParam := query.ParseQueryParameter(request)
-	templateList := &v1alpha1.TemplateList{}
-	if err := h.genericClient.List(context.Background(), templateList, client.InNamespace(devopsName)); err != nil {
-		return nil, err
-	}
-	return v1alpha3.ToListResult(toObjects(templateList.Items), queryParam, nil), nil
-}
-
-func (h *handler) handleGet(request *restful.Request, response *restful.Response) {
-	if template, err := h.getTemplate(request); err != nil {
-		kapis.HandleError(request, response, err)
-	} else {
-		_ = response.WriteEntity(template)
-	}
-}
-
-func (h *handler) getTemplate(request *restful.Request) (*v1alpha1.Template, error) {
-	devopsName := request.PathParameter(kapisv1alpha1.DevopsPathParameter.Data().Name)
-	templateName := request.PathParameter(TemplatePathParameter.Data().Name)
-	template := &v1alpha1.Template{}
-	if err := h.genericClient.Get(context.Background(), client.ObjectKey{Namespace: devopsName, Name: templateName}, template); err != nil {
-		return nil, err
-	}
-	return template, nil
-}
-
-func (h *handler) handleRender(request *restful.Request, response *restful.Response) {
-	if template, err := h.renderTemplate(request); err != nil {
-		kapis.HandleError(request, response, err)
-	} else {
-		_ = response.WriteEntity(template)
-	}
-}
-
-func (h *handler) renderTemplate(request *restful.Request) (*v1alpha1.Template, error) {
-	tmpl, err := h.getTemplate(request)
-	if err != nil {
-		return nil, err
-	}
-
-	// TODO render template using parameters
-
-	tmplCopy := tmpl.DeepCopy()
-	if tmplCopy.GetAnnotations() == nil {
-		tmplCopy.SetAnnotations(map[string]string{})
-	}
-
-	tmplCopy.GetAnnotations()[devops.GroupName+devops.RenderResultAnnoKey] = tmplCopy.Spec.Template
-
-	// get parameter
-	return tmplCopy, nil
-}
-
-func toObjects(templates []v1alpha1.Template) []runtime.Object {
-	var objects []runtime.Object
-	for i := range templates {
-		objects = append(objects, &templates[i])
-	}
-	return objects
-}
diff --git a/pkg/kapis/devops/v1alpha1/template/render.go b/pkg/kapis/devops/v1alpha1/template/render.go
new file mode 100644
index 0000000..a56d005
--- /dev/null
+++ b/pkg/kapis/devops/v1alpha1/template/render.go
@@ -0,0 +1,34 @@
+// Copyright 2022 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 template
+
+import (
+	"kubesphere.io/devops/pkg/api/devops"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
+)
+
+func render(templateObjectCopy v1alpha1.TemplateObject) v1alpha1.TemplateObject {
+	templateObjectCopy = templateObjectCopy.DeepCopyObject().(v1alpha1.TemplateObject)
+	// TODO Render template using parameters
+	template := templateObjectCopy.TemplateSpec().Template
+
+	// set template into annotations
+	if templateObjectCopy.GetAnnotations() == nil {
+		templateObjectCopy.SetAnnotations(map[string]string{})
+	}
+	templateObjectCopy.GetAnnotations()[devops.GroupName+devops.RenderResultAnnoKey] = template
+	return templateObjectCopy
+}
diff --git a/pkg/kapis/devops/v1alpha1/template/route.go b/pkg/kapis/devops/v1alpha1/template/route.go
index 71f7d10..eb37a71 100644
--- a/pkg/kapis/devops/v1alpha1/template/route.go
+++ b/pkg/kapis/devops/v1alpha1/template/route.go
@@ -28,22 +28,31 @@ import (
 )
 
 var (
-	// TemplatePathParameter is a path parameter definition for template.
+	// TemplatePathParameter is path parameter definition of template.
 	TemplatePathParameter = restful.PathParameter("template", "Template name")
+	// ClusterTemplatePathParameter is path parameter definition of ClusterTemplate.
+	ClusterTemplatePathParameter = restful.PathParameter("clustertemplate", "Name of ClusterTemplate.")
 )
 
+// PageResult is the model of Template page result.
+type PageResult struct {
+	Items []v1alpha1.Template `json:"items"`
+	Total int                 `json:"total"`
+}
+
 // RegisterRoutes is for registering template routes into WebService.
 func RegisterRoutes(service *restful.WebService, options *kapisv1alpha1.Options) {
 	handler := newHandler(options)
+	// Template
 	service.Route(service.GET("/devops/{devops}/templates").
 		To(handler.handleQuery).
 		Param(kapisv1alpha1.DevopsPathParameter).
 		Doc("Query templates for a DevOps Project.").
-		Returns(http.StatusOK, api.StatusOK, api.ListResult{Items: []interface{}{}}).
+		Returns(http.StatusOK, api.StatusOK, PageResult{}).
 		Metadata(restfulspec.KeyOpenAPITags, []string{constants.DevOpsTemplateTag}))
 
 	service.Route(service.GET("/devops/{devops}/templates/{template}").
-		To(handler.handleGet).
+		To(handler.handleGetTemplate).
 		Param(kapisv1alpha1.DevopsPathParameter).
 		Param(TemplatePathParameter).
 		Doc("Get template").
@@ -51,10 +60,24 @@ func RegisterRoutes(service *restful.WebService, options *kapisv1alpha1.Options)
 		Metadata(restfulspec.KeyOpenAPITags, []string{constants.DevOpsTemplateTag}))
 
 	service.Route(service.POST("/devops/{devops}/templates/{template}/render").
-		To(handler.handleRender).
+		To(handler.handleRenderTemplate).
 		Param(kapisv1alpha1.DevopsPathParameter).
 		Param(TemplatePathParameter).
 		Doc(fmt.Sprintf("Render template and return render result into annotations (%s/%s) inside template", devops.GroupName, devops.RenderResultAnnoKey)).
 		Returns(http.StatusOK, api.StatusOK, v1alpha1.Template{}).
 		Metadata(restfulspec.KeyOpenAPITags, []string{constants.DevOpsTemplateTag}))
+
+	// ClusterTemplate
+	service.Route(service.GET("/clustertemplates").
+		To(handler.handleQueryClusterTemplates).
+		Doc("Query cluster templates.").
+		Returns(http.StatusOK, api.StatusOK, PageResult{}).
+		Metadata(restfulspec.KeyOpenAPITags, []string{constants.DevOpsClusterTemplateTag}))
+
+	service.Route(service.POST("/clustertemplates/{clustertemplate}/render").
+		To(handler.handleRenderClusterTemplate).
+		Param(ClusterTemplatePathParameter).
+		Doc("Render cluster template.").
+		Returns(http.StatusOK, api.StatusOK, v1alpha1.ClusterTemplate{}).
+		Metadata(restfulspec.KeyOpenAPITags, []string{constants.DevOpsClusterTemplateTag}))
 }
diff --git a/pkg/kapis/devops/v1alpha1/template/template_handler.go b/pkg/kapis/devops/v1alpha1/template/template_handler.go
new file mode 100644
index 0000000..82030d2
--- /dev/null
+++ b/pkg/kapis/devops/v1alpha1/template/template_handler.go
@@ -0,0 +1,87 @@
+// Copyright 2022 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 template
+
+import (
+	"context"
+	"github.com/emicklei/go-restful"
+	"k8s.io/apimachinery/pkg/runtime"
+	"kubesphere.io/devops/pkg/api"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
+	"kubesphere.io/devops/pkg/apiserver/query"
+	"kubesphere.io/devops/pkg/kapis"
+	kapisv1alpha1 "kubesphere.io/devops/pkg/kapis/devops/v1alpha1/common"
+	"kubesphere.io/devops/pkg/models/resources/v1alpha3"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+func (h *handler) handleQuery(request *restful.Request, response *restful.Response) {
+	devopsName := request.PathParameter(kapisv1alpha1.DevopsPathParameter.Data().Name)
+	commonQuery := query.ParseQueryParameter(request)
+
+	kapis.ResponseWriter{Response: response}.WriteEntityOrError(h.queryTemplate(devopsName, commonQuery))
+}
+
+func (h *handler) queryTemplate(devopsName string, commonQuery *query.Query) (*api.ListResult, error) {
+	templateList := &v1alpha1.TemplateList{}
+	if err := h.genericClient.List(context.Background(),
+		templateList,
+		client.InNamespace(devopsName),
+		client.MatchingLabelsSelector{
+			Selector: commonQuery.Selector(),
+		}); err != nil {
+		return nil, err
+	}
+	return v1alpha3.ToListResult(templatesToObjects(templateList.Items), commonQuery, nil), nil
+}
+
+func (h *handler) handleGetTemplate(request *restful.Request, response *restful.Response) {
+	devopsName := request.PathParameter(kapisv1alpha1.DevopsPathParameter.Data().Name)
+	templateName := request.PathParameter(TemplatePathParameter.Data().Name)
+
+	kapis.ResponseWriter{Response: response}.WriteEntityOrError(h.getTemplate(devopsName, templateName))
+}
+
+func (h *handler) getTemplate(devopsName, templateName string) (*v1alpha1.Template, error) {
+	template := &v1alpha1.Template{}
+	if err := h.genericClient.Get(context.Background(), client.ObjectKey{Namespace: devopsName, Name: templateName}, template); err != nil {
+		return nil, err
+	}
+	return template, nil
+}
+
+func (h *handler) handleRenderTemplate(request *restful.Request, response *restful.Response) {
+	devopsName := request.PathParameter(kapisv1alpha1.DevopsPathParameter.Data().Name)
+	templateName := request.PathParameter(TemplatePathParameter.Data().Name)
+
+	kapis.ResponseWriter{Response: response}.WriteEntityOrError(h.renderTemplate(devopsName, templateName))
+}
+
+func (h *handler) renderTemplate(devopsName, templateName string) (v1alpha1.TemplateObject, error) {
+	tmpl, err := h.getTemplate(devopsName, templateName)
+	if err != nil {
+		return nil, err
+	}
+	return render(tmpl), nil
+}
+
+func templatesToObjects(templates []v1alpha1.Template) []runtime.Object {
+	var objects []runtime.Object
+	for i := range templates {
+		objects = append(objects, &templates[i])
+	}
+	return objects
+}
diff --git a/pkg/kapis/utils.go b/pkg/kapis/utils.go
index 6e54cb2..82cf17f 100644
--- a/pkg/kapis/utils.go
+++ b/pkg/kapis/utils.go
@@ -88,6 +88,20 @@ func IgnoreEOF(err error) error {
 	return err
 }
 
+// ResponseWriter is a handler for response.
+type ResponseWriter struct {
+	*restful.Response
+}
+
+// WriteEntityOrError writes entity to the response if no error and writes error message to response if error occurred.
+func (handler ResponseWriter) WriteEntityOrError(entity interface{}, err error) {
+	if err != nil {
+		HandleError(nil, handler.Response, err)
+		return
+	}
+	_ = handler.WriteEntity(entity)
+}
+
 func handle(statusCode int, req *restful.Request, response *restful.Response, err error) {
 	_, fn, line, _ := runtime.Caller(2)
 	klog.Errorf("%s:%d %v", fn, line, err)


Test patch:
diff --git a/pkg/kapis/devops/v1alpha1/register_test.go b/pkg/kapis/devops/v1alpha1/register_test.go
index f5701b5..5d6a8fa 100644
--- a/pkg/kapis/devops/v1alpha1/register_test.go
+++ b/pkg/kapis/devops/v1alpha1/register_test.go
@@ -54,6 +54,18 @@ func TestAPIsExist(t *testing.T) {
 			method: http.MethodPost,
 			uri:    "/devops/fake-devops/templates/fake-template/render",
 		},
+	}, {
+		name: "List cluster templates",
+		args: args{
+			method: http.MethodGet,
+			uri:    "/clustertemplates",
+		},
+	}, {
+		name: "Render a cluster template",
+		args: args{
+			method: http.MethodPost,
+			uri:    "/clustertemplates/fake-template/render",
+		},
 	}}
 	for _, tt := range tests {
 		container := restful.NewContainer()
diff --git a/pkg/kapis/devops/v1alpha1/template/cluster_template_handler_test.go b/pkg/kapis/devops/v1alpha1/template/cluster_template_handler_test.go
new file mode 100644
index 0000000..e10ee23
--- /dev/null
+++ b/pkg/kapis/devops/v1alpha1/template/cluster_template_handler_test.go
@@ -0,0 +1,242 @@
+// Copyright 2022 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 template
+
+import (
+	"encoding/json"
+	"github.com/emicklei/go-restful"
+	"github.com/stretchr/testify/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	"k8s.io/client-go/kubernetes/scheme"
+	"kubesphere.io/devops/pkg/api"
+	"kubesphere.io/devops/pkg/api/devops"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"testing"
+)
+
+func Test_clusterTemplatesToObjects(t *testing.T) {
+	createTemplate := func(name string) *v1alpha1.ClusterTemplate {
+		return &v1alpha1.ClusterTemplate{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: name,
+			},
+		}
+	}
+	type args struct {
+		templates []v1alpha1.ClusterTemplate
+	}
+	tests := []struct {
+		name string
+		args args
+		want []runtime.Object
+	}{{
+		name: "Should convert correctly",
+		args: args{
+			templates: []v1alpha1.ClusterTemplate{
+				*createTemplate("template1"),
+				*createTemplate("template2"),
+			},
+		},
+		want: []runtime.Object{
+			createTemplate("template1"),
+			createTemplate("template2"),
+		},
+	}, {
+		name: "Should return nil if templates argument is nil",
+		args: args{
+			templates: nil,
+		},
+		want: nil,
+	}, {
+		name: "Should return nil if templates argument is an empty slice",
+		args: args{
+			templates: []v1alpha1.ClusterTemplate{},
+		},
+		want: nil,
+	},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := clusterTemplatesToObjects(tt.args.templates); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("clusterTemplatesToObjects() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func Test_handler_handleQueryClusterTemplates(t *testing.T) {
+	createTemplate := func(name string) *v1alpha1.ClusterTemplate {
+		return &v1alpha1.ClusterTemplate{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: name,
+			},
+		}
+	}
+	createRequest := func(uri string) *restful.Request {
+		fakeRequest := httptest.NewRequest(http.MethodGet, uri, nil)
+		request := restful.NewRequest(fakeRequest)
+		return request
+	}
+	type args struct {
+		initObjects []runtime.Object
+		request     *restful.Request
+	}
+	tests := []struct {
+		name         string
+		args         args
+		wantResponse interface{}
+	}{{
+		name: "Should return empty list if no templates found",
+		args: args{
+			request: createRequest("/v1alpha1/clustertemplates"),
+		},
+		wantResponse: api.ListResult{
+			Items: []interface{}{},
+		},
+	}, {
+		name: "Should return non-empty list if templates found",
+		args: args{
+			request: createRequest("/v1alpha1/clustertemplates?sortBy=name&ascending=true"),
+			initObjects: []runtime.Object{
+				createTemplate("template1"),
+				createTemplate("template2"),
+				createTemplate("template3"),
+			},
+		},
+		wantResponse: api.ListResult{
+			Items: []interface{}{
+				*createTemplate("template1"),
+				*createTemplate("template2"),
+				*createTemplate("template3"),
+			},
+			TotalItems: 3,
+		},
+	}, {
+		name: "Should return empty list if out of page",
+		args: args{
+			request: createRequest("/v1alpha1/clustertemplates?sortBy=name&ascending=true&page=10"),
+			initObjects: []runtime.Object{
+				createTemplate("template1"),
+				createTemplate("template2"),
+				createTemplate("template3"),
+			},
+		},
+		wantResponse: api.ListResult{
+			Items:      []interface{}{},
+			TotalItems: 3,
+		},
+	},
+	}
+	for _, tt := range tests {
+		utilruntime.Must(v1alpha1.AddToScheme(scheme.Scheme))
+		utilruntime.Must(v1alpha3.AddToScheme(scheme.Scheme))
+		fakeClient := fake.NewFakeClientWithScheme(scheme.Scheme, tt.args.initObjects...)
+
+		t.Run(tt.name, func(t *testing.T) {
+			h := &handler{
+				genericClient: fakeClient,
+			}
+			request := tt.args.request
+			recorder := httptest.NewRecorder()
+			response := restful.NewResponse(recorder)
+			response.SetRequestAccepts(restful.MIME_JSON)
+			h.handleQueryClusterTemplates(request, response)
+
+			assert.Equal(t, 200, recorder.Code)
+			wantResponseBytes, err := json.Marshal(tt.wantResponse)
+			assert.Nil(t, err)
+			assert.JSONEq(t, string(wantResponseBytes), recorder.Body.String())
+		})
+	}
+}
+
+func Test_handler_handleRenderClusterTemplate(t *testing.T) {
+	fakeTemplate := &v1alpha1.ClusterTemplate{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "fake-template",
+		},
+		Spec: v1alpha1.TemplateSpec{
+			Template: "fake template content",
+		},
+	}
+	createRequest := func(uri, templateName string) *restful.Request {
+		fakeRequest := httptest.NewRequest(http.MethodGet, uri, nil)
+		request := restful.NewRequest(fakeRequest)
+		request.PathParameters()[ClusterTemplatePathParameter.Data().Name] = templateName
+		return request
+	}
+	type args struct {
+		initObjects []runtime.Object
+		request     *restful.Request
+	}
+	tests := []struct {
+		name      string
+		args      args
+		wantCode  int
+		assertion func(*testing.T, *httptest.ResponseRecorder)
+	}{{
+		name: "Should return not found if template not found",
+		args: args{
+			request: createRequest("/v1alpha1/clustertemplates/fake-template/render", "fake-template"),
+		},
+		wantCode: 404,
+		assertion: func(t *testing.T, recorder *httptest.ResponseRecorder) {
+			assert.Equal(t, "clustertemplates.devops.kubesphere.io \"fake-template\" not found\n", recorder.Body.String())
+		},
+	}, {
+		name: "Should set render result into annotations properly if no parameters needed",
+		args: args{
+			request: createRequest("/v1alpha1/clustertemplates/fake-template/render", "fake-template"),
+			initObjects: []runtime.Object{
+				fakeTemplate,
+			},
+		},
+		wantCode: 200,
+		assertion: func(t *testing.T, recorder *httptest.ResponseRecorder) {
+			gotTemplate := &v1alpha1.Template{}
+			_ = json.Unmarshal(recorder.Body.Bytes(), gotTemplate)
+			renderResult := gotTemplate.GetAnnotations()[devops.GroupName+devops.RenderResultAnnoKey]
+			assert.Equal(t, fakeTemplate.Spec.Template, renderResult)
+		},
+	}}
+	for _, tt := range tests {
+		utilruntime.Must(v1alpha1.AddToScheme(scheme.Scheme))
+		utilruntime.Must(v1alpha3.AddToScheme(scheme.Scheme))
+		fakeClient := fake.NewFakeClientWithScheme(scheme.Scheme, tt.args.initObjects...)
+		t.Run(tt.name, func(t *testing.T) {
+			h := &handler{
+				genericClient: fakeClient,
+			}
+
+			recorder := httptest.NewRecorder()
+			response := restful.NewResponse(recorder)
+			response.SetRequestAccepts(restful.MIME_JSON)
+			h.handleRenderClusterTemplate(tt.args.request, response)
+
+			assert.Equal(t, tt.wantCode, recorder.Code)
+			if tt.assertion != nil {
+				tt.assertion(t, recorder)
+			}
+		})
+	}
+}
diff --git a/pkg/kapis/devops/v1alpha1/template/handler_test.go b/pkg/kapis/devops/v1alpha1/template/handler_test.go
index 56af2e2..2223430 100644
--- a/pkg/kapis/devops/v1alpha1/template/handler_test.go
+++ b/pkg/kapis/devops/v1alpha1/template/handler_test.go
@@ -15,76 +15,13 @@
 package template
 
 import (
-	"encoding/json"
-	"github.com/emicklei/go-restful"
-	"github.com/stretchr/testify/assert"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/runtime"
-	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
 	"k8s.io/client-go/kubernetes/scheme"
-	"kubesphere.io/devops/pkg/api"
-	"kubesphere.io/devops/pkg/api/devops"
-	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
-	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
 	kapisv1alpha1 "kubesphere.io/devops/pkg/kapis/devops/v1alpha1/common"
-	"net/http"
-	"net/http/httptest"
 	"reflect"
 	"sigs.k8s.io/controller-runtime/pkg/client/fake"
 	"testing"
 )
 
-func Test_toObjects(t *testing.T) {
-	createTemplate := func(name string) *v1alpha1.Template {
-		return &v1alpha1.Template{
-			ObjectMeta: metav1.ObjectMeta{
-				Name:      name,
-				Namespace: "fake-namespace",
-			},
-		}
-	}
-	type args struct {
-		templates []v1alpha1.Template
-	}
-	tests := []struct {
-		name string
-		args args
-		want []runtime.Object
-	}{{
-		name: "Should convert correctly",
-		args: args{
-			templates: []v1alpha1.Template{
-				*createTemplate("template1"),
-				*createTemplate("template2"),
-			},
-		},
-		want: []runtime.Object{
-			createTemplate("template1"),
-			createTemplate("template2"),
-		},
-	}, {
-		name: "Should return nil if templates argument is nil",
-		args: args{
-			templates: nil,
-		},
-		want: nil,
-	}, {
-		name: "Should return nil if templates argument is an empty slice",
-		args: args{
-			templates: []v1alpha1.Template{},
-		},
-		want: nil,
-	},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			if got := toObjects(tt.args.templates); !reflect.DeepEqual(got, tt.want) {
-				t.Errorf("toObjects() = %v, want %v", got, tt.want)
-			}
-		})
-	}
-}
-
 func Test_newHandler(t *testing.T) {
 	fakeClient := fake.NewFakeClientWithScheme(scheme.Scheme)
 	type args struct {
@@ -114,233 +51,3 @@ func Test_newHandler(t *testing.T) {
 		})
 	}
 }
-
-func Test_handler_handleQuery(t *testing.T) {
-	createTemplate := func(name string) *v1alpha1.Template {
-		return &v1alpha1.Template{
-			ObjectMeta: metav1.ObjectMeta{
-				Name:      name,
-				Namespace: "fake-devops",
-			},
-		}
-	}
-	createRequest := func(uri, devopsName string) *restful.Request {
-		fakeRequest := httptest.NewRequest(http.MethodGet, uri, nil)
-		request := restful.NewRequest(fakeRequest)
-		request.PathParameters()[kapisv1alpha1.DevopsPathParameter.Data().Name] = devopsName
-		return request
-	}
-	type args struct {
-		initObjects []runtime.Object
-		request     *restful.Request
-	}
-	tests := []struct {
-		name         string
-		args         args
-		wantResponse interface{}
-	}{{
-		name: "Should return empty list if no templates found",
-		args: args{
-			request: createRequest("/v1alpha1/devops/fake-devops/templates", "fake-devops"),
-		},
-		wantResponse: api.ListResult{
-			Items: []interface{}{},
-		},
-	}, {
-		name: "Should return non-empty list if templates found",
-		args: args{
-			request: createRequest("/v1alpha1/devops/fake-devops/templates?sortBy=name&ascending=true", "fake-devops"),
-			initObjects: []runtime.Object{
-				createTemplate("template1"),
-				createTemplate("template2"),
-				createTemplate("template3"),
-			},
-		},
-		wantResponse: api.ListResult{
-			Items: []interface{}{
-				*createTemplate("template1"),
-				*createTemplate("template2"),
-				*createTemplate("template3"),
-			},
-			TotalItems: 3,
-		},
-	}, {
-		name: "Should return empty list if out of page",
-		args: args{
-			request: createRequest("/v1alpha1/devops/fake-devops/templates?sortBy=name&ascending=true&page=10", "fake-devops"),
-			initObjects: []runtime.Object{
-				createTemplate("template1"),
-				createTemplate("template2"),
-				createTemplate("template3"),
-			},
-		},
-		wantResponse: api.ListResult{
-			Items:      []interface{}{},
-			TotalItems: 3,
-		},
-	},
-	}
-	for _, tt := range tests {
-		utilruntime.Must(v1alpha1.AddToScheme(scheme.Scheme))
-		utilruntime.Must(v1alpha3.AddToScheme(scheme.Scheme))
-		fakeClient := fake.NewFakeClientWithScheme(scheme.Scheme, tt.args.initObjects...)
-
-		t.Run(tt.name, func(t *testing.T) {
-			h := &handler{
-				genericClient: fakeClient,
-			}
-			request := tt.args.request
-			recorder := httptest.NewRecorder()
-			response := restful.NewResponse(recorder)
-			response.SetRequestAccepts(restful.MIME_JSON)
-			h.handleQuery(request, response)
-
-			assert.Equal(t, 200, recorder.Code)
-			wantResponseBytes, err := json.Marshal(tt.wantResponse)
-			assert.Nil(t, err)
-			assert.JSONEq(t, string(wantResponseBytes), recorder.Body.String())
-		})
-	}
-}
-
-func Test_handler_handleGet(t *testing.T) {
-	fakeTemplate := &v1alpha1.Template{
-		ObjectMeta: metav1.ObjectMeta{
-			Name:      "fake-template",
-			Namespace: "fake-devops",
-		},
-	}
-	createRequest := func(uri, devopsName, templateName string) *restful.Request {
-		fakeRequest := httptest.NewRequest(http.MethodGet, uri, nil)
-		request := restful.NewRequest(fakeRequest)
-		request.PathParameters()[kapisv1alpha1.DevopsPathParameter.Data().Name] = devopsName
-		request.PathParameters()[TemplatePathParameter.Data().Name] = templateName
-		return request
-	}
-	type args struct {
-		initObjects []runtime.Object
-		request     *restful.Request
-	}
-	tests := []struct {
-		name      string
-		args      args
-		wantCode  int
-		assertion func(*testing.T, *httptest.ResponseRecorder)
-	}{{
-		name: "Should return not found if template does not exist",
-		args: args{
-			request: createRequest("/v1alpha1/devops/fake-devops/templates/fake-template", "fake-devops", "fake-template"),
-		},
-		wantCode: 404,
-		assertion: func(t *testing.T, recorder *httptest.ResponseRecorder) {
-			assert.Equal(t, "templates.devops.kubesphere.io \"fake-template\" not found\n", recorder.Body.String())
-		},
-	}, {
-		name: "Should return template if template exists ",
-		args: args{
-			request:     createRequest("/v1alpha1/devops/fake-devops/templates/fake-template", "fake-devops", "fake-template"),
-			initObjects: []runtime.Object{fakeTemplate},
-		},
-		wantCode: 200,
-		assertion: func(t *testing.T, recorder *httptest.ResponseRecorder) {
-			gotTemplate := &v1alpha1.Template{}
-			_ = json.Unmarshal(recorder.Body.Bytes(), gotTemplate)
-			assert.Equal(t, fakeTemplate.ObjectMeta, gotTemplate.ObjectMeta)
-			assert.Equal(t, fakeTemplate.Spec, gotTemplate.Spec)
-			assert.Equal(t, fakeTemplate.Status, gotTemplate.Status)
-		},
-	}}
-	for _, tt := range tests {
-		utilruntime.Must(v1alpha1.AddToScheme(scheme.Scheme))
-		utilruntime.Must(v1alpha3.AddToScheme(scheme.Scheme))
-		fakeClient := fake.NewFakeClientWithScheme(scheme.Scheme, tt.args.initObjects...)
-
-		t.Run(tt.name, func(t *testing.T) {
-			h := &handler{
-				genericClient: fakeClient,
-			}
-			recorder := httptest.NewRecorder()
-			response := restful.NewResponse(recorder)
-			response.SetRequestAccepts(restful.MIME_JSON)
-			h.handleGet(tt.args.request, response)
-
-			assert.Equal(t, tt.wantCode, recorder.Code)
-			if tt.assertion != nil {
-				tt.assertion(t, recorder)
-			}
-		})
-	}
-}
-
-func Test_handler_handleRender(t *testing.T) {
-	fakeTemplate := &v1alpha1.Template{
-		ObjectMeta: metav1.ObjectMeta{
-			Name:      "fake-template",
-			Namespace: "fake-devops",
-		},
-		Spec: v1alpha1.TemplateSpec{
-			Template: "fake template content",
-		},
-	}
-	createRequest := func(uri, devopsName, templateName string) *restful.Request {
-		fakeRequest := httptest.NewRequest(http.MethodGet, uri, nil)
-		request := restful.NewRequest(fakeRequest)
-		request.PathParameters()[kapisv1alpha1.DevopsPathParameter.Data().Name] = devopsName
-		request.PathParameters()[TemplatePathParameter.Data().Name] = templateName
-		return request
-	}
-	type args struct {
-		initObjects []runtime.Object
-		request     *restful.Request
-	}
-	tests := []struct {
-		name      string
-		args      args
-		wantCode  int
-		assertion func(*testing.T, *httptest.ResponseRecorder)
-	}{{
-		name: "Should return not found if template not found",
-		args: args{
-			request: createRequest("/v1alpha1/devops/fake-devops/templates/fake-template/render", "fake-devops", "fake-template"),
-		},
-		wantCode: 404,
-		assertion: func(t *testing.T, recorder *httptest.ResponseRecorder) {
-			assert.Equal(t, "templates.devops.kubesphere.io \"fake-template\" not found\n", recorder.Body.String())
-		},
-	}, {
-		name: "Should set render result into annotations properly if no parameters needed",
-		args: args{
-			request: createRequest("/v1alpha1/devops/fake-devops/templates/fake-template/render", "fake-devops", "fake-template"),
-			initObjects: []runtime.Object{
-				fakeTemplate,
-			},
-		},
-		wantCode: 200,
-		assertion: func(t *testing.T, recorder *httptest.ResponseRecorder) {
-			gotTemplate := &v1alpha1.Template{}
-			_ = json.Unmarshal(recorder.Body.Bytes(), gotTemplate)
-			renderResult := gotTemplate.GetAnnotations()[devops.GroupName+devops.RenderResultAnnoKey]
-			assert.Equal(t, fakeTemplate.Spec.Template, renderResult)
-		},
-	}}
-	for _, tt := range tests {
-		utilruntime.Must(v1alpha1.AddToScheme(scheme.Scheme))
-		utilruntime.Must(v1alpha3.AddToScheme(scheme.Scheme))
-		fakeClient := fake.NewFakeClientWithScheme(scheme.Scheme, tt.args.initObjects...)
-		t.Run(tt.name, func(t *testing.T) {
-			h := &handler{
-				genericClient: fakeClient,
-			}
-
-			recorder := httptest.NewRecorder()
-			response := restful.NewResponse(recorder)
-			response.SetRequestAccepts(restful.MIME_JSON)
-			h.handleRender(tt.args.request, response)
-
-			assert.Equal(t, tt.wantCode, recorder.Code)
-			if tt.assertion != nil {
-				tt.assertion(t, recorder)
-			}
-		})
-	}
-}
diff --git a/pkg/kapis/devops/v1alpha1/template/render_test.go b/pkg/kapis/devops/v1alpha1/template/render_test.go
new file mode 100644
index 0000000..98c7fc5
--- /dev/null
+++ b/pkg/kapis/devops/v1alpha1/template/render_test.go
@@ -0,0 +1,61 @@
+// Copyright 2022 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 template
+
+import (
+	"github.com/stretchr/testify/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"kubesphere.io/devops/pkg/api/devops"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
+	"testing"
+)
+
+func Test_render(t *testing.T) {
+	type args struct {
+		templateObject v1alpha1.TemplateObject
+	}
+	tests := []struct {
+		name   string
+		args   args
+		verify func(t *testing.T, object v1alpha1.TemplateObject)
+	}{{
+		name: "Should render template into annotations",
+		args: args{
+			templateObject: &v1alpha1.Template{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "fake-template",
+				},
+				Spec: v1alpha1.TemplateSpec{
+					Template: "fake-template-content",
+				},
+			},
+		},
+		verify: func(t *testing.T, object v1alpha1.TemplateObject) {
+			gotRenderResult := object.GetAnnotations()[devops.GroupName+devops.RenderResultAnnoKey]
+			wantRenderResult := object.TemplateSpec().Template
+			assert.Equal(t, wantRenderResult, gotRenderResult)
+		},
+	},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			templateObject := render(tt.args.templateObject)
+			if tt.verify != nil {
+				tt.verify(t, templateObject)
+			}
+		})
+	}
+}
diff --git a/pkg/kapis/devops/v1alpha1/template/template_handler_test.go b/pkg/kapis/devops/v1alpha1/template/template_handler_test.go
new file mode 100644
index 0000000..52480e4
--- /dev/null
+++ b/pkg/kapis/devops/v1alpha1/template/template_handler_test.go
@@ -0,0 +1,316 @@
+// Copyright 2022 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 template
+
+import (
+	"encoding/json"
+	"github.com/emicklei/go-restful"
+	"github.com/stretchr/testify/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	"k8s.io/client-go/kubernetes/scheme"
+	"kubesphere.io/devops/pkg/api"
+	"kubesphere.io/devops/pkg/api/devops"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha1"
+	"kubesphere.io/devops/pkg/api/devops/v1alpha3"
+	kapisv1alpha1 "kubesphere.io/devops/pkg/kapis/devops/v1alpha1/common"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"testing"
+)
+
+func Test_templatesToObjects(t *testing.T) {
+	createTemplate := func(name string) *v1alpha1.Template {
+		return &v1alpha1.Template{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      name,
+				Namespace: "fake-namespace",
+			},
+		}
+	}
+	type args struct {
+		templates []v1alpha1.Template
+	}
+	tests := []struct {
+		name string
+		args args
+		want []runtime.Object
+	}{{
+		name: "Should convert correctly",
+		args: args{
+			templates: []v1alpha1.Template{
+				*createTemplate("template1"),
+				*createTemplate("template2"),
+			},
+		},
+		want: []runtime.Object{
+			createTemplate("template1"),
+			createTemplate("template2"),
+		},
+	}, {
+		name: "Should return nil if templates argument is nil",
+		args: args{
+			templates: nil,
+		},
+		want: nil,
+	}, {
+		name: "Should return nil if templates argument is an empty slice",
+		args: args{
+			templates: []v1alpha1.Template{},
+		},
+		want: nil,
+	},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := templatesToObjects(tt.args.templates); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("templatesToObjects() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+func Test_handler_handleQueryTemplates(t *testing.T) {
+	createTemplate := func(name string) *v1alpha1.Template {
+		return &v1alpha1.Template{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      name,
+				Namespace: "fake-devops",
+			},
+		}
+	}
+	createRequest := func(uri, devopsName string) *restful.Request {
+		fakeRequest := httptest.NewRequest(http.MethodGet, uri, nil)
+		request := restful.NewRequest(fakeRequest)
+		request.PathParameters()[kapisv1alpha1.DevopsPathParameter.Data().Name] = devopsName
+		return request
+	}
+	type args struct {
+		initObjects []runtime.Object
+		request     *restful.Request
+	}
+	tests := []struct {
+		name         string
+		args         args
+		wantResponse interface{}
+	}{{
+		name: "Should return empty list if no templates found",
+		args: args{
+			request: createRequest("/v1alpha1/devops/fake-devops/templates", "fake-devops"),
+		},
+		wantResponse: api.ListResult{
+			Items: []interface{}{},
+		},
+	}, {
+		name: "Should return non-empty list if templates found",
+		args: args{
+			request: createRequest("/v1alpha1/devops/fake-devops/templates?sortBy=name&ascending=true", "fake-devops"),
+			initObjects: []runtime.Object{
+				createTemplate("template1"),
+				createTemplate("template2"),
+				createTemplate("template3"),
+			},
+		},
+		wantResponse: api.ListResult{
+			Items: []interface{}{
+				*createTemplate("template1"),
+				*createTemplate("template2"),
+				*createTemplate("template3"),
+			},
+			TotalItems: 3,
+		},
+	}, {
+		name: "Should return empty list if out of page",
+		args: args{
+			request: createRequest("/v1alpha1/devops/fake-devops/templates?sortBy=name&ascending=true&page=10", "fake-devops"),
+			initObjects: []runtime.Object{
+				createTemplate("template1"),
+				createTemplate("template2"),
+				createTemplate("template3"),
+			},
+		},
+		wantResponse: api.ListResult{
+			Items:      []interface{}{},
+			TotalItems: 3,
+		},
+	},
+	}
+	for _, tt := range tests {
+		utilruntime.Must(v1alpha1.AddToScheme(scheme.Scheme))
+		utilruntime.Must(v1alpha3.AddToScheme(scheme.Scheme))
+		fakeClient := fake.NewFakeClientWithScheme(scheme.Scheme, tt.args.initObjects...)
+
+		t.Run(tt.name, func(t *testing.T) {
+			h := &handler{
+				genericClient: fakeClient,
+			}
+			request := tt.args.request
+			recorder := httptest.NewRecorder()
+			response := restful.NewResponse(recorder)
+			response.SetRequestAccepts(restful.MIME_JSON)
+			h.handleQuery(request, response)
+
+			assert.Equal(t, 200, recorder.Code)
+			wantResponseBytes, err := json.Marshal(tt.wantResponse)
+			assert.Nil(t, err)
+			assert.JSONEq(t, string(wantResponseBytes), recorder.Body.String())
+		})
+	}
+}
+
+func Test_handler_handleGetTemplate(t *testing.T) {
+	fakeTemplate := &v1alpha1.Template{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "fake-template",
+			Namespace: "fake-devops",
+		},
+	}
+	createRequest := func(uri, devopsName, templateName string) *restful.Request {
+		fakeRequest := httptest.NewRequest(http.MethodGet, uri, nil)
+		request := restful.NewRequest(fakeRequest)
+		request.PathParameters()[kapisv1alpha1.DevopsPathParameter.Data().Name] = devopsName
+		request.PathParameters()[TemplatePathParameter.Data().Name] = templateName
+		return request
+	}
+	type args struct {
+		initObjects []runtime.Object
+		request     *restful.Request
+	}
+	tests := []struct {
+		name      string
+		args      args
+		wantCode  int
+		assertion func(*testing.T, *httptest.ResponseRecorder)
+	}{{
+		name: "Should return not found if template does not exist",
+		args: args{
+			request: createRequest("/v1alpha1/devops/fake-devops/templates/fake-template", "fake-devops", "fake-template"),
+		},
+		wantCode: 404,
+		assertion: func(t *testing.T, recorder *httptest.ResponseRecorder) {
+			assert.Equal(t, "templates.devops.kubesphere.io \"fake-template\" not found\n", recorder.Body.String())
+		},
+	}, {
+		name: "Should return template if template exists ",
+		args: args{
+			request:     createRequest("/v1alpha1/devops/fake-devops/templates/fake-template", "fake-devops", "fake-template"),
+			initObjects: []runtime.Object{fakeTemplate},
+		},
+		wantCode: 200,
+		assertion: func(t *testing.T, recorder *httptest.ResponseRecorder) {
+			gotTemplate := &v1alpha1.Template{}
+			_ = json.Unmarshal(recorder.Body.Bytes(), gotTemplate)
+			assert.Equal(t, fakeTemplate.ObjectMeta, gotTemplate.ObjectMeta)
+			assert.Equal(t, fakeTemplate.Spec, gotTemplate.Spec)
+			assert.Equal(t, fakeTemplate.Status, gotTemplate.Status)
+		},
+	}}
+	for _, tt := range tests {
+		utilruntime.Must(v1alpha1.AddToScheme(scheme.Scheme))
+		utilruntime.Must(v1alpha3.AddToScheme(scheme.Scheme))
+		fakeClient := fake.NewFakeClientWithScheme(scheme.Scheme, tt.args.initObjects...)
+
+		t.Run(tt.name, func(t *testing.T) {
+			h := &handler{
+				genericClient: fakeClient,
+			}
+			recorder := httptest.NewRecorder()
+			response := restful.NewResponse(recorder)
+			response.SetRequestAccepts(restful.MIME_JSON)
+			h.handleGetTemplate(tt.args.request, response)
+
+			assert.Equal(t, tt.wantCode, recorder.Code)
+			if tt.assertion != nil {
+				tt.assertion(t, recorder)
+			}
+		})
+	}
+}
+
+func Test_handler_handleRenderTemplate(t *testing.T) {
+	fakeTemplate := &v1alpha1.Template{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "fake-template",
+			Namespace: "fake-devops",
+		},
+		Spec: v1alpha1.TemplateSpec{
+			Template: "fake template content",
+		},
+	}
+	createRequest := func(uri, devopsName, templateName string) *restful.Request {
+		fakeRequest := httptest.NewRequest(http.MethodGet, uri, nil)
+		request := restful.NewRequest(fakeRequest)
+		request.PathParameters()[kapisv1alpha1.DevopsPathParameter.Data().Name] = devopsName
+		request.PathParameters()[TemplatePathParameter.Data().Name] = templateName
+		return request
+	}
+	type args struct {
+		initObjects []runtime.Object
+		request     *restful.Request
+	}
+	tests := []struct {
+		name      string
+		args      args
+		wantCode  int
+		assertion func(*testing.T, *httptest.ResponseRecorder)
+	}{{
+		name: "Should return not found if template not found",
+		args: args{
+			request: createRequest("/v1alpha1/devops/fake-devops/templates/fake-template/render", "fake-devops", "fake-template"),
+		},
+		wantCode: 404,
+		assertion: func(t *testing.T, recorder *httptest.ResponseRecorder) {
+			assert.Equal(t, "templates.devops.kubesphere.io \"fake-template\" not found\n", recorder.Body.String())
+		},
+	}, {
+		name: "Should set render result into annotations properly if no parameters needed",
+		args: args{
+			request: createRequest("/v1alpha1/devops/fake-devops/templates/fake-template/render", "fake-devops", "fake-template"),
+			initObjects: []runtime.Object{
+				fakeTemplate,
+			},
+		},
+		wantCode: 200,
+		assertion: func(t *testing.T, recorder *httptest.ResponseRecorder) {
+			gotTemplate := &v1alpha1.Template{}
+			_ = json.Unmarshal(recorder.Body.Bytes(), gotTemplate)
+			renderResult := gotTemplate.GetAnnotations()[devops.GroupName+devops.RenderResultAnnoKey]
+			assert.Equal(t, fakeTemplate.Spec.Template, renderResult)
+		},
+	}}
+	for _, tt := range tests {
+		utilruntime.Must(v1alpha1.AddToScheme(scheme.Scheme))
+		utilruntime.Must(v1alpha3.AddToScheme(scheme.Scheme))
+		fakeClient := fake.NewFakeClientWithScheme(scheme.Scheme, tt.args.initObjects...)
+		t.Run(tt.name, func(t *testing.T) {
+			h := &handler{
+				genericClient: fakeClient,
+			}
+
+			recorder := httptest.NewRecorder()
+			response := restful.NewResponse(recorder)
+			response.SetRequestAccepts(restful.MIME_JSON)
+			h.handleRenderTemplate(tt.args.request, response)
+
+			assert.Equal(t, tt.wantCode, recorder.Code)
+			if tt.assertion != nil {
+				tt.assertion(t, recorder)
+			}
+		})
+	}
+}
diff --git a/pkg/kapis/utils_test.go b/pkg/kapis/utils_test.go
index 10aa6a6..aa1bfa1 100644
--- a/pkg/kapis/utils_test.go
+++ b/pkg/kapis/utils_test.go
@@ -15,8 +15,11 @@
 package kapis
 
 import (
+	"github.com/emicklei/go-restful"
+	"github.com/stretchr/testify/assert"
 	"io"
 	"kubesphere.io/devops/pkg/server/errors"
+	"net/http/httptest"
 	"reflect"
 	"testing"
 )
@@ -50,3 +53,53 @@ func TestIgnoreEOF(t *testing.T) {
 		})
 	}
 }
+
+func TestResponseWriter_WriteEntityOrError(t *testing.T) {
+	type fakeType struct {
+		Name string `json:"name"`
+	}
+	type args struct {
+		entity interface{}
+		err    error
+	}
+	tests := []struct {
+		name         string
+		args         args
+		wantErr      bool
+		wantResponse string
+	}{{
+		name: "Should response correctly when no error",
+		args: args{
+			entity: fakeType{Name: "fake-name"},
+			err:    nil,
+		},
+		wantResponse: `{"name":"fake-name"}`,
+	}, {
+		name: "Should response error when err is not nil",
+		args: args{
+			entity: fakeType{Name: "fake-name"},
+			err:    errors.New("fake error occurred"),
+		},
+		wantErr:      true,
+		wantResponse: "fake error occurred\n",
+	},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			recorder := httptest.NewRecorder()
+			response := restful.NewResponse(recorder)
+			response.SetRequestAccepts(restful.MIME_JSON)
+			re := ResponseWriter{
+				response,
+			}
+			re.WriteEntityOrError(tt.args.entity, tt.args.err)
+			if tt.wantErr {
+				assert.False(t, recorder.Code >= 200 && recorder.Code < 300)
+				assert.Equal(t, tt.wantResponse, recorder.Body.String())
+				return
+			}
+			got := recorder.Body.String()
+			assert.JSONEq(t, got, tt.wantResponse)
+		})
+	}
+}