Repository: kurator-dev/kurator
Pull request #548 (2024-01-09 03:40:27)

pipeline: add render pipeline func
**What type of PR is this?**

/kind feature


**What this PR does / why we need it**:

part of #493 

**Which issue(s) this PR fixes**:
Fixes #

**Special notes for your reviewer**:

**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.
-->
```release-note

```




Patch:
diff --git a/docs/content/en/references/apps_v1alpha1_types.html b/docs/content/en/references/apps_v1alpha1_types.html
index 67cbae9..32edbf1 100644
--- a/docs/content/en/references/apps_v1alpha1_types.html
+++ b/docs/content/en/references/apps_v1alpha1_types.html
@@ -1830,7 +1830,7 @@ TrafficAnalysis
 <td>
 <code>rolloutTimeoutSeconds</code><br>
 <em>
-int
+int32
 </em>
 </td>
 <td>
diff --git a/docs/content/en/references/pipeline_v1alpha1_types.html b/docs/content/en/references/pipeline_v1alpha1_types.html
index 58054e8..fa300d1 100644
--- a/docs/content/en/references/pipeline_v1alpha1_types.html
+++ b/docs/content/en/references/pipeline_v1alpha1_types.html
@@ -204,7 +204,7 @@ Cannot be updated.</p>
 </tr>
 <tr>
 <td>
-<code>computeResources</code><br>
+<code>resourceRequirements</code><br>
 <em>
 <a href="https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.25/#resourcerequirements-v1-core">
 Kubernetes core/v1.ResourceRequirements
@@ -518,7 +518,7 @@ This affects how the volume can be mounted.
 </tr>
 <tr>
 <td>
-<code>requestsStorage</code><br>
+<code>storageRequest</code><br>
 <em>
 string
 </em>
diff --git a/manifests/charts/fleet-manager/crds/apps.kurator.dev_applications.yaml b/manifests/charts/fleet-manager/crds/apps.kurator.dev_applications.yaml
index 2c3b806..39a4963 100644
--- a/manifests/charts/fleet-manager/crds/apps.kurator.dev_applications.yaml
+++ b/manifests/charts/fleet-manager/crds/apps.kurator.dev_applications.yaml
@@ -1213,6 +1213,7 @@ spec:
                                 time in seconds for a preview deployment to make progress
                                 before it is considered to be failed. Defaults to
                                 600.
+                              format: int32
                               type: integer
                             skipTrafficAnalysis:
                               description: SkipTrafficAnalysis promotes the preview
diff --git a/manifests/charts/fleet-manager/crds/pipeline.kurator.dev_pipelines.yaml b/manifests/charts/fleet-manager/crds/pipeline.kurator.dev_pipelines.yaml
index fe14da7..b9378b5 100644
--- a/manifests/charts/fleet-manager/crds/pipeline.kurator.dev_pipelines.yaml
+++ b/manifests/charts/fleet-manager/crds/pipeline.kurator.dev_pipelines.yaml
@@ -58,17 +58,17 @@ spec:
                       exactly 1 pod, cannot be used in combination with other access
                       modes
                     type: string
-                  requestsStorage:
-                    description: StorageRequest defines the storage size required
-                      for this PVC, e.g., 1Gi, 100Mi. It specifies the storage capacity
-                      needed as part of ResourceRequirements.
-                    pattern: ^[0-9]+(\.[0-9]+)?(Gi|Mi)$
-                    type: string
                   storageClassName:
                     description: StorageClassName specifies the StorageClass name
                       to which this persistent volume belongs, e.g., manual. It allows
                       the PVC to use the characteristics defined by the StorageClass.
                     type: string
+                  storageRequest:
+                    description: StorageRequest defines the storage size required
+                      for this PVC, e.g., 1Gi, 100Mi. It specifies the storage capacity
+                      needed as part of ResourceRequirements.
+                    pattern: ^[0-9]+(\.[0-9]+)?(Gi|Mi)$
+                    type: string
                   volumeMode:
                     description: VolumeMode specifies whether the volume should be
                       used with a formatted filesystem (Filesystem) or remain in raw
@@ -107,57 +107,6 @@ spec:
                             type: string
                           type: array
                           x-kubernetes-list-type: atomic
-                        computeResources:
-                          description: 'ResourceRequirements required by this Step.
-                            Cannot be updated. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/'
-                          properties:
-                            claims:
-                              description: "Claims lists the names of resources, defined
-                                in spec.resourceClaims, that are used by this container.
-                                \n This is an alpha field and requires enabling the
-                                DynamicResourceAllocation feature gate. \n This field
-                                is immutable. It can only be set for containers."
-                              items:
-                                description: ResourceClaim references one entry in
-                                  PodSpec.ResourceClaims.
-                                properties:
-                                  name:
-                                    description: Name must match the name of one entry
-                                      in pod.spec.resourceClaims of the Pod where
-                                      this field is used. It makes that resource available
-                                      inside a container.
-                                    type: string
-                                required:
-                                - name
-                                type: object
-                              type: array
-                              x-kubernetes-list-map-keys:
-                              - name
-                              x-kubernetes-list-type: map
-                            limits:
-                              additionalProperties:
-                                anyOf:
-                                - type: integer
-                                - type: string
-                                pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
-                                x-kubernetes-int-or-string: true
-                              description: 'Limits describes the maximum amount of
-                                compute resources allowed. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/'
-                              type: object
-                            requests:
-                              additionalProperties:
-                                anyOf:
-                                - type: integer
-                                - type: string
-                                pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
-                                x-kubernetes-int-or-string: true
-                              description: 'Requests describes the minimum amount
-                                of compute resources required. If Requests is omitted
-                                for a container, it defaults to Limits if that is
-                                explicitly specified, otherwise to an implementation-defined
-                                value. Requests cannot exceed Limits. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/'
-                              type: object
-                          type: object
                         env:
                           description: List of environment variables to set in the
                             Step. Cannot be updated.
@@ -279,6 +228,57 @@ spec:
                           description: 'Image specifies the Docker image name. More
                             info: https://kubernetes.io/docs/concepts/containers/images'
                           type: string
+                        resourceRequirements:
+                          description: 'ResourceRequirements required by this Step.
+                            Cannot be updated. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/'
+                          properties:
+                            claims:
+                              description: "Claims lists the names of resources, defined
+                                in spec.resourceClaims, that are used by this container.
+                                \n This is an alpha field and requires enabling the
+                                DynamicResourceAllocation feature gate. \n This field
+                                is immutable. It can only be set for containers."
+                              items:
+                                description: ResourceClaim references one entry in
+                                  PodSpec.ResourceClaims.
+                                properties:
+                                  name:
+                                    description: Name must match the name of one entry
+                                      in pod.spec.resourceClaims of the Pod where
+                                      this field is used. It makes that resource available
+                                      inside a container.
+                                    type: string
+                                required:
+                                - name
+                                type: object
+                              type: array
+                              x-kubernetes-list-map-keys:
+                              - name
+                              x-kubernetes-list-type: map
+                            limits:
+                              additionalProperties:
+                                anyOf:
+                                - type: integer
+                                - type: string
+                                pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
+                                x-kubernetes-int-or-string: true
+                              description: 'Limits describes the maximum amount of
+                                compute resources allowed. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/'
+                              type: object
+                            requests:
+                              additionalProperties:
+                                anyOf:
+                                - type: integer
+                                - type: string
+                                pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
+                                x-kubernetes-int-or-string: true
+                              description: 'Requests describes the minimum amount
+                                of compute resources required. If Requests is omitted
+                                for a container, it defaults to Limits if that is
+                                explicitly specified, otherwise to an implementation-defined
+                                value. Requests cannot exceed Limits. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/'
+                              type: object
+                          type: object
                         script:
                           description: Script is the contents of an executable file
                             to execute. If Script is not empty, the CustomTask cannot
diff --git a/manifests/charts/fleet-manager/templates/rbac.yaml b/manifests/charts/fleet-manager/templates/rbac.yaml
index 50abbf8..3636921 100644
--- a/manifests/charts/fleet-manager/templates/rbac.yaml
+++ b/manifests/charts/fleet-manager/templates/rbac.yaml
@@ -162,6 +162,7 @@ rules:
       - create
       - delete
       - list
+      - update
   - apiGroups:
       - "source.toolkit.fluxcd.io"
     resources:
diff --git a/pkg/apis/apps/v1alpha1/types.go b/pkg/apis/apps/v1alpha1/types.go
index 8516a6d..f896e01 100644
--- a/pkg/apis/apps/v1alpha1/types.go
+++ b/pkg/apis/apps/v1alpha1/types.go
@@ -156,7 +156,7 @@ type RolloutPolicy struct {
 	// preview deployment to make progress before it is considered to be failed.
 	// Defaults to 600.
 	// +optional
-	RolloutTimeoutSeconds *int `json:"rolloutTimeoutSeconds,omitempty"`
+	RolloutTimeoutSeconds *int32 `json:"rolloutTimeoutSeconds,omitempty"`
 
 	// SkipTrafficAnalysis promotes the preview release without analyzing it.
 	// +optional
diff --git a/pkg/apis/apps/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/apps/v1alpha1/zz_generated.deepcopy.go
index 2a61284..5e16a7a 100644
--- a/pkg/apis/apps/v1alpha1/zz_generated.deepcopy.go
+++ b/pkg/apis/apps/v1alpha1/zz_generated.deepcopy.go
@@ -738,7 +738,7 @@ func (in *RolloutPolicy) DeepCopyInto(out *RolloutPolicy) {
 	}
 	if in.RolloutTimeoutSeconds != nil {
 		in, out := &in.RolloutTimeoutSeconds, &out.RolloutTimeoutSeconds
-		*out = new(int)
+		*out = new(int32)
 		**out = **in
 	}
 	return
diff --git a/pkg/apis/pipeline/v1alpha1/type.go b/pkg/apis/pipeline/v1alpha1/type.go
index a487be7..e234b98 100644
--- a/pkg/apis/pipeline/v1alpha1/type.go
+++ b/pkg/apis/pipeline/v1alpha1/type.go
@@ -68,7 +68,7 @@ type VolumeClaimTemplate struct {
 	// StorageRequest defines the storage size required for this PVC, e.g., 1Gi, 100Mi.
 	// It specifies the storage capacity needed as part of ResourceRequirements.
 	// +kubebuilder:validation:Pattern="^[0-9]+(\\.[0-9]+)?(Gi|Mi)$"
-	StorageRequest string `json:"requestsStorage,omitempty"`
+	StorageRequest string `json:"storageRequest,omitempty"`
 
 	// StorageClassName specifies the StorageClass name to which this persistent volume belongs, e.g., manual.
 	// It allows the PVC to use the characteristics defined by the StorageClass.
@@ -214,7 +214,7 @@ type CustomTask struct {
 	// Cannot be updated.
 	// More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
 	// +optional
-	ResourceRequirements corev1.ResourceRequirements `json:"computeResources,omitempty"`
+	ResourceRequirements corev1.ResourceRequirements `json:"resourceRequirements,omitempty"`
 
 	// Script is the contents of an executable file to execute.
 	// If Script is not empty, the CustomTask cannot have a Command and the Args will be passed to the Script.
diff --git a/pkg/fleet-manager/application/helper.go b/pkg/fleet-manager/application/helper.go
index 41574cb..a2d427b 100644
--- a/pkg/fleet-manager/application/helper.go
+++ b/pkg/fleet-manager/application/helper.go
@@ -41,8 +41,9 @@ import (
 
 // syncPolicyResource synchronizes the sync policy resources for a given application.
 func (a *ApplicationManager) syncPolicyResource(ctx context.Context, app *applicationapi.Application, fleet *fleetapi.Fleet, syncPolicy *applicationapi.ApplicationSyncPolicy, policyName string) (ctrl.Result, error) {
-	policyKind := getSyncPolicyKind(syncPolicy)
+	log := ctrl.LoggerFrom(ctx)
 
+	policyKind := getSyncPolicyKind(syncPolicy)
 	destination := getPolicyDestination(app, syncPolicy)
 
 	// fetch fleet cluster list that recorded in fleet and matches the destination's cluster selector
@@ -60,6 +61,19 @@ func (a *ApplicationManager) syncPolicyResource(ctx context.Context, app *applic
 		}
 	}
 
+	if syncPolicy.Rollout != nil {
+		// after finish application install, start handling rollout policy
+		rolloutClusters, err := a.fetchRolloutClusters(ctx, app, a.Client, fleet, syncPolicy)
+		if err != nil {
+			log.Error(err, "failed to fetch destination clusters for rollout")
+			return ctrl.Result{}, err
+		}
+
+		if result, err := a.syncRolloutPolicyForCluster(ctx, syncPolicy.Rollout, rolloutClusters, policyName); err != nil {
+			return result, errors.Wrapf(err, "failed to syncRolloutPolicy")
+		}
+	}
+
 	return ctrl.Result{}, nil
 }
 
diff --git a/pkg/fleet-manager/application/rollout_helper.go b/pkg/fleet-manager/application/rollout_helper.go
new file mode 100644
index 0000000..c66d8a2
--- /dev/null
+++ b/pkg/fleet-manager/application/rollout_helper.go
@@ -0,0 +1,378 @@
+/*
+Copyright Kurator 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 application
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	flaggerv1b1 "github.com/fluxcd/flagger/pkg/apis/flagger/v1beta1"
+	"github.com/pkg/errors"
+	appsv1 "k8s.io/api/apps/v1"
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	applicationapi "kurator.dev/kurator/pkg/apis/apps/v1alpha1"
+	fleetapi "kurator.dev/kurator/pkg/apis/fleet/v1alpha1"
+	fleetmanager "kurator.dev/kurator/pkg/fleet-manager"
+	render "kurator.dev/kurator/pkg/fleet-manager/application/manifests"
+	plugin "kurator.dev/kurator/pkg/fleet-manager/plugin"
+	"kurator.dev/kurator/pkg/infra/util"
+)
+
+const (
+	// kurator rollout labels
+	RolloutIdentifier = "kurator.dev/rollout"
+	sidecarInject     = "istio-injection"
+
+	// StatusSyncInterval specifies the interval for requeueing when synchronizing status. It determines how frequently the status should be checked and updated.
+	StatusSyncInterval = 30 * time.Second
+)
+
+func (a *ApplicationManager) fetchRolloutClusters(ctx context.Context,
+	app *applicationapi.Application,
+	kubeClient client.Client,
+	fleet *fleetapi.Fleet,
+	syncPolicy *applicationapi.ApplicationSyncPolicy,
+) (map[fleetmanager.ClusterKey]*fleetmanager.FleetCluster, error) {
+	log := ctrl.LoggerFrom(ctx)
+	destination := getPolicyDestination(app, syncPolicy)
+	ClusterInterfaceList, result, err := a.fetchFleetClusterList(ctx, fleet, destination.ClusterSelector)
+	if err != nil || result.RequeueAfter > 0 {
+		return nil, err
+	}
+
+	fleetclusters := make(map[fleetmanager.ClusterKey]*fleetmanager.FleetCluster, len(ClusterInterfaceList))
+	for _, cluster := range ClusterInterfaceList {
+		kclient, err := fleetmanager.ClientForCluster(kubeClient, fleet.Namespace, cluster)
+		if err != nil {
+			return nil, err
+		}
+
+		kind := cluster.GetObject().GetObjectKind().GroupVersionKind().Kind
+		fleetclusters[fleetmanager.ClusterKey{Kind: kind, Name: cluster.GetObject().GetName()}] = &fleetmanager.FleetCluster{
+			Secret:    cluster.GetSecretName(),
+			SecretKey: cluster.GetSecretKey(),
+			Client:    kclient,
+		}
+	}
+	log.Info("Successful to fetch destination clusters for Rollout")
+	return fleetclusters, nil
+}
+
+func (a *ApplicationManager) syncRolloutPolicyForCluster(ctx context.Context,
+	rolloutPolicy *applicationapi.RolloutConfig,
+	destinationClusters map[fleetmanager.ClusterKey]*fleetmanager.FleetCluster,
+	policyName string,
+) (ctrl.Result, error) {
+	log := ctrl.LoggerFrom(ctx)
+
+	serviceNamespaceName := types.NamespacedName{
+		Namespace: rolloutPolicy.Workload.Namespace,
+		Name:      rolloutPolicy.ServiceName,
+	}
+
+	testloaderNamespaceName := types.NamespacedName{
+		Namespace: rolloutPolicy.Workload.Namespace,
+		Name:      rolloutPolicy.Workload.Name + "-testloader",
+	}
+
+	annotation := map[string]string{
+		RolloutIdentifier: policyName,
+	}
+
+	for clusterKey, fleetCluster := range destinationClusters {
+		fleetClusterClient := fleetCluster.Client.CtrlRuntimeClient()
+
+		// If the trafficRoutingProvider is Istio, add the sidecar injection label to the workload's namespace.
+		if rolloutPolicy.TrafficRoutingProvider == "istio" {
+			err := enableIstioSidecarInjection(ctx, fleetClusterClient, rolloutPolicy.Workload.Namespace)
+			if err != nil {
+				return ctrl.Result{}, errors.Wrapf(err, "failed to set namespace %s istio-injection enable", rolloutPolicy.Workload.Namespace)
+			}
+		}
+
+		// if delete private testloader when rollout polity has changed
+		if rolloutPolicy.TestLoader == nil || !*rolloutPolicy.TestLoader {
+			testloaderDeploy := &appsv1.Deployment{}
+			if err := deleteResourceCreatedByKurator(ctx, testloaderNamespaceName, fleetClusterClient, testloaderDeploy); err != nil {
+				return ctrl.Result{}, err
+			}
+			testloaderSvc := &corev1.Service{}
+			if err := deleteResourceCreatedByKurator(ctx, testloaderNamespaceName, fleetClusterClient, testloaderSvc); err != nil {
+				return ctrl.Result{}, err
+			}
+		} else {
+			// Installation of private testloader if needed
+			if err := installPrivateTestloader(ctx, testloaderNamespaceName, RolloutIdentifier, policyName); err != nil {
+				return ctrl.Result{}, fmt.Errorf("failed to install private testloader for workload: %w", err)
+			}
+		}
+
+		// Get the configuration of the workload's service and generate a canaryService.
+		service := &corev1.Service{}
+		if err := fleetClusterClient.Get(ctx, serviceNamespaceName, service); err != nil {
+			if apierrors.IsNotFound(err) {
+				return ctrl.Result{RequeueAfter: StatusSyncInterval}, errors.Wrapf(err, "not found service %s in %s", rolloutPolicy.ServiceName, rolloutPolicy.Workload.Namespace)
+			}
+			return ctrl.Result{}, errors.Wrapf(err, "failed to get service %s in %s", rolloutPolicy.ServiceName, rolloutPolicy.Workload.Namespace)
+		}
+
+		canaryInCluster := &flaggerv1b1.Canary{}
+		getErr := fleetClusterClient.Get(ctx, serviceNamespaceName, canaryInCluster)
+		if getErr != nil && !apierrors.IsNotFound(getErr) {
+			return ctrl.Result{}, errors.Wrapf(getErr, "failed to get canary %s in %s", serviceNamespaceName.Name, serviceNamespaceName.Namespace)
+		}
+
+		canaryInCluster = renderCanary(*rolloutPolicy, canaryInCluster)
+		if canaryService, err := renderCanaryService(*rolloutPolicy, service); err != nil {
+			return ctrl.Result{}, errors.Wrapf(err, "failed rander canary configuration")
+		} else {
+			canaryInCluster.Spec.Service = *canaryService
+		}
+		canaryInCluster.Spec.Analysis = renderCanaryAnalysis(*rolloutPolicy, clusterKey.Name)
+		// Set up annotations to make sure it's a resource created by kurator
+		canaryInCluster.SetAnnotations(annotation)
+
+		if apierrors.IsNotFound(getErr) {
+			if err := fleetClusterClient.Create(ctx, canaryInCluster); err != nil {
+				return ctrl.Result{}, fmt.Errorf("failed to create rolloutPolicy: %v", err)
+			}
+		} else {
+			if err := fleetClusterClient.Update(ctx, canaryInCluster); err != nil {
+				return ctrl.Result{}, fmt.Errorf("failed to update rolloutPolicy: %v", err)
+			}
+		}
+
+		log.Info("sync rolloutPolicy successful")
+	}
+	return ctrl.Result{}, nil
+}
+
+func enableIstioSidecarInjection(ctx context.Context, kubeClient client.Client, namespace string) error {
+	log := ctrl.LoggerFrom(ctx)
+
+	ns := &corev1.Namespace{}
+	namespacedName := types.NamespacedName{
+		Namespace: namespace,
+		Name:      namespace,
+	}
+	if err := kubeClient.Get(ctx, namespacedName, ns); err != nil {
+		// if no found, create a namespace
+		if apierrors.IsNotFound(err) {
+			ns.SetName(namespace)
+			ns.SetLabels(map[string]string{
+				sidecarInject: "enabled",
+			})
+			if createErr := kubeClient.Create(ctx, ns); createErr != nil {
+				return errors.Wrapf(createErr, "failed to create namespace %s", namespacedName.Namespace)
+			}
+		}
+		ns := addLabels(ns, sidecarInject, "enabled")
+		if updateErr := kubeClient.Update(ctx, ns); updateErr != nil {
+			return errors.Wrapf(updateErr, "failed to update namespace %s", namespacedName.Namespace)
+		}
+	}
+	log.Info("Inject sidecar successful")
+	return nil
+}
+
+func installPrivateTestloader(ctx context.Context, namespacedName types.NamespacedName, annotationKey, annotationValue string) error {
+	log := ctrl.LoggerFrom(ctx)
+	// apply testloader deployment resource
+	testloaderDeploy, deployErr := render.RenderTestloaderConfig(render.TestlaoderDeployment, namespacedName, annotationKey, annotationValue)
+	if deployErr != nil {
+		return deployErr
+	}
+	if _, err := util.PatchResources(testloaderDeploy); err != nil {
+		return err
+	}
+
+	// apply testloader service resource
+	testloaderSvc, svcErr := render.RenderTestloaderConfig(render.TestlaoderService, namespacedName, annotationKey, annotationValue)
+	if svcErr != nil {
+		return svcErr
+	}
+	if _, err := util.PatchResources(testloaderSvc); err != nil {
+		return err
+	}
+
+	log.Info("install testloader successful")
+	return nil
+}
+
+func deleteResourceCreatedByKurator(ctx context.Context, namespaceName types.NamespacedName, kubeClient client.Client, obj client.Object) error {
+	if err := kubeClient.Get(ctx, namespaceName, obj); err != nil {
+		if !apierrors.IsNotFound(err) {
+			return errors.Wrapf(err, "falied to get resource %s in %s", namespaceName.Name, namespaceName.Namespace)
+		}
+	} else {
+		// verify if the deployment were created by kurator
+		annotations := obj.GetAnnotations()
+		if _, exist := annotations[RolloutIdentifier]; exist {
+			if deleteErr := kubeClient.Delete(ctx, obj); deleteErr != nil && !apierrors.IsNotFound(deleteErr) {
+				return errors.Wrapf(deleteErr, "failed to delete kubernetes resource")
+			}
+		}
+	}
+	return nil
+}
+
+// create/update canary configuration
+func renderCanary(rolloutPolicy applicationapi.RolloutConfig, canaryInCluster *flaggerv1b1.Canary) *flaggerv1b1.Canary {
+	canaryInCluster.ObjectMeta.Namespace = rolloutPolicy.Workload.Namespace
+	canaryInCluster.ObjectMeta.Name = rolloutPolicy.Workload.Name
+	canaryInCluster.TypeMeta.Kind = "Canary"
+	canaryInCluster.TypeMeta.APIVersion = "flagger.app/v1beta1"
+	canaryInCluster.Spec = flaggerv1b1.CanarySpec{
+		Provider: rolloutPolicy.TrafficRoutingProvider,
+		TargetRef: flaggerv1b1.LocalObjectReference{
+			APIVersion: rolloutPolicy.Workload.APIVersion,
+			Kind:       rolloutPolicy.Workload.Kind,
+			Name:       rolloutPolicy.Workload.Name,
+		},
+		ProgressDeadlineSeconds: rolloutPolicy.RolloutPolicy.RolloutTimeoutSeconds,
+		SkipAnalysis:            rolloutPolicy.RolloutPolicy.SkipTrafficAnalysis,
+		RevertOnDeletion:        rolloutPolicy.RolloutPolicy.RevertOnDeletion,
+		Suspend:                 rolloutPolicy.RolloutPolicy.Suspend,
+	}
+
+	return canaryInCluster
+}
+
+func renderCanaryService(rolloutPolicy applicationapi.RolloutConfig, service *corev1.Service) (*flaggerv1b1.CanaryService, error) {
+	if service == nil {
+		return nil, fmt.Errorf("service is nil, build canaryService configuration failed")
+	}
+	ports := service.Spec.Ports
+	canaryService := &flaggerv1b1.CanaryService{
+		Name:       rolloutPolicy.ServiceName,
+		Port:       rolloutPolicy.Port,
+		Gateways:   rolloutPolicy.RolloutPolicy.TrafficRouting.Gateways,
+		Hosts:      rolloutPolicy.RolloutPolicy.TrafficRouting.Hosts,
+		Retries:    rolloutPolicy.RolloutPolicy.TrafficRouting.Retries,
+		Headers:    rolloutPolicy.RolloutPolicy.TrafficRouting.Headers,
+		CorsPolicy: rolloutPolicy.RolloutPolicy.TrafficRouting.CorsPolicy,
+		Primary:    (*flaggerv1b1.CustomMetadata)(rolloutPolicy.Primary),
+		Canary:     (*flaggerv1b1.CustomMetadata)(rolloutPolicy.Preview),
+	}
+
+	Timeout := fmt.Sprintf("%d", rolloutPolicy.RolloutPolicy.TrafficRouting.TimeoutSeconds) + "s"
+	canaryService.Timeout = Timeout
+
+	for _, port := range ports {
+		if port.Port == rolloutPolicy.Port {
+			canaryService.TargetPort = port.TargetPort
+			break
+		}
+	}
+
+	return canaryService, nil
+}
+
+func renderCanaryAnalysis(rolloutPolicy applicationapi.RolloutConfig, clusterName string) *flaggerv1b1.CanaryAnalysis {
+	canaryAnalysis := flaggerv1b1.CanaryAnalysis{
+		Iterations:      rolloutPolicy.RolloutPolicy.TrafficRouting.AnalysisTimes,
+		Threshold:       *rolloutPolicy.RolloutPolicy.TrafficAnalysis.CheckFailedTimes,
+		Match:           rolloutPolicy.RolloutPolicy.TrafficRouting.Match,
+		SessionAffinity: (*flaggerv1b1.SessionAffinity)(rolloutPolicy.RolloutPolicy.TrafficAnalysis.SessionAffinity),
+	}
+
+	if rolloutPolicy.RolloutPolicy.TrafficRouting.CanaryStrategy != nil {
+		canaryAnalysis.MaxWeight = rolloutPolicy.RolloutPolicy.TrafficRouting.CanaryStrategy.MaxWeight
+		canaryAnalysis.StepWeight = rolloutPolicy.RolloutPolicy.TrafficRouting.CanaryStrategy.StepWeight
+		canaryAnalysis.StepWeights = rolloutPolicy.RolloutPolicy.TrafficRouting.CanaryStrategy.StepWeights
+		canaryAnalysis.StepWeightPromotion = rolloutPolicy.RolloutPolicy.TrafficRouting.CanaryStrategy.StepWeightPromotion
+	}
+
+	CheckInterval := fmt.Sprintf("%d", *rolloutPolicy.RolloutPolicy.TrafficAnalysis.CheckIntervalSeconds) + "s"
+	canaryAnalysis.Interval = CheckInterval
+
+	canaryMetric := []flaggerv1b1.CanaryMetric{}
+	for _, metric := range rolloutPolicy.RolloutPolicy.TrafficAnalysis.Metrics {
+		metricInterval := fmt.Sprintf("%d", *metric.IntervalSeconds) + "s"
+		templateMetric := flaggerv1b1.CanaryMetric{
+			Name:           string(metric.Name),
+			Interval:       metricInterval,
+			ThresholdRange: (*flaggerv1b1.CanaryThresholdRange)(metric.ThresholdRange),
+		}
+		canaryMetric = append(canaryMetric, templateMetric)
+	}
+	canaryAnalysis.Metrics = canaryMetric
+
+	// Trigger testloader to request service before analysis by webhook.
+	webhookTemplate := flaggerv1b1.CanaryWebhook{
+		Name:    "generated-testload",
+		Timeout: "60s",
+	}
+
+	if len(rolloutPolicy.RolloutPolicy.TrafficAnalysis.Webhooks.Commands) != 0 {
+		var url string
+		// if have private webhook, webhook url is private testloader url
+		// else is public testloader url
+		if rolloutPolicy.TestLoader != nil && *rolloutPolicy.TestLoader {
+			name := rolloutPolicy.ServiceName + "-testloader"
+			namespace := rolloutPolicy.Workload.Namespace
+			url = generateWebhookUrl(name, namespace)
+		} else if namespace, exist := plugin.ProviderNamespace[fleetapi.Provider(rolloutPolicy.TrafficRoutingProvider)]; exist {
+			name := namespace + "-testloader-" + clusterName + "-loadtester"
+			url = generateWebhookUrl(name, namespace)
+		}
+		webhookTemplate.URL = url
+
+		timeout := fmt.Sprintf("%d", *rolloutPolicy.RolloutPolicy.TrafficAnalysis.Webhooks.TimeoutSeconds) + "s"
+		webhookTemplate.Timeout = timeout
+
+		canaryWebhook := []flaggerv1b1.CanaryWebhook{}
+		bakName := webhookTemplate.Name
+		for index, command := range rolloutPolicy.RolloutPolicy.TrafficAnalysis.Webhooks.Commands {
+			metadata := map[string]string{
+				"type": "cmd",
+				"cmd":  command,
+			}
+			webhookTemplate.Metadata = &metadata
+			webhookTemplate.Name = bakName + "-" + fmt.Sprintf("%d", index)
+			canaryWebhook = append(canaryWebhook, webhookTemplate)
+		}
+
+		canaryAnalysis.Webhooks = canaryWebhook
+	}
+	return &canaryAnalysis
+}
+
+func generateWebhookUrl(name, namespace string) string {
+	url := "http://" + name + "." + namespace + "/"
+	return url
+}
+
+func addLabels(obj client.Object, key, value string) client.Object {
+	labels := obj.GetLabels()
+	// prevent nil pointer panic
+	if labels == nil {
+		obj.SetLabels(map[string]string{
+			key: value,
+		})
+		return obj
+	}
+	labels[key] = value
+	obj.SetLabels(labels)
+	return obj
+}
diff --git a/pkg/fleet-manager/pipeline/render/pipeline.go b/pkg/fleet-manager/pipeline/render/pipeline.go
new file mode 100644
index 0000000..46a466b
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/pipeline.go
@@ -0,0 +1,206 @@
+/*
+Copyright Kurator 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 render
+
+import (
+	"fmt"
+	"strings"
+	"text/template"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	pipelineapi "kurator.dev/kurator/pkg/apis/pipeline/v1alpha1"
+)
+
+const (
+	PipelineTemplateName       = "pipeline-template"
+	DockerCredentialsName      = "dockerconfig"
+	DockerCredentialsWorkspace = "docker-credentials"
+)
+
+// PipelineConfig defines the configuration needed to render a pipeline.
+type PipelineConfig struct {
+	PipelineName      string
+	PipelineNamespace string
+
+	// TasksInfo contains the necessary information to integrate tasks into the pipeline.
+	TasksInfo      string
+	OwnerReference *metav1.OwnerReference
+
+	// DockerCredentials is the name of Docker credentials secret for image build tasks.
+	DockerCredentials string
+}
+
+// RenderPipelineWithPipeline renders the pipeline configuration as a YAML byte array.
+// It uses the specified pipeline to gather necessary information.
+func RenderPipelineWithPipeline(pipeline *pipelineapi.Pipeline) ([]byte, error) {
+	dockerCredentials, tasksInfo, err := generateTasksInfo(pipeline.Name, pipeline.Spec.Tasks)
+	if err != nil {
+		return nil, err
+	}
+
+	cfg := PipelineConfig{
+		PipelineName:      pipeline.Name,
+		PipelineNamespace: pipeline.Namespace,
+		TasksInfo:         tasksInfo,
+		OwnerReference:    GeneratePipelineOwnerRef(pipeline),
+		DockerCredentials: dockerCredentials,
+	}
+
+	return renderPipeline(cfg)
+}
+
+// renderPipeline creates a YAML representation of the pipeline configuration.
+func renderPipeline(cfg PipelineConfig) ([]byte, error) {
+	return renderTemplate(PipelineTemplateContent, PipelineTemplateName, cfg)
+}
+
+type TaskInfo struct {
+	Name       string
+	TaskRef    string
+	RunAfter   string
+	Workspaces []Workspace
+	Retries    int
+}
+
+type Workspace struct {
+	Name      string
+	Workspace string
+}
+
+func generateTasksInfo(pipelineName string, tasks []pipelineapi.PipelineTask) (dockerCredentialsWorkspace string, tasksInfo string, err error) {
+	var tasksInfoBuilder strings.Builder
+	tmpl, err := template.New("task").Parse(taskTemplate)
+	if err != nil {
+		return "", "", err
+	}
+
+	lastTask := string(pipelineapi.GitClone) // GitCloneTask is always the first task.
+	for _, task := range tasks {
+		if task.Name == string(pipelineapi.GitClone) {
+			continue // Skip the first git-clone task because it is already fixed in template.
+		}
+
+		// Validate task
+		if (task.CustomTask == nil && task.PredefinedTask == nil) || (task.CustomTask != nil && task.PredefinedTask != nil) {
+			return "", "", fmt.Errorf("only one of 'PredefinedTask' or 'CustomTask' must be set in 'PipelineTask'")
+		}
+
+		taskInfo := TaskInfo{
+			Name:     task.Name,
+			TaskRef:  generatePipelineTaskName(task.Name, pipelineName),
+			RunAfter: lastTask,
+			Retries:  task.Retries,
+			Workspaces: []Workspace{
+				{Name: "source", Workspace: "kurator-pipeline-shared-data"},
+			},
+		}
+
+		// Handle special cases
+		if task.Name == string(pipelineapi.BuildPushImage) {
+			taskInfo.Workspaces = append(taskInfo.Workspaces, Workspace{Name: DockerCredentialsName, Workspace: DockerCredentialsWorkspace})
+			dockerCredentialsWorkspace = DockerCredentialsWorkspace
+		}
+
+		// Render task info using template
+		if err := tmpl.Execute(&tasksInfoBuilder, taskInfo); err != nil {
+			return "", "", err
+		}
+
+		lastTask = task.Name // Update the last task.
+	}
+
+	return dockerCredentialsWorkspace, tasksInfoBuilder.String(), nil
+}
+
+func generatePipelineTaskName(taskName, pipelineName string) string {
+	return taskName + "-" + pipelineName
+}
+
+const taskTemplate = `  - name: {{.Name}}
+    taskRef:
+      name: {{.TaskRef}}
+    runAfter: ["{{.RunAfter}}"]
+    workspaces:
+    {{- range .Workspaces}}
+    - name: {{.Name}}
+      workspace: {{.Workspace}}
+    {{- end}}
+    {{- if gt .Retries 0}}
+    retries: {{.Retries}}
+    {{- end}}
+`
+
+const PipelineTemplateContent = `apiVersion: tekton.dev/v1beta1
+kind: Pipeline
+metadata:
+  name: {{ .PipelineName}}
+  namespace: {{ .PipelineNamespace }}
+{{- if .OwnerReference }}
+  ownerReferences:
+  - apiVersion: "{{ .OwnerReference.APIVersion }}"
+    kind: "{{ .OwnerReference.Kind }}"
+    name: "{{ .OwnerReference.Name }}"
+    uid: "{{ .OwnerReference.UID }}"
+{{- end }}
+spec:
+  description: |
+    This is a universal pipeline with the following settings: 
+      1. No parameters are passed because all user parameters have already been rendered into the corresponding tasks. 
+      2. All tasks are strictly executed in the order defined by the user, with each task starting only after the previous one is completed. 
+      3. There is only one workspace, which is used by all tasks. The PVC for this workspace will be configured in the trigger.
+  params:
+  - name: repo-url
+    type: string
+    description: The git repository URL to clone from.
+  - name: revision
+    type: string
+    description: The git branch to clone.
+  workspaces:
+  - name: kurator-pipeline-shared-data
+    description: |
+      This workspace is used by all tasks
+  - name: git-credentials
+    description: |
+      A Workspace containing a .gitconfig and .git-credentials file. These
+      will be copied to the user's home before any git commands are run. Any
+      other files in this Workspace are ignored.
+{{- if .DockerCredentials }}
+  - name: docker-credentials
+    description: |
+      This is the credentials for build and push image task.
+{{- end }}
+  tasks:
+  - name: git-clone
+    # Key points about 'git-clone':
+    # - Fundamental for all tasks.
+    # - Closely integrated with the trigger.
+    # - Always the first task in the pipeline.
+    # - Cannot be modified via templates.
+    taskRef:
+      name: git-clone-{{ .PipelineName }}
+    workspaces:
+    - name: source
+      workspace: kurator-pipeline-shared-data
+    - name: basic-auth
+      workspace: git-credentials
+    params:
+    - name: url
+      value: $(params.repo-url)
+    - name: revision
+      value: $(params.revision)
+{{ .TasksInfo }}`
diff --git a/pkg/fleet-manager/pipeline/render/predefined_task.go b/pkg/fleet-manager/pipeline/render/predefined_task.go
new file mode 100644
index 0000000..9cedf00
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/predefined_task.go
@@ -0,0 +1,74 @@
+/*
+Copyright Kurator 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 render
+
+import (
+	"fmt"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	pipelineapi "kurator.dev/kurator/pkg/apis/pipeline/v1alpha1"
+)
+
+type PredefinedTaskConfig struct {
+	PipelineName string
+	Namespace    string
+	// TemplateName is set by user in `Pipeline.Tasks[i].PredefinedTask.Name`
+	TemplateName string
+	// Params is set by user in `Pipeline.Tasks[i].PredefinedTask.Params`
+	Params         map[string]string
+	OwnerReference *metav1.OwnerReference
+}
+
+// PredefinedTaskName is the name of Predefined task object, in case different pipeline have the same name task.
+func (cfg PredefinedTaskConfig) PredefinedTaskName() string {
+	return cfg.TemplateName + "-" + cfg.PipelineName
+}
+
+// RenderPredefinedTaskWithPipeline takes a Pipeline object and generates YAML byte array configuration representing the PredefinedTask configuration.
+func RenderPredefinedTaskWithPipeline(pipeline *pipelineapi.Pipeline, task *pipelineapi.PredefinedTask) ([]byte, error) {
+	cfg := PredefinedTaskConfig{
+		PipelineName:   pipeline.Name,
+		Namespace:      pipeline.Namespace,
+		TemplateName:   string(task.Name),
+		Params:         task.Params,
+		OwnerReference: GeneratePipelineOwnerRef(pipeline),
+	}
+
+	return RenderPredefinedTask(cfg)
+}
+
+// RenderPredefinedTask takes a PredefinedTaskConfig object and generates YAML byte array configuration representing the PredefinedTask configuration.
+func RenderPredefinedTask(cfg PredefinedTaskConfig) ([]byte, error) {
+	templateContent, ok := predefinedTaskTemplates[cfg.TemplateName]
+	if !ok {
+		return nil, fmt.Errorf("predefinedTask template content named '%s' not found", cfg.TemplateName)
+	}
+
+	return renderTemplate(templateContent, generateTaskTemplateName(cfg.TemplateName), cfg)
+}
+
+func generateTaskTemplateName(taskType string) string {
+	return "pipeline-" + taskType + "-task template"
+}
+
+var predefinedTaskTemplates = map[string]string{
+	string(pipelineapi.GitClone):       GitCloneTaskContent,
+	string(pipelineapi.GoTest):         GoTestTaskContent,
+	string(pipelineapi.GoLint):         GoLintTaskContent,
+	string(pipelineapi.BuildPushImage): BuildPushImageContent,
+}
diff --git a/pkg/fleet-manager/pipeline/render/predefined_task_content.go b/pkg/fleet-manager/pipeline/render/predefined_task_content.go
new file mode 100644
index 0000000..d91ecf6
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/predefined_task_content.go
@@ -0,0 +1,499 @@
+/*
+Copyright Kurator 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 render
+
+const GitCloneTaskContent = `apiVersion: tekton.dev/v1beta1
+kind: Task
+metadata:
+  name: {{ .PredefinedTaskName }}
+  namespace: {{ .Namespace }}
+  labels:
+    app.kubernetes.io/version: "0.9"
+  annotations:
+    tekton.dev/pipelines.minVersion: "0.38.0"
+    tekton.dev/categories: Git
+    tekton.dev/tags: git
+    tekton.dev/displayName: "git clone"
+    tekton.dev/platforms: "linux/amd64,linux/s390x,linux/ppc64le,linux/arm64"
+{{- if .OwnerReference }}
+  ownerReferences:
+  - apiVersion: "{{ .OwnerReference.APIVersion }}"
+    kind: "{{ .OwnerReference.Kind }}"
+    name: "{{ .OwnerReference.Name }}"
+    uid: "{{ .OwnerReference.UID }}"
+{{- end }}
+spec:
+  description: >-
+    These Tasks are Git tasks to work with repositories used by other tasks
+    in your Pipeline.
+    The git-clone Task will clone a repo from the provided url into the
+    source Workspace. By default the repo will be cloned into the root of
+    your Workspace. You can clone into a subdirectory by setting this Task's
+    subdirectory param. This Task also supports sparse checkouts. To perform
+    a sparse checkout, pass a list of comma separated directory patterns to
+    this Task's sparseCheckoutDirectories param.
+  workspaces:
+  - name: source
+    description: The git repo will be cloned onto the volume backing this Workspace.
+  - name: ssh-directory
+    optional: true
+    description: |
+      A .ssh directory with private key, known_hosts, config, etc. Copied to
+      the user's home before git commands are executed. Used to authenticate
+      with the git remote when performing the clone. Binding a Secret to this
+      Workspace is strongly recommended over other volume types.
+  - name: basic-auth
+    optional: true
+    description: |
+      A Workspace containing a .gitconfig and .git-credentials file. These
+      will be copied to the user's home before any git commands are run. Any
+      other files in this Workspace are ignored. It is strongly recommended
+      to use ssh-directory over basic-auth whenever possible and to bind a
+      Secret to this Workspace over other volume types.
+  - name: ssl-ca-directory
+    optional: true
+    description: |
+      A workspace containing CA certificates, this will be used by Git to
+      verify the peer with when fetching or pushing over HTTPS.
+  params:
+  - name: url
+    description: Repository URL to clone from.
+    type: string
+  - name: revision
+    description: Revision to checkout. (branch, tag, sha, ref, etc...)
+    type: string
+    default: ""
+  - name: refspec
+    description: Refspec to fetch before checking out revision.
+    default: ""
+  - name: submodules
+    description: Initialize and fetch git submodules.
+    type: string
+    default: "true"
+  - name: depth
+    description: Perform a shallow clone, fetching only the most recent N commits.
+    type: string
+    default: "1"
+  - name: sslVerify
+    description: Set the "http.sslVerify" global git config. Setting this to "false" is not advised unless you are sure that you trust your git remote.
+    type: string
+    default: "true"
+  - name: crtFileName
+    description: file name of mounted crt using ssl-ca-directory workspace. default value is ca-bundle.crt.
+    type: string
+    default: "ca-bundle.crt"
+  - name: subdirectory
+    description: Subdirectory inside the "source" Workspace to clone the repo into.
+    type: string
+    default: ""
+  - name: sparseCheckoutDirectories
+    description: Define the directory patterns to match or exclude when performing a sparse checkout.
+    type: string
+    default: ""
+  - name: deleteExisting
+    description: Clean out the contents of the destination directory if it already exists before cloning.
+    type: string
+    default: "true"
+  - name: httpProxy
+    description: HTTP proxy server for non-SSL requests.
+    type: string
+    default: ""
+  - name: httpsProxy
+    description: HTTPS proxy server for SSL requests.
+    type: string
+    default: ""
+  - name: noProxy
+    description: Opt out of proxying HTTP/HTTPS requests.
+    type: string
+    default: ""
+  - name: verbose
+    description: Log the commands that are executed during "git-clone"'s operation.
+    type: string
+    default: "true"
+  - name: gitInitImage
+    description: The image providing the git-init binary that this Task runs.
+    type: string
+    default: "gcr.io/tekton-releases/github.com/tektoncd/pipeline/cmd/git-init:v0.40.2"
+  - name: userHome
+    description: |
+      Absolute path to the user's home directory.
+    type: string
+    default: "/home/git"
+  results:
+  - name: commit
+    description: The precise commit SHA that was fetched by this Task.
+  - name: url
+    description: The precise URL that was fetched by this Task.
+  - name: committer-date
+    description: The epoch timestamp of the commit that was fetched by this Task.
+  steps:
+  - name: clone
+    image: "$(params.gitInitImage)"
+    env:
+    - name: HOME
+      value: "$(params.userHome)"
+    - name: PARAM_URL
+      value: $(params.url)
+    - name: PARAM_REVISION
+      value: $(params.revision)
+    - name: PARAM_REFSPEC
+      value: $(params.refspec)
+    - name: PARAM_SUBMODULES
+      value: $(params.submodules)
+    - name: PARAM_DEPTH
+      value: $(params.depth)
+    - name: PARAM_SSL_VERIFY
+      value: $(params.sslVerify)
+    - name: PARAM_CRT_FILENAME
+      value: $(params.crtFileName)
+    - name: PARAM_SUBDIRECTORY
+      value: $(params.subdirectory)
+    - name: PARAM_DELETE_EXISTING
+      value: $(params.deleteExisting)
+    - name: PARAM_HTTP_PROXY
+      value: $(params.httpProxy)
+    - name: PARAM_HTTPS_PROXY
+      value: $(params.httpsProxy)
+    - name: PARAM_NO_PROXY
+      value: $(params.noProxy)
+    - name: PARAM_VERBOSE
+      value: $(params.verbose)
+    - name: PARAM_SPARSE_CHECKOUT_DIRECTORIES
+      value: $(params.sparseCheckoutDirectories)
+    - name: PARAM_USER_HOME
+      value: $(params.userHome)
+    - name: WORKSPACE_SOURCE_PATH
+      value: $(workspaces.source.path)
+    - name: WORKSPACE_SSH_DIRECTORY_BOUND
+      value: $(workspaces.ssh-directory.bound)
+    - name: WORKSPACE_SSH_DIRECTORY_PATH
+      value: $(workspaces.ssh-directory.path)
+    - name: WORKSPACE_BASIC_AUTH_DIRECTORY_BOUND
+      value: $(workspaces.basic-auth.bound)
+    - name: WORKSPACE_BASIC_AUTH_DIRECTORY_PATH
+      value: $(workspaces.basic-auth.path)
+    - name: WORKSPACE_SSL_CA_DIRECTORY_BOUND
+      value: $(workspaces.ssl-ca-directory.bound)
+    - name: WORKSPACE_SSL_CA_DIRECTORY_PATH
+      value: $(workspaces.ssl-ca-directory.path)
+    securityContext:
+      runAsNonRoot: true
+      runAsUser: 65532
+    script: |
+      #!/usr/bin/env sh
+      set -eu
+      if [ "${PARAM_VERBOSE}" = "true" ] ; then
+        set -x
+      fi
+      if [ "${WORKSPACE_BASIC_AUTH_DIRECTORY_BOUND}" = "true" ] ; then
+        cp "${WORKSPACE_BASIC_AUTH_DIRECTORY_PATH}/.git-credentials" "${PARAM_USER_HOME}/.git-credentials"
+        cp "${WORKSPACE_BASIC_AUTH_DIRECTORY_PATH}/.gitconfig" "${PARAM_USER_HOME}/.gitconfig"
+        chmod 400 "${PARAM_USER_HOME}/.git-credentials"
+        chmod 400 "${PARAM_USER_HOME}/.gitconfig"
+      fi
+      if [ "${WORKSPACE_SSH_DIRECTORY_BOUND}" = "true" ] ; then
+        cp -R "${WORKSPACE_SSH_DIRECTORY_PATH}" "${PARAM_USER_HOME}"/.ssh
+        chmod 700 "${PARAM_USER_HOME}"/.ssh
+        chmod -R 400 "${PARAM_USER_HOME}"/.ssh/*
+      fi
+      if [ "${WORKSPACE_SSL_CA_DIRECTORY_BOUND}" = "true" ] ; then
+         export GIT_SSL_CAPATH="${WORKSPACE_SSL_CA_DIRECTORY_PATH}"
+         if [ "${PARAM_CRT_FILENAME}" != "" ] ; then
+            export GIT_SSL_CAINFO="${WORKSPACE_SSL_CA_DIRECTORY_PATH}/${PARAM_CRT_FILENAME}"
+         fi
+      fi
+      CHECKOUT_DIR="${WORKSPACE_SOURCE_PATH}/${PARAM_SUBDIRECTORY}"
+      cleandir() {
+        # Delete any existing contents of the repo directory if it exists.
+        #
+        # We don't just "rm -rf ${CHECKOUT_DIR}" because ${CHECKOUT_DIR} might be "/"
+        # or the root of a mounted volume.
+        if [ -d "${CHECKOUT_DIR}" ] ; then
+          # Delete non-hidden files and directories
+          rm -rf "${CHECKOUT_DIR:?}"/*
+          # Delete files and directories starting with . but excluding ..
+          rm -rf "${CHECKOUT_DIR}"/.[!.]*
+          # Delete files and directories starting with .. plus any other character
+          rm -rf "${CHECKOUT_DIR}"/..?*
+        fi
+      }
+      if [ "${PARAM_DELETE_EXISTING}" = "true" ] ; then
+        cleandir || true
+      fi
+      test -z "${PARAM_HTTP_PROXY}" || export HTTP_PROXY="${PARAM_HTTP_PROXY}"
+      test -z "${PARAM_HTTPS_PROXY}" || export HTTPS_PROXY="${PARAM_HTTPS_PROXY}"
+      test -z "${PARAM_NO_PROXY}" || export NO_PROXY="${PARAM_NO_PROXY}"
+      git config --global --add safe.directory "${WORKSPACE_SOURCE_PATH}"
+      /ko-app/git-init \
+        -url="${PARAM_URL}" \
+        -revision="${PARAM_REVISION}" \
+        -refspec="${PARAM_REFSPEC}" \
+        -path="${CHECKOUT_DIR}" \
+        -sslVerify="${PARAM_SSL_VERIFY}" \
+        -submodules="${PARAM_SUBMODULES}" \
+        -depth="${PARAM_DEPTH}" \
+        -sparseCheckoutDirectories="${PARAM_SPARSE_CHECKOUT_DIRECTORIES}"
+      cd "${CHECKOUT_DIR}"
+      RESULT_SHA="$(git rev-parse HEAD)"
+      EXIT_CODE="$?"
+      if [ "${EXIT_CODE}" != 0 ] ; then
+        exit "${EXIT_CODE}"
+      fi
+      RESULT_COMMITTER_DATE="$(git log -1 --pretty=%ct)"
+      printf "%s" "${RESULT_COMMITTER_DATE}" > "$(results.committer-date.path)"
+      printf "%s" "${RESULT_SHA}" > "$(results.commit.path)"
+      printf "%s" "${PARAM_URL}" > "$(results.url.path)"
+`
+
+const GoTestTaskContent = `apiVersion: tekton.dev/v1beta1
+kind: Task
+metadata:
+  name: {{ .PredefinedTaskName }}
+  namespace: {{ .Namespace }}
+  labels:
+    app.kubernetes.io/version: "0.2"
+  annotations:
+    tekton.dev/pipelines.minVersion: "0.12.1"
+    tekton.dev/categories: Testing
+    tekton.dev/tags: test
+    tekton.dev/displayName: "golang test"
+    tekton.dev/platforms: "linux/amd64,linux/s390x,linux/ppc64le"
+{{- if .OwnerReference }}
+  ownerReferences:
+  - apiVersion: "{{ .OwnerReference.APIVersion }}"
+    kind: "{{ .OwnerReference.Kind }}"
+    name: "{{ .OwnerReference.Name }}"
+    uid: "{{ .OwnerReference.UID }}"
+{{- end }}
+spec:
+  description: >-
+    This Task is Golang task to test Go projects.
+
+  params:
+  - name: package
+    description: package (and its children) under test
+    default: "{{ default "." .Params.package }}"
+  - name: packages
+    description: "packages to test (default: ./...)"
+    default: "{{ default "./..." .Params.packages }}"
+  - name: context
+    description: path to the directory to use as context.
+    default: "{{ default "." .Params.context }}"
+  - name: version
+    description: golang version to use for tests
+    default: "{{ default "latest" .Params.version }}"
+  - name: flags
+    description: flags to use for the test command
+    default: "{{ default "-race -cover -v" .Params.flags }}"
+  - name: GOOS
+    description: "running program's operating system target"
+    default: "{{ default "linux" .Params.GOOS }}"
+  - name: GOARCH
+    description: "running program's architecture target"
+    default: "{{ default "amd64" .Params.GOARCH }}"
+  - name: GO111MODULE
+    description: "value of module support"
+    default: "{{ default "auto" .Params.GO111MODULE }}"
+  - name: GOCACHE
+    description: "Go caching directory path"
+    default: "{{ default "" .Params.GOCACHE }}"
+  - name: GOMODCACHE
+    description: "Go mod caching directory path"
+    default: "{{ default "" .Params.GOMODCACHE }}"
+  workspaces:
+  - name: source
+  steps:
+  - name: unit-test
+    image: docker.io/library/golang:$(params.version)
+    workingDir: $(workspaces.source.path)
+    script: |
+      if [ ! -e $GOPATH/src/$(params.package)/go.mod ];then
+         SRC_PATH="$GOPATH/src/$(params.package)"
+         mkdir -p $SRC_PATH
+         cp -R "$(workspaces.source.path)/$(params.context)"/* $SRC_PATH
+         cd $SRC_PATH
+      fi
+      go test $(params.flags) $(params.packages)
+    env:
+    - name: GOOS
+      value: "$(params.GOOS)"
+    - name: GOARCH
+      value: "$(params.GOARCH)"
+    - name: GO111MODULE
+      value: "$(params.GO111MODULE)"
+    - name: GOCACHE
+      value: "$(params.GOCACHE)"
+    - name: GOMODCACHE
+      value: "$(params.GOMODCACHE)"
+`
+
+const GoLintTaskContent = `apiVersion: tekton.dev/v1beta1
+kind: Task
+metadata:
+  name: {{ .PredefinedTaskName }}
+  namespace: {{ .Namespace }}
+  labels:
+    app.kubernetes.io/version: "0.2"
+  annotations:
+    tekton.dev/pipelines.minVersion: "0.12.1"
+    tekton.dev/categories: Code Quality
+    tekton.dev/tags: lint
+    tekton.dev/displayName: "golangci lint"
+    tekton.dev/platforms: "linux/amd64"
+{{- if .OwnerReference }}
+  ownerReferences:
+  - apiVersion: "{{ .OwnerReference.APIVersion }}"
+    kind: "{{ .OwnerReference.Kind }}"
+    name: "{{ .OwnerReference.Name }}"
+    uid: "{{ .OwnerReference.UID }}"
+{{- end }}
+spec:
+  description: >-
+    This Task is Golang task to validate Go projects.
+
+  params:
+  - name: package
+    description: base package (and its children) under validation
+    default: "{{ default "." .Params.package }}"
+  - name: context
+    description: path to the directory to use as context.
+    default: "{{ default "." .Params.context }}"
+  - name: flags
+    description: flags to use for the lint command
+    default: "{{ default "--verbose" .Params.flags }}"
+  - name: version
+    description: golangci-lint version to use
+    default: "{{ default "latest" .Params.version }}"
+  - name: GOOS
+    description: "running program's operating system target"
+    default: "{{ default "linux" .Params.GOOS }}"
+  - name: GOARCH
+    description: "running program's architecture target"
+    default: "{{ default "amd64" .Params.GOARCH }}"
+  - name: GO111MODULE
+    description: "value of module support"
+    default: "{{ default "auto" .Params.GO111MODULE }}"
+  - name: GOCACHE
+    description: "Go caching directory path"
+    default: "{{ default "" .Params.GOCACHE }}"
+  - name: GOMODCACHE
+    description: "Go mod caching directory path"
+    default: "{{ default "" .Params.GOMODCACHE }}"
+  - name: GOLANGCI_LINT_CACHE
+    description: "golangci-lint cache path"
+    default: "{{ default "" .Params.GOLANGCI_LINT_CACHE }}"
+  workspaces:
+  - name: source
+    mountPath: /workspace/src/$(params.package)
+  steps:
+  - name: lint
+    image: docker.io/golangci/golangci-lint:$(params.version)
+    workingDir: $(workspaces.source.path)/$(params.context)
+    script: |
+      golangci-lint run $(params.flags)
+    env:
+    - name: GOPATH
+      value: /workspace
+    - name: GOOS
+      value: "$(params.GOOS)"
+    - name: GOARCH
+      value: "$(params.GOARCH)"
+    - name: GO111MODULE
+      value: "$(params.GO111MODULE)"
+    - name: GOCACHE
+      value: "$(params.GOCACHE)"
+    - name: GOMODCACHE
+      value: "$(params.GOMODCACHE)"
+    - name: GOLANGCI_LINT_CACHE
+      value: "$(params.GOLANGCI_LINT_CACHE)"
+`
+const BuildPushImageContent = `apiVersion: tekton.dev/v1beta1
+kind: Task
+metadata:
+  name: {{ .PredefinedTaskName }}
+  namespace: {{ .Namespace }}
+  labels:
+    app.kubernetes.io/version: "0.6"
+  annotations:
+    tekton.dev/pipelines.minVersion: "0.17.0"
+    tekton.dev/categories: Image Build
+    tekton.dev/tags: image-build
+    tekton.dev/displayName: "Build and upload container image using Kaniko"
+    tekton.dev/platforms: "linux/amd64,linux/arm64,linux/ppc64le"
+{{- if .OwnerReference }}
+  ownerReferences:
+  - apiVersion: "{{ .OwnerReference.APIVersion }}"
+    kind: "{{ .OwnerReference.Kind }}"
+    name: "{{ .OwnerReference.Name }}"
+    uid: "{{ .OwnerReference.UID }}"
+{{- end }}
+spec:
+  description: >-
+    This Task builds a simple Dockerfile with kaniko and pushes to a registry.
+    This Task stores the image name and digest as results, allowing Tekton Chains to pick up
+    that an image was built & sign it.
+  params:
+  - name: IMAGE # This is a parameter that must be set.
+    description: Name (reference) of the image to build.
+    default: {{ default "Unknown" .Params.image }}
+  - name: DOCKERFILE
+    description: Path to the Dockerfile to build.
+    default: {{ default "./Dockerfile" .Params.dockerfile }}
+  - name: CONTEXT
+    description: The build context used by Kaniko.
+    default: {{ default "./" .Params.context }}
+  - name: EXTRA_ARGS # more details see https://github.com/GoogleContainerTools/kaniko?tab=readme-ov-file#additional-flags
+    type: array
+    default: {{ default "[]" .Params.extra_args }}
+  - name: BUILDER_IMAGE
+    description: The image on which builds will run (default is v1.19.2 debug)
+    default: {{ default "gcr.io/kaniko-project/executor@sha256:899886a2db1c127ff1565d5c7b1e574af1810bbdad048e9850e4f40b5848d79c" .Params.builder_image }}
+  workspaces:
+  - name: source
+    description: Holds the context and Dockerfile
+  - name: dockerconfig
+    description: Includes a docker "config.json"
+    optional: true
+    mountPath: /kaniko/.docker
+  results:
+  - name: IMAGE_DIGEST
+    description: Digest of the image just built.
+  - name: IMAGE_URL
+    description: URL of the image just built.
+  steps:
+  - name: build-and-push
+    workingDir: $(workspaces.source.path)
+    image: $(params.BUILDER_IMAGE)
+    args:
+    - $(params.EXTRA_ARGS)
+    - --dockerfile=$(params.DOCKERFILE)
+    - --context=$(workspaces.source.path)/$(params.CONTEXT) # The user does not need to care the workspace and the source.
+    - --destination=$(params.IMAGE)
+    - --digest-file=$(results.IMAGE_DIGEST.path)
+    - --ignore-path=/product_uuid # in case kind cluster run failed, see https://github.com/GoogleContainerTools/kaniko/issues/2164
+    # kaniko assumes it is running as root, which means this example fails on platforms
+    # that default to run containers as random uid (like OpenShift). Adding this securityContext
+    # makes it explicit that it needs to run as root.
+    securityContext:
+      runAsUser: 0
+  - name: write-url
+    image: docker.io/library/bash:5.1.4@sha256:c523c636b722339f41b6a431b44588ab2f762c5de5ec3bd7964420ff982fb1d9
+    script: |
+      set -e
+      image="$(params.IMAGE)"
+      echo -n "${image}" | tee "$(results.IMAGE_URL.path)"
+`
diff --git a/pkg/fleet-manager/pipeline/render/trigger.go b/pkg/fleet-manager/pipeline/render/trigger.go
new file mode 100644
index 0000000..3aa0ccc
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/trigger.go
@@ -0,0 +1,163 @@
+/*
+Copyright Kurator 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 render
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	pipelineapi "kurator.dev/kurator/pkg/apis/pipeline/v1alpha1"
+)
+
+const (
+	TriggerTemplateName = "pipeline trigger template"
+)
+
+type TriggerConfig struct {
+	PipelineName      string
+	PipelineNamespace string
+	OwnerReference    *metav1.OwnerReference
+	AccessMode        string
+	StorageRequest    string
+	StorageClassName  string
+	VolumeMode        string
+}
+
+// ServiceAccountName is the service account used by trigger
+func (cfg TriggerConfig) ServiceAccountName() string {
+	return cfg.PipelineName
+}
+
+// RenderTriggerWithPipeline takes a pipeline object and generates YAML byte array configuration representing the trigger configuration.
+func RenderTriggerWithPipeline(pipeline *pipelineapi.Pipeline) ([]byte, error) {
+	config := TriggerConfig{
+		PipelineName:      pipeline.Name,
+		PipelineNamespace: pipeline.Namespace,
+		OwnerReference:    GeneratePipelineOwnerRef(pipeline),
+	}
+	if pipeline.Spec.SharedWorkspace != nil {
+		config.AccessMode = string(pipeline.Spec.SharedWorkspace.AccessMode)
+		config.StorageRequest = pipeline.Spec.SharedWorkspace.StorageRequest
+		config.StorageClassName = pipeline.Spec.SharedWorkspace.StorageClassName
+		config.VolumeMode = string(pipeline.Spec.SharedWorkspace.VolumeMode)
+	}
+
+	return RenderTrigger(config)
+}
+
+// RenderTrigger takes a TriggerConfig object and generates YAML byte array configuration representing the trigger configuration.
+func RenderTrigger(cfg TriggerConfig) ([]byte, error) {
+	return renderTemplate(TriggerTemplateContent, TriggerTemplateName, cfg)
+}
+
+const TriggerTemplateContent = `apiVersion: triggers.tekton.dev/v1alpha1
+kind: TriggerTemplate
+metadata:
+  name: {{ .PipelineName }}-triggertemplate
+  namespace: {{ .PipelineNamespace }}
+{{- if .OwnerReference }}
+  ownerReferences:
+  - apiVersion: "{{ .OwnerReference.APIVersion }}"
+    kind: "{{ .OwnerReference.Kind }}"
+    name: "{{ .OwnerReference.Name }}"
+    uid: "{{ .OwnerReference.UID }}"
+{{- end }}
+spec:
+  params:
+  - name: gitrevision
+    description: The git revision
+  - name: gitrepositoryurl
+    description: The git repository url
+  - name: namespace
+    description: The namespace to create the resources
+  resourceTemplates:
+  - apiVersion: tekton.dev/v1beta1
+    kind: PipelineRun
+    metadata:
+      generateName: {{ .PipelineName }}-run-
+      namespace: $(tt.params.namespace)
+    spec:
+      serviceAccountName: {{ .ServiceAccountName }}
+      pipelineRef:
+        name: {{ .PipelineName }}
+      params:
+      - name: revision
+        value: $(tt.params.gitrevision)
+      - name: repo-url
+        value: $(tt.params.gitrepositoryurl)
+      workspaces:
+      - name: kurator-pipeline-shared-data # there only one pvc workspace in each pipeline, and the name is kurator-pipeline-shared-data
+        volumeClaimTemplate:
+          spec:
+            accessModes:
+              - {{ default "ReadWriteOnce" .AccessMode }}
+            resources:
+              requests:
+                storage: {{ default "1Gi" .StorageRequest }}
+{{- if .VolumeMode }}
+            volumeMode: {{ .VolumeMode }}
+{{- end }}
+{{- if .StorageClassName }}
+            storageClassName: {{ .StorageClassName }}
+{{- end }}
+      - name: git-credentials
+        secret:
+          secretName: git-credentials
+      - name: docker-credentials
+        secret:
+          secretName: docker-credentials  # auth for task
+---
+apiVersion: triggers.tekton.dev/v1alpha1
+kind: TriggerBinding
+metadata:
+  name: {{ .PipelineName }}-triggerbinding
+  namespace: {{ .PipelineNamespace}}
+{{- if .OwnerReference }}
+  ownerReferences:
+  - apiVersion: "{{ .OwnerReference.APIVersion }}"
+    kind: "{{ .OwnerReference.Kind }}"
+    name: "{{ .OwnerReference.Name }}"
+    uid: "{{ .OwnerReference.UID }}"
+{{- end }}
+spec:
+  params:
+  - name: gitrevision
+    value: $(body.head_commit.id)
+  - name: namespace
+    value: {{ .PipelineNamespace}}
+  - name: gitrepositoryurl
+    value: "https://github.com/$(body.repository.full_name)"
+---
+apiVersion: triggers.tekton.dev/v1alpha1
+kind: EventListener
+metadata:
+  name: {{ .PipelineName }}-listener
+  namespace: {{ .PipelineNamespace}}
+{{- if .OwnerReference }}
+  ownerReferences:
+  - apiVersion: "{{ .OwnerReference.APIVersion }}"
+    kind: "{{ .OwnerReference.Kind }}"
+    name: "{{ .OwnerReference.Name }}"
+    uid: "{{ .OwnerReference.UID }}"
+{{- end }}
+spec:
+  serviceAccountName: {{ .ServiceAccountName }}
+  triggers:
+  - bindings:
+    - ref: {{ .PipelineName }}-triggerbinding
+    template:
+      ref: {{ .PipelineName }}-triggertemplate
+`


Test patch:
diff --git a/pkg/fleet-manager/application/manifests/rollout_testloader.go b/pkg/fleet-manager/application/manifests/rollout_testloader.go
new file mode 100644
index 0000000..8ca0196
--- /dev/null
+++ b/pkg/fleet-manager/application/manifests/rollout_testloader.go
@@ -0,0 +1,151 @@
+/*
+Copyright Kurator 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 render
+
+import (
+	"bytes"
+	"text/template"
+
+	"k8s.io/apimachinery/pkg/types"
+)
+
+type TestloaderConfig struct {
+	Name            string
+	Namespace       string
+	LabelKey        string
+	LabelValue      string
+	AnnotationKey   string
+	AnnotationValue string
+}
+
+const testloaderTemplateName = "testloader"
+
+// RenderTestloaderConfig takes generates YAML byte array configuration representing the testloader configuration.
+func RenderTestloaderConfig(constTemplateName string, namespacedName types.NamespacedName, annotationKey, annotationsValue string) ([]byte, error) {
+	cfg := TestloaderConfig{
+		Name:            namespacedName.Name,
+		Namespace:       namespacedName.Namespace,
+		LabelKey:        "app",
+		LabelValue:      namespacedName.Name,
+		AnnotationKey:   annotationKey,
+		AnnotationValue: annotationsValue,
+	}
+
+	return renderTestloaderTemplateConfig(constTemplateName, cfg)
+}
+
+// renderTestloaderTemplateConfig reads, parses, and renders a template file using the provided configuration data.
+func renderTestloaderTemplateConfig(constTemplateName string, cfg TestloaderConfig) ([]byte, error) {
+	tql, err := template.New(testloaderTemplateName).Funcs(funMap()).Parse(constTemplateName)
+	if err != nil {
+		return nil, err
+	}
+
+	var b bytes.Buffer
+	if err := tql.Execute(&b, cfg); err != nil {
+		return nil, err
+	}
+
+	return b.Bytes(), nil
+}
+
+func funMap() template.FuncMap {
+	return nil
+}
+
+const TestlaoderDeployment = `apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{.Name}}
+  namespace: {{.Namespace}}
+  labels:
+    {{.LabelKey}}: {{.LabelValue}}
+  annotations:
+    {{.AnnotationKey}}: {{.AnnotationValue}}
+spec:
+  selector:
+    matchLabels:
+      {{.LabelKey}}: {{.LabelValue}}
+  template:
+    metadata:
+      labels:
+        {{.LabelKey}}: {{.LabelValue}}
+      annotations:
+        prometheus.io/scrape: "true"
+        prometheus.io/port: "8080"
+        openservicemesh.io/inbound-port-exclusion-list: "80, 8080"
+    spec:
+      containers:
+        - name: loadtester
+          image: ghcr.io/fluxcd/flagger-loadtester:0.29.0
+          imagePullPolicy: IfNotPresent
+          ports:
+            - name: http
+              containerPort: 8080
+          command:
+            - ./loadtester
+            - -port=8080
+            - -log-level=info
+            - -timeout=1h
+          livenessProbe:
+            exec:
+              command:
+                - wget
+                - --quiet
+                - --tries=1
+                - --timeout=4
+                - --spider
+                - http://localhost:8080/healthz
+            timeoutSeconds: 5
+          readinessProbe:
+            exec:
+              command:
+                - wget
+                - --quiet
+                - --tries=1
+                - --timeout=4
+                - --spider
+                - http://localhost:8080/healthz
+            timeoutSeconds: 5
+          resources:
+            limits:
+              memory: "512Mi"
+              cpu: "1000m"
+            requests:
+              memory: "32Mi"
+              cpu: "10m"
+          securityContext:
+            readOnlyRootFilesystem: true
+            runAsUser: 10001
+`
+
+const TestlaoderService = `apiVersion: v1
+kind: Service
+metadata:
+  name: {{.Name}}
+  namespace: {{.Namespace}}
+  labels:
+    {{.LabelKey}}: {{.LabelValue}}
+  annotations:
+    {{.AnnotationKey}}: {{.AnnotationValue}}
+spec:
+  type: ClusterIP
+  selector:
+    {{.LabelKey}}: {{.LabelValue}}
+  ports:
+    - name: http
+      port: 80
+      protocol: TCP
+      targetPort: http
+`
diff --git a/pkg/fleet-manager/application/manifests/rollout_testloader_test.go b/pkg/fleet-manager/application/manifests/rollout_testloader_test.go
new file mode 100644
index 0000000..2e47ac2
--- /dev/null
+++ b/pkg/fleet-manager/application/manifests/rollout_testloader_test.go
@@ -0,0 +1,61 @@
+/*
+Copyright Kurator 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 render
+
+import (
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"k8s.io/apimachinery/pkg/types"
+)
+
+func TestRenderTestloaderConfig(t *testing.T) {
+	expectedTestDataFile := "testdata/"
+	namespacedName := types.NamespacedName{
+		Namespace: "test",
+		Name:      "testloader",
+	}
+	annotationKey := "kurator.dev/rollout"
+	annotationValue := "policy"
+	cases := []struct {
+		name              string
+		constTemplateName string
+		expectFileName    string
+	}{
+		{
+			name:              "testloader deploy template",
+			constTemplateName: TestlaoderDeployment,
+			expectFileName:    "testloader-deploy.yaml",
+		},
+		{
+			name:              "testloader svc template",
+			constTemplateName: TestlaoderService,
+			expectFileName:    "testloader-svc.yaml",
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			result, err := RenderTestloaderConfig(tc.constTemplateName, namespacedName, annotationKey, annotationValue)
+			if err != nil {
+				assert.Error(t, err)
+			} else {
+				expected, err := os.ReadFile(expectedTestDataFile + tc.expectFileName)
+				assert.NoError(t, err)
+				assert.Equal(t, result, expected)
+			}
+		})
+	}
+}
diff --git a/pkg/fleet-manager/application/manifests/testdata/testloader-deploy.yaml b/pkg/fleet-manager/application/manifests/testdata/testloader-deploy.yaml
new file mode 100644
index 0000000..19fe590
--- /dev/null
+++ b/pkg/fleet-manager/application/manifests/testdata/testloader-deploy.yaml
@@ -0,0 +1,64 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: testloader
+  namespace: test
+  labels:
+    app: testloader
+  annotations:
+    kurator.dev/rollout: policy
+spec:
+  selector:
+    matchLabels:
+      app: testloader
+  template:
+    metadata:
+      labels:
+        app: testloader
+      annotations:
+        prometheus.io/scrape: "true"
+        prometheus.io/port: "8080"
+        openservicemesh.io/inbound-port-exclusion-list: "80, 8080"
+    spec:
+      containers:
+        - name: loadtester
+          image: ghcr.io/fluxcd/flagger-loadtester:0.29.0
+          imagePullPolicy: IfNotPresent
+          ports:
+            - name: http
+              containerPort: 8080
+          command:
+            - ./loadtester
+            - -port=8080
+            - -log-level=info
+            - -timeout=1h
+          livenessProbe:
+            exec:
+              command:
+                - wget
+                - --quiet
+                - --tries=1
+                - --timeout=4
+                - --spider
+                - http://localhost:8080/healthz
+            timeoutSeconds: 5
+          readinessProbe:
+            exec:
+              command:
+                - wget
+                - --quiet
+                - --tries=1
+                - --timeout=4
+                - --spider
+                - http://localhost:8080/healthz
+            timeoutSeconds: 5
+          resources:
+            limits:
+              memory: "512Mi"
+              cpu: "1000m"
+            requests:
+              memory: "32Mi"
+              cpu: "10m"
+          securityContext:
+            readOnlyRootFilesystem: true
+            runAsUser: 10001
diff --git a/pkg/fleet-manager/application/manifests/testdata/testloader-svc.yaml b/pkg/fleet-manager/application/manifests/testdata/testloader-svc.yaml
new file mode 100644
index 0000000..ebfb35d
--- /dev/null
+++ b/pkg/fleet-manager/application/manifests/testdata/testloader-svc.yaml
@@ -0,0 +1,18 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: testloader
+  namespace: test
+  labels:
+    app: testloader
+  annotations:
+    kurator.dev/rollout: policy
+spec:
+  type: ClusterIP
+  selector:
+    app: testloader
+  ports:
+    - name: http
+      port: 80
+      protocol: TCP
+      targetPort: http
diff --git a/pkg/fleet-manager/application/rollout_helper_test.go b/pkg/fleet-manager/application/rollout_helper_test.go
new file mode 100644
index 0000000..4bf90e1
--- /dev/null
+++ b/pkg/fleet-manager/application/rollout_helper_test.go
@@ -0,0 +1,506 @@
+/*
+Copyright Kurator 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 application
+
+import (
+	"reflect"
+	"testing"
+
+	flaggerv1b1 "github.com/fluxcd/flagger/pkg/apis/flagger/v1beta1"
+	"github.com/fluxcd/flagger/pkg/apis/istio/common/v1alpha1"
+	istiov1alpha3 "github.com/fluxcd/flagger/pkg/apis/istio/v1alpha3"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/intstr"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	applicationapi "kurator.dev/kurator/pkg/apis/apps/v1alpha1"
+)
+
+func generateRolloutPloicy(installPrivateTestloader *bool) applicationapi.RolloutConfig {
+	timeout := 50
+	RolloutTimeoutSeconds := int32(50)
+	min := 99.0
+	max := 500.0
+
+	rolloutPolicy := applicationapi.RolloutConfig{
+		TestLoader:             installPrivateTestloader,
+		TrafficRoutingProvider: "istio",
+		Workload: &applicationapi.CrossNamespaceObjectReference{
+			APIVersion: "appv1/deployment",
+			Kind:       "Deployment",
+			Name:       "podinfo",
+			Namespace:  "test",
+		},
+		ServiceName: "podinfo-service",
+		Port:        80,
+		RolloutPolicy: &applicationapi.RolloutPolicy{
+			TrafficRouting: &applicationapi.TrafficRoutingConfig{
+				TimeoutSeconds: 50,
+				Gateways: []string{
+					"istio-system/public-gateway",
+				},
+				Hosts: []string{
+					"app.example.com",
+				},
+				Retries: &istiov1alpha3.HTTPRetry{
+					Attempts:      10,
+					PerTryTimeout: "40s",
+					RetryOn:       "gateway-error, connect-failure, refused-stream",
+				},
+				Headers: &istiov1alpha3.Headers{
+					Request: &istiov1alpha3.HeaderOperations{
+						Add: map[string]string{
+							"x-some-header": "value",
+						},
+					},
+				},
+				CorsPolicy: &istiov1alpha3.CorsPolicy{
+					AllowOrigin:      []string{"example"},
+					AllowMethods:     []string{"GET"},
+					AllowCredentials: false,
+					AllowHeaders:     []string{"x-some-header"},
+					MaxAge:           "24h",
+				},
+				CanaryStrategy: &applicationapi.CanaryConfig{
+					MaxWeight:  50,
+					StepWeight: 10,
+					StepWeights: []int{
+						1, 20, 40, 80,
+					},
+					StepWeightPromotion: 30,
+				},
+				AnalysisTimes: 5,
+				Match: []istiov1alpha3.HTTPMatchRequest{
+					{
+						Headers: map[string]v1alpha1.StringMatch{
+							"user-agent": {
+								Regex: ".*Firefox.*",
+							},
+							"cookie": {
+								Regex: "^(.*?;)?(type=insider)(;.*)?$",
+							},
+						},
+					},
+				},
+			},
+			TrafficAnalysis: &applicationapi.TrafficAnalysis{
+				CheckIntervalSeconds: &timeout,
+				CheckFailedTimes:     &timeout,
+				Metrics: []applicationapi.Metric{
+					{
+						Name:            "request-success-rate",
+						IntervalSeconds: &timeout,
+						ThresholdRange: &applicationapi.CanaryThresholdRange{
+							Min: &min,
+						},
+					},
+					{
+						Name:            "request-duration",
+						IntervalSeconds: &timeout,
+						ThresholdRange: &applicationapi.CanaryThresholdRange{
+							Max: &max,
+						},
+					},
+				},
+				Webhooks: applicationapi.Webhook{
+					TimeoutSeconds: &timeout,
+					Commands: []string{
+						"hey -z 1m -q 10 -c 2 http://podinfo-canary.test:9898/",
+						"curl -sd 'test' http://podinfo-canary:9898/token | grep token",
+					},
+				},
+				SessionAffinity: &applicationapi.SessionAffinity{
+					CookieName: "User",
+					MaxAge:     24,
+				},
+			},
+			RolloutTimeoutSeconds: &RolloutTimeoutSeconds,
+			SkipTrafficAnalysis:   false,
+			RevertOnDeletion:      false,
+			Suspend:               false,
+		},
+	}
+	return rolloutPolicy
+}
+
+func Test_renderCanary(t *testing.T) {
+	int32Time := int32(50)
+	sign := true
+	type args struct {
+		rolloutPolicy applicationapi.RolloutConfig
+	}
+	tests := []struct {
+		name string
+		args args
+		want *flaggerv1b1.Canary
+	}{
+		{
+			name: "functional test",
+			args: args{
+				rolloutPolicy: generateRolloutPloicy(&sign),
+			},
+			want: &flaggerv1b1.Canary{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: "test",
+					Name:      "podinfo",
+				},
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "Canary",
+					APIVersion: "flagger.app/v1beta1",
+				},
+				Spec: flaggerv1b1.CanarySpec{
+					Provider: "istio",
+					TargetRef: flaggerv1b1.LocalObjectReference{
+						APIVersion: "appv1/deployment",
+						Kind:       "Deployment",
+						Name:       "podinfo",
+					},
+					ProgressDeadlineSeconds: &int32Time,
+					SkipAnalysis:            false,
+					RevertOnDeletion:        false,
+					Suspend:                 false,
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := renderCanary(tt.args.rolloutPolicy, &flaggerv1b1.Canary{}); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("renderCanary() = %v\n, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func Test_renderCanaryService(t *testing.T) {
+	sign := true
+	rolloutPolicy := generateRolloutPloicy(&sign)
+	type args struct {
+		rolloutPolicy applicationapi.RolloutConfig
+		service       *corev1.Service
+	}
+	tests := []struct {
+		name string
+		args args
+		want *flaggerv1b1.CanaryService
+	}{
+		{
+			name: "functional test",
+			args: args{
+				rolloutPolicy: rolloutPolicy,
+				service: &corev1.Service{
+					ObjectMeta: metav1.ObjectMeta{
+						Namespace: "test",
+						Name:      "podinfo-service",
+					},
+					Spec: corev1.ServiceSpec{
+						Selector: map[string]string{
+							"app": "podinfo",
+						},
+						Ports: []corev1.ServicePort{
+							{
+								Protocol:   corev1.ProtocolTCP,
+								Port:       80,
+								TargetPort: intstr.FromInt(8080),
+							},
+						},
+					},
+				},
+			},
+			want: &flaggerv1b1.CanaryService{
+				Name:       "podinfo-service",
+				Port:       80,
+				Timeout:    "50s",
+				TargetPort: intstr.FromInt(8080),
+				Gateways: []string{
+					"istio-system/public-gateway",
+				},
+				Hosts: []string{
+					"app.example.com",
+				},
+				Retries:    rolloutPolicy.RolloutPolicy.TrafficRouting.Retries,
+				Headers:    rolloutPolicy.RolloutPolicy.TrafficRouting.Headers,
+				CorsPolicy: rolloutPolicy.RolloutPolicy.TrafficRouting.CorsPolicy,
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got, _ := renderCanaryService(tt.args.rolloutPolicy, tt.args.service); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("renderCanaryService() = %v\n, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func Test_renderCanaryAnalysis(t *testing.T) {
+	sign := true
+	wantFalse := false
+	timeout := 50
+	rolloutPolicy := generateRolloutPloicy(&sign)
+	wantPublicTestloaderRolloutPolicy := generateRolloutPloicy(&wantFalse)
+	type args struct {
+		rolloutPolicy applicationapi.RolloutConfig
+	}
+	tests := []struct {
+		name string
+		args args
+		want *flaggerv1b1.CanaryAnalysis
+	}{
+		{
+			name: "functional test",
+			args: args{
+				rolloutPolicy: rolloutPolicy,
+			},
+			want: &flaggerv1b1.CanaryAnalysis{
+				Interval:   "50s",
+				Iterations: 5,
+				MaxWeight:  50,
+				StepWeight: 10,
+				StepWeights: []int{
+					1, 20, 40, 80,
+				},
+				StepWeightPromotion: 30,
+				Threshold:           timeout,
+				Match: []istiov1alpha3.HTTPMatchRequest{
+					{
+						Headers: map[string]v1alpha1.StringMatch{
+							"user-agent": {
+								Regex: ".*Firefox.*",
+							},
+							"cookie": {
+								Regex: "^(.*?;)?(type=insider)(;.*)?$",
+							},
+						},
+					},
+				},
+				SessionAffinity: (*flaggerv1b1.SessionAffinity)(rolloutPolicy.RolloutPolicy.TrafficAnalysis.SessionAffinity),
+				Metrics: []flaggerv1b1.CanaryMetric{
+					{
+						Name:           "request-success-rate",
+						Interval:       "50s",
+						ThresholdRange: (*flaggerv1b1.CanaryThresholdRange)(rolloutPolicy.RolloutPolicy.TrafficAnalysis.Metrics[0].ThresholdRange),
+					},
+					{
+						Name:           "request-duration",
+						Interval:       "50s",
+						ThresholdRange: (*flaggerv1b1.CanaryThresholdRange)(rolloutPolicy.RolloutPolicy.TrafficAnalysis.Metrics[1].ThresholdRange),
+					},
+				},
+				Webhooks: []flaggerv1b1.CanaryWebhook{
+					{
+						Name:    "generated-testload-0",
+						Timeout: "50s",
+						URL:     "http://podinfo-service-testloader.test/",
+						Metadata: &map[string]string{
+							"type": "cmd",
+							"cmd":  "hey -z 1m -q 10 -c 2 http://podinfo-canary.test:9898/",
+						},
+					},
+					{
+						Name:    "generated-testload-1",
+						Timeout: "50s",
+						URL:     "http://podinfo-service-testloader.test/",
+						Metadata: &map[string]string{
+							"type": "cmd",
+							"cmd":  "curl -sd 'test' http://podinfo-canary:9898/token | grep token",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "public Testloader",
+			args: args{
+				rolloutPolicy: wantPublicTestloaderRolloutPolicy,
+			},
+			want: &flaggerv1b1.CanaryAnalysis{
+				Interval:   "50s",
+				Iterations: 5,
+				MaxWeight:  50,
+				StepWeight: 10,
+				StepWeights: []int{
+					1, 20, 40, 80,
+				},
+				StepWeightPromotion: 30,
+				Threshold:           timeout,
+				Match: []istiov1alpha3.HTTPMatchRequest{
+					{
+						Headers: map[string]v1alpha1.StringMatch{
+							"user-agent": {
+								Regex: ".*Firefox.*",
+							},
+							"cookie": {
+								Regex: "^(.*?;)?(type=insider)(;.*)?$",
+							},
+						},
+					},
+				},
+				SessionAffinity: (*flaggerv1b1.SessionAffinity)(rolloutPolicy.RolloutPolicy.TrafficAnalysis.SessionAffinity),
+				Metrics: []flaggerv1b1.CanaryMetric{
+					{
+						Name:           "request-success-rate",
+						Interval:       "50s",
+						ThresholdRange: (*flaggerv1b1.CanaryThresholdRange)(rolloutPolicy.RolloutPolicy.TrafficAnalysis.Metrics[0].ThresholdRange),
+					},
+					{
+						Name:           "request-duration",
+						Interval:       "50s",
+						ThresholdRange: (*flaggerv1b1.CanaryThresholdRange)(rolloutPolicy.RolloutPolicy.TrafficAnalysis.Metrics[1].ThresholdRange),
+					},
+				},
+				Webhooks: []flaggerv1b1.CanaryWebhook{
+					{
+						Name:    "generated-testload-0",
+						Timeout: "50s",
+						URL:     "http://istio-system-testloader-kurator-member-loadtester.istio-system/",
+						Metadata: &map[string]string{
+							"type": "cmd",
+							"cmd":  "hey -z 1m -q 10 -c 2 http://podinfo-canary.test:9898/",
+						},
+					},
+					{
+						Name:    "generated-testload-1",
+						Timeout: "50s",
+						URL:     "http://istio-system-testloader-kurator-member-loadtester.istio-system/",
+						Metadata: &map[string]string{
+							"type": "cmd",
+							"cmd":  "curl -sd 'test' http://podinfo-canary:9898/token | grep token",
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := renderCanaryAnalysis(tt.args.rolloutPolicy, "kurator-member"); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("renderCanaryAnalysis() = %v\n, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+/*
+func Test_generateDeployConfig(t *testing.T) {
+	filepath := manifests.BuiltinOrDir("")
+	//fmt.Printf("%s", filepath)
+	deployname := "plugins/testloader-deploy.yaml"
+	namespacedName := types.NamespacedName{
+		Namespace: "test",
+		Name:      "podinfo",
+	}
+	if _, err := generateDeployConfig(filepath, deployname, namespacedName.Name, namespacedName.Namespace); err != nil {
+		fmt.Printf("failed get testloader deployment configuration: %v", err)
+	}
+}
+
+func Test_generateSvcConfig(t *testing.T) {
+	filepath := manifests.BuiltinOrDir("")
+	//fmt.Printf("%s", filepath)
+	svcname := "plugins/testloader-svc.yaml"
+	namespacedName := types.NamespacedName{
+		Namespace: "test",
+		Name:      "podinfo",
+	}
+	if _, err := generateSvcConfig(filepath, svcname, namespacedName.Name, namespacedName.Namespace); err != nil {
+		fmt.Printf("failed get testloader deployment configuration: %v", err)
+	}
+}
+*/
+
+func Test_addLables(t *testing.T) {
+	type args struct {
+		obj   client.Object
+		key   string
+		value string
+	}
+	tests := []struct {
+		name string
+		args args
+		want client.Object
+	}{
+		{
+			name: "function test",
+			args: args{
+				obj: &corev1.Namespace{
+					TypeMeta: metav1.TypeMeta{
+						Kind:       "Namespace",
+						APIVersion: "v1",
+					},
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "webapp",
+						Labels: map[string]string{
+							"xxx": "abc",
+						},
+					},
+				},
+				key:   "istio-injection",
+				value: "ebabled",
+			},
+			want: &corev1.Namespace{
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "Namespace",
+					APIVersion: "v1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "webapp",
+					Labels: map[string]string{
+						"xxx":             "abc",
+						"istio-injection": "ebabled",
+					},
+				},
+			},
+		},
+		{
+			name: "empty labels test",
+			args: args{
+				obj: &corev1.Namespace{
+					TypeMeta: metav1.TypeMeta{
+						Kind:       "Namespace",
+						APIVersion: "v1",
+					},
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "webapp",
+					},
+				},
+				key:   "XXX",
+				value: "abc",
+			},
+			want: &corev1.Namespace{
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "Namespace",
+					APIVersion: "v1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "webapp",
+					Labels: map[string]string{
+						"XXX": "abc",
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := addLabels(tt.args.obj, tt.args.key, tt.args.value); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("addLablesOrAnnotaions() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/pkg/fleet-manager/pipeline/render/pipeline_test.go b/pkg/fleet-manager/pipeline/render/pipeline_test.go
new file mode 100644
index 0000000..cf5b23d
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/pipeline_test.go
@@ -0,0 +1,116 @@
+/*
+Copyright Kurator 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 render
+
+import (
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	pipelineapi "kurator.dev/kurator/pkg/apis/pipeline/v1alpha1"
+)
+
+func TestRenderPipelineWithPipeline(t *testing.T) {
+	expectedPipelineFilePath := "testdata/pipeline/"
+	testPipeline := pipelineapi.Pipeline{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "test-pipeline",
+			Namespace: "kurator-pipeline",
+			OwnerReferences: []metav1.OwnerReference{
+				{
+					APIVersion: "v1",
+					Kind:       "Deployment",
+					Name:       "example-deployment",
+					UID:        "22345678-1234-1234-1234-123456789abc",
+				},
+			},
+		},
+	}
+	cases := []struct {
+		name         string
+		tasks        []pipelineapi.PipelineTask
+		expectError  bool
+		expectedFile string
+	}{
+		{
+			name: "valid pipeline configuration, contains tasks: git-clone, cat-readme, go-test",
+			tasks: []pipelineapi.PipelineTask{
+				{
+					Name:           "git-clone",
+					PredefinedTask: &pipelineapi.PredefinedTask{Name: pipelineapi.GitClone},
+				},
+				{
+					Name:       "cat-readme",
+					CustomTask: &pipelineapi.CustomTask{},
+				},
+				{
+					Name:           "go-test",
+					PredefinedTask: &pipelineapi.PredefinedTask{Name: pipelineapi.GoTest},
+				},
+			},
+			expectError:  false,
+			expectedFile: "readme-test.yaml",
+		},
+		{
+			name: "valid comprehensive pipeline configuration, contains tasks: git-clone, cat-readme, go-test, go-lint, push-and-build-image",
+			tasks: []pipelineapi.PipelineTask{
+				{
+					Name:           "git-clone",
+					PredefinedTask: &pipelineapi.PredefinedTask{Name: pipelineapi.GitClone},
+				},
+				{
+					Name:       "cat-readme",
+					CustomTask: &pipelineapi.CustomTask{},
+				},
+				{
+					Name:           "go-test",
+					PredefinedTask: &pipelineapi.PredefinedTask{Name: pipelineapi.GoTest},
+				},
+				{
+					Name:           "go-lint",
+					PredefinedTask: &pipelineapi.PredefinedTask{Name: pipelineapi.GoLint},
+				},
+				{
+					Name:           "build-and-push-image",
+					PredefinedTask: &pipelineapi.PredefinedTask{Name: pipelineapi.BuildPushImage},
+				},
+			},
+			expectError:  false,
+			expectedFile: "comprehensive.yaml",
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			testPipeline.Spec.Tasks = tc.tasks
+
+			result, err := RenderPipelineWithPipeline(&testPipeline)
+
+			if tc.expectError {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+
+				expected, err := os.ReadFile(expectedPipelineFilePath + tc.expectedFile)
+				assert.NoError(t, err)
+				assert.Equal(t, string(expected), string(result))
+			}
+		})
+	}
+}
diff --git a/pkg/fleet-manager/pipeline/render/predefined_task_test.go b/pkg/fleet-manager/pipeline/render/predefined_task_test.go
new file mode 100644
index 0000000..0af9be2
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/predefined_task_test.go
@@ -0,0 +1,188 @@
+/*
+Copyright Kurator 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 render
+
+import (
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	pipelineapi "kurator.dev/kurator/pkg/apis/pipeline/v1alpha1"
+)
+
+func TestRenderPredefinedTask(t *testing.T) {
+	expectedTaskFilePath := "testdata/predefined-task/"
+	// Define test cases for various task templates and configurations.
+	cases := []struct {
+		name         string
+		cfg          PredefinedTaskConfig
+		expectError  bool
+		expectedFile string
+	}{
+		// ---- Case: Default Configuration for Git Clone ----
+		// This case tests the basic configuration of the 'git-clone' template.
+		// It will not include auth, because auth will add in pipeline.
+		{
+			name: "git-clone with basic parameters",
+			cfg: PredefinedTaskConfig{
+				PipelineName: "test-pipeline",
+				Namespace:    "kurator-pipeline",
+				TemplateName: string(pipelineapi.GitClone),
+				Params:       map[string]string{},
+			},
+			expectError:  false,
+			expectedFile: "git-clone.yaml",
+		},
+
+		// ---- Case: Default Configuration for Go Test ----
+		// This case tests the default configuration of the 'go-test' template.
+		// It uses the default namespace and relies on all default parameter values.
+		{
+			name: "go-test with default parameters",
+			cfg: PredefinedTaskConfig{
+				PipelineName: "test-pipeline",
+				Namespace:    "default",
+				TemplateName: string(pipelineapi.GoTest),
+				Params:       map[string]string{},
+			},
+			expectError:  false,
+			expectedFile: "go-test-default.yaml",
+		},
+
+		// ---- Case: Custom Configuration for Go Test ----
+		// This case customizes the 'go-test' template: setting Go version to 1.20,
+		// targeting the './pkg/...' package path, and specifying the Linux ARM architecture.
+		{
+			name: "go-test with custom parameters - Go 1.20, ./pkg/..., Linux ARM",
+			cfg: PredefinedTaskConfig{
+				PipelineName: "test-pipeline",
+				Namespace:    "kurator-pipeline",
+				TemplateName: string(pipelineapi.GoTest),
+				Params: map[string]string{
+					"packages": "./pkg/...",
+					"version":  "1.20",
+					"GOOS":     "linux",
+					"GOARCH":   "arm",
+					"flags":    "-race -cover -v",
+				},
+			},
+			expectError:  false,
+			expectedFile: "go-test-custom-value.yaml",
+		},
+
+		// ---- Case: Custom Configuration for Go Lint ----
+		// This case customizes the 'go-lint' template: setting golangci-lint version to latest,
+		// just specifying additional linting flags.
+		{
+			name: "go-lint with custom parameters - latest version, ./src/..., extra flags",
+			cfg: PredefinedTaskConfig{
+				PipelineName: "test-pipeline",
+				Namespace:    "kurator-pipeline",
+				TemplateName: string(pipelineapi.GoLint),
+				Params:       map[string]string{},
+			},
+			expectError:  false,
+			expectedFile: "go-lint-custom-value.yaml",
+		},
+
+		// ---- Case: Advanced Custom Configuration for Go Lint ----
+		// This case customizes the 'go-lint' template for a more complex scenario: setting
+		// golangci-lint version to a specific older version (1.25.0), targeting a specific
+		// package path './cmd/...', and specifying custom linting flags for that context.
+		{
+			name: "advanced go-lint custom configuration - version 1.25.0, ./cmd/..., specific flags",
+			cfg: PredefinedTaskConfig{
+				PipelineName: "test-pipeline",
+				Namespace:    "kurator-pipeline",
+				TemplateName: string(pipelineapi.GoLint),
+				Params: map[string]string{
+					"package": "./cmd/...",
+					"version": "1.25.0",
+					"flags":   "--enable=govet --enable=errcheck",
+					"GOOS":    "linux",
+					"GOARCH":  "arm",
+				},
+			},
+			expectError:  false,
+			expectedFile: "go-lint-advanced-config.yaml",
+		},
+
+		// ---- Case: default Configuration for build and upload image ----
+		{
+			name: "Custom Configuration for Kaniko Build Task",
+			cfg: PredefinedTaskConfig{
+				PipelineName: "test-pipeline",
+				Namespace:    "kurator-pipeline",
+				TemplateName: string(pipelineapi.BuildPushImage),
+				Params: map[string]string{
+					"image": "ghcr.io/test-orz/test-image:0.3.1",
+				},
+				OwnerReference: &metav1.OwnerReference{
+					APIVersion: "v1",
+					Kind:       "Deployment",
+					Name:       "example-deployment",
+					UID:        "22345678-1234-1234-1234-123456789abc",
+				},
+			},
+			expectError:  false,
+			expectedFile: "build-and-push-image-default.yaml",
+		},
+
+		{
+			name: "Custom Dockerfile and Context for Kaniko Build Task",
+			cfg: PredefinedTaskConfig{
+				PipelineName: "test-pipeline",
+				Namespace:    "kurator-pipeline",
+				TemplateName: string(pipelineapi.BuildPushImage),
+				Params: map[string]string{
+					"image":      "ghcr.io/test-orz/test-image:0.3.2",
+					"dockerfile": "./app/Dockerfile",
+					"context":    "./app/",
+				},
+				OwnerReference: &metav1.OwnerReference{
+					APIVersion: "v1",
+					Kind:       "Deployment",
+					Name:       "example-deployment",
+					UID:        "22345678-1234-1234-1234-123456789abc",
+				},
+			},
+			expectError:  false,
+			expectedFile: "build-and-push-image-custom.yaml",
+		},
+
+		// TODO: Add more test cases here for different task templates or configurations
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			result, err := RenderPredefinedTask(tc.cfg)
+
+			// Test assertions
+			if tc.expectError {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+
+				expected, err := os.ReadFile(expectedTaskFilePath + tc.expectedFile)
+				assert.NoError(t, err)
+				assert.Equal(t, string(expected), string(result))
+			}
+		})
+	}
+}
diff --git a/pkg/fleet-manager/pipeline/render/testdata/pipeline/comprehensive.yaml b/pkg/fleet-manager/pipeline/render/testdata/pipeline/comprehensive.yaml
new file mode 100644
index 0000000..909cc48
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/testdata/pipeline/comprehensive.yaml
@@ -0,0 +1,84 @@
+apiVersion: tekton.dev/v1beta1
+kind: Pipeline
+metadata:
+  name: test-pipeline
+  namespace: kurator-pipeline
+  ownerReferences:
+  - apiVersion: ""
+    kind: ""
+    name: "test-pipeline"
+    uid: ""
+spec:
+  description: |
+    This is a universal pipeline with the following settings: 
+      1. No parameters are passed because all user parameters have already been rendered into the corresponding tasks. 
+      2. All tasks are strictly executed in the order defined by the user, with each task starting only after the previous one is completed. 
+      3. There is only one workspace, which is used by all tasks. The PVC for this workspace will be configured in the trigger.
+  params:
+  - name: repo-url
+    type: string
+    description: The git repository URL to clone from.
+  - name: revision
+    type: string
+    description: The git branch to clone.
+  workspaces:
+  - name: kurator-pipeline-shared-data
+    description: |
+      This workspace is used by all tasks
+  - name: git-credentials
+    description: |
+      A Workspace containing a .gitconfig and .git-credentials file. These
+      will be copied to the user's home before any git commands are run. Any
+      other files in this Workspace are ignored.
+  - name: docker-credentials
+    description: |
+      This is the credentials for build and push image task.
+  tasks:
+  - name: git-clone
+    # Key points about 'git-clone':
+    # - Fundamental for all tasks.
+    # - Closely integrated with the trigger.
+    # - Always the first task in the pipeline.
+    # - Cannot be modified via templates.
+    taskRef:
+      name: git-clone-test-pipeline
+    workspaces:
+    - name: source
+      workspace: kurator-pipeline-shared-data
+    - name: basic-auth
+      workspace: git-credentials
+    params:
+    - name: url
+      value: $(params.repo-url)
+    - name: revision
+      value: $(params.revision)
+  - name: cat-readme
+    taskRef:
+      name: cat-readme-test-pipeline
+    runAfter: ["git-clone"]
+    workspaces:
+    - name: source
+      workspace: kurator-pipeline-shared-data
+  - name: go-test
+    taskRef:
+      name: go-test-test-pipeline
+    runAfter: ["cat-readme"]
+    workspaces:
+    - name: source
+      workspace: kurator-pipeline-shared-data
+  - name: go-lint
+    taskRef:
+      name: go-lint-test-pipeline
+    runAfter: ["go-test"]
+    workspaces:
+    - name: source
+      workspace: kurator-pipeline-shared-data
+  - name: build-and-push-image
+    taskRef:
+      name: build-and-push-image-test-pipeline
+    runAfter: ["go-lint"]
+    workspaces:
+    - name: source
+      workspace: kurator-pipeline-shared-data
+    - name: dockerconfig
+      workspace: docker-credentials
diff --git a/pkg/fleet-manager/pipeline/render/testdata/pipeline/readme-test.yaml b/pkg/fleet-manager/pipeline/render/testdata/pipeline/readme-test.yaml
new file mode 100644
index 0000000..5876f43
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/testdata/pipeline/readme-test.yaml
@@ -0,0 +1,65 @@
+apiVersion: tekton.dev/v1beta1
+kind: Pipeline
+metadata:
+  name: test-pipeline
+  namespace: kurator-pipeline
+  ownerReferences:
+  - apiVersion: ""
+    kind: ""
+    name: "test-pipeline"
+    uid: ""
+spec:
+  description: |
+    This is a universal pipeline with the following settings: 
+      1. No parameters are passed because all user parameters have already been rendered into the corresponding tasks. 
+      2. All tasks are strictly executed in the order defined by the user, with each task starting only after the previous one is completed. 
+      3. There is only one workspace, which is used by all tasks. The PVC for this workspace will be configured in the trigger.
+  params:
+  - name: repo-url
+    type: string
+    description: The git repository URL to clone from.
+  - name: revision
+    type: string
+    description: The git branch to clone.
+  workspaces:
+  - name: kurator-pipeline-shared-data
+    description: |
+      This workspace is used by all tasks
+  - name: git-credentials
+    description: |
+      A Workspace containing a .gitconfig and .git-credentials file. These
+      will be copied to the user's home before any git commands are run. Any
+      other files in this Workspace are ignored.
+  tasks:
+  - name: git-clone
+    # Key points about 'git-clone':
+    # - Fundamental for all tasks.
+    # - Closely integrated with the trigger.
+    # - Always the first task in the pipeline.
+    # - Cannot be modified via templates.
+    taskRef:
+      name: git-clone-test-pipeline
+    workspaces:
+    - name: source
+      workspace: kurator-pipeline-shared-data
+    - name: basic-auth
+      workspace: git-credentials
+    params:
+    - name: url
+      value: $(params.repo-url)
+    - name: revision
+      value: $(params.revision)
+  - name: cat-readme
+    taskRef:
+      name: cat-readme-test-pipeline
+    runAfter: ["git-clone"]
+    workspaces:
+    - name: source
+      workspace: kurator-pipeline-shared-data
+  - name: go-test
+    taskRef:
+      name: go-test-test-pipeline
+    runAfter: ["cat-readme"]
+    workspaces:
+    - name: source
+      workspace: kurator-pipeline-shared-data
diff --git a/pkg/fleet-manager/pipeline/render/testdata/predefined-task/build-and-push-image-custom.yaml b/pkg/fleet-manager/pipeline/render/testdata/predefined-task/build-and-push-image-custom.yaml
new file mode 100644
index 0000000..e4014d0
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/testdata/predefined-task/build-and-push-image-custom.yaml
@@ -0,0 +1,73 @@
+apiVersion: tekton.dev/v1beta1
+kind: Task
+metadata:
+  name: build-and-push-image-test-pipeline
+  namespace: kurator-pipeline
+  labels:
+    app.kubernetes.io/version: "0.6"
+  annotations:
+    tekton.dev/pipelines.minVersion: "0.17.0"
+    tekton.dev/categories: Image Build
+    tekton.dev/tags: image-build
+    tekton.dev/displayName: "Build and upload container image using Kaniko"
+    tekton.dev/platforms: "linux/amd64,linux/arm64,linux/ppc64le"
+  ownerReferences:
+  - apiVersion: "v1"
+    kind: "Deployment"
+    name: "example-deployment"
+    uid: "22345678-1234-1234-1234-123456789abc"
+spec:
+  description: >-
+    This Task builds a simple Dockerfile with kaniko and pushes to a registry.
+    This Task stores the image name and digest as results, allowing Tekton Chains to pick up
+    that an image was built & sign it.
+  params:
+  - name: IMAGE # This is a parameter that must be set.
+    description: Name (reference) of the image to build.
+    default: ghcr.io/test-orz/test-image:0.3.2
+  - name: DOCKERFILE
+    description: Path to the Dockerfile to build.
+    default: ./app/Dockerfile
+  - name: CONTEXT
+    description: The build context used by Kaniko.
+    default: ./app/
+  - name: EXTRA_ARGS # more details see https://github.com/GoogleContainerTools/kaniko?tab=readme-ov-file#additional-flags
+    type: array
+    default: []
+  - name: BUILDER_IMAGE
+    description: The image on which builds will run (default is v1.19.2 debug)
+    default: gcr.io/kaniko-project/executor@sha256:899886a2db1c127ff1565d5c7b1e574af1810bbdad048e9850e4f40b5848d79c
+  workspaces:
+  - name: source
+    description: Holds the context and Dockerfile
+  - name: dockerconfig
+    description: Includes a docker "config.json"
+    optional: true
+    mountPath: /kaniko/.docker
+  results:
+  - name: IMAGE_DIGEST
+    description: Digest of the image just built.
+  - name: IMAGE_URL
+    description: URL of the image just built.
+  steps:
+  - name: build-and-push
+    workingDir: $(workspaces.source.path)
+    image: $(params.BUILDER_IMAGE)
+    args:
+    - $(params.EXTRA_ARGS)
+    - --dockerfile=$(params.DOCKERFILE)
+    - --context=$(workspaces.source.path)/$(params.CONTEXT) # The user does not need to care the workspace and the source.
+    - --destination=$(params.IMAGE)
+    - --digest-file=$(results.IMAGE_DIGEST.path)
+    - --ignore-path=/product_uuid # in case kind cluster run failed, see https://github.com/GoogleContainerTools/kaniko/issues/2164
+    # kaniko assumes it is running as root, which means this example fails on platforms
+    # that default to run containers as random uid (like OpenShift). Adding this securityContext
+    # makes it explicit that it needs to run as root.
+    securityContext:
+      runAsUser: 0
+  - name: write-url
+    image: docker.io/library/bash:5.1.4@sha256:c523c636b722339f41b6a431b44588ab2f762c5de5ec3bd7964420ff982fb1d9
+    script: |
+      set -e
+      image="$(params.IMAGE)"
+      echo -n "${image}" | tee "$(results.IMAGE_URL.path)"
diff --git a/pkg/fleet-manager/pipeline/render/testdata/predefined-task/build-and-push-image-default.yaml b/pkg/fleet-manager/pipeline/render/testdata/predefined-task/build-and-push-image-default.yaml
new file mode 100644
index 0000000..dabcb4b
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/testdata/predefined-task/build-and-push-image-default.yaml
@@ -0,0 +1,73 @@
+apiVersion: tekton.dev/v1beta1
+kind: Task
+metadata:
+  name: build-and-push-image-test-pipeline
+  namespace: kurator-pipeline
+  labels:
+    app.kubernetes.io/version: "0.6"
+  annotations:
+    tekton.dev/pipelines.minVersion: "0.17.0"
+    tekton.dev/categories: Image Build
+    tekton.dev/tags: image-build
+    tekton.dev/displayName: "Build and upload container image using Kaniko"
+    tekton.dev/platforms: "linux/amd64,linux/arm64,linux/ppc64le"
+  ownerReferences:
+  - apiVersion: "v1"
+    kind: "Deployment"
+    name: "example-deployment"
+    uid: "22345678-1234-1234-1234-123456789abc"
+spec:
+  description: >-
+    This Task builds a simple Dockerfile with kaniko and pushes to a registry.
+    This Task stores the image name and digest as results, allowing Tekton Chains to pick up
+    that an image was built & sign it.
+  params:
+  - name: IMAGE # This is a parameter that must be set.
+    description: Name (reference) of the image to build.
+    default: ghcr.io/test-orz/test-image:0.3.1
+  - name: DOCKERFILE
+    description: Path to the Dockerfile to build.
+    default: ./Dockerfile
+  - name: CONTEXT
+    description: The build context used by Kaniko.
+    default: ./
+  - name: EXTRA_ARGS # more details see https://github.com/GoogleContainerTools/kaniko?tab=readme-ov-file#additional-flags
+    type: array
+    default: []
+  - name: BUILDER_IMAGE
+    description: The image on which builds will run (default is v1.19.2 debug)
+    default: gcr.io/kaniko-project/executor@sha256:899886a2db1c127ff1565d5c7b1e574af1810bbdad048e9850e4f40b5848d79c
+  workspaces:
+  - name: source
+    description: Holds the context and Dockerfile
+  - name: dockerconfig
+    description: Includes a docker "config.json"
+    optional: true
+    mountPath: /kaniko/.docker
+  results:
+  - name: IMAGE_DIGEST
+    description: Digest of the image just built.
+  - name: IMAGE_URL
+    description: URL of the image just built.
+  steps:
+  - name: build-and-push
+    workingDir: $(workspaces.source.path)
+    image: $(params.BUILDER_IMAGE)
+    args:
+    - $(params.EXTRA_ARGS)
+    - --dockerfile=$(params.DOCKERFILE)
+    - --context=$(workspaces.source.path)/$(params.CONTEXT) # The user does not need to care the workspace and the source.
+    - --destination=$(params.IMAGE)
+    - --digest-file=$(results.IMAGE_DIGEST.path)
+    - --ignore-path=/product_uuid # in case kind cluster run failed, see https://github.com/GoogleContainerTools/kaniko/issues/2164
+    # kaniko assumes it is running as root, which means this example fails on platforms
+    # that default to run containers as random uid (like OpenShift). Adding this securityContext
+    # makes it explicit that it needs to run as root.
+    securityContext:
+      runAsUser: 0
+  - name: write-url
+    image: docker.io/library/bash:5.1.4@sha256:c523c636b722339f41b6a431b44588ab2f762c5de5ec3bd7964420ff982fb1d9
+    script: |
+      set -e
+      image="$(params.IMAGE)"
+      echo -n "${image}" | tee "$(results.IMAGE_URL.path)"
diff --git a/pkg/fleet-manager/pipeline/render/testdata/predefined-task/git-clone.yaml b/pkg/fleet-manager/pipeline/render/testdata/predefined-task/git-clone.yaml
new file mode 100644
index 0000000..31974a3
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/testdata/predefined-task/git-clone.yaml
@@ -0,0 +1,234 @@
+apiVersion: tekton.dev/v1beta1
+kind: Task
+metadata:
+  name: git-clone-test-pipeline
+  namespace: kurator-pipeline
+  labels:
+    app.kubernetes.io/version: "0.9"
+  annotations:
+    tekton.dev/pipelines.minVersion: "0.38.0"
+    tekton.dev/categories: Git
+    tekton.dev/tags: git
+    tekton.dev/displayName: "git clone"
+    tekton.dev/platforms: "linux/amd64,linux/s390x,linux/ppc64le,linux/arm64"
+spec:
+  description: >-
+    These Tasks are Git tasks to work with repositories used by other tasks
+    in your Pipeline.
+    The git-clone Task will clone a repo from the provided url into the
+    source Workspace. By default the repo will be cloned into the root of
+    your Workspace. You can clone into a subdirectory by setting this Task's
+    subdirectory param. This Task also supports sparse checkouts. To perform
+    a sparse checkout, pass a list of comma separated directory patterns to
+    this Task's sparseCheckoutDirectories param.
+  workspaces:
+  - name: source
+    description: The git repo will be cloned onto the volume backing this Workspace.
+  - name: ssh-directory
+    optional: true
+    description: |
+      A .ssh directory with private key, known_hosts, config, etc. Copied to
+      the user's home before git commands are executed. Used to authenticate
+      with the git remote when performing the clone. Binding a Secret to this
+      Workspace is strongly recommended over other volume types.
+  - name: basic-auth
+    optional: true
+    description: |
+      A Workspace containing a .gitconfig and .git-credentials file. These
+      will be copied to the user's home before any git commands are run. Any
+      other files in this Workspace are ignored. It is strongly recommended
+      to use ssh-directory over basic-auth whenever possible and to bind a
+      Secret to this Workspace over other volume types.
+  - name: ssl-ca-directory
+    optional: true
+    description: |
+      A workspace containing CA certificates, this will be used by Git to
+      verify the peer with when fetching or pushing over HTTPS.
+  params:
+  - name: url
+    description: Repository URL to clone from.
+    type: string
+  - name: revision
+    description: Revision to checkout. (branch, tag, sha, ref, etc...)
+    type: string
+    default: ""
+  - name: refspec
+    description: Refspec to fetch before checking out revision.
+    default: ""
+  - name: submodules
+    description: Initialize and fetch git submodules.
+    type: string
+    default: "true"
+  - name: depth
+    description: Perform a shallow clone, fetching only the most recent N commits.
+    type: string
+    default: "1"
+  - name: sslVerify
+    description: Set the "http.sslVerify" global git config. Setting this to "false" is not advised unless you are sure that you trust your git remote.
+    type: string
+    default: "true"
+  - name: crtFileName
+    description: file name of mounted crt using ssl-ca-directory workspace. default value is ca-bundle.crt.
+    type: string
+    default: "ca-bundle.crt"
+  - name: subdirectory
+    description: Subdirectory inside the "source" Workspace to clone the repo into.
+    type: string
+    default: ""
+  - name: sparseCheckoutDirectories
+    description: Define the directory patterns to match or exclude when performing a sparse checkout.
+    type: string
+    default: ""
+  - name: deleteExisting
+    description: Clean out the contents of the destination directory if it already exists before cloning.
+    type: string
+    default: "true"
+  - name: httpProxy
+    description: HTTP proxy server for non-SSL requests.
+    type: string
+    default: ""
+  - name: httpsProxy
+    description: HTTPS proxy server for SSL requests.
+    type: string
+    default: ""
+  - name: noProxy
+    description: Opt out of proxying HTTP/HTTPS requests.
+    type: string
+    default: ""
+  - name: verbose
+    description: Log the commands that are executed during "git-clone"'s operation.
+    type: string
+    default: "true"
+  - name: gitInitImage
+    description: The image providing the git-init binary that this Task runs.
+    type: string
+    default: "gcr.io/tekton-releases/github.com/tektoncd/pipeline/cmd/git-init:v0.40.2"
+  - name: userHome
+    description: |
+      Absolute path to the user's home directory.
+    type: string
+    default: "/home/git"
+  results:
+  - name: commit
+    description: The precise commit SHA that was fetched by this Task.
+  - name: url
+    description: The precise URL that was fetched by this Task.
+  - name: committer-date
+    description: The epoch timestamp of the commit that was fetched by this Task.
+  steps:
+  - name: clone
+    image: "$(params.gitInitImage)"
+    env:
+    - name: HOME
+      value: "$(params.userHome)"
+    - name: PARAM_URL
+      value: $(params.url)
+    - name: PARAM_REVISION
+      value: $(params.revision)
+    - name: PARAM_REFSPEC
+      value: $(params.refspec)
+    - name: PARAM_SUBMODULES
+      value: $(params.submodules)
+    - name: PARAM_DEPTH
+      value: $(params.depth)
+    - name: PARAM_SSL_VERIFY
+      value: $(params.sslVerify)
+    - name: PARAM_CRT_FILENAME
+      value: $(params.crtFileName)
+    - name: PARAM_SUBDIRECTORY
+      value: $(params.subdirectory)
+    - name: PARAM_DELETE_EXISTING
+      value: $(params.deleteExisting)
+    - name: PARAM_HTTP_PROXY
+      value: $(params.httpProxy)
+    - name: PARAM_HTTPS_PROXY
+      value: $(params.httpsProxy)
+    - name: PARAM_NO_PROXY
+      value: $(params.noProxy)
+    - name: PARAM_VERBOSE
+      value: $(params.verbose)
+    - name: PARAM_SPARSE_CHECKOUT_DIRECTORIES
+      value: $(params.sparseCheckoutDirectories)
+    - name: PARAM_USER_HOME
+      value: $(params.userHome)
+    - name: WORKSPACE_SOURCE_PATH
+      value: $(workspaces.source.path)
+    - name: WORKSPACE_SSH_DIRECTORY_BOUND
+      value: $(workspaces.ssh-directory.bound)
+    - name: WORKSPACE_SSH_DIRECTORY_PATH
+      value: $(workspaces.ssh-directory.path)
+    - name: WORKSPACE_BASIC_AUTH_DIRECTORY_BOUND
+      value: $(workspaces.basic-auth.bound)
+    - name: WORKSPACE_BASIC_AUTH_DIRECTORY_PATH
+      value: $(workspaces.basic-auth.path)
+    - name: WORKSPACE_SSL_CA_DIRECTORY_BOUND
+      value: $(workspaces.ssl-ca-directory.bound)
+    - name: WORKSPACE_SSL_CA_DIRECTORY_PATH
+      value: $(workspaces.ssl-ca-directory.path)
+    securityContext:
+      runAsNonRoot: true
+      runAsUser: 65532
+    script: |
+      #!/usr/bin/env sh
+      set -eu
+      if [ "${PARAM_VERBOSE}" = "true" ] ; then
+        set -x
+      fi
+      if [ "${WORKSPACE_BASIC_AUTH_DIRECTORY_BOUND}" = "true" ] ; then
+        cp "${WORKSPACE_BASIC_AUTH_DIRECTORY_PATH}/.git-credentials" "${PARAM_USER_HOME}/.git-credentials"
+        cp "${WORKSPACE_BASIC_AUTH_DIRECTORY_PATH}/.gitconfig" "${PARAM_USER_HOME}/.gitconfig"
+        chmod 400 "${PARAM_USER_HOME}/.git-credentials"
+        chmod 400 "${PARAM_USER_HOME}/.gitconfig"
+      fi
+      if [ "${WORKSPACE_SSH_DIRECTORY_BOUND}" = "true" ] ; then
+        cp -R "${WORKSPACE_SSH_DIRECTORY_PATH}" "${PARAM_USER_HOME}"/.ssh
+        chmod 700 "${PARAM_USER_HOME}"/.ssh
+        chmod -R 400 "${PARAM_USER_HOME}"/.ssh/*
+      fi
+      if [ "${WORKSPACE_SSL_CA_DIRECTORY_BOUND}" = "true" ] ; then
+         export GIT_SSL_CAPATH="${WORKSPACE_SSL_CA_DIRECTORY_PATH}"
+         if [ "${PARAM_CRT_FILENAME}" != "" ] ; then
+            export GIT_SSL_CAINFO="${WORKSPACE_SSL_CA_DIRECTORY_PATH}/${PARAM_CRT_FILENAME}"
+         fi
+      fi
+      CHECKOUT_DIR="${WORKSPACE_SOURCE_PATH}/${PARAM_SUBDIRECTORY}"
+      cleandir() {
+        # Delete any existing contents of the repo directory if it exists.
+        #
+        # We don't just "rm -rf ${CHECKOUT_DIR}" because ${CHECKOUT_DIR} might be "/"
+        # or the root of a mounted volume.
+        if [ -d "${CHECKOUT_DIR}" ] ; then
+          # Delete non-hidden files and directories
+          rm -rf "${CHECKOUT_DIR:?}"/*
+          # Delete files and directories starting with . but excluding ..
+          rm -rf "${CHECKOUT_DIR}"/.[!.]*
+          # Delete files and directories starting with .. plus any other character
+          rm -rf "${CHECKOUT_DIR}"/..?*
+        fi
+      }
+      if [ "${PARAM_DELETE_EXISTING}" = "true" ] ; then
+        cleandir || true
+      fi
+      test -z "${PARAM_HTTP_PROXY}" || export HTTP_PROXY="${PARAM_HTTP_PROXY}"
+      test -z "${PARAM_HTTPS_PROXY}" || export HTTPS_PROXY="${PARAM_HTTPS_PROXY}"
+      test -z "${PARAM_NO_PROXY}" || export NO_PROXY="${PARAM_NO_PROXY}"
+      git config --global --add safe.directory "${WORKSPACE_SOURCE_PATH}"
+      /ko-app/git-init \
+        -url="${PARAM_URL}" \
+        -revision="${PARAM_REVISION}" \
+        -refspec="${PARAM_REFSPEC}" \
+        -path="${CHECKOUT_DIR}" \
+        -sslVerify="${PARAM_SSL_VERIFY}" \
+        -submodules="${PARAM_SUBMODULES}" \
+        -depth="${PARAM_DEPTH}" \
+        -sparseCheckoutDirectories="${PARAM_SPARSE_CHECKOUT_DIRECTORIES}"
+      cd "${CHECKOUT_DIR}"
+      RESULT_SHA="$(git rev-parse HEAD)"
+      EXIT_CODE="$?"
+      if [ "${EXIT_CODE}" != 0 ] ; then
+        exit "${EXIT_CODE}"
+      fi
+      RESULT_COMMITTER_DATE="$(git log -1 --pretty=%ct)"
+      printf "%s" "${RESULT_COMMITTER_DATE}" > "$(results.committer-date.path)"
+      printf "%s" "${RESULT_SHA}" > "$(results.commit.path)"
+      printf "%s" "${PARAM_URL}" > "$(results.url.path)"
diff --git a/pkg/fleet-manager/pipeline/render/testdata/predefined-task/go-lint-advanced-config.yaml b/pkg/fleet-manager/pipeline/render/testdata/predefined-task/go-lint-advanced-config.yaml
new file mode 100644
index 0000000..1239a9a
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/testdata/predefined-task/go-lint-advanced-config.yaml
@@ -0,0 +1,72 @@
+apiVersion: tekton.dev/v1beta1
+kind: Task
+metadata:
+  name: go-lint-test-pipeline
+  namespace: kurator-pipeline
+  labels:
+    app.kubernetes.io/version: "0.2"
+  annotations:
+    tekton.dev/pipelines.minVersion: "0.12.1"
+    tekton.dev/categories: Code Quality
+    tekton.dev/tags: lint
+    tekton.dev/displayName: "golangci lint"
+    tekton.dev/platforms: "linux/amd64"
+spec:
+  description: >-
+    This Task is Golang task to validate Go projects.
+
+  params:
+  - name: package
+    description: base package (and its children) under validation
+    default: "./cmd/..."
+  - name: context
+    description: path to the directory to use as context.
+    default: "."
+  - name: flags
+    description: flags to use for the lint command
+    default: "--enable=govet --enable=errcheck"
+  - name: version
+    description: golangci-lint version to use
+    default: "1.25.0"
+  - name: GOOS
+    description: "running program's operating system target"
+    default: "linux"
+  - name: GOARCH
+    description: "running program's architecture target"
+    default: "arm"
+  - name: GO111MODULE
+    description: "value of module support"
+    default: "auto"
+  - name: GOCACHE
+    description: "Go caching directory path"
+    default: ""
+  - name: GOMODCACHE
+    description: "Go mod caching directory path"
+    default: ""
+  - name: GOLANGCI_LINT_CACHE
+    description: "golangci-lint cache path"
+    default: ""
+  workspaces:
+  - name: source
+    mountPath: /workspace/src/$(params.package)
+  steps:
+  - name: lint
+    image: docker.io/golangci/golangci-lint:$(params.version)
+    workingDir: $(workspaces.source.path)/$(params.context)
+    script: |
+      golangci-lint run $(params.flags)
+    env:
+    - name: GOPATH
+      value: /workspace
+    - name: GOOS
+      value: "$(params.GOOS)"
+    - name: GOARCH
+      value: "$(params.GOARCH)"
+    - name: GO111MODULE
+      value: "$(params.GO111MODULE)"
+    - name: GOCACHE
+      value: "$(params.GOCACHE)"
+    - name: GOMODCACHE
+      value: "$(params.GOMODCACHE)"
+    - name: GOLANGCI_LINT_CACHE
+      value: "$(params.GOLANGCI_LINT_CACHE)"
diff --git a/pkg/fleet-manager/pipeline/render/testdata/predefined-task/go-lint-custom-value.yaml b/pkg/fleet-manager/pipeline/render/testdata/predefined-task/go-lint-custom-value.yaml
new file mode 100644
index 0000000..d3f6208
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/testdata/predefined-task/go-lint-custom-value.yaml
@@ -0,0 +1,72 @@
+apiVersion: tekton.dev/v1beta1
+kind: Task
+metadata:
+  name: go-lint-test-pipeline
+  namespace: kurator-pipeline
+  labels:
+    app.kubernetes.io/version: "0.2"
+  annotations:
+    tekton.dev/pipelines.minVersion: "0.12.1"
+    tekton.dev/categories: Code Quality
+    tekton.dev/tags: lint
+    tekton.dev/displayName: "golangci lint"
+    tekton.dev/platforms: "linux/amd64"
+spec:
+  description: >-
+    This Task is Golang task to validate Go projects.
+
+  params:
+  - name: package
+    description: base package (and its children) under validation
+    default: "."
+  - name: context
+    description: path to the directory to use as context.
+    default: "."
+  - name: flags
+    description: flags to use for the lint command
+    default: "--verbose"
+  - name: version
+    description: golangci-lint version to use
+    default: "latest"
+  - name: GOOS
+    description: "running program's operating system target"
+    default: "linux"
+  - name: GOARCH
+    description: "running program's architecture target"
+    default: "amd64"
+  - name: GO111MODULE
+    description: "value of module support"
+    default: "auto"
+  - name: GOCACHE
+    description: "Go caching directory path"
+    default: ""
+  - name: GOMODCACHE
+    description: "Go mod caching directory path"
+    default: ""
+  - name: GOLANGCI_LINT_CACHE
+    description: "golangci-lint cache path"
+    default: ""
+  workspaces:
+  - name: source
+    mountPath: /workspace/src/$(params.package)
+  steps:
+  - name: lint
+    image: docker.io/golangci/golangci-lint:$(params.version)
+    workingDir: $(workspaces.source.path)/$(params.context)
+    script: |
+      golangci-lint run $(params.flags)
+    env:
+    - name: GOPATH
+      value: /workspace
+    - name: GOOS
+      value: "$(params.GOOS)"
+    - name: GOARCH
+      value: "$(params.GOARCH)"
+    - name: GO111MODULE
+      value: "$(params.GO111MODULE)"
+    - name: GOCACHE
+      value: "$(params.GOCACHE)"
+    - name: GOMODCACHE
+      value: "$(params.GOMODCACHE)"
+    - name: GOLANGCI_LINT_CACHE
+      value: "$(params.GOLANGCI_LINT_CACHE)"
diff --git a/pkg/fleet-manager/pipeline/render/testdata/predefined-task/go-test-custom-value.yaml b/pkg/fleet-manager/pipeline/render/testdata/predefined-task/go-test-custom-value.yaml
new file mode 100644
index 0000000..def4707
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/testdata/predefined-task/go-test-custom-value.yaml
@@ -0,0 +1,73 @@
+apiVersion: tekton.dev/v1beta1
+kind: Task
+metadata:
+  name: go-test-test-pipeline
+  namespace: kurator-pipeline
+  labels:
+    app.kubernetes.io/version: "0.2"
+  annotations:
+    tekton.dev/pipelines.minVersion: "0.12.1"
+    tekton.dev/categories: Testing
+    tekton.dev/tags: test
+    tekton.dev/displayName: "golang test"
+    tekton.dev/platforms: "linux/amd64,linux/s390x,linux/ppc64le"
+spec:
+  description: >-
+    This Task is Golang task to test Go projects.
+
+  params:
+  - name: package
+    description: package (and its children) under test
+    default: "."
+  - name: packages
+    description: "packages to test (default: ./...)"
+    default: "./pkg/..."
+  - name: context
+    description: path to the directory to use as context.
+    default: "."
+  - name: version
+    description: golang version to use for tests
+    default: "1.20"
+  - name: flags
+    description: flags to use for the test command
+    default: "-race -cover -v"
+  - name: GOOS
+    description: "running program's operating system target"
+    default: "linux"
+  - name: GOARCH
+    description: "running program's architecture target"
+    default: "arm"
+  - name: GO111MODULE
+    description: "value of module support"
+    default: "auto"
+  - name: GOCACHE
+    description: "Go caching directory path"
+    default: ""
+  - name: GOMODCACHE
+    description: "Go mod caching directory path"
+    default: ""
+  workspaces:
+  - name: source
+  steps:
+  - name: unit-test
+    image: docker.io/library/golang:$(params.version)
+    workingDir: $(workspaces.source.path)
+    script: |
+      if [ ! -e $GOPATH/src/$(params.package)/go.mod ];then
+         SRC_PATH="$GOPATH/src/$(params.package)"
+         mkdir -p $SRC_PATH
+         cp -R "$(workspaces.source.path)/$(params.context)"/* $SRC_PATH
+         cd $SRC_PATH
+      fi
+      go test $(params.flags) $(params.packages)
+    env:
+    - name: GOOS
+      value: "$(params.GOOS)"
+    - name: GOARCH
+      value: "$(params.GOARCH)"
+    - name: GO111MODULE
+      value: "$(params.GO111MODULE)"
+    - name: GOCACHE
+      value: "$(params.GOCACHE)"
+    - name: GOMODCACHE
+      value: "$(params.GOMODCACHE)"
diff --git a/pkg/fleet-manager/pipeline/render/testdata/predefined-task/go-test-default.yaml b/pkg/fleet-manager/pipeline/render/testdata/predefined-task/go-test-default.yaml
new file mode 100644
index 0000000..1e66eb3
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/testdata/predefined-task/go-test-default.yaml
@@ -0,0 +1,73 @@
+apiVersion: tekton.dev/v1beta1
+kind: Task
+metadata:
+  name: go-test-test-pipeline
+  namespace: default
+  labels:
+    app.kubernetes.io/version: "0.2"
+  annotations:
+    tekton.dev/pipelines.minVersion: "0.12.1"
+    tekton.dev/categories: Testing
+    tekton.dev/tags: test
+    tekton.dev/displayName: "golang test"
+    tekton.dev/platforms: "linux/amd64,linux/s390x,linux/ppc64le"
+spec:
+  description: >-
+    This Task is Golang task to test Go projects.
+
+  params:
+  - name: package
+    description: package (and its children) under test
+    default: "."
+  - name: packages
+    description: "packages to test (default: ./...)"
+    default: "./..."
+  - name: context
+    description: path to the directory to use as context.
+    default: "."
+  - name: version
+    description: golang version to use for tests
+    default: "latest"
+  - name: flags
+    description: flags to use for the test command
+    default: "-race -cover -v"
+  - name: GOOS
+    description: "running program's operating system target"
+    default: "linux"
+  - name: GOARCH
+    description: "running program's architecture target"
+    default: "amd64"
+  - name: GO111MODULE
+    description: "value of module support"
+    default: "auto"
+  - name: GOCACHE
+    description: "Go caching directory path"
+    default: ""
+  - name: GOMODCACHE
+    description: "Go mod caching directory path"
+    default: ""
+  workspaces:
+  - name: source
+  steps:
+  - name: unit-test
+    image: docker.io/library/golang:$(params.version)
+    workingDir: $(workspaces.source.path)
+    script: |
+      if [ ! -e $GOPATH/src/$(params.package)/go.mod ];then
+         SRC_PATH="$GOPATH/src/$(params.package)"
+         mkdir -p $SRC_PATH
+         cp -R "$(workspaces.source.path)/$(params.context)"/* $SRC_PATH
+         cd $SRC_PATH
+      fi
+      go test $(params.flags) $(params.packages)
+    env:
+    - name: GOOS
+      value: "$(params.GOOS)"
+    - name: GOARCH
+      value: "$(params.GOARCH)"
+    - name: GO111MODULE
+      value: "$(params.GO111MODULE)"
+    - name: GOCACHE
+      value: "$(params.GOCACHE)"
+    - name: GOMODCACHE
+      value: "$(params.GOMODCACHE)"
diff --git a/pkg/fleet-manager/pipeline/render/testdata/trigger/default.yaml b/pkg/fleet-manager/pipeline/render/testdata/trigger/default.yaml
new file mode 100644
index 0000000..6fd299c
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/testdata/trigger/default.yaml
@@ -0,0 +1,70 @@
+apiVersion: triggers.tekton.dev/v1alpha1
+kind: TriggerTemplate
+metadata:
+  name: test-pipeline-triggertemplate
+  namespace: kurator-pipeline
+spec:
+  params:
+  - name: gitrevision
+    description: The git revision
+  - name: gitrepositoryurl
+    description: The git repository url
+  - name: namespace
+    description: The namespace to create the resources
+  resourceTemplates:
+  - apiVersion: tekton.dev/v1beta1
+    kind: PipelineRun
+    metadata:
+      generateName: test-pipeline-run-
+      namespace: $(tt.params.namespace)
+    spec:
+      serviceAccountName: test-pipeline
+      pipelineRef:
+        name: test-pipeline
+      params:
+      - name: revision
+        value: $(tt.params.gitrevision)
+      - name: repo-url
+        value: $(tt.params.gitrepositoryurl)
+      workspaces:
+      - name: kurator-pipeline-shared-data # there only one pvc workspace in each pipeline, and the name is kurator-pipeline-shared-data
+        volumeClaimTemplate:
+          spec:
+            accessModes:
+              - ReadWriteOnce
+            resources:
+              requests:
+                storage: 1Gi
+      - name: git-credentials
+        secret:
+          secretName: git-credentials
+      - name: docker-credentials
+        secret:
+          secretName: docker-credentials  # auth for task
+---
+apiVersion: triggers.tekton.dev/v1alpha1
+kind: TriggerBinding
+metadata:
+  name: test-pipeline-triggerbinding
+  namespace: kurator-pipeline
+spec:
+  params:
+  - name: gitrevision
+    value: $(body.head_commit.id)
+  - name: namespace
+    value: kurator-pipeline
+  - name: gitrepositoryurl
+    value: "https://github.com/$(body.repository.full_name)"
+---
+apiVersion: triggers.tekton.dev/v1alpha1
+kind: EventListener
+metadata:
+  name: test-pipeline-listener
+  namespace: kurator-pipeline
+spec:
+  serviceAccountName: test-pipeline
+  triggers:
+  - bindings:
+    - ref: test-pipeline-triggerbinding
+    template:
+      ref: test-pipeline-triggertemplate
diff --git a/pkg/fleet-manager/pipeline/render/testdata/trigger/with-volume-claim.yaml b/pkg/fleet-manager/pipeline/render/testdata/trigger/with-volume-claim.yaml
new file mode 100644
index 0000000..e18ab7d
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/testdata/trigger/with-volume-claim.yaml
@@ -0,0 +1,72 @@
+apiVersion: triggers.tekton.dev/v1alpha1
+kind: TriggerTemplate
+metadata:
+  name: test-pipeline-triggertemplate
+  namespace: kurator-pipeline
+spec:
+  params:
+  - name: gitrevision
+    description: The git revision
+  - name: gitrepositoryurl
+    description: The git repository url
+  - name: namespace
+    description: The namespace to create the resources
+  resourceTemplates:
+  - apiVersion: tekton.dev/v1beta1
+    kind: PipelineRun
+    metadata:
+      generateName: test-pipeline-run-
+      namespace: $(tt.params.namespace)
+    spec:
+      serviceAccountName: test-pipeline
+      pipelineRef:
+        name: test-pipeline
+      params:
+      - name: revision
+        value: $(tt.params.gitrevision)
+      - name: repo-url
+        value: $(tt.params.gitrepositoryurl)
+      workspaces:
+      - name: kurator-pipeline-shared-data # there only one pvc workspace in each pipeline, and the name is kurator-pipeline-shared-data
+        volumeClaimTemplate:
+          spec:
+            accessModes:
+              - ReadWriteOnce
+            resources:
+              requests:
+                storage: 500Mi
+            volumeMode: Filesystem
+            storageClassName: manual
+      - name: git-credentials
+        secret:
+          secretName: git-credentials
+      - name: docker-credentials
+        secret:
+          secretName: docker-credentials  # auth for task
+---
+apiVersion: triggers.tekton.dev/v1alpha1
+kind: TriggerBinding
+metadata:
+  name: test-pipeline-triggerbinding
+  namespace: kurator-pipeline
+spec:
+  params:
+  - name: gitrevision
+    value: $(body.head_commit.id)
+  - name: namespace
+    value: kurator-pipeline
+  - name: gitrepositoryurl
+    value: "https://github.com/$(body.repository.full_name)"
+---
+apiVersion: triggers.tekton.dev/v1alpha1
+kind: EventListener
+metadata:
+  name: test-pipeline-listener
+  namespace: kurator-pipeline
+spec:
+  serviceAccountName: test-pipeline
+  triggers:
+  - bindings:
+    - ref: test-pipeline-triggerbinding
+    template:
+      ref: test-pipeline-triggertemplate
diff --git a/pkg/fleet-manager/pipeline/render/trigger_test.go b/pkg/fleet-manager/pipeline/render/trigger_test.go
new file mode 100644
index 0000000..ac51844
--- /dev/null
+++ b/pkg/fleet-manager/pipeline/render/trigger_test.go
@@ -0,0 +1,73 @@
+/*
+Copyright Kurator 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 render
+
+import (
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRenderTrigger(t *testing.T) {
+	expectedRBACFilePath := "testdata/trigger/"
+	cases := []struct {
+		name         string
+		cfg          TriggerConfig
+		expectError  bool
+		expectedFile string
+	}{
+		{
+			name: "default trigger configuration",
+			cfg: TriggerConfig{
+				PipelineName:      "test-pipeline",
+				PipelineNamespace: "kurator-pipeline",
+			},
+			expectError:  false,
+			expectedFile: "default.yaml",
+		},
+		{
+			name: "trigger configuration with custom volume claim",
+			cfg: TriggerConfig{
+				PipelineName:      "test-pipeline",
+				PipelineNamespace: "kurator-pipeline",
+				AccessMode:        "ReadWriteOnce",
+				StorageRequest:    "500Mi",
+				StorageClassName:  "manual",
+				VolumeMode:        "Filesystem",
+			},
+			expectError:  false,
+			expectedFile: "with-volume-claim.yaml",
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			result, err := RenderTrigger(tc.cfg)
+
+			if tc.expectError {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+
+				expected, err := os.ReadFile(expectedRBACFilePath + tc.expectedFile)
+				assert.NoError(t, err)
+				assert.Equal(t, string(expected), string(result))
+			}
+		})
+	}
+}