diff --git a/cmd/controller/app/controllers.go b/cmd/controller/app/controllers.go index 73f842adf..f7628b1f0 100644 --- a/cmd/controller/app/controllers.go +++ b/cmd/controller/app/controllers.go @@ -30,6 +30,7 @@ import ( "github.com/kubesphere/ks-devops/controllers/jenkins/config" jenkinspipeline "github.com/kubesphere/ks-devops/controllers/jenkins/pipeline" "github.com/kubesphere/ks-devops/controllers/jenkins/pipelinerun" + tektoncontroller "github.com/kubesphere/ks-devops/controllers/tekton" "github.com/kubesphere/ks-devops/pkg/client/devops" "github.com/kubesphere/ks-devops/pkg/client/k8s" "github.com/kubesphere/ks-devops/pkg/informers" @@ -45,6 +46,9 @@ func addControllers(mgr manager.Manager, client k8s.Client, informerFactory info } reconcilers := getAllControllers(mgr, client, informerFactory, devopsClient, s, jenkinsCore) + reconcilers["tekton"] = func(mgr manager.Manager) error { + return (&tektoncontroller.Reconciler{Client: mgr.GetClient()}).SetupWithManager(mgr) + } reconcilers["pipeline"] = func(mgr manager.Manager) (err error) { // add PipelineRun controller if err = (&pipelinerun.Reconciler{ diff --git a/cmd/controller/app/options/feature.go b/cmd/controller/app/options/feature.go index 5bdcd2b62..642e0a60f 100644 --- a/cmd/controller/app/options/feature.go +++ b/cmd/controller/app/options/feature.go @@ -42,6 +42,7 @@ func (o *FeatureOptions) GetControllers() map[string]bool { "jenkinsagent": true, "gitrepository": true, "pipeline": true, + "tekton": true, } // support to only enable the specific controllers diff --git a/cmd/controller/app/options/feature_test.go b/cmd/controller/app/options/feature_test.go index 7cf2cf125..6ba85abd0 100644 --- a/cmd/controller/app/options/feature_test.go +++ b/cmd/controller/app/options/feature_test.go @@ -43,6 +43,7 @@ func TestFeatureOptions_GetControllers(t *testing.T) { "jenkinsagent": true, "gitrepository": true, "pipeline": true, + "tekton": true, }, }, { name: "no input (be nil) from users", @@ -55,6 +56,7 @@ func TestFeatureOptions_GetControllers(t *testing.T) { "jenkinsagent": true, "gitrepository": true, "pipeline": true, + "tekton": true, }, }, { name: "merge with the input from users", @@ -69,6 +71,7 @@ func TestFeatureOptions_GetControllers(t *testing.T) { "jenkinsagent": true, "gitrepository": true, "pipeline": true, + "tekton": true, "fake": true, }, }, { diff --git a/config/crd/bases/devops.kubesphere.io_pipelineruns.yaml b/config/crd/bases/devops.kubesphere.io_pipelineruns.yaml index 10c84eead..e08727ac8 100644 --- a/config/crd/bases/devops.kubesphere.io_pipelineruns.yaml +++ b/config/crd/bases/devops.kubesphere.io_pipelineruns.yaml @@ -114,6 +114,19 @@ spec: description: PipelineSpec is the specification of Pipeline when the current PipelineRun is created. properties: + engine: + description: Engine selects the backend that executes this Pipeline. + When omitted, the Pipeline uses Jenkins for backward compatibility. + properties: + type: + description: Type is the execution engine name. + enum: + - jenkins + - tekton + type: string + required: + - type + type: object multi_branch_pipeline: properties: bitbucket_server_source: diff --git a/config/crd/bases/devops.kubesphere.io_pipelines.yaml b/config/crd/bases/devops.kubesphere.io_pipelines.yaml index 163a9b4e0..b06f92b4b 100644 --- a/config/crd/bases/devops.kubesphere.io_pipelines.yaml +++ b/config/crd/bases/devops.kubesphere.io_pipelines.yaml @@ -49,6 +49,19 @@ spec: spec: description: PipelineSpec defines the desired state of Pipeline properties: + engine: + description: Engine selects the backend that executes this Pipeline. + When omitted, the Pipeline uses Jenkins for backward compatibility. + properties: + type: + description: Type is the execution engine name. + enum: + - jenkins + - tekton + type: string + required: + - type + type: object multi_branch_pipeline: properties: bitbucket_server_source: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 0290d28e7..a47ab6848 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -338,3 +338,14 @@ rules: - get - list - watch +- apiGroups: + - tekton.dev + resources: + - pipelineruns + verbs: + - create + - get + - list + - patch + - update + - watch diff --git a/config/samples/tekton/kse-pipeline.yaml b/config/samples/tekton/kse-pipeline.yaml new file mode 100644 index 000000000..2aa9d4e3a --- /dev/null +++ b/config/samples/tekton/kse-pipeline.yaml @@ -0,0 +1,15 @@ +apiVersion: devops.kubesphere.io/v1alpha3 +kind: Pipeline +metadata: + name: smoke-demo + namespace: kse-tekton-demo + annotations: + devops.kubesphere.io/tekton-pipeline: smoke-demo + devops.kubesphere.io/tekton-timeout: 5m +spec: + engine: + type: tekton + type: pipeline + pipeline: + name: smoke-demo + description: KubeSphere facade for the native Tekton smoke-demo Pipeline. diff --git a/config/samples/tekton/kse-pipelinerun.yaml b/config/samples/tekton/kse-pipelinerun.yaml new file mode 100644 index 000000000..780341000 --- /dev/null +++ b/config/samples/tekton/kse-pipelinerun.yaml @@ -0,0 +1,18 @@ +apiVersion: devops.kubesphere.io/v1alpha3 +kind: PipelineRun +metadata: + generateName: smoke-demo-manual- + namespace: kse-tekton-demo +spec: + pipelineRef: + apiVersion: devops.kubesphere.io/v1alpha3 + kind: Pipeline + name: smoke-demo + namespace: kse-tekton-demo + pipelineSpec: + engine: + type: tekton + type: pipeline + parameters: + - name: message + value: hello-from-kse-tekton diff --git a/config/samples/tekton/kustomization.yaml b/config/samples/tekton/kustomization.yaml new file mode 100644 index 000000000..999774ffc --- /dev/null +++ b/config/samples/tekton/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - native-pipeline.yaml + - secure-image-pipeline.yaml + - kse-pipeline.yaml + - secure-image-kse-pipeline.yaml diff --git a/config/samples/tekton/namespace.yaml b/config/samples/tekton/namespace.yaml new file mode 100644 index 000000000..ba649b34f --- /dev/null +++ b/config/samples/tekton/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: kse-tekton-demo diff --git a/config/samples/tekton/native-pipeline.yaml b/config/samples/tekton/native-pipeline.yaml new file mode 100644 index 000000000..c16a81a99 --- /dev/null +++ b/config/samples/tekton/native-pipeline.yaml @@ -0,0 +1,28 @@ +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: smoke-demo + namespace: kse-tekton-demo +spec: + description: A deterministic smoke test for the KubeSphere Tekton adapter. + params: + - name: message + type: string + default: hello-from-kse-tekton + tasks: + - name: smoke-test + params: + - name: message + value: $(params.message) + taskSpec: + params: + - name: message + type: string + steps: + - name: run + image: alpine:3.21 + command: + - /bin/sh + - -c + args: + - echo "$(params.message)" diff --git a/config/samples/tekton/secure-image-kse-pipeline.yaml b/config/samples/tekton/secure-image-kse-pipeline.yaml new file mode 100644 index 000000000..297c44631 --- /dev/null +++ b/config/samples/tekton/secure-image-kse-pipeline.yaml @@ -0,0 +1,15 @@ +apiVersion: devops.kubesphere.io/v1alpha3 +kind: Pipeline +metadata: + name: secure-image-demo + namespace: kse-tekton-demo + annotations: + devops.kubesphere.io/tekton-pipeline: secure-image-demo + devops.kubesphere.io/tekton-timeout: 20m +spec: + engine: + type: tekton + type: pipeline + pipeline: + name: secure-image-demo + description: KubeSphere facade for a blocking Trivy image scan. diff --git a/config/samples/tekton/secure-image-kse-pipelinerun.yaml b/config/samples/tekton/secure-image-kse-pipelinerun.yaml new file mode 100644 index 000000000..d82b0ec5a --- /dev/null +++ b/config/samples/tekton/secure-image-kse-pipelinerun.yaml @@ -0,0 +1,18 @@ +apiVersion: devops.kubesphere.io/v1alpha3 +kind: PipelineRun +metadata: + generateName: secure-image-demo-manual- + namespace: kse-tekton-demo +spec: + pipelineRef: + apiVersion: devops.kubesphere.io/v1alpha3 + kind: Pipeline + name: secure-image-demo + namespace: kse-tekton-demo + pipelineSpec: + engine: + type: tekton + type: pipeline + parameters: + - name: image + value: alpine:3.21 diff --git a/config/samples/tekton/secure-image-pipeline.yaml b/config/samples/tekton/secure-image-pipeline.yaml new file mode 100644 index 000000000..88787315c --- /dev/null +++ b/config/samples/tekton/secure-image-pipeline.yaml @@ -0,0 +1,43 @@ +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: secure-image-demo + namespace: kse-tekton-demo +spec: + description: A blocking Trivy policy gate for an image that was built earlier in CI. + params: + - name: image + type: string + description: Immutable image reference to scan; use a digest in production. + tasks: + - name: scan-image + params: + - name: image + value: $(params.image) + taskSpec: + params: + - name: image + type: string + steps: + - name: trivy + # Pin this image to a verified multi-architecture digest in production. + image: aquasec/trivy:0.70.0 + command: + - trivy + args: + - image + - --exit-code + - "1" + - --severity + - CRITICAL + - --ignore-unfixed + - --scanners + - vuln,secret + - $(params.image) + computeResources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "1" + memory: 1Gi diff --git a/controllers/jenkins/pipeline/json_converter.go b/controllers/jenkins/pipeline/json_converter.go index 917607058..d8c16e830 100644 --- a/controllers/jenkins/pipeline/json_converter.go +++ b/controllers/jenkins/pipeline/json_converter.go @@ -31,6 +31,7 @@ import ( "k8s.io/client-go/util/retry" v1alpha3 "github.com/kubesphere/ks-devops/pkg/api/devops/v1alpha3" + "github.com/kubesphere/ks-devops/pkg/pipelineengine" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/event" @@ -59,6 +60,9 @@ func (r *JenkinsfileReconciler) Reconcile(ctx context.Context, req ctrl.Request) err = client.IgnoreNotFound(err) return } + if pipelineengine.IsTekton(pip) { + return + } if pip.Spec.Type != v1alpha3.NoScmPipelineType || pip.Spec.Pipeline == nil { return diff --git a/controllers/jenkins/pipeline/pipeline_controller.go b/controllers/jenkins/pipeline/pipeline_controller.go index ea48ad2c8..8fc8452f8 100644 --- a/controllers/jenkins/pipeline/pipeline_controller.go +++ b/controllers/jenkins/pipeline/pipeline_controller.go @@ -50,6 +50,7 @@ import ( devopsinformers "github.com/kubesphere/ks-devops/pkg/client/informers/externalversions/devops/v1alpha3" devopslisters "github.com/kubesphere/ks-devops/pkg/client/listers/devops/v1alpha3" "github.com/kubesphere/ks-devops/pkg/constants" + "github.com/kubesphere/ks-devops/pkg/pipelineengine" ) // Controller is the controller of the Pipeline @@ -232,6 +233,9 @@ func (c *Controller) syncHandler(key string) error { klog.Error(err, fmt.Sprintf("could not get copyPipeline %s ", key)) return err } + if pipelineengine.IsTekton(pipeline) { + return nil + } copyPipeline := pipeline.DeepCopy() // DeletionTimestamp.IsZero() means copyPipeline has not been deleted. diff --git a/controllers/jenkins/pipeline/pipeline_metadata_controller.go b/controllers/jenkins/pipeline/pipeline_metadata_controller.go index 2b4f10955..4839a0a1e 100644 --- a/controllers/jenkins/pipeline/pipeline_metadata_controller.go +++ b/controllers/jenkins/pipeline/pipeline_metadata_controller.go @@ -26,6 +26,7 @@ import ( "github.com/jenkins-zh/jenkins-client/pkg/core" "github.com/jenkins-zh/jenkins-client/pkg/job" "github.com/kubesphere/ks-devops/pkg/api/devops/v1alpha3" + "github.com/kubesphere/ks-devops/pkg/pipelineengine" v1 "k8s.io/api/core/v1" "k8s.io/client-go/tools/record" "k8s.io/client-go/util/retry" @@ -63,6 +64,9 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu // ignore resource not found due to deletion return ctrl.Result{}, client.IgnoreNotFound(err) } + if pipelineengine.IsTekton(pipeline) { + return ctrl.Result{}, nil + } if err := r.obtainAndUpdatePipelineMetadata(pipeline); err != nil { log.Error(err, "unable to obtain and update Pipeline metadata from Jenkins") diff --git a/controllers/jenkins/pipelinerun/pipelinerun_controller.go b/controllers/jenkins/pipelinerun/pipelinerun_controller.go index 3ce232e94..393512656 100644 --- a/controllers/jenkins/pipelinerun/pipelinerun_controller.go +++ b/controllers/jenkins/pipelinerun/pipelinerun_controller.go @@ -41,6 +41,7 @@ import ( "github.com/kubesphere/ks-devops/pkg/api/devops/v1alpha3" devopsClient "github.com/kubesphere/ks-devops/pkg/client/devops" "github.com/kubesphere/ks-devops/pkg/client/devops/jenkins" + "github.com/kubesphere/ks-devops/pkg/pipelineengine" cmstore "github.com/kubesphere/ks-devops/pkg/store/configmap" storeInter "github.com/kubesphere/ks-devops/pkg/store/store" "github.com/kubesphere/ks-devops/pkg/utils/k8sutil" @@ -80,6 +81,11 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu if err = r.Client.Get(ctx, req.NamespacedName, pipelineRun); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } + if skip, skipErr := r.shouldSkipForTekton(ctx, pipelineRun); skipErr != nil { + return ctrl.Result{}, skipErr + } else if skip { + return ctrl.Result{}, nil + } jHandler := &jenkinsHandler{&r.JenkinsCore} @@ -276,6 +282,22 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu return ctrl.Result{}, nil } +// shouldSkipForTekton prevents Jenkins from reconciling runs owned by the Tekton engine. +func (r *Reconciler) shouldSkipForTekton(ctx context.Context, run *v1alpha3.PipelineRun) (bool, error) { + if pipelineengine.IsTekton(run) { + return true, nil + } + if run.Spec.PipelineRef == nil || run.Spec.PipelineRef.Name == "" { + return false, nil + } + pipeline := &v1alpha3.Pipeline{} + key := client.ObjectKey{Namespace: run.Namespace, Name: run.Spec.PipelineRef.Name} + if err := r.Get(ctx, key, pipeline); err != nil { + return false, client.IgnoreNotFound(err) + } + return pipelineengine.IsTekton(pipeline), nil +} + // match /blue/rest/organizations/jenkins/pipelines/{devops}/{pipeline}/runs/{run}/log/?start=0 // match /blue/rest/organizations/jenkins/pipelines/%s/pipelines/%s/branches/%s/runs/%s/log/? func (r *Reconciler) getAgentInfo(ctx context.Context, pr *v1alpha3.PipelineRun) error { diff --git a/controllers/jenkins/pipelinerun/pipelinerun_controller_test.go b/controllers/jenkins/pipelinerun/pipelinerun_controller_test.go index a6ca39695..028d9b46c 100644 --- a/controllers/jenkins/pipelinerun/pipelinerun_controller_test.go +++ b/controllers/jenkins/pipelinerun/pipelinerun_controller_test.go @@ -47,6 +47,29 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" ) +// TestShouldSkipForTektonSpec verifies that Jenkins ignores a run whose PipelineSpec snapshot selects Tekton. +func TestShouldSkipForTektonSpec(t *testing.T) { + pipeline := &v1alpha3.Pipeline{ + ObjectMeta: metav1.ObjectMeta{Name: "tekton-pipeline", Namespace: "demo"}, + Spec: v1alpha3.PipelineSpec{ + Engine: &v1alpha3.PipelineEngineSpec{Type: v1alpha3.PipelineEngineTekton}, + Type: v1alpha3.NoScmPipelineType, + }, + } + run := &v1alpha3.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{Name: "tekton-run", Namespace: "demo"}, + Spec: v1alpha3.PipelineRunSpec{ + PipelineRef: &v1.ObjectReference{Name: pipeline.Name}, + PipelineSpec: pipeline.Spec.DeepCopy(), + }, + } + reconciler := &Reconciler{Client: fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(pipeline).Build()} + + skip, err := reconciler.shouldSkipForTekton(context.Background(), run) + assert.NoError(t, err) + assert.True(t, skip) +} + func Test_getBranch(t *testing.T) { type args struct { prSpec *v1alpha3.PipelineRunSpec diff --git a/controllers/jenkins/pipelinerun/pipelinerun_synchronizer.go b/controllers/jenkins/pipelinerun/pipelinerun_synchronizer.go index a65290ec3..742ad839c 100644 --- a/controllers/jenkins/pipelinerun/pipelinerun_synchronizer.go +++ b/controllers/jenkins/pipelinerun/pipelinerun_synchronizer.go @@ -24,6 +24,7 @@ import ( "github.com/jenkins-zh/jenkins-client/pkg/job" "github.com/kubesphere/ks-devops/pkg/api/devops/v1alpha3" "github.com/kubesphere/ks-devops/pkg/kapis/devops/v1alpha3/pipelinerun" + "github.com/kubesphere/ks-devops/pkg/pipelineengine" v1 "k8s.io/api/core/v1" utilerrors "k8s.io/apimachinery/pkg/util/errors" "k8s.io/client-go/tools/record" @@ -58,6 +59,9 @@ func (r *SyncReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. if err := r.Client.Get(ctx, req.NamespacedName, pipeline); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } + if pipelineengine.IsTekton(pipeline) { + return ctrl.Result{}, nil + } if _, ok := pipeline.Annotations[v1alpha3.PipelineRequestToSyncRunsAnnoKey]; !ok { // skip the PipelineRun synchronization due to synchronized already before return ctrl.Result{}, nil diff --git a/controllers/tekton/pipelinerun_controller.go b/controllers/tekton/pipelinerun_controller.go new file mode 100644 index 000000000..59e7ad2b8 --- /dev/null +++ b/controllers/tekton/pipelinerun_controller.go @@ -0,0 +1,403 @@ +/* +Copyright 2026 The KubeSphere Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package tekton implements the Tekton execution adapter for KubeSphere DevOps. +package tekton + +import ( + "context" + "fmt" + "reflect" + "strconv" + "strings" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + devopsv1alpha3 "github.com/kubesphere/ks-devops/pkg/api/devops/v1alpha3" + "github.com/kubesphere/ks-devops/pkg/pipelineengine" +) + +const ( + defaultPollInterval = 2 * time.Second + managedByValue = "ks-devops-tekton-adapter" + labelManagedBy = "app.kubernetes.io/managed-by" + annotationKSEPipeline = "devops.kubesphere.io/kse-pipeline" + annotationKSEPipelineRun = "devops.kubesphere.io/kse-pipelinerun" +) + +var tektonPipelineRunGVK = schema.GroupVersionKind{ + Group: "tekton.dev", + Version: "v1", + Kind: "PipelineRun", +} + +// Reconciler creates native Tekton PipelineRuns and mirrors their status to KubeSphere PipelineRuns. +type Reconciler struct { + client.Client + PollInterval time.Duration +} + +//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=pipelines,verbs=get;list;watch +//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=pipelineruns,verbs=get;list;watch;update;patch +//+kubebuilder:rbac:groups=devops.kubesphere.io,resources=pipelineruns/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=tekton.dev,resources=pipelineruns,verbs=get;list;watch;create;update;patch + +// Reconcile ensures that one KubeSphere Tekton PipelineRun has one native Tekton PipelineRun. +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + run := &devopsv1alpha3.PipelineRun{} + if err := r.Get(ctx, req.NamespacedName, run); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + runSelectsTekton := pipelineengine.IsTekton(run) + if !runSelectsTekton && (run.Spec.PipelineRef == nil || run.Spec.PipelineRef.Name == "") { + return ctrl.Result{}, nil + } + pipeline, err := r.getPipeline(ctx, run) + if err != nil { + if !runSelectsTekton && apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + if !runSelectsTekton && !pipelineengine.IsTekton(pipeline) { + return ctrl.Result{}, nil + } + if !run.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + + desired, err := buildTektonPipelineRun(run, pipeline) + if err != nil { + return ctrl.Result{}, r.markConfigurationFailed(ctx, run, err) + } + + actual := newTektonPipelineRun(run.Namespace, run.Name) + if err = r.Get(ctx, client.ObjectKeyFromObject(actual), actual); apierrors.IsNotFound(err) { + if err = r.Create(ctx, desired); err != nil { + return ctrl.Result{}, err + } + if err = r.recordNativeName(ctx, run, desired.GetName()); err != nil { + return ctrl.Result{}, err + } + if err = r.updateStatus(ctx, run, pendingStatus()); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: r.pollInterval()}, nil + } else if err != nil { + return ctrl.Result{}, err + } + + if !isManagedByRun(actual, run) { + return ctrl.Result{}, fmt.Errorf("native Tekton PipelineRun %s/%s already exists and is not managed by KubeSphere PipelineRun %s", actual.GetNamespace(), actual.GetName(), run.Name) + } + if err = r.recordNativeName(ctx, run, actual.GetName()); err != nil { + return ctrl.Result{}, err + } + + status := statusFromTekton(actual) + if !isTerminal(status.Phase) { + if err = r.cancelIfRequested(ctx, run, actual); err != nil { + return ctrl.Result{}, err + } + } + if err = r.updateStatus(ctx, run, status); err != nil { + return ctrl.Result{}, err + } + if isTerminal(status.Phase) { + return ctrl.Result{}, nil + } + return ctrl.Result{RequeueAfter: r.pollInterval()}, nil +} + +// SetupWithManager registers the Tekton adapter with the controller manager. +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + Named("tekton_pipelinerun_controller"). + For(&devopsv1alpha3.PipelineRun{}). + Complete(r) +} + +// getPipeline returns the KubeSphere Pipeline referenced by a PipelineRun. +func (r *Reconciler) getPipeline(ctx context.Context, run *devopsv1alpha3.PipelineRun) (*devopsv1alpha3.Pipeline, error) { + if run.Spec.PipelineRef == nil || run.Spec.PipelineRef.Name == "" { + return nil, fmt.Errorf("PipelineRun %s/%s does not define spec.pipelineRef.name", run.Namespace, run.Name) + } + pipeline := &devopsv1alpha3.Pipeline{} + key := client.ObjectKey{Namespace: run.Namespace, Name: run.Spec.PipelineRef.Name} + if err := r.Get(ctx, key, pipeline); err != nil { + return nil, fmt.Errorf("get Pipeline %s: %w", key, err) + } + return pipeline, nil +} + +// pollInterval returns the configured status polling interval. +func (r *Reconciler) pollInterval() time.Duration { + if r.PollInterval > 0 { + return r.PollInterval + } + return defaultPollInterval +} + +// recordNativeName stores the native Tekton PipelineRun identity on the KubeSphere object. +func (r *Reconciler) recordNativeName(ctx context.Context, run *devopsv1alpha3.PipelineRun, name string) error { + if run.Annotations[pipelineengine.AnnotationTektonPipelineRun] == name { + return nil + } + base := run.DeepCopy() + if run.Annotations == nil { + run.Annotations = map[string]string{} + } + run.Annotations[pipelineengine.AnnotationTektonPipelineRun] = name + return r.Patch(ctx, run, client.MergeFrom(base)) +} + +// cancelIfRequested propagates a KubeSphere stop action to Tekton. +func (r *Reconciler) cancelIfRequested(ctx context.Context, run *devopsv1alpha3.PipelineRun, native *unstructured.Unstructured) error { + if run.Spec.Action == nil || *run.Spec.Action != devopsv1alpha3.Stop { + return nil + } + status, _, _ := unstructured.NestedString(native.Object, "spec", "status") + if status == "Cancelled" { + return nil + } + if err := unstructured.SetNestedField(native.Object, "Cancelled", "spec", "status"); err != nil { + return err + } + return r.Update(ctx, native) +} + +// markConfigurationFailed exposes invalid adapter configuration through the normal KubeSphere status. +func (r *Reconciler) markConfigurationFailed(ctx context.Context, run *devopsv1alpha3.PipelineRun, cause error) error { + now := metav1.Now() + status := devopsv1alpha3.PipelineRunStatus{ + CompletionTime: &now, + Phase: devopsv1alpha3.Failed, + Conditions: []devopsv1alpha3.Condition{{ + Type: devopsv1alpha3.ConditionSucceeded, + Status: devopsv1alpha3.ConditionFalse, + Reason: "InvalidTektonConfiguration", + Message: cause.Error(), + LastProbeTime: now, + LastTransitionTime: now, + }}, + } + return r.updateStatus(ctx, run, status) +} + +// updateStatus updates the KubeSphere status only when the mirrored state changed. +func (r *Reconciler) updateStatus(ctx context.Context, run *devopsv1alpha3.PipelineRun, desired devopsv1alpha3.PipelineRunStatus) error { + desired.UpdateTime = nil + current := run.Status.DeepCopy() + current.UpdateTime = nil + if reflect.DeepEqual(*current, desired) { + return nil + } + base := run.DeepCopy() + now := metav1.Now() + desired.UpdateTime = &now + run.Status = desired + return r.Status().Patch(ctx, run, client.MergeFrom(base)) +} + +// buildTektonPipelineRun translates the engine-neutral KubeSphere run into a native Tekton v1 resource. +func buildTektonPipelineRun(run *devopsv1alpha3.PipelineRun, pipeline *devopsv1alpha3.Pipeline) (*unstructured.Unstructured, error) { + pipelineName := pipelineengine.ResolveAnnotation(run, pipeline, pipelineengine.AnnotationTektonPipeline) + if pipelineName == "" { + pipelineName = pipeline.Name + } + if pipelineName == "" { + return nil, fmt.Errorf("native Tekton Pipeline name is empty") + } + + params := make([]interface{}, 0, len(run.Spec.Parameters)) + for _, parameter := range run.Spec.Parameters { + params = append(params, map[string]interface{}{ + "name": parameter.Name, + "value": parameter.Value, + }) + } + spec := map[string]interface{}{ + "pipelineRef": map[string]interface{}{"name": pipelineName}, + } + if len(params) > 0 { + spec["params"] = params + } + if serviceAccount := pipelineengine.ResolveAnnotation(run, pipeline, pipelineengine.AnnotationTektonServiceAccount); serviceAccount != "" { + spec["taskRunTemplate"] = map[string]interface{}{"serviceAccountName": serviceAccount} + } + if timeout := pipelineengine.ResolveAnnotation(run, pipeline, pipelineengine.AnnotationTektonTimeout); timeout != "" { + if _, err := time.ParseDuration(timeout); err != nil { + return nil, fmt.Errorf("invalid Tekton timeout %q: %w", timeout, err) + } + spec["timeouts"] = map[string]interface{}{"pipeline": timeout} + } + if workspace, err := workspaceBinding(run, pipeline); err != nil { + return nil, err + } else if workspace != nil { + spec["workspaces"] = []interface{}{workspace} + } + + native := newTektonPipelineRun(run.Namespace, run.Name) + native.SetLabels(map[string]string{ + labelManagedBy: managedByValue, + }) + native.SetAnnotations(map[string]string{ + annotationKSEPipeline: pipeline.Name, + annotationKSEPipelineRun: run.Name, + }) + controller, blockOwnerDeletion := true, true + native.SetOwnerReferences([]metav1.OwnerReference{{ + APIVersion: devopsv1alpha3.GroupVersion.String(), + Kind: "PipelineRun", + Name: run.Name, + UID: run.UID, + Controller: &controller, + BlockOwnerDeletion: &blockOwnerDeletion, + }}) + native.Object["spec"] = spec + return native, nil +} + +// workspaceBinding builds one optional Tekton workspace binding from Pipeline annotations. +func workspaceBinding(run *devopsv1alpha3.PipelineRun, pipeline *devopsv1alpha3.Pipeline) (map[string]interface{}, error) { + name := pipelineengine.ResolveAnnotation(run, pipeline, pipelineengine.AnnotationTektonWorkspaceName) + claim := pipelineengine.ResolveAnnotation(run, pipeline, pipelineengine.AnnotationTektonWorkspaceClaim) + emptyDirValue := pipelineengine.ResolveAnnotation(run, pipeline, pipelineengine.AnnotationTektonWorkspaceEmptyDir) + if name == "" && claim == "" && emptyDirValue == "" { + return nil, nil + } + if name == "" { + return nil, fmt.Errorf("%s is required when a Tekton workspace binding is configured", pipelineengine.AnnotationTektonWorkspaceName) + } + binding := map[string]interface{}{"name": name} + if claim != "" { + binding["persistentVolumeClaim"] = map[string]interface{}{"claimName": claim} + return binding, nil + } + emptyDir, err := strconv.ParseBool(emptyDirValue) + if err != nil || !emptyDir { + return nil, fmt.Errorf("workspace %q requires either %s or %s=true", name, pipelineengine.AnnotationTektonWorkspaceClaim, pipelineengine.AnnotationTektonWorkspaceEmptyDir) + } + binding["emptyDir"] = map[string]interface{}{} + return binding, nil +} + +// newTektonPipelineRun creates an unstructured native Tekton PipelineRun identity. +func newTektonPipelineRun(namespace, name string) *unstructured.Unstructured { + object := &unstructured.Unstructured{} + object.SetGroupVersionKind(tektonPipelineRunGVK) + object.SetNamespace(namespace) + object.SetName(name) + return object +} + +// isManagedByRun verifies that a same-name native object belongs to the expected KubeSphere run. +func isManagedByRun(native *unstructured.Unstructured, run *devopsv1alpha3.PipelineRun) bool { + labels := native.GetLabels() + return labels[labelManagedBy] == managedByValue && metav1.IsControlledBy(native, run) +} + +// pendingStatus returns the initial state exposed while Tekton schedules the run. +func pendingStatus() devopsv1alpha3.PipelineRunStatus { + return devopsv1alpha3.PipelineRunStatus{Phase: devopsv1alpha3.Pending} +} + +// statusFromTekton converts native Tekton status fields into the existing KubeSphere status contract. +func statusFromTekton(native *unstructured.Unstructured) devopsv1alpha3.PipelineRunStatus { + status := pendingStatus() + status.StartTime = nestedTime(native.Object, "status", "startTime") + status.CompletionTime = nestedTime(native.Object, "status", "completionTime") + conditions, _, _ := unstructured.NestedSlice(native.Object, "status", "conditions") + for _, item := range conditions { + conditionMap, ok := item.(map[string]interface{}) + if !ok || stringValue(conditionMap, "type") != "Succeeded" { + continue + } + transition := parseTime(stringValue(conditionMap, "lastTransitionTime")) + condition := devopsv1alpha3.Condition{ + Type: devopsv1alpha3.ConditionSucceeded, + Status: devopsv1alpha3.ConditionStatus(stringValue(conditionMap, "status")), + Reason: stringValue(conditionMap, "reason"), + Message: stringValue(conditionMap, "message"), + LastProbeTime: transition, + LastTransitionTime: transition, + } + status.Conditions = []devopsv1alpha3.Condition{condition} + status.Phase = phaseFromCondition(condition) + break + } + if len(status.Conditions) == 0 && status.StartTime != nil { + status.Phase = devopsv1alpha3.Running + } + return status +} + +// phaseFromCondition maps the Tekton Succeeded condition to a KubeSphere phase. +func phaseFromCondition(condition devopsv1alpha3.Condition) devopsv1alpha3.RunPhase { + switch condition.Status { + case devopsv1alpha3.ConditionTrue: + return devopsv1alpha3.Succeeded + case devopsv1alpha3.ConditionFalse: + if strings.Contains(strings.ToLower(condition.Reason), "cancel") || strings.Contains(strings.ToLower(condition.Message), "cancel") { + return devopsv1alpha3.Cancelled + } + return devopsv1alpha3.Failed + case devopsv1alpha3.ConditionUnknown: + return devopsv1alpha3.Running + default: + return devopsv1alpha3.Unknown + } +} + +// nestedTime reads an RFC3339 timestamp from an unstructured object. +func nestedTime(object map[string]interface{}, fields ...string) *metav1.Time { + value, found, _ := unstructured.NestedString(object, fields...) + if !found || value == "" { + return nil + } + parsed := parseTime(value) + if parsed.IsZero() { + return nil + } + return &parsed +} + +// parseTime converts an RFC3339 timestamp and returns the zero value when it is invalid. +func parseTime(value string) metav1.Time { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return metav1.Time{} + } + return metav1.NewTime(parsed) +} + +// stringValue returns one string field from an unstructured map. +func stringValue(object map[string]interface{}, key string) string { + value, _ := object[key].(string) + return value +} + +// isTerminal reports whether a KubeSphere run phase no longer requires polling. +func isTerminal(phase devopsv1alpha3.RunPhase) bool { + return phase == devopsv1alpha3.Succeeded || phase == devopsv1alpha3.Failed || phase == devopsv1alpha3.Cancelled +} diff --git a/controllers/tekton/pipelinerun_controller_test.go b/controllers/tekton/pipelinerun_controller_test.go new file mode 100644 index 000000000..a2e23500d --- /dev/null +++ b/controllers/tekton/pipelinerun_controller_test.go @@ -0,0 +1,260 @@ +/* +Copyright 2026 The KubeSphere Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tekton + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + devopsv1alpha3 "github.com/kubesphere/ks-devops/pkg/api/devops/v1alpha3" + "github.com/kubesphere/ks-devops/pkg/pipelineengine" +) + +// TestBuildTektonPipelineRun verifies translation of parameters, identity, credentials, workspace, and timeout. +func TestBuildTektonPipelineRun(t *testing.T) { + pipeline, run := testObjects() + pipeline.Annotations[pipelineengine.AnnotationTektonPipeline] = "native-build" + pipeline.Annotations[pipelineengine.AnnotationTektonServiceAccount] = "builder" + pipeline.Annotations[pipelineengine.AnnotationTektonWorkspaceName] = "source" + pipeline.Annotations[pipelineengine.AnnotationTektonWorkspaceClaim] = "source-cache" + pipeline.Annotations[pipelineengine.AnnotationTektonTimeout] = "20m" + run.Spec.Parameters = []devopsv1alpha3.Parameter{{Name: "image", Value: "registry.example/app@sha256:123"}} + + native, err := buildTektonPipelineRun(run, pipeline) + require.NoError(t, err) + + assert.Equal(t, tektonPipelineRunGVK, native.GroupVersionKind()) + assert.Equal(t, "native-build", mustNestedString(t, native.Object, "spec", "pipelineRef", "name")) + assert.Equal(t, "builder", mustNestedString(t, native.Object, "spec", "taskRunTemplate", "serviceAccountName")) + assert.Equal(t, "20m", mustNestedString(t, native.Object, "spec", "timeouts", "pipeline")) + assert.Equal(t, "source-cache", mustNestedString(t, native.Object, "spec", "workspaces", "0", "persistentVolumeClaim", "claimName")) + assert.Equal(t, managedByValue, native.GetLabels()[labelManagedBy]) + require.Len(t, native.GetOwnerReferences(), 1) + assert.Equal(t, run.Name, native.GetOwnerReferences()[0].Name) +} + +// TestBuildTektonPipelineRunRejectsInvalidConfig verifies fail-fast validation for user annotations. +func TestBuildTektonPipelineRunRejectsInvalidConfig(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + }{ + { + name: "invalid timeout", + annotations: map[string]string{ + pipelineengine.AnnotationTektonTimeout: "fifteen minutes", + }, + }, + { + name: "workspace without storage", + annotations: map[string]string{ + pipelineengine.AnnotationTektonWorkspaceName: "source", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + pipeline, run := testObjects() + for key, value := range test.annotations { + pipeline.Annotations[key] = value + } + _, err := buildTektonPipelineRun(run, pipeline) + require.Error(t, err) + }) + } +} + +// TestReconcileCreatesNativeRun verifies idempotent native PipelineRun creation. +func TestReconcileCreatesNativeRun(t *testing.T) { + pipeline, run := testObjects() + delete(run.Annotations, pipelineengine.AnnotationEngine) + reconciler := newTestReconciler(t, pipeline, run) + + result, err := reconciler.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(run)}) + require.NoError(t, err) + assert.Greater(t, result.RequeueAfter, time.Duration(0)) + + native := newTektonPipelineRun(run.Namespace, run.Name) + require.NoError(t, reconciler.Get(context.Background(), client.ObjectKeyFromObject(native), native)) + assert.Equal(t, pipeline.Name, mustNestedString(t, native.Object, "spec", "pipelineRef", "name")) + + updated := &devopsv1alpha3.PipelineRun{} + require.NoError(t, reconciler.Get(context.Background(), client.ObjectKeyFromObject(run), updated)) + assert.Equal(t, run.Name, updated.Annotations[pipelineengine.AnnotationTektonPipelineRun]) + assert.Equal(t, devopsv1alpha3.Pending, updated.Status.Phase) +} + +// TestReconcileIgnoresJenkinsRun verifies that unrelated Jenkins resources do not require Tekton configuration. +func TestReconcileIgnoresJenkinsRun(t *testing.T) { + run := &devopsv1alpha3.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{Name: "jenkins-run", Namespace: "demo"}, + } + reconciler := newTestReconciler(t, run) + + result, err := reconciler.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(run)}) + require.NoError(t, err) + assert.Zero(t, result.RequeueAfter) +} + +// TestReconcileMirrorsSucceededStatus verifies Tekton-to-KubeSphere status translation. +func TestReconcileMirrorsSucceededStatus(t *testing.T) { + pipeline, run := testObjects() + native, err := buildTektonPipelineRun(run, pipeline) + require.NoError(t, err) + native.Object["status"] = map[string]interface{}{ + "startTime": "2026-07-27T01:00:00Z", + "completionTime": "2026-07-27T01:01:00Z", + "conditions": []interface{}{map[string]interface{}{ + "type": "Succeeded", + "status": "True", + "reason": "Succeeded", + "message": "Tasks Completed: 2 (Failed: 0, Cancelled 0), Skipped: 0", + "lastTransitionTime": "2026-07-27T01:01:00Z", + }}, + } + reconciler := newTestReconciler(t, pipeline, run, native) + + result, err := reconciler.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(run)}) + require.NoError(t, err) + assert.Zero(t, result.RequeueAfter) + + updated := &devopsv1alpha3.PipelineRun{} + require.NoError(t, reconciler.Get(context.Background(), client.ObjectKeyFromObject(run), updated)) + assert.Equal(t, devopsv1alpha3.Succeeded, updated.Status.Phase) + require.NotNil(t, updated.Status.StartTime) + require.NotNil(t, updated.Status.CompletionTime) + require.Len(t, updated.Status.Conditions, 1) + assert.Equal(t, "Succeeded", updated.Status.Conditions[0].Reason) +} + +// TestReconcilePropagatesCancellation verifies that the existing KSE stop action cancels Tekton. +func TestReconcilePropagatesCancellation(t *testing.T) { + pipeline, run := testObjects() + action := devopsv1alpha3.Stop + run.Spec.Action = &action + native, err := buildTektonPipelineRun(run, pipeline) + require.NoError(t, err) + reconciler := newTestReconciler(t, pipeline, run, native) + + _, err = reconciler.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(run)}) + require.NoError(t, err) + + updatedNative := newTektonPipelineRun(run.Namespace, run.Name) + require.NoError(t, reconciler.Get(context.Background(), client.ObjectKeyFromObject(updatedNative), updatedNative)) + assert.Equal(t, "Cancelled", mustNestedString(t, updatedNative.Object, "spec", "status")) +} + +// TestStatusFromTekton verifies running, failed, and cancelled condition mappings. +func TestStatusFromTekton(t *testing.T) { + tests := []struct { + name string + condition map[string]interface{} + expected devopsv1alpha3.RunPhase + }{ + {name: "running", condition: map[string]interface{}{"type": "Succeeded", "status": "Unknown"}, expected: devopsv1alpha3.Running}, + {name: "failed", condition: map[string]interface{}{"type": "Succeeded", "status": "False", "reason": "Failed"}, expected: devopsv1alpha3.Failed}, + {name: "cancelled", condition: map[string]interface{}{"type": "Succeeded", "status": "False", "reason": "PipelineRunCancelled"}, expected: devopsv1alpha3.Cancelled}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + native := newTektonPipelineRun("demo", "run") + native.Object["status"] = map[string]interface{}{"conditions": []interface{}{test.condition}} + assert.Equal(t, test.expected, statusFromTekton(native).Phase) + }) + } +} + +// testObjects returns a minimal Tekton-backed KubeSphere Pipeline and PipelineRun. +func testObjects() (*devopsv1alpha3.Pipeline, *devopsv1alpha3.PipelineRun) { + pipeline := &devopsv1alpha3.Pipeline{ + TypeMeta: metav1.TypeMeta{APIVersion: devopsv1alpha3.GroupVersion.String(), Kind: "Pipeline"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "hello-world", + Namespace: "demo", + Annotations: map[string]string{}, + }, + Spec: devopsv1alpha3.PipelineSpec{ + Engine: &devopsv1alpha3.PipelineEngineSpec{Type: devopsv1alpha3.PipelineEngineTekton}, + Type: devopsv1alpha3.NoScmPipelineType, + Pipeline: &devopsv1alpha3.NoScmPipeline{ + Name: "hello-world", + }, + }, + } + run := &devopsv1alpha3.PipelineRun{ + TypeMeta: metav1.TypeMeta{APIVersion: devopsv1alpha3.GroupVersion.String(), Kind: "PipelineRun"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "hello-world-1", + Namespace: "demo", + UID: types.UID("run-uid"), + Annotations: map[string]string{}, + }, + Spec: devopsv1alpha3.PipelineRunSpec{ + PipelineRef: &corev1.ObjectReference{Name: pipeline.Name}, + PipelineSpec: pipeline.Spec.DeepCopy(), + }, + } + return pipeline, run +} + +// newTestReconciler creates a fake-client-backed Tekton reconciler. +func newTestReconciler(t *testing.T, objects ...client.Object) *Reconciler { + scheme := runtime.NewScheme() + require.NoError(t, devopsv1alpha3.AddToScheme(scheme)) + scheme.AddKnownTypeWithName(tektonPipelineRunGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(tektonPipelineRunGVK.GroupVersion().WithKind("PipelineRunList"), &unstructured.UnstructuredList{}) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&devopsv1alpha3.PipelineRun{}). + WithObjects(objects...). + Build() + return &Reconciler{Client: fakeClient, PollInterval: time.Millisecond} +} + +// mustNestedString reads a string from maps and array indices in test assertions. +func mustNestedString(t *testing.T, object interface{}, path ...string) string { + current := object + for _, segment := range path { + switch value := current.(type) { + case map[string]interface{}: + current = value[segment] + case []interface{}: + index, err := strconv.Atoi(segment) + require.NoError(t, err) + require.Less(t, index, len(value)) + current = value[index] + default: + t.Fatalf("path %v cannot traverse %T", path, current) + } + } + result, ok := current.(string) + require.True(t, ok, "path %v yielded %T", path, current) + return result +} diff --git a/docs/tekton-devops-design-report.md b/docs/tekton-devops-design-report.md new file mode 100644 index 000000000..bc2cda5a0 --- /dev/null +++ b/docs/tekton-devops-design-report.md @@ -0,0 +1,759 @@ +# KubeSphere DevOps 双引擎设计报告 + +> 文档状态:当前设计与 MVP 实现说明 +> +> 更新时间:2026-07-30 +> 适用范围:KSE 4.2.x、`kubesphere/ks-devops`、`kubesphere-extensions/devops` + +阅读建议:产品和项目负责人可以先阅读第 1~3、12~16 节;后端开发重点阅读第 4~7、10、13 节;前端开发重点阅读第 4、8、11 节。 + +文中的状态含义: + +- **已实现**:当前分支已经存在对应代码。 +- **已验证**:已经通过单元测试或真实集群 smoke。 +- **目标/建议**:产品化方案,尚未进入正式 Chart 或前端。 + +## 1. 先看结论 + +本设计不是用 Tekton 替换 Jenkins,而是在现有 KubeSphere DevOps 控制面下增加第二个流水线执行引擎。 + +- 原有流水线默认继续使用 Jenkins,已有用户不需要迁移。 +- 新流水线可以显式选择 Tekton。 +- KubeSphere `Pipeline` 和 `PipelineRun` 继续作为面向用户的统一 API。 +- Jenkins 和 Tekton 只负责执行,KSE 继续负责项目、权限、凭证、审计和统一入口。 +- 镜像扫描、AI Code Review 等能力应实现为可组合的 Tekton Task,而不是写死在 Controller 中。 + +当前完成度可以概括为: + +| 能力 | 状态 | +| --- | --- | +| Jenkins/Tekton 引擎路由 | 已实现并验证 | +| `spec.engine.type` 与旧 annotation 兼容 | 已实现并通过 CRD 校验 | +| 创建原生 Tekton `PipelineRun` | 已实现并验证 | +| 参数、ServiceAccount、超时、单 Workspace | 已实现 | +| 总体状态同步 | 已实现并验证 | +| 停止 Tekton 流水线 | 已实现并验证 | +| Jenkins 兼容性 | 已完成基本隔离并通过 smoke | +| 镜像安全扫描样例 | 已提供 Trivy 阻断样例 | +| KSE 4.2.x Helm 扩展集成 | 尚未完成 | +| KSE Console Tekton 界面 | 尚未完成 | +| Task 级状态、日志和结果 | 尚未完成 | +| Webhook、定时任务、多分支 | 尚未映射到 Tekton | +| AI Code Review | 只有设计方向,尚未实现 | + +因此,这一版本已经证明“双引擎后端可行”,但还不是可以直接交给最终用户使用的完整产品版本。 + +## 2. 为什么采用双引擎 + +### 2.1 现有 KSE DevOps + +现有 DevOps 主要围绕 Jenkins 构建: + +- KSE `Pipeline` 保存流水线配置和 Jenkins 元数据。 +- KSE `PipelineRun` 表示一次运行。 +- Controller 将资源同步到 Jenkins。 +- Apiserver 将 Jenkins 状态转换为 KSE API。 +- Console 读取 Jenkins 注解并展示运行详情。 + +这一模式能够继续服务现有 Jenkinsfile、插件、凭证和多分支流水线,但也导致控制面与 Jenkins 数据结构耦合较深。 + +### 2.2 行业通用做法 + +可演进的 CI/CD 平台通常把系统分为三层: + +1. **平台控制层**:用户、项目、权限、凭证、审计和统一 API。 +2. **流水线编排层**:Pipeline、Task、触发器、制品和策略。 +3. **执行层**:Jenkins、Tekton 或其他执行引擎。 + +这样做的主要价值是: + +- 不要求所有用户一次性迁移。 +- 平台 API 不必随执行引擎变化。 +- 安全扫描、代码检查等步骤可以复用。 +- 单个引擎故障或不兼容时,可以按流水线回退。 + +### 2.3 当前设计的选择 + +当前方案保留 KSE CRD 作为统一入口,通过 `spec.engine.type` 选择执行引擎,同时兼容 MVP 早期使用的 annotation。这保留了已有对象的兼容性,也让新对象具备强类型校验。 + +| 维度 | 现有方案 | 行业理想形态 | 当前双引擎设计 | +| --- | --- | --- | --- | +| 用户 API | KSE CRD,但偏 Jenkins | 引擎无关 API | 复用 KSE CRD,增加 engine spec | +| 默认执行器 | Jenkins | 可配置 | Jenkins | +| 新执行器 | 无 | 插件化 | Tekton Adapter | +| 迁移方式 | 整体迁移 | 逐流水线迁移 | 逐 Pipeline 标记 | +| 安全能力 | Jenkins 插件/脚本 | 可组合 Task | 规划为 Tekton Task | +| 风险 | Jenkins 耦合 | 新 API 成本高 | 保留兼容,后续逐步抽象 | + +## 3. 总体架构 + +```mermaid +flowchart LR + U["用户 / KSE Console"] --> A["DevOps Apiserver"] + A --> KP["KSE Pipeline"] + A --> KR["KSE PipelineRun"] + KR --> R{"spec.engine.type"} + R -->|"缺省或 jenkins"| JC["Jenkins Controller"] + JC --> J["Jenkins"] + R -->|"tekton"| TC["Tekton Adapter"] + TC --> TP["Tekton PipelineRun"] + TP --> T["Tekton Tasks / Pods"] + J --> KR + TP --> KR + KR --> A + A --> U +``` + +核心原则: + +- KSE `PipelineRun` 是用户看到的统一运行记录。 +- Jenkins 和 Tekton 不能同时处理同一个运行。 +- 未声明引擎时必须继续走 Jenkins。 +- Tekton 原生对象由 KSE 对象拥有,保持生命周期关联。 +- Controller 只负责适配和状态同步,不负责实现具体 CI 步骤。 + +## 4. 两个代码仓库的职责 + +### 4.1 `kubesphere/ks-devops` + +这是后端核心仓库,负责: + +- DevOps CRD 和 API。 +- Controller 和 Apiserver。 +- Jenkins/Tekton 路由。 +- Tekton 对象转换和状态同步。 +- 后端单元测试与 smoke 测试。 + +主要构建产物: + +- `devops-controller` +- `devops-apiserver` +- `devops-tools` + +本次 Tekton 后端改动主要位于该仓库。 + +### 4.2 `kubesphere-extensions/devops` + +这是 KSE 4.2.x DevOps 扩展仓库,负责: + +- React/TypeScript 前端。 +- KSE Extension 元数据。 +- Frontend 和 Agent Helm Chart。 +- Controller、Apiserver、Jenkins、Argo CD 的安装。 +- CRD、RBAC、镜像版本和扩展发布。 + +它不应重复实现流水线业务逻辑,而是引用 `ks-devops` 构建出的后端镜像并将它们安装到集群。 + +```text +ks-devops 源码 + ↓ 构建 +Controller / Apiserver 镜像 + ↓ 引用 +kubesphere-extensions/devops Helm Chart + ↓ 发布 +KSE 扩展市场 +``` + +## 5. 引擎选择契约 + +### 5.1 默认行为 + +新对象使用 Pipeline Spec: + +```yaml +spec: + engine: + type: tekton +``` + +规则: + +- `spec.engine.type` 为 `jenkins`:Jenkins。 +- `spec.engine.type` 为 `tekton`:Tekton。 +- 缺少 engine spec 时读取旧 engine annotation。 +- 两者都缺少时使用 Jenkins。 +- Spec 优先于兼容 annotation。 +- KSE API 创建 `PipelineRun` 时保存完整 PipelineSpec 快照,并只复制允许的 Tekton 配置注解。 + +默认 Jenkins 是整个兼容策略的基础。安装新版本后,用户已有流水线不应因未配置引擎而改变行为。 + +### 5.2 兼容 annotation + +| Annotation | 用途 | +| --- | --- | +| `devops.kubesphere.io/pipeline-engine` | 旧版引擎选择入口;仅用于兼容 | +| `devops.kubesphere.io/tekton-pipeline` | 指定原生 Tekton Pipeline 名称;缺省与 KSE Pipeline 同名 | +| `devops.kubesphere.io/tekton-service-account` | 指定 Tekton TaskRun 使用的 ServiceAccount | +| `devops.kubesphere.io/tekton-timeout` | 指定 Pipeline 超时,例如 `15m` | +| `devops.kubesphere.io/tekton-workspace-name` | 指定 Workspace 名称 | +| `devops.kubesphere.io/tekton-workspace-claim` | 使用指定 PVC 绑定 Workspace | +| `devops.kubesphere.io/tekton-workspace-empty-dir` | 设为 `true` 时使用临时 Workspace | +| `devops.kubesphere.io/tekton-pipelinerun` | 回写原生 Tekton PipelineRun 名称 | + +引擎选择优先读取 Spec。其他 Tekton 配置仍使用 annotation,运行对象上的值优先于 Pipeline 上的值。 + +### 5.3 当前需要改进的校验 + +CRD 已将 `spec.engine.type` 严格限制为: + +```text +jenkins | tekton +``` + +旧 engine annotation 无法由 CRD OpenAPI 校验,后续应在 admission 或迁移完成后移除该入口。 + +## 6. Tekton 运行流程 + +### 6.1 前置资源 + +MVP 不会把 Jenkinsfile 自动翻译成 Tekton Pipeline。使用 Tekton 前,需要存在一个经过验证的原生 `tekton.dev/v1 Pipeline`。 + +原因是 Jenkinsfile 是 Groovy 程序,可能包含插件、自定义方法、动态条件和外部状态。自动翻译无法稳定保证语义一致。 + +### 6.2 创建运行 + +```mermaid +sequenceDiagram + participant User as 用户或 Console + participant API as DevOps Apiserver + participant KSE as KSE PipelineRun + participant Adapter as Tekton Adapter + participant Tekton as Tekton PipelineRun + + User->>API: 运行 Pipeline + API->>KSE: 创建并固化 PipelineSpec 快照 + Adapter->>KSE: 读取 PipelineRun + Adapter->>Tekton: 创建同名 PipelineRun + Adapter->>KSE: 记录原生运行名称和 Pending 状态 + Tekton-->>Adapter: 更新 Succeeded Condition + Adapter->>KSE: 回写阶段和时间 + API-->>User: 返回统一运行状态 +``` + +创建出的原生对象具有以下特点: + +- 与 KSE `PipelineRun` 位于同一 namespace。 +- 默认使用相同名称。 +- 使用 OwnerReference 指向 KSE `PipelineRun`。 +- 带有 managed-by label,防止接管不属于自己的同名资源。 +- 参数从 KSE `PipelineRun.spec.parameters` 传递。 + +如果同名 Tekton `PipelineRun` 已存在但不属于该 KSE 运行,Controller 会报错而不是覆盖它。 + +### 6.3 状态同步 + +当前同步 Tekton `Succeeded` Condition: + +| Tekton Condition | KSE Phase | +| --- | --- | +| 尚无 Condition,尚未开始 | `Pending` | +| 已有开始时间但未结束 | `Running` | +| `Succeeded=True` | `Succeeded` | +| `Succeeded=False` 且为取消原因 | `Cancelled` | +| `Succeeded=False` | `Failed` | +| `Succeeded=Unknown` | `Running` | + +同时同步: + +- `startTime` +- `completionTime` +- Condition reason +- Condition message +- 最后更新时间 + +当前采用默认 2 秒轮询。MVP 足够,但大规模使用时应改为监听 Tekton 对象事件或采用更合理的退避策略,避免运行数量增加后产生持续 API 压力。 + +### 6.4 停止运行 + +用户通过现有 KSE API 请求停止后: + +```yaml +spec: + action: Stop +``` + +Adapter 将其转换为: + +```yaml +spec: + status: Cancelled +``` + +随后 Tekton 的取消状态会同步回 KSE `PipelineRun.status`。 + +## 7. Jenkins 兼容策略 + +本设计最重要的兼容要求是:启用 Tekton 后不能改变已有 Jenkins 流水线。 + +为此进行了两层隔离: + +1. Jenkins Pipeline Controller 遇到 Tekton Pipeline 时直接跳过。 +2. Jenkins PipelineRun Controller 同时检查运行和被引用 Pipeline,确认属于 Tekton 后不再访问 Jenkins。 + +因此: + +- 没有 engine spec 和兼容 annotation 的旧流水线继续运行。 +- Jenkins 运行不会创建同名 Tekton `PipelineRun`。 +- Tekton 运行不会创建 Jenkins Build。 +- 同一个 KSE `PipelineRun` 只由一个引擎负责。 + +迁移建议按 Pipeline 进行,而不是按项目或集群一次性切换: + +1. 保留已有 Jenkinsfile。 +2. 为目标流水线编写并验证 Tekton Pipeline。 +3. 在 KSE Pipeline 上设置 `spec.engine.type: tekton`。 +4. 验证结果、日志、安全门禁和回滚。 +5. 成功后再迁移下一条流水线。 + +## 8. 一个面向用户的 CI/CD 案例 + +用户希望将一个 Java 服务从 Git 仓库构建并部署到测试环境: + +```text +提交代码 + ↓ +拉取代码 + ↓ +单元测试 + ↓ +AI Code Review + ↓ +构建镜像 + ↓ +Trivy 镜像扫描 + ↓ +推送镜像 + ↓ +更新部署清单或 Helm values + ↓ +部署测试环境 +``` + +在理想的 Console 中,用户只需要: + +1. 创建 DevOps 项目。 +2. 绑定代码仓库和镜像仓库凭证。 +3. 创建流水线并选择 Tekton。 +4. 选择或编辑流水线模板。 +5. 配置触发条件和部署目标。 +6. 查看运行、日志、扫描结果和审批状态。 + +当前 MVP 已经能承载“运行已有 Tekton Pipeline”和“同步总体结果”,但完整的构建、推送、部署和审批能力仍需要 Task 模板、触发器与前端共同完成。 + +## 9. 镜像安全检查和 AI Code Review + +### 9.1 设计原则 + +这些能力属于流水线步骤,不属于引擎路由 Controller。 + +正确的分层方式是: + +```text +Controller:选择引擎、创建运行、同步状态 +Pipeline:定义步骤顺序和门禁 +Task:执行扫描、测试、构建、AI Review +``` + +这样能够: + +- 独立升级扫描器或模型。 +- 在不同 Pipeline 中复用。 +- 按项目配置凭证和策略。 +- 清晰记录输入、输出和失败原因。 +- 避免模型调用或扫描逻辑影响 Controller 稳定性。 + +### 9.2 镜像安全检查 + +仓库已提供 Trivy 阻断样例: + +- 输入一个镜像引用。 +- 扫描漏洞和 Secret。 +- 发现可修复的 Critical 漏洞时返回非零状态。 +- Pipeline 因安全门禁失败而停止。 + +生产化还需要: + +- 镜像使用 digest,不只使用 tag。 +- 扫描器镜像锁定 digest。 +- 内网同步漏洞数据库。 +- 生成并保存 SBOM。 +- 对镜像签名。 +- 在部署阶段增加准入策略。 +- 将扫描报告作为结构化结果展示,而不只是日志。 + +### 9.3 AI Code Review + +推荐实现为独立 Task: + +1. 拉取 PR/MR 变更。 +2. 过滤敏感文件和超大 diff。 +3. 调用企业批准的模型服务。 +4. 生成结构化问题列表。 +5. 回写 GitHub/GitLab Review。 +6. 根据策略决定提示或阻断。 + +必须先明确: + +- 模型服务和网络位置。 +- 源代码是否允许离开企业网络。 +- Token、API Key 和 Git 凭证管理。 +- Prompt 注入防护。 +- 日志脱敏和审计。 +- AI 结论是否允许直接阻断发布。 + +## 10. 部署设计 + +### 10.1 已验证的实验拓扑 + +为了不直接替换重要环境中的正式 Controller,验证期间使用过: + +- 独立 Tekton Adapter。 +- 独立 Canary Apiserver。 +- 统一 Controller Canary。 +- Leader Lease 进行受控切换。 + +这些资源用于验证和回滚,不是最终 Helm 部署形态。 + +验证结束后,集群恢复为: + +- 正式 `devops-controller`:运行 Jenkins 等现有控制器。 +- 独立 `devops-tekton-adapter`:运行 Tekton Controller。 +- 统一 Controller Canary:0 副本待命。 + +### 10.2 正式目标拓扑 + +产品化后不应长期保留第二个 Adapter Deployment。目标是一个统一的 `devops-controller` Deployment,其中同时注册 Jenkins 和 Tekton Controller: + +```text +devops-controller + ├─ Jenkins reconcilers + ├─ Tekton reconciler + ├─ GitRepository reconciler + └─ 其他现有 reconcilers +``` + +目标 Helm 配置建议: + +```yaml +tekton: + enabled: false + install: false +``` + +第一阶段推荐: + +- `enabled` 缺省为 `false`。 +- 不由 DevOps Chart 自动安装 Tekton。 +- 启用时检查 `pipelines.tekton.dev` 和 `pipelineruns.tekton.dev` CRD。 +- 使用固定、验证过的 Tekton 版本。 +- 缺少依赖时给出明确安装错误,不让 Controller 持续报错。 + +这样能避免 DevOps 扩展升级时意外升级集群级 Tekton CRD 和 Controller。 + +### 10.3 Leader Election 和 Lease + +正式 Controller 应启用 Leader Election。Lease 不只是 Canary 切换需要,也用于: + +- 多副本 Controller 只允许一个实例执行 reconcile。 +- 滚动升级期间防止新旧 Pod 同时创建 Jenkins Build 或 Tekton PipelineRun。 +- Leader 故障后由备用 Pod 接管。 + +建议由 Helm 预创建固定名称的 Lease,然后只向 Controller 授予: + +```text +get | update | patch +``` + +不授予 Lease 的 `delete`;如果由 Helm 创建 Lease,也不需要 Controller 拥有 `create`。 + +如果正式 Controller 和独立 Adapter 暂时并存,它们不能共享同一个 Lease,因为两者都必须工作。最终统一 Controller 后只需要一个 Lease。 + +Leader Election 只能避免多个 Controller 实例同时工作,不能代替幂等设计。创建外部任务前仍需要检查已有对象和唯一标识。 + +### 10.4 Tekton RBAC + +当前 Adapter 对原生 Tekton `PipelineRun` 需要: + +```text +get | list | watch | create | update | patch +``` + +当前不需要 `delete`: + +- 停止运行通过更新 `spec.status` 完成。 +- 生命周期清理由 OwnerReference 和 Kubernetes Garbage Collector 负责。 + +权限应通过 `kubesphere-extensions/devops` Agent Chart 安装,而不是要求用户手工执行 YAML。 + +### 10.5 多集群演进 + +当前 DevOps 扩展是 `Multicluster` 安装模式,但成员 Agent 同时包含 Apiserver、Controller、Jenkins 和 Argo CD,且 APIService 指向成员集群本地 `devops-apiserver`。因此不能仅通过停止成员 Agent 实现中央化。 + +推荐演进为: + +1. 第一阶段由管理集群集中运行 DevOps、Jenkins、Tekton 和 GitOps 控制面,成员集群只作为部署目标。 +2. 第二阶段为确有隔离或算力需求的集群提供轻量 Tekton Runner,不安装完整 DevOps 和 Jenkins。 +3. 复用 KSE `Cluster` CR 作为集群身份,不把 kubeconfig 写入 Pipeline/PipelineRun。 +4. Pipeline 的引擎和执行位置进入 spec,PipelineRun 保存执行快照及远端 nativeRef。 +5. DevOps API 和 CR 归属管理集群,不能继续依赖成员集群本地 APIService。 + +详细设计见 [Tekton 多集群设计](./tekton-multicluster-design.md)。 + +## 11. 前端设计 + +### 11.1 当前前端状态 + +当前 `kubesphere-extensions/devops` 前端仍明显依赖 Jenkins: + +- Pipeline mapper 读取 Jenkins metadata annotation。 +- PipelineRun mapper 读取 Jenkins run status 和 Jenkins run ID。 +- 运行详情复用旧版 Jenkins 流水线页面。 +- 创建页面没有 Jenkins/Tekton 选择。 +- 目前没有 Tekton Task、日志、结果和 Workspace 界面。 + +所以,当前已经安装的 KSE Console 不能直接使用新 Tekton 后端。 + +### 11.2 最小可用前端 + +第一版前端建议只实现: + +1. Pipeline 创建/编辑页增加执行引擎选择。 +2. Tekton Pipeline 名称或 YAML 配置入口。 +3. 运行列表读取 KSE `PipelineRun.status`,不再只解析 Jenkins annotation。 +4. 运行和取消操作复用现有 DevOps API。 +5. Tekton 运行详情显示总体状态、开始时间、完成时间和失败信息。 + +第二版再增加: + +- TaskRun 拓扑。 +- Pod 日志。 +- Task results。 +- 扫描报告。 +- AI Review 结果。 +- 重试、跳过、审批和制品。 + +前端不应直接拼接 Tekton Kubernetes API。推荐由 DevOps Apiserver 提供稳定的引擎无关接口,再由后端访问 Tekton TaskRun、Pod 日志或 Tekton Results。 + +## 12. 测试与验证结果 + +### 12.1 单元测试 + +2026-07-30 已通过: + +```text +./pkg/pipelineengine +./controllers/tekton +./pkg/kapis/devops/v1alpha3/pipelinerun +./cmd/controller/app/options +``` + +覆盖内容包括: + +- 默认 Jenkins。 +- Annotation 传播。 +- Tekton 对象构造。 +- 参数、Workspace、ServiceAccount 和超时。 +- 同名资源归属检查。 +- 状态转换。 +- 停止操作。 +- Tekton Controller 注册开关。 + +### 12.2 集群验证 + +在 KSE 4.2.1 测试环境完成过以下验证: + +- 原生 Tekton PipelineRun 成功。 +- KSE PipelineRun 创建同名 Tekton PipelineRun。 +- Tekton 成功状态回写 KSE。 +- KSE 停止操作转换为 Tekton Cancelled。 +- Canary Apiserver 可以通过 KSE REST API 创建和查询运行。 +- 统一 Controller 接管期间,Jenkins smoke 成功。 +- 同一期间 Tekton smoke 成功。 +- Jenkins 运行没有创建同名 Tekton 运行。 +- 测试完成后恢复原 Controller 与独立 Adapter 拓扑。 + +验证过的代表性运行: + +| 类型 | 运行 | 结果 | +| --- | --- | --- | +| Jenkins | `jenkins-smoke-g4hpv` | Succeeded | +| Tekton | `smoke-demo-m8gxs` | Succeeded | +| Tekton cancel | `cancel-demo-run` | Cancelled | + +这证明了路由和基本兼容性,但不等同于完成生产级压力、故障恢复和升级测试。 + +## 13. 已知限制和风险 + +### 13.1 API 模型仍偏 Jenkins + +KSE Pipeline CRD 中存在 Jenkinsfile、SCM 和 Jenkins metadata 等结构。当前已经加入最小的 engine spec,但 Tekton 详细配置仍使用 annotation。如果 Tekton 能力持续扩展,后续需要评估: + +- 扩展现有 CRD 的 engine-specific spec。 +- 新建独立 PipelineDefinition CRD。 +- 或将原生 Tekton Pipeline 作为主要定义,KSE 只保存引用。 + +### 13.2 参数和 Workspace 能力有限 + +当前仅支持: + +- 字符串参数。 +- 一个 Workspace。 +- PVC 或 emptyDir。 + +尚不支持: + +- 数组和对象参数。 +- 多 Workspace。 +- Workspace 的 Secret、ConfigMap、VolumeClaimTemplate。 +- Matrix、PipelineSpec 内联定义等高级能力。 + +### 13.3 只同步总体状态 + +当前没有同步: + +- TaskRun 列表和状态。 +- Step 状态。 +- Pod 和容器日志。 +- Tekton Results。 +- 产物和扫描报告。 + +这会限制前端体验,也是下一阶段最重要的 API 工作。 + +### 13.4 触发能力未完成 + +当前 Jenkins 的 SCM、多分支、定时触发尚未映射到: + +- Tekton Triggers。 +- EventListener。 +- TriggerBinding。 +- TriggerTemplate。 + +正式设计还需决定继续复用 KSE GitRepository/Webhook Controller,还是引入 Tekton Triggers 作为执行层。 + +### 13.5 运行规模 + +每个非终态运行默认每 2 秒轮询一次。大量并发运行时可能增加 Apiserver 压力。应增加: + +- 原生对象事件监听。 +- 指数退避。 +- Controller 并发配置。 +- 指标和告警。 +- Tekton Results 持久化。 + +### 13.6 安装和版本兼容 + +Tekton 是集群级组件,涉及 CRD、Webhook 和多个 Controller。需要明确: + +- 支持的 Kubernetes 版本。 +- 支持的 Tekton 版本范围。 +- CRD 升级策略。 +- 离线镜像清单。 +- amd64/arm64 镜像验证。 +- DevOps 扩展卸载时是否保留 Tekton。 + +## 14. 推荐的产品化路线 + +### 阶段一:完成后端提交 + +- Review 并提交 `ks-devops` 改动。 +- 增加未知 engine 校验。 +- 增加并发 reconcile 和冲突场景测试。 +- 明确 Tekton 版本兼容范围。 + +验收:后端 PR 可独立构建,定向测试通过,默认 Jenkins 行为不变。 + +### 阶段二:完成 Helm 扩展 + +在 `kubesphere-extensions/devops` 中: + +- 增加 `tekton.enabled`。 +- 更新 Controller 和 Apiserver 镜像。 +- 增加 Tekton RBAC。 +- 增加 Leader Lease 和 Leader Election 参数。 +- 增加 Tekton CRD 前置检查。 +- 更新 `extension.yaml` 镜像清单和版本。 +- 不包含 Canary 和独立 Adapter。 + +验收:新装、升级、禁用和回滚均不会影响已有 Jenkins 流水线。 + +### 阶段三:完成最小前端 + +- 增加引擎选择。 +- 增加 Tekton Pipeline 配置入口。 +- 运行列表改为优先读取统一状态。 +- 增加 Tekton 总体状态和取消操作。 + +验收:用户不使用 kubectl,即可在 Console 创建并运行一条 Tekton Pipeline。 + +### 阶段四:提供真实 CI 模板 + +至少提供: + +```text +git-clone → unit-test → build-image → trivy → push-image +``` + +验收:能够在离线或受限网络环境中完成真实镜像构建与安全门禁。 + +### 阶段五:企业能力 + +- Tekton Results。 +- 日志与制品持久化。 +- Webhook 和多分支。 +- 审批和策略。 +- SBOM、签名和准入。 +- AI Code Review。 +- 配额、审计、指标和告警。 + +### 阶段六:多集群集中化 + +- 增加中央 `control-plane`、兼容 `legacy-agent` 和轻量 `tekton-runner` 安装档位。 +- DevOps API 与 CR 收敛到管理集群。 +- 成员集群默认只作为 GitOps Target,不安装完整 DevOps。 +- 第二阶段再实现远程 Tekton PipelineRun 派发、状态同步、日志结果和凭证治理。 + +验收:目标集群无需 Jenkins/Tekton 即可接收部署;远程 Runner 只安装轻量 Tekton,且网络中断或 Controller 重启不会重复运行。 + +## 15. 当前不应做的事情 + +- 不应删除 Jenkins 或把已有流水线批量改成 Tekton。 +- 不应自动翻译任意 Jenkinsfile。 +- 不应让两个 Controller 同时处理同一个 PipelineRun。 +- 不应把 Canary、Adapter 和手工切换 YAML作为正式安装方式。 +- 不应让前端绕过 DevOps API 直接拥有宽泛的 Tekton 权限。 +- 不应把 Trivy、AI 模型等业务逻辑写进 Controller。 +- 不应在重要环境中自动升级未知版本的 Tekton CRD。 + +## 16. Review 时建议重点确认 + +1. 默认 Jenkins 是否是长期兼容策略。 +2. 第一版是否只支持外部安装的 Tekton。 +3. KSE Pipeline 与原生 Tekton Pipeline 是否继续使用名称映射。 +4. Tekton 详细配置从 annotation 迁入 engine-specific spec 的版本边界。 +5. 前端第一版是否只做总体状态,不立即实现 Task 详情和日志。 +6. Tekton 版本支持范围和离线镜像责任由哪个团队维护。 +7. AI Code Review 的模型、数据和凭证安全边界。 + +## 17. 代码索引 + +后端核心: + +- `pkg/pipelineengine/engine.go`:引擎 Spec 与兼容 annotation 的解析契约。 +- `controllers/tekton/pipelinerun_controller.go`:Tekton Adapter。 +- `cmd/controller/app/controllers.go`:Controller 注册。 +- `controllers/jenkins/`:Jenkins 隔离逻辑。 +- `pkg/kapis/devops/v1alpha3/pipelinerun/`:API 创建、PipelineSpec 快照与兼容 annotation 传播。 +- `config/rbac/role.yaml`:Tekton 后端权限源定义。 +- `config/samples/tekton/`:Tekton 和安全扫描样例。 +- `hack/smoke-tekton-mvp.sh`:集群 smoke 测试。 + +扩展产品化: + +- `kubesphere-extensions/devops/charts/devops/extension.yaml`:扩展元数据和镜像清单。 +- `kubesphere-extensions/devops/charts/devops/charts/agent/`:后端 Helm Chart。 +- `kubesphere-extensions/devops/charts/devops/charts/frontend/`:前端 Helm Chart。 +- `kubesphere-extensions/devops/web/extensions/devops/`:KSE Console DevOps 前端。 + +--- + +本报告描述的是当前已经验证的设计和实现边界。正式发布前,仍需完成 Helm 扩展、前端接入、升级回滚、规模测试和安全评审。 diff --git a/docs/tekton-engine-mvp.md b/docs/tekton-engine-mvp.md new file mode 100644 index 000000000..4cd9da35c --- /dev/null +++ b/docs/tekton-engine-mvp.md @@ -0,0 +1,115 @@ +# KubeSphere DevOps Tekton Engine MVP + +这版实现不是用 Tekton 替换 Jenkins,而是在现有 KSE DevOps 控制面后增加第二个执行引擎。未声明执行引擎的流水线仍走 Jenkins;只有显式标记为 `tekton` 的流水线才由 Tekton 适配器处理。 + +## 设计边界 + +业界常见的多引擎 CI/CD 控制面会把“用户 API、权限、审计”与“实际执行器”分开。这样既能逐条迁移流水线,也能在出现兼容问题时快速切回旧引擎。本 MVP 沿用 KSE 的 `devops.kubesphere.io/v1alpha3` `Pipeline` 和 `PipelineRun` 作为用户 API,以原生 `tekton.dev/v1` `Pipeline` 和 `PipelineRun` 作为执行 API。 + +执行流程如下: + +1. 用户或现有 KSE API 创建 KSE `PipelineRun`。 +2. `Pipeline.spec.engine.type: tekton` 将该流水线路由到 Tekton;未配置时继续使用 Jenkins。 +3. 适配器创建同名的原生 Tekton `PipelineRun`,传递字符串参数、ServiceAccount、超时和一个可选 Workspace。 +4. 适配器把 Tekton 的开始时间、完成时间、`Succeeded` Condition 和阶段回写到 KSE `PipelineRun.status`。 +5. KSE `spec.action: Stop` 会转换为 Tekton `spec.status: Cancelled`。 + +KSE Pipeline 与原生 Tekton Pipeline 当前是一层显式映射,而不是把 Jenkinsfile 自动翻译为 Tekton YAML。自动翻译 Groovy 通常不可可靠验证,建议在迁移期同时维护已验证的 Tekton Pipeline,再逐步把公共步骤沉淀为 Tekton Tasks。 + +## 引擎与注解契约 + +引擎使用强类型 Spec: + +```yaml +spec: + engine: + type: tekton +``` + +CRD 只允许 `jenkins` 或 `tekton`。为兼容已经创建的 MVP 对象,缺少 `spec.engine` 时仍读取旧的 `devops.kubesphere.io/pipeline-engine` annotation,最终缺省为 Jenkins。新建 PipelineRun 会保存完整的 PipelineSpec 快照,因此 Pipeline 后续变更不会改变已经发起的运行。 + +| 注解 | 含义 | +| --- | --- | +| `devops.kubesphere.io/tekton-pipeline` | 原生 Tekton Pipeline 名称;缺省与 KSE Pipeline 同名 | +| `devops.kubesphere.io/tekton-service-account` | Tekton TaskRun 使用的 ServiceAccount | +| `devops.kubesphere.io/tekton-timeout` | Go duration 格式的 Pipeline 超时,例如 `15m` | +| `devops.kubesphere.io/tekton-workspace-name` | 原生 Pipeline 声明的 Workspace 名称 | +| `devops.kubesphere.io/tekton-workspace-claim` | Workspace 使用的 PVC 名称 | +| `devops.kubesphere.io/tekton-workspace-empty-dir` | 设为 `true` 时使用临时 Workspace;不能与 PVC 同时使用 | +| `devops.kubesphere.io/tekton-pipelinerun` | 适配器回写的原生 PipelineRun 名称 | + +运行级 Tekton 配置注解优先于流水线级注解。通过 KSE API 发起运行时,允许的流水线级 Tekton 注解会被复制到新建的 KSE `PipelineRun`。 + +## 前置条件 + +- Kubernetes 集群已经安装 KSE DevOps CRD。 +- 安装支持稳定 `tekton.dev/v1` API 的 Tekton Pipelines。先根据 KSE 所在 Kubernetes 版本核对 Tekton release 的兼容矩阵;下面的开发验证固定为 v1.13.0,不使用 `latest`: + + ```bash + kubectl apply --filename https://storage.googleapis.com/tekton-releases/pipeline/previous/v1.13.0/release.yaml + kubectl wait --namespace tekton-pipelines --for=condition=Available deployment/tekton-pipelines-controller --timeout=5m + ``` + +- 构建并部署本分支的 `devops-controller` 镜像。沿用项目现有发布流程: + + ```bash + export CONTROLLER_IMG=registry.example.com/kse/devops-controller:tekton-mvp + make docker-build-controller CONTROLLER_IMG="${CONTROLLER_IMG}" + docker push "${CONTROLLER_IMG}" + make deploy CONTROLLER_IMG="${CONTROLLER_IMG}" + ``` + +现有 KSE 环境如果由 Helm 或扩展组件管理,应在对应 values 中替换 controller 镜像,不要并行部署两个 ks-devops controller-manager。`config/rbac/role.yaml` 已包含对 `tekton.dev/pipelineruns` 的最小权限。 + +## 单元测试 + +仓库要求 Go 1.23 或更新版本。运行定向测试: + +```bash +go test ./pkg/pipelineengine ./controllers/tekton ./pkg/kapis/devops/v1alpha3/pipelinerun +``` + +测试覆盖默认 Jenkins 路由、Spec 优先级、PipelineSpec 快照、兼容注解传播、Tekton 对象转换、创建原生 PipelineRun、成功状态回写和取消传播。 + +## 集群冒烟测试 + +确认新 controller 镜像和 Tekton 已就绪后运行: + +```bash +./hack/smoke-tekton-mvp.sh +``` + +脚本只创建测试资源,不执行清理操作。它会创建一个带随机后缀的 KSE `PipelineRun`,等待同名 Tekton `PipelineRun` 成功,再检查 KSE 状态已经变成 `Succeeded`。 + +也可以手动执行: + +```bash +kubectl apply -k config/samples/tekton +kubectl create -f config/samples/tekton/kse-pipelinerun.yaml +kubectl get pipelineruns.devops.kubesphere.io -n kse-tekton-demo +kubectl get pipelineruns.tekton.dev -n kse-tekton-demo +``` + +停止一个仍在运行的 KSE PipelineRun: + +```bash +kubectl patch pipelinerun.devops.kubesphere.io RUN_NAME -n kse-tekton-demo --type=merge -p '{"spec":{"action":"Stop"}}' +``` + +## 镜像安全门禁 + +`secure-image-pipeline.yaml` 提供了一个 Trivy 阻断门禁:扫描发现可修复的 `CRITICAL` 漏洞时 Task 以非零状态结束。先部署示例定义,再创建一次扫描: + +```bash +kubectl create -f config/samples/tekton/secure-image-kse-pipelinerun.yaml +``` + +示例为了可读性固定为 `aquasec/trivy:0.70.0`。生产环境必须把构建器、扫描器和业务镜像都锁定到经过验证的 digest,并为扫描数据库配置可信镜像源或内部缓存。建议把 SBOM 生成、镜像签名和准入策略作为后续 Tasks,而不是把所有安全逻辑写进控制器。 + +## 当前限制与下一步 + +- KSE 只镜像 PipelineRun 总体状态;TaskRun 阶段、日志和结果仍从 Tekton API 或 `tkn` 查看。 +- 仅支持字符串参数和一个注解式 Workspace;数组/对象参数及多 Workspace 需要扩展 KSE API 或增加独立配置 CRD。 +- SCM 触发、多分支发现、定时触发仍是 Jenkins 能力,尚未映射到 Tekton Triggers。 +- AI Code Review 需要确定代码托管平台、模型服务、数据出境与凭据策略。推荐实现成独立 Tekton Task,在拉取代码后执行,把结果回写 PR/MR;不要把模型调用耦合进 PipelineRun 控制器。 +- 下一阶段应增加 Tekton Results 持久化、TaskRun/日志聚合、Trigger 适配、供应链签名与策略准入,并补充真实集群 e2e 测试。 diff --git a/docs/tekton-multicluster-design.md b/docs/tekton-multicluster-design.md new file mode 100644 index 000000000..b5efd712f --- /dev/null +++ b/docs/tekton-multicluster-design.md @@ -0,0 +1,501 @@ +# KubeSphere DevOps Tekton 多集群设计 + +> 状态:设计草案,不代表当前代码已经实现 +> +> 日期:2026-07-30 +> 相关报告:[Tekton DevOps 双引擎设计](./tekton-devops-design-report.md) + +## 1. 决策摘要 + +本设计解决两个问题: + +1. KSE DevOps 不再要求每个成员集群安装完整的 Jenkins、DevOps Controller、Apiserver 和 Argo CD。 +2. 在保留 Jenkins 兼容性的前提下,引入 Tekton,并为后续远程 Runner 留出标准模型。 + +推荐按两个阶段落地: + +- 第一阶段:管理集群集中运行 KSE DevOps、Jenkins、Tekton 和 GitOps 控制面;成员集群只作为部署目标。 +- 第二阶段:有隔离或算力需求的成员集群可以选择安装轻量 Tekton Runner;管理集群负责派发和汇总。 + +关键决策: + +- 复用 KSE 已有的 `cluster.kubesphere.io/v1alpha1 Cluster` 作为集群身份和连接来源。 +- 不创建重复保存 kubeconfig 的 Runner CRD,也不把 kubeconfig 写入 Pipeline、PipelineRun 或 Task。 +- 用户选择的引擎和执行位置进入 `spec`;annotation 只用于 MVP 兼容和内部元数据。 +- PipelineRun 保存执行快照;已经创建的运行不会因 Pipeline 后续变更而切换引擎或 Runner。 +- CI 执行位置与 CD 部署目标分开建模。Pipeline 不直接保存一组部署集群。 +- 现有 Jenkins 是默认引擎,已有 Pipeline 在升级后行为不变。 +- 第一阶段不自动把已有成员集群 DevOps 安装切换为集中模式。 + +## 2. 术语 + +| 角色 | 职责 | 是否运行 CI 引擎 | +|---|---|---| +| Management Cluster | 保存 KSE DevOps CR、提供 API/UI、调度运行、汇总状态 | 是 | +| Runner Cluster | 执行编译、测试、镜像构建和扫描 | 是,Jenkins Agent 或 Tekton | +| Target Cluster | 接收应用部署 | 否 | +| Kubernetes Control Plane Node | 运行 kube-apiserver、scheduler 等 Kubernetes 控制面 | 与 CI/CD 集群角色无直接关系 | +| Kubernetes Worker Node | 承载 Pod | 与 CI/CD 集群角色无直接关系 | + +同一个 Kubernetes 集群可以同时承担 Management、Runner 和 Target 角色。集群角色是产品与调度概念,不等同于节点角色。 + +## 3. 行业通用做法 + +### 3.1 控制面与执行面分离 + +成熟 CI/CD 平台通常将流水线定义、权限、审计和调度集中管理,把任务执行下沉到 Runner。这样可以: + +- 减少每个目标集群的常驻组件。 +- 统一模板、策略、凭证和审计。 +- 让 Runner 按安全域、架构、地域或算力分类。 +- 独立扩缩容执行资源,不影响管理 API。 + +### 3.2 CI 与 CD 分离 + +推荐流程是: + +```text +代码提交 + → CI Runner:测试、构建、扫描、签名、推送制品 + → 更新部署仓库或发布对象 + → GitOps Controller:部署到 Target Cluster +``` + +CI Pipeline 负责产生可信制品和发布意图;Argo CD 或 Flux 负责持续收敛目标集群。这样能避免构建任务长期持有多个生产集群的高权限 kubeconfig。 + +### 3.3 集群引用与凭证分离 + +业务 CR 只引用逻辑集群名称。连接凭证由平台集中管理,并按最小权限生成客户端。凭证不应复制给 Task 容器,也不应散落在每个 DevOps Project Namespace。 + +### 3.4 不跨集群模拟 OwnerReference + +Kubernetes OwnerReference 只在同一集群内有效。跨集群运行必须通过显式状态、唯一键和 finalizer 管理,不能依赖远端垃圾回收自动完成一致性。 + +## 4. 对现有方案的评估 + +### 4.1 同事建议的方向 + +“只在一个集群安装 DevOps,通过其他集群的 kubeconfig 把它们作为 Runner 或 Target”方向正确,但 Runner 与 Target 必须区分: + +- Target Cluster 不需要安装 Jenkins 或 Tekton。 +- Runner Cluster 必须存在任务运行时。Tekton Runner 至少需要 Tekton CRD、Controller、Webhook 和执行所需 RBAC。 +- kubeconfig 应由控制面使用,不应直接交给流水线步骤。 + +### 4.2 当前 KSE 的实际模型 + +当前 `kubesphere-extensions/devops` 声明 `installationMode: Multicluster`,Agent Chart 包含: + +- DevOps Apiserver。 +- DevOps Controller。 +- Jenkins。 +- Argo CD。 +- CRD、RBAC 和相关服务。 + +Agent 在所在集群注册指向本地 `devops-apiserver` Service 的 `APIService`。前端请求路径又带有 `klusters/:cluster`。因此当前模型实质是“成员集群本地 DevOps 后端”,并非中央 DevOps 自动管理所有成员集群。 + +直接停止成员集群 Agent 会让现有 API 路由失去后端。集中化不仅是 Helm 裁剪,还需要改变 API 和 CR 的归属。 + +### 4.3 KSE 已有多集群能力 + +KSE 已有 `cluster.kubesphere.io/v1alpha1 Cluster`: + +- 支持 `Direct` 和 `Proxy` 连接模式。 +- 保存 Kubernetes/KubeSphere API 端点和连接信息。 +- ClusterClient 可以生成 `rest.Config`、Kubernetes Client 和 controller-runtime Client。 +- KSE Apiserver 可以按 `clusters/{cluster}` 路径转发用户请求。 + +这些能力可以作为集群身份与客户端基础,但当前 KSE HTTP 转发过滤器不是 ks-devops Reconciler 可直接调用的远程调度接口。ks-devops 仍需要独立的 `ClusterClientProvider` 抽象。 + +### 4.4 现有 Flux 多集群实现 + +ks-devops 当前会把每个 Cluster 的 kubeconfig 复制到每个 DevOps Project Namespace 的 Secret 中,供 Flux 使用。 + +这个实现证明 KSE 已有集群来源,但不建议直接复制到远程 Runner: + +- 凭证副本数量随项目和集群乘积增长。 +- 任意获得该 Secret 的 Task 可能取得目标集群权限。 +- 凭证轮换和吊销成本高。 +- Proxy 集群的可用性还取决于实际连接数据和代理链路。 + +## 5. 目标架构 + +### 5.1 第一阶段:集中控制、集中执行、远程部署 + +```mermaid +flowchart LR + U["KSE Console / API"] --> D["Management Cluster\nDevOps Apiserver + Controller"] + D --> J["Jenkins"] + D --> T["Tekton Pipelines"] + J --> R["CI Workloads"] + T --> R + R --> I["Registry / Artifact Store"] + R --> G["GitOps Repository"] + G --> C["Argo CD / Flux Control Plane"] + C --> A["Target Cluster A"] + C --> B["Target Cluster B"] +``` + +特点: + +- KSE Pipeline、PipelineRun 和 DevOps Project 统一保存在管理集群。 +- Jenkins 与 Tekton 都在管理集群运行。 +- 成员集群不安装完整 DevOps Agent,仅作为 GitOps Target。 +- PipelineRun 的默认执行集群是管理集群。 +- 现有每集群模式继续保留,集中模式通过显式安装档位启用。 + +这一步已经能解决大部分“每个集群安装完整 DevOps 太重”的问题。 + +### 5.2 第二阶段:可选远程 Tekton Runner + +```mermaid +flowchart LR + D["Management Cluster\nDevOps Control Plane"] --> L["Local Jenkins / Tekton Runner"] + D --> X["ClusterClientProvider"] + X --> RA["Runner Cluster A\nLightweight Tekton"] + X --> RB["Runner Cluster B\nLightweight Tekton"] + L --> S["Status / Logs / Results"] + RA --> S + RB --> S + S --> D +``` + +轻量 Runner 安装档位只包含: + +- Tekton Pipelines 运行时,或对已有 Tekton 进行版本检查。 +- 最小的 KSE Runner ServiceAccount 和 RBAC。 +- 可选日志、Results 或缓存组件。 +- 不包含 Jenkins、DevOps Apiserver、完整 DevOps Controller 和 Argo CD。 + +中央 Controller 负责: + +- 解析 `execution.clusterRef`。 +- 在目标 Runner 创建原生 Tekton PipelineRun。 +- 保存远端对象 UID。 +- 轮询或 Watch 状态并同步到 KSE PipelineRun。 +- 处理取消、超时、连接失败、重试和最终清理。 + +## 6. API 设计 + +### 6.1 PipelineSpec + +当前分支已经实现最小的 `spec.engine.type`,并保留旧 annotation 兼容。下面的 Tekton 详细配置和 execution 字段仍是产品化目标: + +```yaml +apiVersion: devops.kubesphere.io/v1alpha3 +kind: Pipeline +metadata: + name: java-build + namespace: demo +spec: + type: pipeline + engine: + type: tekton + tekton: + pipelineRef: + name: java-build + serviceAccountName: builder + timeout: 30m + workspaces: + - name: source + claimTemplateRef: + name: source-workspace + execution: + clusterRef: + name: host + namespace: demo +``` + +建议类型关系: + +```text +PipelineSpec + ├─ engine: PipelineEngineSpec + │ ├─ type: jenkins | tekton + │ ├─ jenkins: JenkinsEngineSpec + │ └─ tekton: TektonEngineSpec + └─ execution: PipelineExecutionSpec + ├─ clusterRef.name + └─ namespace +``` + +规则: + +- `engine.type` 为空时默认为 `jenkins`,保证已有对象兼容。 +- `engine.type` 使用 CRD enum;未知值在准入阶段拒绝。 +- 只能设置与 `engine.type` 对应的配置块。 +- `execution.clusterRef` 为空时默认当前管理集群。 +- `execution.namespace` 为空时默认 KSE Pipeline 所在 Namespace。 +- `clusterRef` 引用 KSE `Cluster` 名称,不包含 kubeconfig。 +- 第一阶段只允许本地执行;字段提前稳定,但远程 Runner 由 feature gate 控制。 + +### 6.2 PipelineRunSpec + +当前 PipelineRun 已保存 `pipelineSpec` 快照。产品化时应保证其中包含 engine 和 execution,并显式记录最终调度结果: + +```yaml +spec: + pipelineRef: + name: java-build + pipelineSpec: + engine: + type: tekton + tekton: {} + execution: + clusterRef: + name: runner-amd64 + namespace: demo + parameters: + - name: revision + value: main +``` + +建议第一版不允许创建 PipelineRun 时任意覆盖 `clusterRef`。Runner 由 Pipeline 或平台策略确定,防止普通项目成员绕过隔离、配额和成本策略。后续如需覆盖,应由独立字段表达并经过 RBAC/Admission 校验。 + +### 6.3 PipelineRunStatus + +原生引擎运行身份属于观测结果,应写入 status,而不是 annotation: + +```yaml +status: + phase: Running + engine: tekton + execution: + clusterRef: + name: runner-amd64 + namespace: demo + nativeRef: + apiVersion: tekton.dev/v1 + kind: PipelineRun + name: java-build-x7k2m + uid: 6bf9... + conditions: + - type: Scheduled + status: "True" + reason: RunnerAvailable + - type: Succeeded + status: Unknown + reason: Running +``` + +状态至少需要区分: + +- Runner 未找到或不允许使用。 +- Runner 暂时不可达。 +- Runner 缺少 Tekton CRD 或版本不兼容。 +- 原生 Pipeline/PipelineRun 不存在。 +- 原生运行已创建、运行、成功、失败或取消。 +- 状态同步延迟。 + +### 6.4 为什么不继续使用 annotation + +`spec` 适合用户声明的长期契约,可由 OpenAPI 校验、前端生成、GitOps Diff 和策略引擎识别。annotation 没有强类型和结构校验,容易拼写错误,也无法稳定表达多集群、Workspace、超时和运行策略。 + +迁移期读取顺序建议: + +1. 有 `spec.engine` 时使用 spec。 +2. 否则读取 MVP engine annotation。 +3. 两者都没有时使用 Jenkins。 +4. 新版 Apiserver 写 spec,不再主动写 annotation。 +5. 经过一个兼容周期后再移除 annotation 入口。 + +## 7. API 与资源归属改造 + +集中模式下,Console 对任意成员集群上下文发起 DevOps 请求时,最终都应落到管理集群 DevOps Apiserver。建议提供两种兼容路径之一: + +### 7.1 推荐:DevOps 资源归管理集群 + +- DevOps Project、Pipeline 和 PipelineRun 只创建在管理集群。 +- 前端的 DevOps 模块使用管理集群 API,不再把当前工作负载集群当作 CR 存储位置。 +- Target Cluster 作为表单字段或 GitOps Application 的 destination,而不是 API 路由目的地。 +- 现有 `klusters/:cluster` 路由在集中模式下由 Apiserver 解释为上下文或兼容参数,不再转发到成员 DevOps Agent。 + +优点是资源归属明确,最符合集中控制面模型。 + +### 7.2 不推荐:透明代理保持成员资源归属 + +继续把 Pipeline CR 分散在成员集群,再由管理集群代理 reconcile,会导致: + +- 每个成员仍需 DevOps CRD。 +- Watch、缓存和故障恢复复杂。 +- 项目列表、审计、配额和日志需要跨集群聚合。 +- 集中控制面收益有限。 + +因此它只适合作为迁移兼容,不作为最终模型。 + +## 8. ClusterClientProvider + +ks-devops 应定义引擎无关的客户端抽象,例如: + +```text +ClusterClientProvider + ├─ GetRuntimeClient(clusterName) + ├─ GetRESTConfig(clusterName) + ├─ CheckReady(clusterName) + └─ CheckCapability(clusterName, capability) +``` + +实现要求: + +- 复用 KSE Cluster 身份、连接模式和凭证轮换。 +- 缓存按 Cluster resourceVersion 或连接信息变化失效。 +- 连接失败不得阻塞其他 Runner 的 reconcile。 +- 对 Direct 和 Proxy 连接分别做集成测试。 +- Controller 只获得目标 Namespace 内 Tekton 所需权限。 +- Task Pod 不继承中央控制面的远程集群凭证。 + +当前 UFL `clusterclient` 可以作为实现参考或依赖,但接入前必须确认: + +- Proxy Cluster 的 kubeconfig 在运行时是否完整且可直接使用。 +- ks-devops 进程能否依赖 UFL 内部包或需要抽出公共 API。 +- Client 缓存、凭证轮换和网络超时是否满足长时间 Controller 运行。 + +## 9. 跨集群运行生命周期 + +### 9.1 幂等创建 + +中央 KSE PipelineRun UID 应作为远端原生 PipelineRun 的稳定标签或 annotation。Reconcile 顺序: + +1. 检查 status 中已保存的 nativeRef。 +2. 按 UID 标签查找可能已创建但尚未回写 status 的对象。 +3. 只在两者都不存在时创建。 +4. 创建成功后保存远端 name 和 UID。 + +这样能覆盖 Controller 在“远端创建成功、中央状态写入失败”之间崩溃的情况。 + +### 9.2 取消 + +取消 Tekton PipelineRun 应更新其受支持的取消字段,不依赖删除。中央状态在远端确认取消后进入终态;Runner 不可达时记录 `CancelPending`。 + +### 9.3 最终清理 + +跨集群不能使用 OwnerReference。中央 PipelineRun 删除时: + +- finalizer 发起远端清理或保留策略。 +- Runner 不可达时采用有限重试和明确的 orphan 策略。 +- 不允许 finalizer 无限阻塞 Namespace 删除。 +- 审计记录必须保留远端身份和最终处理结果。 + +### 9.4 状态、日志和结果 + +MVP 可以由中央 Controller Watch 或轮询远端 PipelineRun。生产版建议引入 Tekton Results 或统一日志后端,避免长期依赖远程 Pod 日志: + +- KSE PipelineRun status 保存摘要。 +- TaskRun 拓扑按需查询或缓存。 +- 日志写入 Loki/对象存储等统一后端。 +- 制品、SBOM、扫描和 AI Review 使用结构化结果引用。 + +## 10. 安全边界 + +- 管理集群 Controller 不使用平台管理员 kubeconfig执行日常任务。 +- 每个 Runner 使用专用 ServiceAccount 和 Namespace 范围 RBAC。 +- 普通 Pipeline Task 无权读取 Cluster CR 的连接凭证。 +- Runner 选择必须经过项目权限、允许列表、配额和策略校验。 +- 生产 Target 凭证由 GitOps Controller 管理,不传入构建 Pod。 +- Secret、日志、扫描报告和 AI Review 输入需要脱敏。 +- 跨集群请求记录用户、项目、PipelineRun、Runner 和 native UID。 + +## 11. Helm 安装档位 + +扩展 Chart 建议明确三种安装 Profile,并把无需安装 Release 的目标集群标记为 `target-only`,而不是用多个松散布尔值组合出未知状态: + +| Profile | 安装内容 | 使用场景 | +|---|---|---| +| `legacy-agent` | 当前完整 Agent、Jenkins、Apiserver、Controller、Argo CD | 兼容已有每集群安装 | +| `control-plane` | 中央 Apiserver、Controller、前端、Jenkins/Tekton/GitOps 可选 | 新集中模式 | +| `tekton-runner` | Tekton 运行时、Runner RBAC、可选 Results/日志组件 | 第二阶段远程执行 | +| `target-only` | 不安装 DevOps;仅由 GitOps 注册目标 | 部署目标集群 | + +注意:`target-only` 是逻辑档位,不一定需要创建 Helm Release。 + +升级原则: + +- 已有安装默认保持 `legacy-agent`,不静默迁移。 +- 新装可以显式选择 `control-plane`。 +- Tekton CRD 和 Controller 版本必须固定并通过兼容矩阵校验。 +- 卸载 DevOps 时默认保留 Tekton CRD 和历史运行,避免级联数据损失。 + +## 12. 模板与步骤目录 + +Tekton 的灵活性应通过版本化 Catalog 提供,不把具体工具逻辑写入 DevOps Controller。 + +建议分层: + +- Task:git clone、单元测试、构建镜像、Trivy、SBOM、签名、AI Review、GitOps 更新。 +- Pipeline Template:Java、Go、Node、容器镜像、安全发布等组合。 +- Policy:必须扫描、严重漏洞阈值、签名、审批、允许的 Runner。 +- Environment/Delivery Template:开发、测试、生产的 GitOps 发布策略。 + +模板必须固定版本或 digest,声明所需 Workspace、Secret、网络和权限,并提供离线镜像清单。 + +## 13. 迁移路线 + +### 阶段 0:完成本地双引擎 + +- 合并当前 Tekton MVP 后端。 +- 保持默认 Jenkins。 +- 完成 Helm RBAC、Feature Gate 和前端最小接入。 +- `engine.type` 已从 annotation 迁入 spec;继续迁移 Tekton 详细配置。 + +验收:同一管理集群中 Jenkins 与 Tekton Pipeline 可并存,已有 Pipeline 行为不变。 + +### 阶段 1:集中控制面和 Target-only + +- 增加 `control-plane` 安装档位。 +- DevOps CR 和 API 收敛到管理集群。 +- 前端区分 CI 执行集群与 CD 目标集群。 +- 通过现有 KSE Cluster + GitOps 管理 Target。 +- 验证已有 `legacy-agent` 与新模式可以并存。 + +验收:新成员集群不安装完整 DevOps,也能作为应用部署目标;已有成员 DevOps 不受影响。 + +### 阶段 2:轻量 Tekton Runner + +- 增加 `tekton-runner` Profile。 +- 实现 ClusterClientProvider 和远程 PipelineRun 生命周期。 +- 增加 Runner capability、健康状态、配额和策略。 +- 完成日志、结果和离线镜像方案。 + +验收:中央 PipelineRun 可稳定派发到指定 Runner,网络中断和 Controller 重启不会产生重复运行。 + +### 阶段 3:企业能力 + +- Runner 自动选择和队列调度。 +- 模板与 Task Catalog。 +- Tekton Results、制品、SBOM、签名和策略门禁。 +- AI Code Review 的凭证、隐私、审计和结果模型。 +- 多租户资源隔离、成本统计、SLO 和容量规划。 + +## 14. 暂不实现的内容 + +- 不自动翻译 Jenkinsfile 为 Tekton Pipeline。 +- 不让 Jenkins Job 直接调度到只安装 Tekton 的 Runner。 +- 不把 Target Cluster 当作默认 CI Runner。 +- 不把远程集群 kubeconfig 注入普通 Task。 +- 不在第一阶段实现自动跨集群 Runner 选择。 +- 不在未完成 API 归属改造前卸载成员集群 DevOps Agent。 + +## 15. 需要评审的决策 + +1. 集中模式下 DevOps Project 是否全部归管理集群,答案建议为“是”。 +2. 第一阶段是否只支持本地 Runner,答案建议为“是”。 +3. PipelineRun 是否允许用户覆盖 Runner,答案建议第一版为“否”。 +4. 远程 Runner 是否仅支持 Tekton,答案建议第一版为“是”。 +5. Tekton 由 DevOps 安装还是只检测外部安装,需要产品与运维共同决定。 +6. Proxy Cluster 的 Controller 访问方式需要 UFL 团队确认并做集成测试。 +7. 现有 Flux kubeconfig Secret 复制机制是否同步重构,需要单独安全评审。 + +## 16. 代码依据 + +- KSE Cluster 类型:`unified-foundation-layer/staging/src/kubesphere.io/api/cluster/v1alpha1/types.go` +- KSE 多集群 HTTP 转发:`unified-foundation-layer/pkg/apiserver/filters/multicluster.go` +- KSE ClusterClient:`unified-foundation-layer/pkg/utils/clusterclient/clusterclient.go` +- Flux 多集群 Secret 同步:`ks-devops/controllers/fluxcd/multi-cluster-controller.go` +- Pipeline API:`ks-devops/pkg/api/devops/v1alpha3/pipeline_types.go` +- PipelineRun API:`ks-devops/pkg/api/devops/v1alpha3/pipelinerun_types.go` +- DevOps 扩展安装模式:`devops/charts/devops/extension.yaml` +- 成员 Agent APIService:`devops/charts/devops/charts/agent/templates/extensions.yaml` +- 前端 PipelineRun 路由:`devops/web/extensions/devops/src/stores/pipelineruns.ts` + +--- + +本设计的核心不是“用 Tekton 替换 Jenkins”,而是把 KSE DevOps 从每集群完整安装,演进为集中控制、按需执行、独立交付的多集群平台。 diff --git a/hack/smoke-tekton-mvp.sh b/hack/smoke-tekton-mvp.sh new file mode 100755 index 000000000..4b9725133 --- /dev/null +++ b/hack/smoke-tekton-mvp.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +# Copyright 2026 The KubeSphere Authors. +# Licensed under the Apache License, Version 2.0. + +set -euo pipefail + +REPOSITORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SAMPLE_DIRECTORY="${REPOSITORY_ROOT}/config/samples/tekton" +DEMO_NAMESPACE="${DEMO_NAMESPACE:-kse-tekton-demo}" + +command -v kubectl >/dev/null + +kubectl get crd pipelines.tekton.dev >/dev/null +kubectl get crd pipelineruns.tekton.dev >/dev/null +kubectl get crd pipelines.devops.kubesphere.io >/dev/null +kubectl get crd pipelineruns.devops.kubesphere.io >/dev/null + +kubectl apply -k "${SAMPLE_DIRECTORY}" + +KSE_RUN_RESOURCE="$(kubectl create -f "${SAMPLE_DIRECTORY}/kse-pipelinerun.yaml" -o name)" +KSE_RUN_NAME="${KSE_RUN_RESOURCE##*/}" + +echo "Created KubeSphere PipelineRun ${DEMO_NAMESPACE}/${KSE_RUN_NAME}" +kubectl wait --namespace "${DEMO_NAMESPACE}" \ + --for=condition=Succeeded=True \ + "pipelinerun.tekton.dev/${KSE_RUN_NAME}" \ + --timeout=10m +kubectl wait --namespace "${DEMO_NAMESPACE}" \ + --for=jsonpath='{.status.phase}'=Succeeded \ + "pipelinerun.devops.kubesphere.io/${KSE_RUN_NAME}" \ + --timeout=30s + +kubectl get --namespace "${DEMO_NAMESPACE}" \ + "pipelinerun.devops.kubesphere.io/${KSE_RUN_NAME}" \ + -o custom-columns=NAME:.metadata.name,ENGINE:.spec.pipelineSpec.engine.type,NATIVE:.metadata.annotations.devops\\.kubesphere\\.io/tekton-pipelinerun,PHASE:.status.phase +kubectl get --namespace "${DEMO_NAMESPACE}" \ + "pipelinerun.tekton.dev/${KSE_RUN_NAME}" diff --git a/pkg/api/devops/v1alpha3/pipeline_types.go b/pkg/api/devops/v1alpha3/pipeline_types.go index c4e285677..60bb17af0 100644 --- a/pkg/api/devops/v1alpha3/pipeline_types.go +++ b/pkg/api/devops/v1alpha3/pipeline_types.go @@ -63,11 +63,32 @@ const ( // PipelineSpec defines the desired state of Pipeline type PipelineSpec struct { + // Engine selects the backend that executes this Pipeline. + // When omitted, the Pipeline uses Jenkins for backward compatibility. + // +optional + Engine *PipelineEngineSpec `json:"engine,omitempty"` Type PipelineType `json:"type" description:"type of devops pipeline, in scm or no scm"` Pipeline *NoScmPipeline `json:"pipeline,omitempty" description:"no scm pipeline structs"` MultiBranchPipeline *MultiBranchPipeline `json:"multi_branch_pipeline,omitempty" description:"in scm pipeline structs"` } +// PipelineEngineSpec identifies the backend that executes a Pipeline. +type PipelineEngineSpec struct { + // Type is the execution engine name. + // +kubebuilder:validation:Enum=jenkins;tekton + Type PipelineEngineType `json:"type"` +} + +// PipelineEngineType identifies a supported Pipeline execution backend. +type PipelineEngineType string + +const ( + // PipelineEngineJenkins identifies the existing Jenkins execution backend. + PipelineEngineJenkins PipelineEngineType = "jenkins" + // PipelineEngineTekton identifies the Tekton execution backend. + PipelineEngineTekton PipelineEngineType = "tekton" +) + // PipelineStatus defines the observed state of Pipeline type PipelineStatus struct { // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster diff --git a/pkg/api/devops/v1alpha3/zz_generated.deepcopy.go b/pkg/api/devops/v1alpha3/zz_generated.deepcopy.go index b431e6104..829d045d4 100644 --- a/pkg/api/devops/v1alpha3/zz_generated.deepcopy.go +++ b/pkg/api/devops/v1alpha3/zz_generated.deepcopy.go @@ -1224,9 +1224,29 @@ func (in *PipelineRunStatus) DeepCopy() *PipelineRunStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PipelineEngineSpec) DeepCopyInto(out *PipelineEngineSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PipelineEngineSpec. +func (in *PipelineEngineSpec) DeepCopy() *PipelineEngineSpec { + if in == nil { + return nil + } + out := new(PipelineEngineSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PipelineSpec) DeepCopyInto(out *PipelineSpec) { *out = *in + if in.Engine != nil { + in, out := &in.Engine, &out.Engine + *out = new(PipelineEngineSpec) + **out = **in + } if in.Pipeline != nil { in, out := &in.Pipeline, &out.Pipeline *out = new(NoScmPipeline) diff --git a/pkg/client/devops/fake/fakedevops.go b/pkg/client/devops/fake/fakedevops.go index 593faf4af..c7304115e 100644 --- a/pkg/client/devops/fake/fakedevops.go +++ b/pkg/client/devops/fake/fakedevops.go @@ -164,6 +164,11 @@ func NewFakeDevops(data map[string]interface{}) *Devops { return &fakeData } +// GetKubeConfigCredentialStoreType returns the backward-compatible kubeconfig store used by tests. +func (d *Devops) GetKubeConfigCredentialStoreType() string { + return "" +} + // Pipelinne operator interface func (d *Devops) GetPipeline(projectName, pipelineName string, httpParameters *devops.HttpParameters) (*devops.Pipeline, error) { return nil, nil diff --git a/pkg/kapis/devops/v1alpha3/pipelinerun/backwardlisthandler_test.go b/pkg/kapis/devops/v1alpha3/pipelinerun/backwardlisthandler_test.go index f1ec3deff..2c6e8b1b5 100644 --- a/pkg/kapis/devops/v1alpha3/pipelinerun/backwardlisthandler_test.go +++ b/pkg/kapis/devops/v1alpha3/pipelinerun/backwardlisthandler_test.go @@ -17,7 +17,6 @@ limitations under the License. package pipelinerun import ( - "encoding/json" "reflect" "testing" @@ -29,11 +28,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" ) -func Test_compatibleTransform(t *testing.T) { +// Test_noTransform verifies that backward listing preserves the PipelineRun object. +func Test_noTransform(t *testing.T) { tests := []struct { name string obj runtime.Object - want interface{} }{{ name: "With run status", obj: &v1alpha3.PipelineRun{ @@ -43,29 +42,23 @@ func Test_compatibleTransform(t *testing.T) { }, }, }, - want: json.RawMessage(`{"id": "123"}`), }, { name: "Without annotations", obj: &v1alpha3.PipelineRun{ ObjectMeta: v1.ObjectMeta{}, }, - want: json.RawMessage("{}"), }, { name: "Nil PipelineRun", obj: (*v1alpha3.PipelineRun)(nil), - want: json.RawMessage("{}"), }, { name: "Nil object", obj: nil, - want: json.RawMessage("{}"), }} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := backwardListHandler{} - if got := handler.Transformer()(tt.obj); !reflect.DeepEqual(got, tt.want) { - t.Errorf("backwardTransform() = %v, want %v", got, tt.want) - } else if !reflect.TypeOf(got).AssignableTo(reflect.TypeOf((*json.Marshaler)(nil)).Elem()) { - t.Errorf("backwardTransform() should return an instance of json.Marshaler, current type is %s", reflect.TypeOf(got)) + if got := handler.Transformer()(tt.obj); !reflect.DeepEqual(got, tt.obj) { + t.Errorf("Transformer() = %v, want original object %v", got, tt.obj) } }) } diff --git a/pkg/kapis/devops/v1alpha3/pipelinerun/handler_test.go b/pkg/kapis/devops/v1alpha3/pipelinerun/handler_test.go index ab4626685..7c7fc90a7 100644 --- a/pkg/kapis/devops/v1alpha3/pipelinerun/handler_test.go +++ b/pkg/kapis/devops/v1alpha3/pipelinerun/handler_test.go @@ -27,6 +27,7 @@ import ( "github.com/kubesphere/ks-devops/pkg/api/devops/v1alpha3" "github.com/kubesphere/ks-devops/pkg/apiserver/request" "github.com/kubesphere/ks-devops/pkg/client/devops" + "github.com/kubesphere/ks-devops/pkg/pipelineengine" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apiserver/pkg/authentication/user" @@ -35,6 +36,7 @@ import ( "github.com/kubesphere/ks-devops/pkg/apiserver/runtime" fakedevops "github.com/kubesphere/ks-devops/pkg/client/devops/fake" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "sigs.k8s.io/controller-runtime/pkg/client/fake" ) @@ -128,6 +130,112 @@ func TestApis(t *testing.T) { } } +// TestCreatePipelineRunPropagatesEngineAnnotations verifies engine snapshots and compatibility annotations. +func TestCreatePipelineRunPropagatesEngineAnnotations(t *testing.T) { + tests := []struct { + name string + pipelineEngine *v1alpha3.PipelineEngineSpec + pipelineAnnotations map[string]string + wantAnnotations map[string]string + wantEngine v1alpha3.PipelineEngineType + }{ + { + name: "Tekton Pipeline spec", + pipelineEngine: &v1alpha3.PipelineEngineSpec{Type: v1alpha3.PipelineEngineTekton}, + pipelineAnnotations: map[string]string{ + pipelineengine.AnnotationEngine: pipelineengine.EngineJenkins, + pipelineengine.AnnotationTektonPipeline: "native-pipeline", + "example.com/unrelated": "must-not-propagate", + }, + wantAnnotations: map[string]string{ + pipelineengine.AnnotationTektonPipeline: "native-pipeline", + }, + wantEngine: v1alpha3.PipelineEngineTekton, + }, + { + name: "Legacy Tekton annotation", + pipelineAnnotations: map[string]string{ + pipelineengine.AnnotationEngine: pipelineengine.EngineTekton, + pipelineengine.AnnotationTektonPipeline: "native-pipeline", + "example.com/unrelated": "must-not-propagate", + }, + wantAnnotations: map[string]string{ + pipelineengine.AnnotationEngine: pipelineengine.EngineTekton, + pipelineengine.AnnotationTektonPipeline: "native-pipeline", + }, + }, + { + name: "Default Jenkins pipeline", + pipelineAnnotations: nil, + wantAnnotations: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme, err := v1alpha3.SchemeBuilder.Register().Build() + require.NoError(t, err) + + pipeline := &v1alpha3.Pipeline{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pipeline", + Namespace: "namespace", + Annotations: tt.pipelineAnnotations, + }, + Spec: v1alpha3.PipelineSpec{ + Engine: tt.pipelineEngine, + Type: v1alpha3.NoScmPipelineType, + }, + } + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pipeline).Build() + + container := restful.NewContainer() + webService := runtime.NewWebService(v1alpha3.GroupVersion) + RegisterRoutes(webService, fakedevops.NewFakeDevops(nil), k8sClient) + container.Add(webService) + + payload, err := json.Marshal(&devops.RunPayload{ + Parameters: []devops.Parameter{{Name: "message", Value: "hello"}}, + }) + require.NoError(t, err) + + ctx := request.WithUser(request.NewContext(), &user.DefaultInfo{Name: "bob"}) + httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, + "http://fake.com/kapis/devops.kubesphere.io/v1alpha3/namespaces/namespace/pipelines/pipeline/pipelineruns", + bytes.NewReader(payload)) + require.NoError(t, err) + httpRequest.Header.Set("Content-Type", "application/json") + + recorder := httptest.NewRecorder() + container.ServeHTTP(recorder, httpRequest) + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + + runs := &v1alpha3.PipelineRunList{} + require.NoError(t, k8sClient.List(context.Background(), runs)) + require.Len(t, runs.Items, 1) + + created := runs.Items[0] + for key, value := range tt.wantAnnotations { + assert.Equal(t, value, created.Annotations[key]) + } + if len(tt.wantAnnotations) == 0 { + assert.NotContains(t, created.Annotations, pipelineengine.AnnotationEngine) + } + if tt.wantEngine != "" { + assert.NotContains(t, created.Annotations, pipelineengine.AnnotationEngine) + require.NotNil(t, created.Spec.PipelineSpec) + require.NotNil(t, created.Spec.PipelineSpec.Engine) + assert.Equal(t, tt.wantEngine, created.Spec.PipelineSpec.Engine.Type) + } + assert.NotContains(t, created.Annotations, "example.com/unrelated") + assert.Equal(t, "bob", created.Annotations[v1alpha3.PipelineRunCreatorAnnoKey]) + require.Len(t, created.Spec.Parameters, 1) + assert.Equal(t, "message", created.Spec.Parameters[0].Name) + assert.Equal(t, "hello", created.Spec.Parameters[0].Value) + }) + } +} + func TestGetNodeDetails(t *testing.T) { schema, err := v1alpha3.SchemeBuilder.Register().Build() assert.Nil(t, err) diff --git a/pkg/kapis/devops/v1alpha3/pipelinerun/util.go b/pkg/kapis/devops/v1alpha3/pipelinerun/util.go index 8b3a341ad..51ad246de 100644 --- a/pkg/kapis/devops/v1alpha3/pipelinerun/util.go +++ b/pkg/kapis/devops/v1alpha3/pipelinerun/util.go @@ -23,6 +23,7 @@ import ( "github.com/kubesphere/ks-devops/pkg/api/devops/v1alpha3" "github.com/kubesphere/ks-devops/pkg/client/devops" + "github.com/kubesphere/ks-devops/pkg/pipelineengine" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" @@ -115,10 +116,11 @@ func CreateBarePipelineRun(pipeline *v1alpha3.Pipeline, parameters []v1alpha3.Pa }, Spec: v1alpha3.PipelineRunSpec{ PipelineRef: getPipelineRef(pipeline), - PipelineSpec: &pipeline.Spec, + PipelineSpec: pipeline.Spec.DeepCopy(), Parameters: parameters, SCM: scm, }, } + pipelineengine.PropagatePipelineAnnotations(pipeline, pipelineRun) return pipelineRun } diff --git a/pkg/kapis/devops/v1alpha3/pipelinerun/util_test.go b/pkg/kapis/devops/v1alpha3/pipelinerun/util_test.go index 001663851..ce75bffe7 100644 --- a/pkg/kapis/devops/v1alpha3/pipelinerun/util_test.go +++ b/pkg/kapis/devops/v1alpha3/pipelinerun/util_test.go @@ -18,12 +18,14 @@ package pipelinerun import ( "fmt" - "github.com/stretchr/testify/assert" "reflect" "testing" "github.com/kubesphere/ks-devops/pkg/api/devops/v1alpha3" "github.com/kubesphere/ks-devops/pkg/client/devops" + "github.com/kubesphere/ks-devops/pkg/pipelineengine" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" @@ -221,9 +223,22 @@ func TestCreatePipelineRun(t *testing.T) { pipeline := &v1alpha3.Pipeline{} pipeline.SetName("name") pipeline.Namespace = "namespace" + pipeline.Spec.Engine = &v1alpha3.PipelineEngineSpec{Type: v1alpha3.PipelineEngineTekton} + pipeline.Annotations = map[string]string{ + pipelineengine.AnnotationTektonPipeline: "native-pipeline", + "example.com/unrelated": "must-not-propagate", + } pipelineRun := CreatePipelineRun(pipeline, nil, nil) assert.Equal(t, pipelineRun.GenerateName, pipeline.Name+"-") assert.Equal(t, pipelineRun.Namespace, pipeline.Namespace) assert.NotNil(t, pipelineRun.Annotations) + assert.NotContains(t, pipelineRun.Annotations, pipelineengine.AnnotationEngine) + assert.Equal(t, "native-pipeline", pipelineRun.Annotations[pipelineengine.AnnotationTektonPipeline]) + assert.NotContains(t, pipelineRun.Annotations, "example.com/unrelated") + require.NotNil(t, pipelineRun.Spec.PipelineSpec) + require.NotNil(t, pipelineRun.Spec.PipelineSpec.Engine) + assert.Equal(t, v1alpha3.PipelineEngineTekton, pipelineRun.Spec.PipelineSpec.Engine.Type) + pipeline.Spec.Engine.Type = v1alpha3.PipelineEngineJenkins + assert.Equal(t, v1alpha3.PipelineEngineTekton, pipelineRun.Spec.PipelineSpec.Engine.Type, "the run must own an immutable snapshot") } diff --git a/pkg/pipelineengine/engine.go b/pkg/pipelineengine/engine.go new file mode 100644 index 000000000..2a3c25b05 --- /dev/null +++ b/pkg/pipelineengine/engine.go @@ -0,0 +1,125 @@ +/* +Copyright 2026 The KubeSphere Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package pipelineengine contains the engine selection contract shared by the Jenkins and Tekton adapters. +package pipelineengine + +import ( + devopsv1alpha3 "github.com/kubesphere/ks-devops/pkg/api/devops/v1alpha3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + // AnnotationEngine selects the execution engine for a KubeSphere Pipeline or PipelineRun. + AnnotationEngine = "devops.kubesphere.io/pipeline-engine" + // AnnotationTektonPipeline overrides the native Tekton Pipeline name. + AnnotationTektonPipeline = "devops.kubesphere.io/tekton-pipeline" + // AnnotationTektonPipelineRun records the native Tekton PipelineRun name. + AnnotationTektonPipelineRun = "devops.kubesphere.io/tekton-pipelinerun" + // AnnotationTektonServiceAccount selects the ServiceAccount used by Tekton TaskRuns. + AnnotationTektonServiceAccount = "devops.kubesphere.io/tekton-service-account" + // AnnotationTektonWorkspaceName selects a workspace declared by the native Tekton Pipeline. + AnnotationTektonWorkspaceName = "devops.kubesphere.io/tekton-workspace-name" + // AnnotationTektonWorkspaceClaim binds the selected workspace to a PersistentVolumeClaim. + AnnotationTektonWorkspaceClaim = "devops.kubesphere.io/tekton-workspace-claim" + // AnnotationTektonWorkspaceEmptyDir binds the selected workspace to an ephemeral emptyDir volume. + AnnotationTektonWorkspaceEmptyDir = "devops.kubesphere.io/tekton-workspace-empty-dir" + // AnnotationTektonTimeout configures the native Tekton PipelineRun pipeline timeout. + AnnotationTektonTimeout = "devops.kubesphere.io/tekton-timeout" + + // EngineJenkins identifies the existing Jenkins execution engine. + EngineJenkins = string(devopsv1alpha3.PipelineEngineJenkins) + // EngineTekton identifies the Tekton execution engine. + EngineTekton = string(devopsv1alpha3.PipelineEngineTekton) +) + +var propagatedAnnotations = []string{ + AnnotationEngine, + AnnotationTektonPipeline, + AnnotationTektonServiceAccount, + AnnotationTektonWorkspaceName, + AnnotationTektonWorkspaceClaim, + AnnotationTektonWorkspaceEmptyDir, + AnnotationTektonTimeout, +} + +// Name returns the spec-selected engine, falls back to the compatibility annotation, and finally defaults to Jenkins. +func Name(object metav1.Object) string { + if object == nil { + return EngineJenkins + } + if engine := nameFromSpec(object); engine != "" { + return engine + } + if engine := object.GetAnnotations()[AnnotationEngine]; engine != "" { + return engine + } + return EngineJenkins +} + +// nameFromSpec returns an engine explicitly stored in a Pipeline or PipelineRun snapshot. +func nameFromSpec(object metav1.Object) string { + switch typed := object.(type) { + case *devopsv1alpha3.Pipeline: + if typed.Spec.Engine != nil { + return string(typed.Spec.Engine.Type) + } + case *devopsv1alpha3.PipelineRun: + if typed.Spec.PipelineSpec != nil && typed.Spec.PipelineSpec.Engine != nil { + return string(typed.Spec.PipelineSpec.Engine.Type) + } + } + return "" +} + +// IsTekton reports whether an object explicitly selects the Tekton engine. +func IsTekton(object metav1.Object) bool { + return Name(object) == EngineTekton +} + +// ResolveAnnotation returns a run-level value first and falls back to the Pipeline value. +func ResolveAnnotation(run metav1.Object, pipeline metav1.Object, key string) string { + if run != nil { + if value := run.GetAnnotations()[key]; value != "" { + return value + } + } + if pipeline != nil { + return pipeline.GetAnnotations()[key] + } + return "" +} + +// PropagatePipelineAnnotations copies the engine contract from a Pipeline to a newly created PipelineRun. +func PropagatePipelineAnnotations(pipeline metav1.Object, run metav1.Object) { + if pipeline == nil || run == nil { + return + } + specEngine := nameFromSpec(pipeline) + annotations := run.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + for _, key := range propagatedAnnotations { + if key == AnnotationEngine && specEngine != "" { + continue + } + if value := pipeline.GetAnnotations()[key]; value != "" { + annotations[key] = value + } + } + run.SetAnnotations(annotations) +} diff --git a/pkg/pipelineengine/engine_test.go b/pkg/pipelineengine/engine_test.go new file mode 100644 index 000000000..cb20fc9d7 --- /dev/null +++ b/pkg/pipelineengine/engine_test.go @@ -0,0 +1,87 @@ +/* +Copyright 2026 The KubeSphere Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pipelineengine + +import ( + "testing" + + devopsv1alpha3 "github.com/kubesphere/ks-devops/pkg/api/devops/v1alpha3" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// TestName verifies the backward-compatible engine default and explicit selection. +func TestName(t *testing.T) { + assert.Equal(t, EngineJenkins, Name(nil)) + assert.Equal(t, EngineJenkins, Name(&metav1.ObjectMeta{})) + assert.Equal(t, EngineTekton, Name(&metav1.ObjectMeta{Annotations: map[string]string{AnnotationEngine: EngineTekton}})) + + pipeline := &devopsv1alpha3.Pipeline{ + ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{AnnotationEngine: EngineJenkins}}, + Spec: devopsv1alpha3.PipelineSpec{Engine: &devopsv1alpha3.PipelineEngineSpec{ + Type: devopsv1alpha3.PipelineEngineTekton, + }}, + } + assert.Equal(t, EngineTekton, Name(pipeline), "spec must take precedence over the compatibility annotation") + + run := &devopsv1alpha3.PipelineRun{Spec: devopsv1alpha3.PipelineRunSpec{ + PipelineSpec: pipeline.Spec.DeepCopy(), + }} + assert.Equal(t, EngineTekton, Name(run), "a PipelineRun must use its PipelineSpec snapshot") +} + +// TestPropagatePipelineAnnotations verifies that only the engine contract is copied to a run. +func TestPropagatePipelineAnnotations(t *testing.T) { + pipeline := &metav1.ObjectMeta{Annotations: map[string]string{ + AnnotationEngine: EngineTekton, + AnnotationTektonPipeline: "native-pipeline", + AnnotationTektonServiceAccount: "builder", + "unrelated": "must-not-propagate", + }} + run := &metav1.ObjectMeta{Annotations: map[string]string{"existing": "value"}} + + PropagatePipelineAnnotations(pipeline, run) + + assert.Equal(t, EngineTekton, run.Annotations[AnnotationEngine]) + assert.Equal(t, "native-pipeline", run.Annotations[AnnotationTektonPipeline]) + assert.Equal(t, "builder", run.Annotations[AnnotationTektonServiceAccount]) + assert.Equal(t, "value", run.Annotations["existing"]) + assert.NotContains(t, run.Annotations, "unrelated") + + specPipeline := &devopsv1alpha3.Pipeline{ + ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ + AnnotationEngine: EngineJenkins, + AnnotationTektonPipeline: "native-pipeline", + }}, + Spec: devopsv1alpha3.PipelineSpec{Engine: &devopsv1alpha3.PipelineEngineSpec{ + Type: devopsv1alpha3.PipelineEngineTekton, + }}, + } + specRun := &devopsv1alpha3.PipelineRun{} + PropagatePipelineAnnotations(specPipeline, specRun) + assert.NotContains(t, specRun.Annotations, AnnotationEngine) + assert.Equal(t, "native-pipeline", specRun.Annotations[AnnotationTektonPipeline]) +} + +// TestResolveAnnotation verifies that PipelineRun settings override Pipeline defaults. +func TestResolveAnnotation(t *testing.T) { + pipeline := &metav1.ObjectMeta{Annotations: map[string]string{AnnotationTektonTimeout: "30m"}} + run := &metav1.ObjectMeta{Annotations: map[string]string{AnnotationTektonTimeout: "5m"}} + + assert.Equal(t, "5m", ResolveAnnotation(run, pipeline, AnnotationTektonTimeout)) + assert.Equal(t, "30m", ResolveAnnotation(&metav1.ObjectMeta{}, pipeline, AnnotationTektonTimeout)) +}