Skip to content

feat: override task timeouts in pipelineruns #8636

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config/300-crds/300-pipelinerun.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3895,6 +3895,11 @@ spec:
description: The name of the Step to override.
type: string
x-kubernetes-list-type: atomic
timeout:
description: |-
Time after which the TaskRun times out.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Time after which the TaskRun times out.
Time after which the TaskRun times out.
Must be less than or equal to `pipelineRun.spec.timeouts.tasks`.

Refer Go's ParseDuration documentation for expected format: https://golang.org/pkg/time/#ParseDuration
type: string
x-kubernetes-list-type: atomic
taskRunTemplate:
description: TaskRunTemplate represent template of taskrun
Expand Down
15 changes: 15 additions & 0 deletions docs/pipeline-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3322,6 +3322,21 @@ Kubernetes core/v1.ResourceRequirements
<p>Compute resources to use for this TaskRun</p>
</td>
</tr>
<tr>
<td>
<code>timeout</code><br/>
<em>
<a href="https://godoc.org/k8s.io/apimachinery/pkg/apis/meta/v1#Duration">
Kubernetes meta/v1.Duration
</a>
</em>
</td>
<td>
<em>(Optional)</em>
<p>Time after which the TaskRun times out.
Refer Go&rsquo;s ParseDuration documentation for expected format: <a href="https://golang.org/pkg/time/#ParseDuration">https://golang.org/pkg/time/#ParseDuration</a></p>
</td>
</tr>
</tbody>
</table>
<h3 id="tekton.dev/v1.PipelineTaskRunTemplate">PipelineTaskRunTemplate
Expand Down
5 changes: 4 additions & 1 deletion docs/pipelineruns.md
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,7 @@ spec:
podTemplate:
nodeSelector:
disktype: ssd
timeout: "1h30m"
```
{{% /tab %}}

Expand All @@ -966,12 +967,14 @@ spec:
taskPodTemplate:
nodeSelector:
disktype: ssd
timeout: "1h30m"
```
{{% /tab %}}
{{< /tabs >}}

If used with this `Pipeline`, `build-task` will use the task specific `PodTemplate` (where `nodeSelector` has `disktype` equal to `ssd`)
along with `securityContext` from the `pipelineRun.spec.podTemplate`.
along with `securityContext` from the `pipelineRun.spec.podTemplate`. The task will also have a specific timeout of 1 hour and 30 minutes. This overrides any existing timeout already defined by the pipelineTask as well.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
along with `securityContext` from the `pipelineRun.spec.podTemplate`. The task will also have a specific timeout of 1 hour and 30 minutes. This overrides any existing timeout already defined by the pipelineTask as well.
along with `securityContext` from the `pipelineRun.spec.podTemplate`. The task will also have a specific timeout of 1 hour and 30 minutes. This overrides any existing timeout already defined by the pipelineTask as well, though the specified `pipelineRun.spec.timeouts.tasks` will still take precedence.


`PipelineTaskRunSpec` may also contain `StepSpecs` and `SidecarSpecs`; see
[Overriding `Task` `Steps` and `Sidecars`](./taskruns.md#overriding-task-steps-and-sidecars) for more information.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
name: pipeline-with-timeouts
spec:
tasks:
- name: task-with-timeout
timeout: "1h0m0s"
taskSpec:
steps:
- name: sleep
image: ubuntu
script: |
echo "Starting task..."
sleep 30
echo "Task completed!"

- name: another-task-with-timeout
timeout: "30m0s"
runAfter:
- task-with-timeout
taskSpec:
steps:
- name: sleep
image: ubuntu
script: |
echo "Starting another task..."
sleep 30
echo "Another task completed!"

---
apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
name: pipeline-timeout-override-run
spec:
pipelineRef:
name: pipeline-with-timeouts
timeouts:
pipeline: "3h0m0s" # overall pipeline timeout
taskRunSpecs:
- pipelineTaskName: task-with-timeout
timeout: "2h0m0s" # override timeout to 2 hours
- pipelineTaskName: another-task-with-timeout
timeout: "1h0m0s" # override timeout to 1 hour
8 changes: 7 additions & 1 deletion pkg/apis/pipeline/v1/openapi_generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pkg/apis/pipeline/v1/pipeline_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1362,7 +1362,7 @@ func TestPipelineSpec_Validate_Failure(t *testing.T) {
if err == nil {
t.Errorf("PipelineSpec.Validate() did not return error for invalid pipelineSpec")
}
if d := cmp.Diff(tt.expectedError.Error(), err.Error(), cmpopts.IgnoreUnexported(apis.FieldError{})); d != "" {
if d := cmp.Diff(tt.expectedError.Error(), err.Error()); d != "" {
t.Errorf("PipelineSpec.Validate() errors diff %s", diff.PrintWantGot(d))
}
})
Expand Down
6 changes: 6 additions & 0 deletions pkg/apis/pipeline/v1/pipelinerun_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,11 @@ type PipelineTaskRunSpec struct {

// Compute resources to use for this TaskRun
ComputeResources *corev1.ResourceRequirements `json:"computeResources,omitempty"`

// Time after which the TaskRun times out.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Time after which the TaskRun times out.
// Time after which the TaskRun times out. Overrides the timeout specified
// on the Task's spec if specified. Takes lower precedence to PipelineRun's
// `spec.timeouts.tasks`

// Refer Go's ParseDuration documentation for expected format: https://golang.org/pkg/time/#ParseDuration
// +optional
Timeout *metav1.Duration `json:"timeout,omitempty"`
}

// GetTaskRunSpec returns the task specific spec for a given
Expand All @@ -678,6 +683,7 @@ func (pr *PipelineRun) GetTaskRunSpec(pipelineTaskName string) PipelineTaskRunSp
s.SidecarSpecs = task.SidecarSpecs
s.Metadata = task.Metadata
s.ComputeResources = task.ComputeResources
s.Timeout = task.Timeout
}
}
return s
Expand Down
43 changes: 40 additions & 3 deletions pkg/apis/pipeline/v1/pipelinerun_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ func (ps *PipelineRunSpec) Validate(ctx context.Context) (errs *apis.FieldError)
// Validate propagated parameters
errs = errs.Also(ps.validateInlineParameters(ctx))

// Validate task run specs first
for _, trs := range ps.TaskRunSpecs {
errs = errs.Also(validateTaskRunSpec(ctx, trs).ViaFieldKey("taskRunSpecs", trs.PipelineTaskName))
}

if ps.Timeouts != nil {
// tasks timeout should be a valid duration of at least 0.
errs = errs.Also(validateTimeoutDuration("tasks", ps.Timeouts.Tasks))
Expand All @@ -103,6 +108,8 @@ func (ps *PipelineRunSpec) Validate(ctx context.Context) (errs *apis.FieldError)
defaultTimeout := time.Duration(config.FromContextOrDefaults(ctx).Defaults.DefaultTimeoutMinutes)
errs = errs.Also(ps.validatePipelineTimeout(defaultTimeout, "should be <= default timeout duration"))
}

errs = errs.Also(validatePipelineTaskRunSpecTimeouts(ps, ctx))
}

errs = errs.Also(validateSpecStatus(ps.Status))
Expand All @@ -117,9 +124,6 @@ func (ps *PipelineRunSpec) Validate(ctx context.Context) (errs *apis.FieldError)
wsNames[ws.Name] = idx
}
}
for idx, trs := range ps.TaskRunSpecs {
errs = errs.Also(validateTaskRunSpec(ctx, trs).ViaIndex(idx).ViaField("taskRunSpecs"))
}

if ps.TaskRunTemplate.PodTemplate != nil {
errs = errs.Also(validatePodTemplateEnv(ctx, *ps.TaskRunTemplate.PodTemplate).ViaField("taskRunTemplate"))
Expand Down Expand Up @@ -329,5 +333,38 @@ func validateTaskRunSpec(ctx context.Context, trs PipelineTaskRunSpec) (errs *ap
if trs.PodTemplate != nil {
errs = errs.Also(validatePodTemplateEnv(ctx, *trs.PodTemplate))
}
if trs.Timeout != nil && trs.Timeout.Duration < 0 {
errs = errs.Also(apis.ErrInvalidValue(trs.Timeout.Duration.String()+" should be >= 0", "timeout"))
}
return errs
}

// validatePipelineTaskRunSpecTimeouts validates timeouts for TaskRunSpecs in a PipelineRun
func validatePipelineTaskRunSpecTimeouts(ps *PipelineRunSpec, ctx context.Context) *apis.FieldError {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: conventionally the context is usually the first argument

var errs *apis.FieldError

if len(ps.TaskRunSpecs) == 0 {
return errs
}

pipelineTimeout := ps.Timeouts.Pipeline
if pipelineTimeout == nil {
pipelineTimeout = &metav1.Duration{Duration: time.Duration(config.FromContextOrDefaults(ctx).Defaults.DefaultTimeoutMinutes) * time.Minute}
}
Comment on lines +350 to +353
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this should be comparing ps.Timeouts.Tasks, and if that is not specified then falling back to ps.Timeouts.Pipeline. Since ps.Timeouts.Tasks is already enforced to be less than or equal to ps.Timeouts.Pipeline

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about something like this? This implementation adds a couple things:

  1. Creates a central timeout validation function which falls back to the default if no timeout is supplied, and if the timeout is negative then it of course fails. It DRYs up the validation for both TaskRuns schema versions and could also be used on the PipelineRun.spec.timeouts as well
  2. PipelineRunTask timeout check now ensures that the timeout is 0 < timeout < maxTimeout where maxTimeout is the first present in: pr.spec.timeouts.tasks || pr.spec.timeouts.pipeline || defaultTimeout,
// in the PipelineValidation
func validatePipelineTaskRunSpecTimeouts(ps *PipelineRunSpec, ctx context.Context) *apis.FieldError {
	cfg := config.FromContextOrDefaults(ctx)
	var errs *apis.FieldError

	maxTimeout := &metav1.Duration{Duration: time.Duration(cfg.Defaults.DefaultTimeoutMinutes) * time.Minute}
	if ps.Timeouts != nil {
		// Loop over max-timeouts in order of precedence to find most applicable
		for _, timeout := range []*metav1.Duration{ps.Timeouts.Tasks, ps.Timeouts.Pipeline} {
			if timeout != nil {
				timeout, err := validateTimeout(ps.Timeouts.Tasks, cfg.Defaults.DefaultTimeoutMinutes)
				if err != nil {
					return errs.Also(err)
				}
				maxTimeout = timeout
				break
			}
		}
	}

	if maxTimeout.Duration == config.NoTimeoutDuration || len(ps.TaskRunSpecs) == 0 {
		return errs
	}

	for _, taskRunSpec := range ps.TaskRunSpecs {
		taskRunTimeout, err := validateTimeout(taskRunSpec.Timeout, cfg.Defaults.DefaultTimeoutMinutes)
		if err != nil {
			errs = errs.Also(err)
		} else if taskRunTimeout.Duration > maxTimeout.Duration {
			errs = errs.Also(apis.ErrInvalidValue(fmt.Sprintf("%s should be <= pipeline tasks duration %s", &taskRunTimeout.Duration, maxTimeout.Duration), "taskRunSpecs["+taskRunSpec.PipelineTaskName+"].timeout"))
		}
	}

	return errs

}

// In the TaskRun validation
func (ts *TaskRunSpec) Validate(ctx context.Context) (errs *apis.FieldError) {
	cfg := config.FromContextOrDefaults(ctx)
	// ...
	_, timeoutErr := validateTimeout(ts.Timeout, cfg.Defaults.DefaultTimeoutMinutes)
	errs = errs.Also(timeoutErr)
	// ...
}

// In some resource-agnostic location
func validateTimeout(timeout *metav1.Duration, defaultTimeoutMinutes int) (*metav1.Duration, *apis.FieldError) {
	if timeout == nil {
		return &metav1.Duration{Duration: time.Duration(defaultTimeoutMinutes) * time.Minute}, nil
	}
	if timeout.Duration < 0 {
		return nil, apis.ErrInvalidValue(timeout.Duration.String()+" should be >= 0", "timeout")
	}
	return timeout, nil
}


if pipelineTimeout.Duration == config.NoTimeoutDuration {
return errs
}

for _, taskRunSpec := range ps.TaskRunSpecs {
if taskRunSpec.Timeout == nil {
continue
}

if taskRunSpec.Timeout.Duration > pipelineTimeout.Duration {
errs = errs.Also(apis.ErrInvalidValue(taskRunSpec.Timeout.Duration.String()+" should be <= pipeline duration", "taskRunSpecs["+taskRunSpec.PipelineTaskName+"].timeout"))
}
}

return errs
}
99 changes: 91 additions & 8 deletions pkg/apis/pipeline/v1/pipelinerun_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,28 @@ func TestPipelineRun_Validate(t *testing.T) {
},
},
wc: cfgtesting.EnableAlphaAPIFields,
}, {
name: "valid task-specific timeouts",
pr: v1.PipelineRun{
ObjectMeta: metav1.ObjectMeta{
Name: "pipelinelinename",
},
Spec: v1.PipelineRunSpec{
PipelineRef: &v1.PipelineRef{
Name: "prname",
},
Timeouts: &v1.TimeoutFields{
Pipeline: &metav1.Duration{Duration: 1 * time.Hour},
},
TaskRunSpecs: []v1.PipelineTaskRunSpec{{
PipelineTaskName: "task1",
Timeout: &metav1.Duration{Duration: 30 * time.Minute},
}, {
PipelineTaskName: "task2",
Timeout: &metav1.Duration{Duration: 45 * time.Minute},
}},
},
},
}}

for _, ts := range tests {
Expand Down Expand Up @@ -849,7 +871,7 @@ func TestPipelineRunSpec_Invalidate(t *testing.T) {
},
},
withContext: EnableForbiddenEnv,
wantErr: apis.ErrInvalidValue("PodTemplate cannot update a forbidden env: TEST_ENV", "taskRunSpecs[0].PodTemplate.Env"),
wantErr: apis.ErrInvalidValue("PodTemplate cannot update a forbidden env: TEST_ENV", "taskRunSpecs[task-1].PodTemplate.Env"),
}, {
name: "TaskRunTemplate.PodTemplate contains forbidden env",
spec: v1.PipelineRunSpec{
Expand Down Expand Up @@ -969,7 +991,7 @@ func TestPipelineRunSpec_Invalidate(t *testing.T) {
},
},
},
wantErr: apis.ErrMultipleOneOf("taskRunSpecs[0].stepSpecs[1].name"),
wantErr: apis.ErrMultipleOneOf("taskRunSpecs[bar].stepSpecs[1].name"),
withContext: cfgtesting.EnableAlphaAPIFields,
}, {
name: "stepSpecs disallowed without beta feature gate",
Expand Down Expand Up @@ -1022,7 +1044,7 @@ func TestPipelineRunSpec_Invalidate(t *testing.T) {
},
},
},
wantErr: apis.ErrMissingField("taskRunSpecs[0].stepSpecs[0].name"),
wantErr: apis.ErrMissingField("taskRunSpecs[bar].stepSpecs[0].name"),
withContext: cfgtesting.EnableAlphaAPIFields,
}, {
name: "duplicate sidecarSpec names",
Expand All @@ -1037,7 +1059,7 @@ func TestPipelineRunSpec_Invalidate(t *testing.T) {
},
},
},
wantErr: apis.ErrMultipleOneOf("taskRunSpecs[0].sidecarSpecs[1].name"),
wantErr: apis.ErrMultipleOneOf("taskRunSpecs[bar].sidecarSpecs[1].name"),
withContext: cfgtesting.EnableAlphaAPIFields,
}, {
name: "missing sidecarSpec name",
Expand All @@ -1054,7 +1076,7 @@ func TestPipelineRunSpec_Invalidate(t *testing.T) {
},
},
},
wantErr: apis.ErrMissingField("taskRunSpecs[0].sidecarSpecs[0].name"),
wantErr: apis.ErrMissingField("taskRunSpecs[bar].sidecarSpecs[0].name"),
withContext: cfgtesting.EnableAlphaAPIFields,
}, {
name: "invalid both step-level (stepSpecs.resources) and task-level (taskRunSpecs.resources) resource requirements configured",
Expand All @@ -1076,8 +1098,8 @@ func TestPipelineRunSpec_Invalidate(t *testing.T) {
},
},
wantErr: apis.ErrMultipleOneOf(
"taskRunSpecs[0].stepSpecs.resources",
"taskRunSpecs[0].computeResources",
"taskRunSpecs[pipelineTask].stepSpecs.resources",
"taskRunSpecs[pipelineTask].computeResources",
),
withContext: cfgtesting.EnableBetaAPIFields,
}, {
Expand Down Expand Up @@ -1202,6 +1224,23 @@ func TestPipelineRun_InvalidTimeouts(t *testing.T) {
},
},
want: apis.ErrInvalidValue("-48h0m0s should be >= 0", "spec.timeouts.pipeline"),
}, {
name: "negative task-specific timeout",
pr: v1.PipelineRun{
ObjectMeta: metav1.ObjectMeta{
Name: "pipelinelinename",
},
Spec: v1.PipelineRunSpec{
PipelineRef: &v1.PipelineRef{
Name: "prname",
},
TaskRunSpecs: []v1.PipelineTaskRunSpec{{
PipelineTaskName: "task1",
Timeout: &metav1.Duration{Duration: -1 * time.Hour},
}},
},
},
want: apis.ErrInvalidValue("-1h0m0s should be >= 0", "spec.taskRunSpecs[task1].timeout"),
}, {
name: "negative pipeline tasks Timeout",
pr: v1.PipelineRun{
Expand Down Expand Up @@ -1352,13 +1391,57 @@ func TestPipelineRun_InvalidTimeouts(t *testing.T) {
},
},
want: apis.ErrInvalidValue(`0s (no timeout) should be <= pipeline duration`, "spec.timeouts.finally"),
}, {
name: "task-specific timeout exceeds pipeline timeout",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could add another valid test covering the case when the pipelineTimeout.Duration == config.NoTimeoutDuration, then there should be no error when task timeout exceeds pipeline timeout.

pr: v1.PipelineRun{
ObjectMeta: metav1.ObjectMeta{
Name: "pipelinelinename",
},
Spec: v1.PipelineRunSpec{
PipelineRef: &v1.PipelineRef{
Name: "prname",
},
Timeouts: &v1.TimeoutFields{
Pipeline: &metav1.Duration{Duration: 1 * time.Hour},
},
TaskRunSpecs: []v1.PipelineTaskRunSpec{{
PipelineTaskName: "task1",
Timeout: &metav1.Duration{Duration: 2 * time.Hour},
}},
},
},
want: apis.ErrInvalidValue("2h0m0s should be <= pipeline duration", "spec.taskRunSpecs[task1].timeout"),
}, {
name: "when pipeline timeout is no timeout (0s), task timeouts can exceed it without error",
pr: v1.PipelineRun{
ObjectMeta: metav1.ObjectMeta{
Name: "pipelinelinename",
},
Spec: v1.PipelineRunSpec{
PipelineRef: &v1.PipelineRef{
Name: "prname",
},
Timeouts: &v1.TimeoutFields{
Pipeline: &metav1.Duration{Duration: config.NoTimeoutDuration},
},
TaskRunSpecs: []v1.PipelineTaskRunSpec{{
PipelineTaskName: "task1",
Timeout: &metav1.Duration{Duration: 10 * time.Hour},
}},
},
},
want: nil, // no error expected when pipeline has no timeout
}}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
err := tc.pr.Validate(ctx)
if d := cmp.Diff(tc.want.Error(), err.Error()); d != "" {
if tc.want == nil {
if err != nil {
t.Errorf("Expected no error but got: %v", err)
}
} else if d := cmp.Diff(tc.want.Error(), err.Error()); d != "" {
t.Error(diff.PrintWantGot(d))
}
})
Expand Down
Loading
Loading