From db10212b5ca11d3cc6104e2037004b221b4a51ff Mon Sep 17 00:00:00 2001 From: Khurram Baig Date: Thu, 25 Sep 2025 18:07:04 +0530 Subject: [PATCH] Add Support for managedBy field in TaskRun and PipelineRun Added a "managedBy" field to delegate responsibility of controlling the lifecycle of PipelineRuns/TaskRuns. The semantics of the field: Whenever the value is set, and it does not point to the built-in controller, then we skip the reconciliation. * The field is immutable * The field is not defaulted Assisted By: Gemini --- config/300-crds/300-pipelinerun.yaml | 14 ++ config/300-crds/300-taskrun.yaml | 14 ++ docs/pipelineruns.md | 35 +++++ docs/taskruns.md | 36 +++++ pkg/apis/pipeline/register.go | 4 + pkg/apis/pipeline/v1/openapi_generated.go | 14 ++ pkg/apis/pipeline/v1/pipelinerun_types.go | 6 + .../pipeline/v1/pipelinerun_validation.go | 7 +- .../v1/pipelinerun_validation_test.go | 51 +++++++ pkg/apis/pipeline/v1/swagger.json | 8 + pkg/apis/pipeline/v1/taskrun_types.go | 6 + pkg/apis/pipeline/v1/taskrun_validation.go | 9 +- .../pipeline/v1/taskrun_validation_test.go | 51 +++++++ pkg/apis/pipeline/v1/zz_generated.deepcopy.go | 10 ++ .../pipeline/v1beta1/openapi_generated.go | 14 ++ .../pipeline/v1beta1/pipelinerun_types.go | 6 + .../v1beta1/pipelinerun_validation.go | 6 + .../v1beta1/pipelinerun_validation_test.go | 51 +++++++ pkg/apis/pipeline/v1beta1/swagger.json | 8 + pkg/apis/pipeline/v1beta1/taskrun_types.go | 6 + .../pipeline/v1beta1/taskrun_validation.go | 6 + .../v1beta1/taskrun_validation_test.go | 51 +++++++ .../pipeline/v1beta1/zz_generated.deepcopy.go | 10 ++ pkg/reconciler/pipelinerun/controller.go | 22 ++- pkg/reconciler/pipelinerun/controller_test.go | 87 +++++++++++ .../pipelinerun/pipelinerun_test.go | 91 +++++++++++ pkg/reconciler/taskrun/controller.go | 22 ++- pkg/reconciler/taskrun/controller_test.go | 87 +++++++++++ pkg/reconciler/taskrun/taskrun_test.go | 142 +++++++++++++++++- 29 files changed, 860 insertions(+), 14 deletions(-) create mode 100644 pkg/reconciler/pipelinerun/controller_test.go create mode 100644 pkg/reconciler/taskrun/controller_test.go diff --git a/config/300-crds/300-pipelinerun.yaml b/config/300-crds/300-pipelinerun.yaml index bd89cccf07f..b99b9062dde 100644 --- a/config/300-crds/300-pipelinerun.yaml +++ b/config/300-crds/300-pipelinerun.yaml @@ -61,6 +61,13 @@ spec: description: PipelineRunSpec defines the desired state of PipelineRun type: object properties: + managedBy: + description: |- + ManagedBy indicates which controller is responsible for reconciling + this resource. If unset or set to "tekton.dev/pipeline", the default + Tekton controller will manage this resource. + This field is immutable. + type: string params: description: Params is a list of parameter names and values. type: array @@ -3135,6 +3142,13 @@ spec: description: PipelineRunSpec defines the desired state of PipelineRun type: object properties: + managedBy: + description: |- + ManagedBy indicates which controller is responsible for reconciling + this resource. If unset or set to "tekton.dev/pipeline", the default + Tekton controller will manage this resource. + This field is immutable. + type: string params: description: Params is a list of parameter names and values. type: array diff --git a/config/300-crds/300-taskrun.yaml b/config/300-crds/300-taskrun.yaml index c939014d3a7..46cfe082c3b 100644 --- a/config/300-crds/300-taskrun.yaml +++ b/config/300-crds/300-taskrun.yaml @@ -136,6 +136,13 @@ spec: if enabled, pause TaskRun on failure of a step failed step will not exit type: string + managedBy: + description: |- + ManagedBy indicates which controller is responsible for reconciling + this resource. If unset or set to "tekton.dev/pipeline", the default + Tekton controller will manage this resource. + This field is immutable. + type: string params: description: Params is a list of Param type: array @@ -2336,6 +2343,13 @@ spec: if enabled, pause TaskRun on failure of a step failed step will not exit type: string + managedBy: + description: |- + ManagedBy indicates which controller is responsible for reconciling + this resource. If unset or set to "tekton.dev/pipeline", the default + Tekton controller will manage this resource. + This field is immutable. + type: string params: description: Params is a list of Param type: array diff --git a/docs/pipelineruns.md b/docs/pipelineruns.md index ec3efab4136..dc32a24a522 100644 --- a/docs/pipelineruns.md +++ b/docs/pipelineruns.md @@ -35,6 +35,7 @@ weight: 204 - [The status field](#the-status-field) - [Monitoring execution status](#monitoring-execution-status) - [Marking off user errors](#marking-off-user-errors) + - [Delegating reconciliation](#delegating-reconciliation) - [Cancelling a PipelineRun](#cancelling-a-pipelinerun) - [Gracefully cancelling a PipelineRun](#gracefully-cancelling-a-pipelinerun) - [Gracefully stopping a PipelineRun](#gracefully-stopping-a-pipelinerun) @@ -80,6 +81,7 @@ A `PipelineRun` definition supports the following fields: - [`timeouts`](#configuring-a-failure-timeout) - Specifies the timeout before the `PipelineRun` fails. `timeouts` allows more granular timeout configuration, at the pipeline, tasks, and finally levels - [`podTemplate`](#specifying-a-pod-template) - Specifies a [`Pod` template](./podtemplates.md) to use as the basis for the configuration of the `Pod` that executes each `Task`. - [`workspaces`](#specifying-workspaces) - Specifies a set of workspace bindings which must match the names of workspaces declared in the pipeline being used. + - [`managedBy`](#delegating-reconciliation) - Specifies the controller responsible for managing this PipelineRun's lifecycle. [kubernetes-overview]: https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/#required-fields @@ -1642,6 +1644,39 @@ NAME STARTED DURATION STATUS pipelinerun-with-params 5 seconds ago 0s Failed(ParameterMissing) ``` +## Delegating reconciliation + +The `managedBy` field allows you to delegate the responsibility of managing a `PipelineRun`'s lifecycle to an external controller. When this field is set to a value other than `"tekton.dev/pipeline"`, the Tekton Pipeline controller will ignore the `PipelineRun`, allowing your external controller to take full control. This delegation enables several advanced use cases, such as implementing custom pipeline execution logic, integrating with external management tools, using advanced scheduling algorithms, or coordinating PipelineRuns across multiple clusters (like using [MultiKueue](https://kueue.sigs.k8s.io/docs/concepts/multikueue/)). + +### Example + +```yaml +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + name: externally-managed-pipeline +spec: + pipelineRef: + name: my-pipeline + managedBy: "my-custom-controller" +``` + +### Behavior + +- **When `managedBy` is empty**: The Tekton Pipeline controller manages the PipelineRun normally +- **When `managedBy` is set to `"tekton.dev/pipeline"`**: The Tekton Pipeline controller manages the PipelineRun normally +- **When `managedBy` is set to any other value**: The Tekton Pipeline controller ignores the PipelineRun completely +- **Immutability**: The `managedBy` field is immutable and cannot be changed after creation + +### External controller responsibilities + +When you set `managedBy` to a custom value, your external controller is responsible for: + +- Creating and managing TaskRuns +- Updating PipelineRun status +- Handling timeouts and cancellations +- Managing retries and error handling + ## Cancelling a `PipelineRun` To cancel a `PipelineRun` that's currently executing, update its definition diff --git a/docs/taskruns.md b/docs/taskruns.md index 817e4a53a10..389c721d55e 100644 --- a/docs/taskruns.md +++ b/docs/taskruns.md @@ -40,6 +40,7 @@ weight: 202 - [Debug Environment](#debug-environment) - [Events](events.md#taskruns) - [Running a TaskRun Hermetically](hermetic.md) +- [Delegating reconciliation](#delegating-reconciliation) - [Code examples](#code-examples) - [Example `TaskRun` with a referenced `Task`](#example-taskrun-with-a-referenced-task) - [Example `TaskRun` with an embedded `Task`](#example-taskrun-with-an-embedded-task) @@ -79,6 +80,7 @@ A `TaskRun` definition supports the following fields: - [`debug`](#debugging-a-taskrun)- Specifies any breakpoints and debugging configuration for the `Task` execution. - [`stepSpecs`](#configuring-task-steps-and-sidecars-in-a-taskrun) - Specifies configuration to use to override the `Task`'s `Step`s. - [`sidecarSpecs`](#configuring-task-steps-and-sidecars-in-a-taskrun) - Specifies configuration to use to override the `Task`'s `Sidecar`s. + - [`managedBy`](#delegating-reconciliation) - Specifies the controller responsible for managing this TaskRun's lifecycle. [kubernetes-overview]: https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/#required-fields @@ -1112,6 +1114,40 @@ this is available as a [TaskRun example](../examples/v1/taskruns/run-steps-as-no More information about Pod and Container Security Contexts can be found via the [Kubernetes website](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/#set-the-security-context-for-a-pod). +## Delegating reconciliation + +The `managedBy` field allows you to delegate the responsibility of managing a `TaskRun`'s lifecycle to an external controller. When this field is set to a value other than `"tekton.dev/pipeline"`, the Tekton Pipeline controller will ignore the `TaskRun`, allowing your external controller to take full control. This delegation enables several advanced use cases, such as implementing custom pipeline execution logic, integrating with external management tools, using advanced scheduling algorithms, or coordinating PipelineRuns across multiple clusters (like using [MultiKueue](https://kueue.sigs.k8s.io/docs/concepts/multikueue/)). + +### Example + +```yaml +apiVersion: tekton.dev/v1 +kind: TaskRun +metadata: + name: externally-managed-task +spec: + taskRef: + name: my-task + managedBy: "my-custom-controller" +``` + +### Behavior + +- **When `managedBy` is empty**: The Tekton Pipeline controller manages the TaskRun normally +- **When `managedBy` is set to `"tekton.dev/pipeline"`**: The Tekton Pipeline controller manages the TaskRun normally +- **When `managedBy` is set to any other value**: The Tekton Pipeline controller ignores the TaskRun completely +- **Immutability**: The `managedBy` field is immutable and cannot be changed after creation + +### External controller responsibilities + +When you set `managedBy` to a custom value, your external controller is responsible for: + +- Creating and managing Pods +- Updating TaskRun status +- Handling timeouts and cancellations +- Managing retries and error handling +- Processing step results and artifacts + --- Except as otherwise noted, the content of this page is licensed under the diff --git a/pkg/apis/pipeline/register.go b/pkg/apis/pipeline/register.go index 40662f31882..2fb2fa64e07 100644 --- a/pkg/apis/pipeline/register.go +++ b/pkg/apis/pipeline/register.go @@ -55,6 +55,10 @@ const ( // MemberOfLabelKey is used as the label identifier for a PipelineTask // Set to Tasks/Finally depending on the position of the PipelineTask MemberOfLabelKey = GroupName + "/memberOf" + + // ManagedBy is the value of the "managedBy" field for resources + // managed by the Tekton Pipeline controller. + ManagedBy = GroupName + "/pipeline" ) var ( diff --git a/pkg/apis/pipeline/v1/openapi_generated.go b/pkg/apis/pipeline/v1/openapi_generated.go index 88b98a6ba1e..1b9bb396c39 100644 --- a/pkg/apis/pipeline/v1/openapi_generated.go +++ b/pkg/apis/pipeline/v1/openapi_generated.go @@ -1424,6 +1424,13 @@ func schema_pkg_apis_pipeline_v1_PipelineRunSpec(ref common.ReferenceCallback) c }, }, }, + "managedBy": { + SchemaProps: spec.SchemaProps{ + Description: "ManagedBy indicates which controller is responsible for reconciling this resource. If unset or set to \"tekton.dev/pipeline\", the default Tekton controller will manage this resource. This field is immutable.", + Type: []string{"string"}, + Format: "", + }, + }, }, }, }, @@ -4061,6 +4068,13 @@ func schema_pkg_apis_pipeline_v1_TaskRunSpec(ref common.ReferenceCallback) commo Ref: ref("k8s.io/api/core/v1.ResourceRequirements"), }, }, + "managedBy": { + SchemaProps: spec.SchemaProps{ + Description: "ManagedBy indicates which controller is responsible for reconciling this resource. If unset or set to \"tekton.dev/pipeline\", the default Tekton controller will manage this resource. This field is immutable.", + Type: []string{"string"}, + Format: "", + }, + }, }, }, }, diff --git a/pkg/apis/pipeline/v1/pipelinerun_types.go b/pkg/apis/pipeline/v1/pipelinerun_types.go index e11aeddaf1e..4e2c02189f4 100644 --- a/pkg/apis/pipeline/v1/pipelinerun_types.go +++ b/pkg/apis/pipeline/v1/pipelinerun_types.go @@ -283,6 +283,12 @@ type PipelineRunSpec struct { // +optional // +listType=atomic TaskRunSpecs []PipelineTaskRunSpec `json:"taskRunSpecs,omitempty"` + // ManagedBy indicates which controller is responsible for reconciling + // this resource. If unset or set to "tekton.dev/pipeline", the default + // Tekton controller will manage this resource. + // This field is immutable. + // +optional + ManagedBy *string `json:"managedBy,omitempty"` } // TimeoutFields allows granular specification of pipeline, task, and finally timeouts diff --git a/pkg/apis/pipeline/v1/pipelinerun_validation.go b/pkg/apis/pipeline/v1/pipelinerun_validation.go index 0848851b5e5..b199d89f444 100644 --- a/pkg/apis/pipeline/v1/pipelinerun_validation.go +++ b/pkg/apis/pipeline/v1/pipelinerun_validation.go @@ -138,6 +138,11 @@ func (ps *PipelineRunSpec) ValidateUpdate(ctx context.Context) (errs *apis.Field if !ok || oldObj == nil { return } + + if (oldObj.Spec.ManagedBy == nil) != (ps.ManagedBy == nil) || (oldObj.Spec.ManagedBy != nil && *oldObj.Spec.ManagedBy != *ps.ManagedBy) { + errs = errs.Also(apis.ErrInvalidValue("managedBy is immutable", "spec.managedBy")) + } + if oldObj.IsDone() { // try comparing without any copying first // this handles the common case where only finalizers changed @@ -162,10 +167,10 @@ func (ps *PipelineRunSpec) ValidateUpdate(ctx context.Context) (errs *apis.Field // Handle started but not done case old := oldObj.Spec.DeepCopy() old.Status = ps.Status + old.ManagedBy = ps.ManagedBy // Already tested before if !equality.Semantic.DeepEqual(old, ps) { errs = errs.Also(apis.ErrInvalidValue("Once the PipelineRun has started, only status updates are allowed", "")) } - return } diff --git a/pkg/apis/pipeline/v1/pipelinerun_validation_test.go b/pkg/apis/pipeline/v1/pipelinerun_validation_test.go index 3059c328254..12535e1ba5c 100644 --- a/pkg/apis/pipeline/v1/pipelinerun_validation_test.go +++ b/pkg/apis/pipeline/v1/pipelinerun_validation_test.go @@ -32,6 +32,7 @@ import ( corev1 "k8s.io/api/core/v1" corev1resources "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ptr "k8s.io/utils/pointer" "knative.dev/pkg/apis" duckv1 "knative.dev/pkg/apis/duck/v1" ) @@ -1751,6 +1752,56 @@ func TestPipelineRunSpec_ValidateUpdate(t *testing.T) { Message: `invalid value: Once the PipelineRun is complete, no updates are allowed`, Paths: []string{""}, }, + }, { + name: "is update ctx, baseline is not done, managedBy changes", + baselinePipelineRun: &v1.PipelineRun{ + Spec: v1.PipelineRunSpec{ + ManagedBy: ptr.String("tekton.dev/pipeline"), + }, + Status: v1.PipelineRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + {Type: apis.ConditionSucceeded, Status: corev1.ConditionUnknown}, + }, + }, + }, + }, + pipelineRun: &v1.PipelineRun{ + Spec: v1.PipelineRunSpec{ + ManagedBy: ptr.String("some-other-controller"), + }, + }, + isCreate: false, + isUpdate: true, + expectedError: apis.FieldError{ + Message: `invalid value: managedBy is immutable`, + Paths: []string{"spec.managedBy"}, + }, + }, { + name: "is update ctx, baseline is unknown, managedBy changes", + baselinePipelineRun: &v1.PipelineRun{ + Spec: v1.PipelineRunSpec{ + ManagedBy: ptr.String("tekton.dev/pipeline"), + }, + Status: v1.PipelineRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + {Type: apis.ConditionSucceeded, Status: corev1.ConditionUnknown}, + }, + }, + }, + }, + pipelineRun: &v1.PipelineRun{ + Spec: v1.PipelineRunSpec{ + ManagedBy: ptr.String("some-other-controller"), + }, + }, + isCreate: false, + isUpdate: true, + expectedError: apis.FieldError{ + Message: `invalid value: managedBy is immutable`, + Paths: []string{"spec.managedBy"}, + }, }, } diff --git a/pkg/apis/pipeline/v1/swagger.json b/pkg/apis/pipeline/v1/swagger.json index 47a0e948a53..537df3ba0a0 100644 --- a/pkg/apis/pipeline/v1/swagger.json +++ b/pkg/apis/pipeline/v1/swagger.json @@ -661,6 +661,10 @@ "description": "PipelineRunSpec defines the desired state of PipelineRun", "type": "object", "properties": { + "managedBy": { + "description": "ManagedBy indicates which controller is responsible for reconciling this resource. If unset or set to \"tekton.dev/pipeline\", the default Tekton controller will manage this resource. This field is immutable.", + "type": "string" + }, "params": { "description": "Params is a list of parameter names and values.", "type": "array", @@ -2049,6 +2053,10 @@ "debug": { "$ref": "#/definitions/v1.TaskRunDebug" }, + "managedBy": { + "description": "ManagedBy indicates which controller is responsible for reconciling this resource. If unset or set to \"tekton.dev/pipeline\", the default Tekton controller will manage this resource. This field is immutable.", + "type": "string" + }, "params": { "type": "array", "items": { diff --git a/pkg/apis/pipeline/v1/taskrun_types.go b/pkg/apis/pipeline/v1/taskrun_types.go index 1e2946fc0f2..2e36ecf3fc1 100644 --- a/pkg/apis/pipeline/v1/taskrun_types.go +++ b/pkg/apis/pipeline/v1/taskrun_types.go @@ -85,6 +85,12 @@ type TaskRunSpec struct { SidecarSpecs []TaskRunSidecarSpec `json:"sidecarSpecs,omitempty"` // Compute resources to use for this TaskRun ComputeResources *corev1.ResourceRequirements `json:"computeResources,omitempty"` + // ManagedBy indicates which controller is responsible for reconciling + // this resource. If unset or set to "tekton.dev/pipeline", the default + // Tekton controller will manage this resource. + // This field is immutable. + // +optional + ManagedBy *string `json:"managedBy,omitempty"` } // TaskRunSpecStatus defines the TaskRun spec status the user can provide diff --git a/pkg/apis/pipeline/v1/taskrun_validation.go b/pkg/apis/pipeline/v1/taskrun_validation.go index d966b52d010..f76f55b44f8 100644 --- a/pkg/apis/pipeline/v1/taskrun_validation.go +++ b/pkg/apis/pipeline/v1/taskrun_validation.go @@ -126,17 +126,22 @@ func (ts *TaskRunSpec) ValidateUpdate(ctx context.Context) (errs *apis.FieldErro if !apis.IsInUpdate(ctx) { return } + oldObj, ok := apis.GetBaseline(ctx).(*TaskRun) if !ok || oldObj == nil { return } + + if (oldObj.Spec.ManagedBy == nil) != (ts.ManagedBy == nil) || (oldObj.Spec.ManagedBy != nil && *oldObj.Spec.ManagedBy != *ts.ManagedBy) { + errs = errs.Also(apis.ErrInvalidValue("managedBy is immutable", "spec.managedBy")) + } + if oldObj.IsDone() { // try comparing without any copying first // this handles the common case where only finalizers changed if equality.Semantic.DeepEqual(&oldObj.Spec, ts) { return nil // Specs identical, allow update } - // Specs differ, this could be due to different defaults after upgrade // Apply current defaults to old spec to normalize oldCopy := oldObj.Spec.DeepCopy() @@ -155,10 +160,10 @@ func (ts *TaskRunSpec) ValidateUpdate(ctx context.Context) (errs *apis.FieldErro old := oldObj.Spec.DeepCopy() old.Status = ts.Status old.StatusMessage = ts.StatusMessage + old.ManagedBy = ts.ManagedBy // Already tested before if !equality.Semantic.DeepEqual(old, ts) { errs = errs.Also(apis.ErrInvalidValue("Once the TaskRun has started, only status and statusMessage updates are allowed", "")) } - return } diff --git a/pkg/apis/pipeline/v1/taskrun_validation_test.go b/pkg/apis/pipeline/v1/taskrun_validation_test.go index c0b8746375d..7cab64a4131 100644 --- a/pkg/apis/pipeline/v1/taskrun_validation_test.go +++ b/pkg/apis/pipeline/v1/taskrun_validation_test.go @@ -34,6 +34,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "knative.dev/pkg/apis" duckv1 "knative.dev/pkg/apis/duck/v1" + "knative.dev/pkg/ptr" ) func TestTaskRun_Invalidate(t *testing.T) { @@ -1108,6 +1109,56 @@ func TestTaskRunSpec_ValidateUpdate(t *testing.T) { Message: `invalid value: Once the TaskRun is complete, no updates are allowed`, Paths: []string{""}, }, + }, { + name: "is update ctx, baseline is not done, managedBy changes", + baselineTaskRun: &v1.TaskRun{ + Spec: v1.TaskRunSpec{ + ManagedBy: ptr.String("tekton.dev/pipeline"), + }, + Status: v1.TaskRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + {Type: apis.ConditionSucceeded, Status: corev1.ConditionUnknown}, + }, + }, + }, + }, + taskRun: &v1.TaskRun{ + Spec: v1.TaskRunSpec{ + ManagedBy: ptr.String("some-other-controller"), + }, + }, + isCreate: false, + isUpdate: true, + expectedError: apis.FieldError{ + Message: `invalid value: managedBy is immutable`, + Paths: []string{"spec.managedBy"}, + }, + }, { + name: "is update ctx, baseline is unknown, managedBy changes", + baselineTaskRun: &v1.TaskRun{ + Spec: v1.TaskRunSpec{ + ManagedBy: ptr.String("tekton.dev/pipeline"), + }, + Status: v1.TaskRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + {Type: apis.ConditionSucceeded, Status: corev1.ConditionUnknown}, + }, + }, + }, + }, + taskRun: &v1.TaskRun{ + Spec: v1.TaskRunSpec{ + ManagedBy: ptr.String("some-other-controller"), + }, + }, + isCreate: false, + isUpdate: true, + expectedError: apis.FieldError{ + Message: `invalid value: managedBy is immutable`, + Paths: []string{"spec.managedBy"}, + }, }, } diff --git a/pkg/apis/pipeline/v1/zz_generated.deepcopy.go b/pkg/apis/pipeline/v1/zz_generated.deepcopy.go index 618e05a3529..c6ebc0bc9aa 100644 --- a/pkg/apis/pipeline/v1/zz_generated.deepcopy.go +++ b/pkg/apis/pipeline/v1/zz_generated.deepcopy.go @@ -637,6 +637,11 @@ func (in *PipelineRunSpec) DeepCopyInto(out *PipelineRunSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.ManagedBy != nil { + in, out := &in.ManagedBy, &out.ManagedBy + *out = new(string) + **out = **in + } return } @@ -1947,6 +1952,11 @@ func (in *TaskRunSpec) DeepCopyInto(out *TaskRunSpec) { *out = new(corev1.ResourceRequirements) (*in).DeepCopyInto(*out) } + if in.ManagedBy != nil { + in, out := &in.ManagedBy, &out.ManagedBy + *out = new(string) + **out = **in + } return } diff --git a/pkg/apis/pipeline/v1beta1/openapi_generated.go b/pkg/apis/pipeline/v1beta1/openapi_generated.go index a9450bc889c..82327d285b7 100644 --- a/pkg/apis/pipeline/v1beta1/openapi_generated.go +++ b/pkg/apis/pipeline/v1beta1/openapi_generated.go @@ -1993,6 +1993,13 @@ func schema_pkg_apis_pipeline_v1beta1_PipelineRunSpec(ref common.ReferenceCallba }, }, }, + "managedBy": { + SchemaProps: spec.SchemaProps{ + Description: "ManagedBy indicates which controller is responsible for reconciling this resource. If unset or set to \"tekton.dev/pipeline\", the default Tekton controller will manage this resource. This field is immutable.", + Type: []string{"string"}, + Format: "", + }, + }, }, }, }, @@ -5466,6 +5473,13 @@ func schema_pkg_apis_pipeline_v1beta1_TaskRunSpec(ref common.ReferenceCallback) Ref: ref("k8s.io/api/core/v1.ResourceRequirements"), }, }, + "managedBy": { + SchemaProps: spec.SchemaProps{ + Description: "ManagedBy indicates which controller is responsible for reconciling this resource. If unset or set to \"tekton.dev/pipeline\", the default Tekton controller will manage this resource. This field is immutable.", + Type: []string{"string"}, + Format: "", + }, + }, }, }, }, diff --git a/pkg/apis/pipeline/v1beta1/pipelinerun_types.go b/pkg/apis/pipeline/v1beta1/pipelinerun_types.go index 931a8634c40..a935f5b720e 100644 --- a/pkg/apis/pipeline/v1beta1/pipelinerun_types.go +++ b/pkg/apis/pipeline/v1beta1/pipelinerun_types.go @@ -300,6 +300,12 @@ type PipelineRunSpec struct { // +optional // +listType=atomic TaskRunSpecs []PipelineTaskRunSpec `json:"taskRunSpecs,omitempty"` + // ManagedBy indicates which controller is responsible for reconciling + // this resource. If unset or set to "tekton.dev/pipeline", the default + // Tekton controller will manage this resource. + // This field is immutable. + // +optional + ManagedBy *string `json:"managedBy,omitempty"` } // TimeoutFields allows granular specification of pipeline, task, and finally timeouts diff --git a/pkg/apis/pipeline/v1beta1/pipelinerun_validation.go b/pkg/apis/pipeline/v1beta1/pipelinerun_validation.go index ed10de04805..34be885659b 100644 --- a/pkg/apis/pipeline/v1beta1/pipelinerun_validation.go +++ b/pkg/apis/pipeline/v1beta1/pipelinerun_validation.go @@ -158,6 +158,11 @@ func (ps *PipelineRunSpec) ValidateUpdate(ctx context.Context) (errs *apis.Field if !ok || oldObj == nil { return } + + if (oldObj.Spec.ManagedBy == nil) != (ps.ManagedBy == nil) || (oldObj.Spec.ManagedBy != nil && *oldObj.Spec.ManagedBy != *ps.ManagedBy) { + errs = errs.Also(apis.ErrInvalidValue("managedBy is immutable", "spec.managedBy")) + } + if oldObj.IsDone() { // try comparing without any copying first // this handles the common case where only finalizers changed @@ -182,6 +187,7 @@ func (ps *PipelineRunSpec) ValidateUpdate(ctx context.Context) (errs *apis.Field // Handle started but not done case old := oldObj.Spec.DeepCopy() old.Status = ps.Status + old.ManagedBy = ps.ManagedBy // Already tested before if !equality.Semantic.DeepEqual(old, ps) { errs = errs.Also(apis.ErrInvalidValue("Once the PipelineRun has started, only status updates are allowed", "")) } diff --git a/pkg/apis/pipeline/v1beta1/pipelinerun_validation_test.go b/pkg/apis/pipeline/v1beta1/pipelinerun_validation_test.go index c9f9b880506..2954950b0aa 100644 --- a/pkg/apis/pipeline/v1beta1/pipelinerun_validation_test.go +++ b/pkg/apis/pipeline/v1beta1/pipelinerun_validation_test.go @@ -32,6 +32,7 @@ import ( corev1 "k8s.io/api/core/v1" corev1resources "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ptr "k8s.io/utils/pointer" "knative.dev/pkg/apis" duckv1 "knative.dev/pkg/apis/duck/v1" ) @@ -1898,6 +1899,56 @@ func TestPipelineRunSpec_ValidateUpdate(t *testing.T) { Message: `invalid value: Once the PipelineRun is complete, no updates are allowed`, Paths: []string{""}, }, + }, { + name: "is update ctx, baseline is not done, managedBy changes", + baselinePipelineRun: &v1beta1.PipelineRun{ + Spec: v1beta1.PipelineRunSpec{ + ManagedBy: ptr.String("tekton.dev/pipeline"), + }, + Status: v1beta1.PipelineRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + {Type: apis.ConditionSucceeded, Status: corev1.ConditionUnknown}, + }, + }, + }, + }, + pipelineRun: &v1beta1.PipelineRun{ + Spec: v1beta1.PipelineRunSpec{ + ManagedBy: ptr.String("some-other-controller"), + }, + }, + isCreate: false, + isUpdate: true, + expectedError: apis.FieldError{ + Message: `invalid value: managedBy is immutable`, + Paths: []string{"spec.managedBy"}, + }, + }, { + name: "is update ctx, baseline is unknown, managedBy changes", + baselinePipelineRun: &v1beta1.PipelineRun{ + Spec: v1beta1.PipelineRunSpec{ + ManagedBy: ptr.String("tekton.dev/pipeline"), + }, + Status: v1beta1.PipelineRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + {Type: apis.ConditionSucceeded, Status: corev1.ConditionUnknown}, + }, + }, + }, + }, + pipelineRun: &v1beta1.PipelineRun{ + Spec: v1beta1.PipelineRunSpec{ + ManagedBy: ptr.String("some-other-controller"), + }, + }, + isCreate: false, + isUpdate: true, + expectedError: apis.FieldError{ + Message: `invalid value: managedBy is immutable`, + Paths: []string{"spec.managedBy"}, + }, }, } diff --git a/pkg/apis/pipeline/v1beta1/swagger.json b/pkg/apis/pipeline/v1beta1/swagger.json index 528276c40fa..629cc02417f 100644 --- a/pkg/apis/pipeline/v1beta1/swagger.json +++ b/pkg/apis/pipeline/v1beta1/swagger.json @@ -947,6 +947,10 @@ "description": "PipelineRunSpec defines the desired state of PipelineRun", "type": "object", "properties": { + "managedBy": { + "description": "ManagedBy indicates which controller is responsible for reconciling this resource. If unset or set to \"tekton.dev/pipeline\", the default Tekton controller will manage this resource. This field is immutable.", + "type": "string" + }, "params": { "description": "Params is a list of parameter names and values.", "type": "array", @@ -2944,6 +2948,10 @@ "debug": { "$ref": "#/definitions/v1beta1.TaskRunDebug" }, + "managedBy": { + "description": "ManagedBy indicates which controller is responsible for reconciling this resource. If unset or set to \"tekton.dev/pipeline\", the default Tekton controller will manage this resource. This field is immutable.", + "type": "string" + }, "params": { "type": "array", "items": { diff --git a/pkg/apis/pipeline/v1beta1/taskrun_types.go b/pkg/apis/pipeline/v1beta1/taskrun_types.go index bc7870d67f4..9c18717db55 100644 --- a/pkg/apis/pipeline/v1beta1/taskrun_types.go +++ b/pkg/apis/pipeline/v1beta1/taskrun_types.go @@ -90,6 +90,12 @@ type TaskRunSpec struct { SidecarOverrides []TaskRunSidecarOverride `json:"sidecarOverrides,omitempty"` // Compute resources to use for this TaskRun ComputeResources *corev1.ResourceRequirements `json:"computeResources,omitempty"` + // ManagedBy indicates which controller is responsible for reconciling + // this resource. If unset or set to "tekton.dev/pipeline", the default + // Tekton controller will manage this resource. + // This field is immutable. + // +optional + ManagedBy *string `json:"managedBy,omitempty"` } // TaskRunSpecStatus defines the TaskRun spec status the user can provide diff --git a/pkg/apis/pipeline/v1beta1/taskrun_validation.go b/pkg/apis/pipeline/v1beta1/taskrun_validation.go index b4014e512a3..890e86ab6d2 100644 --- a/pkg/apis/pipeline/v1beta1/taskrun_validation.go +++ b/pkg/apis/pipeline/v1beta1/taskrun_validation.go @@ -130,6 +130,11 @@ func (ts *TaskRunSpec) ValidateUpdate(ctx context.Context) (errs *apis.FieldErro if !ok || oldObj == nil { return } + + if (oldObj.Spec.ManagedBy == nil) != (ts.ManagedBy == nil) || (oldObj.Spec.ManagedBy != nil && *oldObj.Spec.ManagedBy != *ts.ManagedBy) { + errs = errs.Also(apis.ErrInvalidValue("managedBy is immutable", "spec.managedBy")) + } + if oldObj.IsDone() { // try comparing without any copying first // this handles the common case where only finalizers changed @@ -155,6 +160,7 @@ func (ts *TaskRunSpec) ValidateUpdate(ctx context.Context) (errs *apis.FieldErro old := oldObj.Spec.DeepCopy() old.Status = ts.Status old.StatusMessage = ts.StatusMessage + old.ManagedBy = ts.ManagedBy // Already tested before if !equality.Semantic.DeepEqual(old, ts) { errs = errs.Also(apis.ErrInvalidValue("Once the TaskRun has started, only status and statusMessage updates are allowed", "")) } diff --git a/pkg/apis/pipeline/v1beta1/taskrun_validation_test.go b/pkg/apis/pipeline/v1beta1/taskrun_validation_test.go index ab175d11a7e..a41b76f9b28 100644 --- a/pkg/apis/pipeline/v1beta1/taskrun_validation_test.go +++ b/pkg/apis/pipeline/v1beta1/taskrun_validation_test.go @@ -32,6 +32,7 @@ import ( corev1 "k8s.io/api/core/v1" corev1resources "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ptr "k8s.io/utils/pointer" "knative.dev/pkg/apis" duckv1 "knative.dev/pkg/apis/duck/v1" ) @@ -1079,6 +1080,56 @@ func TestTaskRunSpec_ValidateUpdate(t *testing.T) { Message: `invalid value: Once the TaskRun is complete, no updates are allowed`, Paths: []string{""}, }, + }, { + name: "is update ctx, baseline is not done, managedBy changes", + baselineTaskRun: &v1beta1.TaskRun{ + Spec: v1beta1.TaskRunSpec{ + ManagedBy: ptr.String("tekton.dev/pipeline"), + }, + Status: v1beta1.TaskRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + {Type: apis.ConditionSucceeded, Status: corev1.ConditionUnknown}, + }, + }, + }, + }, + taskRun: &v1beta1.TaskRun{ + Spec: v1beta1.TaskRunSpec{ + ManagedBy: ptr.String("some-other-controller"), + }, + }, + isCreate: false, + isUpdate: true, + expectedError: apis.FieldError{ + Message: `invalid value: managedBy is immutable`, + Paths: []string{"spec.managedBy"}, + }, + }, { + name: "is update ctx, baseline is unknown, managedBy changes", + baselineTaskRun: &v1beta1.TaskRun{ + Spec: v1beta1.TaskRunSpec{ + ManagedBy: ptr.String("tekton.dev/pipeline"), + }, + Status: v1beta1.TaskRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + {Type: apis.ConditionSucceeded, Status: corev1.ConditionUnknown}, + }, + }, + }, + }, + taskRun: &v1beta1.TaskRun{ + Spec: v1beta1.TaskRunSpec{ + ManagedBy: ptr.String("some-other-controller"), + }, + }, + isCreate: false, + isUpdate: true, + expectedError: apis.FieldError{ + Message: `invalid value: managedBy is immutable`, + Paths: []string{"spec.managedBy"}, + }, }, } diff --git a/pkg/apis/pipeline/v1beta1/zz_generated.deepcopy.go b/pkg/apis/pipeline/v1beta1/zz_generated.deepcopy.go index e57a992ddae..d28c2a35dbe 100644 --- a/pkg/apis/pipeline/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/pipeline/v1beta1/zz_generated.deepcopy.go @@ -956,6 +956,11 @@ func (in *PipelineRunSpec) DeepCopyInto(out *PipelineRunSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.ManagedBy != nil { + in, out := &in.ManagedBy, &out.ManagedBy + *out = new(string) + **out = **in + } return } @@ -2587,6 +2592,11 @@ func (in *TaskRunSpec) DeepCopyInto(out *TaskRunSpec) { *out = new(corev1.ResourceRequirements) (*in).DeepCopyInto(*out) } + if in.ManagedBy != nil { + in, out := &in.ManagedBy, &out.ManagedBy + *out = new(string) + **out = **in + } return } diff --git a/pkg/reconciler/pipelinerun/controller.go b/pkg/reconciler/pipelinerun/controller.go index d47ef8d7760..306cfafed9c 100644 --- a/pkg/reconciler/pipelinerun/controller.go +++ b/pkg/reconciler/pipelinerun/controller.go @@ -49,6 +49,18 @@ const ( TracerProviderName = "pipelinerun-reconciler" ) +var pipelineRunFilterManagedBy = func(obj interface{}) bool { + pr, ok := obj.(*v1.PipelineRun) + if !ok { + return true + } + // Only promote PipelineRuns that are managed by this controller + if pr.Spec.ManagedBy != nil && *pr.Spec.ManagedBy != pipeline.ManagedBy { + return false + } + return true +} + // NewController instantiates a new controller.Impl from knative.dev/pkg/controller func NewController(opts *pipeline.Options, clock clock.PassiveClock) func(context.Context, configmap.Watcher) *controller.Impl { return func(ctx context.Context, cmw configmap.Watcher) *controller.Impl { @@ -87,8 +99,9 @@ func NewController(opts *pipeline.Options, clock clock.PassiveClock) func(contex } impl := pipelinerunreconciler.NewImpl(ctx, c, func(impl *controller.Impl) controller.Options { return controller.Options{ - AgentName: pipeline.PipelineRunControllerName, - ConfigStore: configStore, + AgentName: pipeline.PipelineRunControllerName, + ConfigStore: configStore, + PromoteFilterFunc: pipelineRunFilterManagedBy, } }) @@ -96,7 +109,10 @@ func NewController(opts *pipeline.Options, clock clock.PassiveClock) func(contex logging.FromContext(ctx).Panicf("Couldn't register Secret informer event handler: %w", err) } - if _, err := pipelineRunInformer.Informer().AddEventHandler(controller.HandleAll(impl.Enqueue)); err != nil { + if _, err := pipelineRunInformer.Informer().AddEventHandler(cache.FilteringResourceEventHandler{ + FilterFunc: pipelineRunFilterManagedBy, + Handler: controller.HandleAll(impl.Enqueue), + }); err != nil { logging.FromContext(ctx).Panicf("Couldn't register PipelineRun informer event handler: %w", err) } diff --git a/pkg/reconciler/pipelinerun/controller_test.go b/pkg/reconciler/pipelinerun/controller_test.go new file mode 100644 index 00000000000..9c8d449109b --- /dev/null +++ b/pkg/reconciler/pipelinerun/controller_test.go @@ -0,0 +1,87 @@ +/* +Copyright 2025 The Tekton 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 pipelinerun + +import ( + "testing" + + "github.com/tektoncd/pipeline/pkg/apis/pipeline" + v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPipelineRunFilterManagedBy(t *testing.T) { + prManaged := &v1.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pipelinerun-managed", + }, + Spec: v1.PipelineRunSpec{ + ManagedBy: &[]string{pipeline.ManagedBy}[0], + }, + } + prNotManaged := &v1.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pipelinerun-not-managed", + }, + Spec: v1.PipelineRunSpec{ + ManagedBy: &[]string{"some-other-controller"}[0], + }, + } + prNilManagedBy := &v1.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pipelinerun-nil-managed-by", + }, + Spec: v1.PipelineRunSpec{}, + } + notAPipelineRun := "not-a-pipelinerun" + + testCases := []struct { + name string + obj interface{} + expected bool + }{ + { + name: "managed by tekton controller", + obj: prManaged, + expected: true, + }, + { + name: "not managed by tekton controller", + obj: prNotManaged, + expected: false, + }, + { + name: "nil managed by", + obj: prNilManagedBy, + expected: true, + }, + { + name: "not a pipelinerun", + obj: notAPipelineRun, + expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := pipelineRunFilterManagedBy(tc.obj) + if result != tc.expected { + t.Errorf("Expected %v, but got %v", tc.expected, result) + } + }) + } +} diff --git a/pkg/reconciler/pipelinerun/pipelinerun_test.go b/pkg/reconciler/pipelinerun/pipelinerun_test.go index c4d17e020c2..25636f48c35 100644 --- a/pkg/reconciler/pipelinerun/pipelinerun_test.go +++ b/pkg/reconciler/pipelinerun/pipelinerun_test.go @@ -47,6 +47,7 @@ import ( "github.com/tektoncd/pipeline/pkg/reconciler/pipelinerun/resources" taskresources "github.com/tektoncd/pipeline/pkg/reconciler/taskrun/resources" th "github.com/tektoncd/pipeline/pkg/reconciler/testing" + ttesting "github.com/tektoncd/pipeline/pkg/reconciler/testing" "github.com/tektoncd/pipeline/pkg/reconciler/volumeclaim" resolutioncommon "github.com/tektoncd/pipeline/pkg/resolution/common" remoteresource "github.com/tektoncd/pipeline/pkg/resolution/resource" @@ -80,6 +81,7 @@ import ( "knative.dev/pkg/kmeta" "knative.dev/pkg/logging" logtesting "knative.dev/pkg/logging/testing" + "knative.dev/pkg/ptr" "knative.dev/pkg/reconciler" "knative.dev/pkg/system" _ "knative.dev/pkg/system/testing" // Setup system.Namespace() @@ -18386,3 +18388,92 @@ spec: }) } } + +func TestReconcile_ManagedBy(t *testing.T) { + namespace := "foo" + prManagedByTektonName := "test-pr-managed-by-tekton" + prManagedByOtherName := "test-pr-managed-by-other" + + ps := []*v1.Pipeline{simpleHelloWorldPipeline} + ts := []*v1.Task{simpleHelloWorldTask} + + prs := []*v1.PipelineRun{ + parse.MustParseV1PipelineRun(t, fmt.Sprintf(` +metadata: + name: %s + namespace: %s +spec: + pipelineRef: + name: test-pipeline + managedBy: "tekton.dev/pipeline" +`, prManagedByTektonName, namespace)), + parse.MustParseV1PipelineRun(t, fmt.Sprintf(` +metadata: + name: %s + namespace: %s +spec: + pipelineRef: + name: test-pipeline + managedBy: "other-controller" +`, prManagedByOtherName, namespace)), + } + // Change ManagedBy to a pointer + prs[0].Spec.ManagedBy = ptr.String("tekton.dev/pipeline") + prs[1].Spec.ManagedBy = ptr.String("other-controller") + + // This data is filtered and simulates what the informer would pass to the reconciler. + // It only contains the PipelineRun that should be reconciled. + filteredData := test.Data{ + PipelineRuns: []*v1.PipelineRun{prs[0]}, // Only the one managed by Tekton + Pipelines: ps, + Tasks: ts, + ConfigMaps: th.NewFeatureFlagsConfigMapInSlice(), + } + + // Initialize controller with filtered data for the listers + prt := newPipelineRunTest(t, filteredData) + defer prt.Cancel() + + // Create clients with all the data for verification + ctx, _ := ttesting.SetupFakeContext(t) + clients, _ := test.SeedTestData(t, ctx, test.Data{ + PipelineRuns: prs, + Pipelines: ps, + Tasks: ts, + ConfigMaps: th.NewFeatureFlagsConfigMapInSlice(), + }) + + // Verify no TaskRuns were created for the externally managed PipelineRun + taskRunsOther := getTaskRunsForPipelineRun(prt.TestAssets.Ctx, t, clients, namespace, prManagedByOtherName) + if len(taskRunsOther) != 0 { + t.Errorf("Expected no TaskRuns for externally managed PipelineRun, but found %d", len(taskRunsOther)) + } + + // Verify its status is unchanged + reconciledOther, err := clients.Pipeline.TektonV1().PipelineRuns(namespace).Get(prt.TestAssets.Ctx, prManagedByOtherName, metav1.GetOptions{}) + if err != nil { + t.Fatalf("Failed to get externally managed PipelineRun: %v", err) + } + if len(reconciledOther.Status.Conditions) != 0 { + t.Errorf("Expected externally managed PipelineRun to have no conditions, but it did") + } + + // 2. Reconcile the PipelineRun managed by "tekton.dev/pipeline" + // We expect this one to be processed normally. + wantEvents := []string{ + "Normal Started", + "Normal Running Tasks Completed: 0", + } + reconciledTekton, clients := prt.reconcileRun(namespace, prManagedByTektonName, wantEvents, false) + + // Verify a TaskRun was created for the Tekton-managed PipelineRun + taskRunsTekton := getTaskRunsForPipelineRun(prt.TestAssets.Ctx, t, clients, namespace, prManagedByTektonName) + if len(taskRunsTekton) != 1 { + t.Errorf("Expected 1 TaskRun for Tekton-managed PipelineRun, but found %d", len(taskRunsTekton)) + } + + // Verify its status was updated + if !reconciledTekton.Status.GetCondition(apis.ConditionSucceeded).IsUnknown() { + t.Errorf("Expected Tekton-managed PipelineRun to be running, but it was not") + } +} diff --git a/pkg/reconciler/taskrun/controller.go b/pkg/reconciler/taskrun/controller.go index 84ab26185d2..90ef672e05a 100644 --- a/pkg/reconciler/taskrun/controller.go +++ b/pkg/reconciler/taskrun/controller.go @@ -51,6 +51,18 @@ const ( TracerProviderName = "taskrun-reconciler" ) +var taskRunFilterManagedBy = func(obj interface{}) bool { + tr, ok := obj.(*v1.TaskRun) + if !ok { + return true + } + // The taskrun-controller should not reconcile TaskRuns managed by other controllers. + if tr.Spec.ManagedBy != nil && *tr.Spec.ManagedBy != pipeline.ManagedBy { + return false + } + return true +} + // NewController instantiates a new controller.Impl from knative.dev/pkg/controller func NewController(opts *pipeline.Options, clock clock.PassiveClock) func(context.Context, configmap.Watcher) *controller.Impl { return func(ctx context.Context, cmw configmap.Watcher) *controller.Impl { @@ -98,8 +110,9 @@ func NewController(opts *pipeline.Options, clock clock.PassiveClock) func(contex } impl := taskrunreconciler.NewImpl(ctx, c, func(impl *controller.Impl) controller.Options { return controller.Options{ - AgentName: pipeline.TaskRunControllerName, - ConfigStore: configStore, + AgentName: pipeline.TaskRunControllerName, + ConfigStore: configStore, + PromoteFilterFunc: taskRunFilterManagedBy, } }) @@ -107,7 +120,10 @@ func NewController(opts *pipeline.Options, clock clock.PassiveClock) func(contex logging.FromContext(ctx).Panicf("Couldn't register Secret informer event handler: %w", err) } - if _, err := taskRunInformer.Informer().AddEventHandler(controller.HandleAll(impl.Enqueue)); err != nil { + if _, err := taskRunInformer.Informer().AddEventHandler(cache.FilteringResourceEventHandler{ + FilterFunc: taskRunFilterManagedBy, + Handler: controller.HandleAll(impl.Enqueue), + }); err != nil { logging.FromContext(ctx).Panicf("Couldn't register TaskRun informer event handler: %w", err) } diff --git a/pkg/reconciler/taskrun/controller_test.go b/pkg/reconciler/taskrun/controller_test.go new file mode 100644 index 00000000000..c81222a8107 --- /dev/null +++ b/pkg/reconciler/taskrun/controller_test.go @@ -0,0 +1,87 @@ +/* +Copyright 2025 The Tekton 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 taskrun + +import ( + "testing" + + "github.com/tektoncd/pipeline/pkg/apis/pipeline" + v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestTaskRunFilterManagedBy(t *testing.T) { + trManaged := &v1.TaskRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "taskrun-managed", + }, + Spec: v1.TaskRunSpec{ + ManagedBy: &[]string{pipeline.ManagedBy}[0], + }, + } + trNotManaged := &v1.TaskRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "taskrun-not-managed", + }, + Spec: v1.TaskRunSpec{ + ManagedBy: &[]string{"some-other-controller"}[0], + }, + } + trNilManagedBy := &v1.TaskRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "taskrun-nil-managed-by", + }, + Spec: v1.TaskRunSpec{}, + } + notATaskRun := "not-a-taskrun" + + testCases := []struct { + name string + obj interface{} + expected bool + }{ + { + name: "managed by tekton controller", + obj: trManaged, + expected: true, + }, + { + name: "not managed by tekton controller", + obj: trNotManaged, + expected: false, + }, + { + name: "nil managed by", + obj: trNilManagedBy, + expected: true, + }, + { + name: "not a taskrun", + obj: notATaskRun, + expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := taskRunFilterManagedBy(tc.obj) + if result != tc.expected { + t.Errorf("Expected %v, but got %v", tc.expected, result) + } + }) + } +} diff --git a/pkg/reconciler/taskrun/taskrun_test.go b/pkg/reconciler/taskrun/taskrun_test.go index 2454d2ab3bb..5d8c44f930f 100644 --- a/pkg/reconciler/taskrun/taskrun_test.go +++ b/pkg/reconciler/taskrun/taskrun_test.go @@ -79,7 +79,7 @@ import ( ktesting "k8s.io/client-go/testing" "k8s.io/client-go/tools/record" clock "k8s.io/utils/clock/testing" - "k8s.io/utils/ptr" + ptr "k8s.io/utils/pointer" "knative.dev/pkg/apis" duckv1 "knative.dev/pkg/apis/duck/v1" cminformer "knative.dev/pkg/configmap/informer" @@ -7641,12 +7641,12 @@ spec: Level: "s0:c123,c456", }, WindowsOptions: &corev1.WindowsSecurityContextOptions{ - GMSACredentialSpecName: ptr.To("customcredential"), - RunAsUserName: ptr.To("arm64-user"), + GMSACredentialSpecName: ptr.String("customcredential"), + RunAsUserName: ptr.String("arm64-user"), }, AppArmorProfile: &corev1.AppArmorProfile{ Type: corev1.AppArmorProfileTypeLocalhost, - LocalhostProfile: ptr.To("localhost/customprofile"), + LocalhostProfile: ptr.String("localhost/customprofile"), }, Sysctls: []corev1.Sysctl{{ Name: "kernel.arm64", @@ -7687,7 +7687,7 @@ spec: Searches: []string{"us-east-1.local"}, Options: []corev1.PodDNSConfigOption{{ Name: "ndots", - Value: ptr.To("2"), + Value: ptr.String("2"), }}, } if d := cmp.Diff(expectedDNSConfig, pod.Spec.DNSConfig); d != "" { @@ -7771,3 +7771,135 @@ spec: t.Errorf("Custom volumes mismatch: %s", diff.PrintWantGot(d)) } } + +func TestReconcile_ManagedBy(t *testing.T) { + namespace := "foo" + trManagedByTektonName := "test-tr-managed-by-tekton" + trManagedByOtherName := "test-tr-managed-by-other" + + ts := []*v1.Task{simpleTask} + + trs := []*v1.TaskRun{ + parse.MustParseV1TaskRun(t, fmt.Sprintf(` +metadata: + name: %s + namespace: %s +spec: + taskRef: + name: test-task +`, trManagedByTektonName, namespace)), + parse.MustParseV1TaskRun(t, fmt.Sprintf(` +metadata: + name: %s + namespace: %s +spec: + taskRef: + name: test-task +`, trManagedByOtherName, namespace)), + } + trs[0].Spec.ManagedBy = ptr.String("tekton.dev/pipeline") + trs[1].Spec.ManagedBy = ptr.String("other-controller") + + // This data includes all resources and will be used by the fake client. + d := test.Data{ + TaskRuns: trs, + Tasks: ts, + ServiceAccounts: []*corev1.ServiceAccount{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + Namespace: namespace, + }, + }}, + ConfigMaps: []*corev1.ConfigMap{ + { + ObjectMeta: metav1.ObjectMeta{Name: config.GetDefaultsConfigName(), Namespace: system.Namespace()}, + Data: map[string]string{ + "default-timeout-minutes": "60", + "default-managed-by-label-value": "tekton-pipelines", + }, + }, + }, + } + + // This data is filtered and simulates what the informer would pass to the reconciler. + // It only contains the TaskRun that should be reconciled. + filteredData := test.Data{ + TaskRuns: []*v1.TaskRun{trs[0]}, // Only the one managed by Tekton + Tasks: ts, + ServiceAccounts: []*corev1.ServiceAccount{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + Namespace: namespace, + }, + }}, + ConfigMaps: []*corev1.ConfigMap{ + { + ObjectMeta: metav1.ObjectMeta{Name: config.GetDefaultsConfigName(), Namespace: system.Namespace()}, + Data: map[string]string{ + "default-timeout-minutes": "60", + "default-managed-by-label-value": "tekton-pipelines", + }, + }, + }, + } + + // Initialize controller with filtered data for the listers + testAssets, cancel := getTaskRunController(t, filteredData) + defer cancel() + + // Create clients with all the data for verification + ctx, _ := ttesting.SetupFakeContext(t) + clients, _ := test.SeedTestData(t, ctx, d) + + // 1. Reconcile the TaskRun managed by "other-controller" + // We expect nothing to happen because it's not in the lister. + err := testAssets.Controller.Reconciler.Reconcile(testAssets.Ctx, getRunName(trs[1])) + if err != nil { + t.Errorf("Expected reconcile to return nil for externally managed TaskRun, but got: %v", err) + } + + // Verify no Pods were created for the externally managed TaskRun + podsOther, err := testAssets.Clients.Kube.CoreV1().Pods(namespace).List(testAssets.Ctx, metav1.ListOptions{}) + if err != nil { + t.Fatalf("Error listing pods: %v", err) + } + if len(podsOther.Items) != 0 { + t.Errorf("Expected no Pods for externally managed TaskRun, but found %d", len(podsOther.Items)) + } + + // Verify its status is unchanged + reconciledOther, err := clients.Pipeline.TektonV1().TaskRuns(namespace).Get(testAssets.Ctx, trManagedByOtherName, metav1.GetOptions{}) + if err != nil { + t.Fatalf("Failed to get externally managed TaskRun: %v", err) + } + if len(reconciledOther.Status.Conditions) != 0 { + t.Errorf("Expected externally managed TaskRun to have no conditions, but it did") + } + + // 2. Reconcile the TaskRun managed by "tekton.dev/pipeline" + // We expect this one to be processed normally. + err = testAssets.Controller.Reconciler.Reconcile(testAssets.Ctx, getRunName(trs[0])) + if err == nil { + t.Errorf("Expected reconcile to return a requeue indicating the pod was created, but got nil") + } else if ok, _ := controller.IsRequeueKey(err); !ok { + t.Errorf("Expected a requeue error, got: %v", err) + } + + // Verify a Pod was created for the Tekton-managed TaskRun + podsTekton, err := testAssets.Clients.Kube.CoreV1().Pods(namespace).List(testAssets.Ctx, metav1.ListOptions{}) + if err != nil { + t.Fatalf("Error listing pods: %v", err) + } + if len(podsTekton.Items) != 1 { + t.Errorf("Expected 1 Pod for Tekton-managed TaskRun, but found %d", len(podsTekton.Items)) + } + + // Verify its status was updated + reconciledTekton, err := clients.Pipeline.TektonV1().TaskRuns(namespace).Get(testAssets.Ctx, trManagedByTektonName, metav1.GetOptions{}) + if err != nil { + t.Fatalf("Failed to get Tekton-managed TaskRun: %v", err) + } + if !reconciledTekton.Status.GetCondition(apis.ConditionSucceeded).IsUnknown() { + t.Errorf("Expected Tekton-managed TaskRun to be running, but it was not") + } +}