Skip to content

Commit f2fb0ba

Browse files
committed
Add timeout setting to Steps
This feature allows a Task author to specify a Step timeout in a Taskrun. An example use case is when a Task author would like to execute a Step for setting up an execution environment. One may expect this Step to execute within a few seconds. If the execution time takes longer than expected one may rather want to fail fast instead of waiting for the TaskRun timeout to abort the TaskRun. Closes tektoncd#1690
1 parent de825d0 commit f2fb0ba

20 files changed

+249
-43
lines changed

cmd/entrypoint/main.go

+12-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package main
1818

1919
import (
20+
"context"
2021
"flag"
2122
"log"
2223
"os"
@@ -40,6 +41,7 @@ var (
4041
terminationPath = flag.String("termination_path", "/tekton/termination", "If specified, file to write upon termination")
4142
results = flag.String("results", "", "If specified, list of file names that might contain task results")
4243
waitPollingInterval = time.Second
44+
timeout = flag.String("timeout", "", "If specified, sets timeout for step")
4345
)
4446

4547
func main() {
@@ -78,7 +80,16 @@ func main() {
7880
log.Printf("non-fatal error copying credentials: %q", err)
7981
}
8082

81-
if err := e.Go(); err != nil {
83+
// Add timeout to context if a non-zero timeout is specified for a step
84+
ctx := context.Background()
85+
timeoutContext, _ := time.ParseDuration(*timeout)
86+
var cancel context.CancelFunc
87+
if timeoutContext != time.Duration(0) {
88+
ctx, cancel = context.WithTimeout(ctx, timeoutContext)
89+
defer cancel()
90+
}
91+
92+
if err := e.Go(ctx); err != nil {
8293
switch t := err.(type) {
8394
case skipError:
8495
log.Print("Skipping step because a previous step failed")

cmd/entrypoint/runner.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"context"
45
"os"
56
"os/exec"
67
"os/signal"
@@ -19,7 +20,7 @@ type realRunner struct {
1920

2021
var _ entrypoint.Runner = (*realRunner)(nil)
2122

22-
func (rr *realRunner) Run(args ...string) error {
23+
func (rr *realRunner) Run(ctx context.Context, args ...string) error {
2324
if len(args) == 0 {
2425
return nil
2526
}
@@ -33,7 +34,7 @@ func (rr *realRunner) Run(args ...string) error {
3334
signal.Notify(rr.signals)
3435
defer signal.Reset()
3536

36-
cmd := exec.Command(name, args...)
37+
cmd := exec.CommandContext(ctx, name, args...)
3738
cmd.Stdout = os.Stdout
3839
cmd.Stderr = os.Stderr
3940
// dedicated PID group used to forward signals to

cmd/entrypoint/runner_test.go

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package main
22

33
import (
4+
"context"
45
"os"
56
"syscall"
67
"testing"
8+
"time"
79
)
810

911
// TestRealRunnerSignalForwarding will artificially put an interrupt signal (SIGINT) in the rr.signals chan.
@@ -14,9 +16,26 @@ func TestRealRunnerSignalForwarding(t *testing.T) {
1416
rr := realRunner{}
1517
rr.signals = make(chan os.Signal, 1)
1618
rr.signals <- syscall.SIGINT
17-
if err := rr.Run("sleep", "3600"); err.Error() == "signal: interrupt" {
19+
if err := rr.Run(context.Background(), "sleep", "3600"); err.Error() == "signal: interrupt" {
1820
t.Logf("SIGINT forwarded to Entrypoint")
1921
} else {
2022
t.Fatalf("Unexpected error received: %v", err)
2123
}
2224
}
25+
26+
// TestRealRunnerTimeout tests whether cmd is killed after a millisecond even though it's supposed to sleep for 10 milliseconds.
27+
func TestRealRunnerTimeout(t *testing.T) {
28+
rr := realRunner{}
29+
timeout := time.Millisecond
30+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
31+
defer cancel()
32+
if err := rr.Run(ctx, "sleep", "0.01"); err != nil {
33+
if ctx.Err() == context.DeadlineExceeded {
34+
t.Logf("killed process since %s deadline was exceeded", timeout.String())
35+
} else if ctx.Err() != nil {
36+
t.Fatalf("unexpected context error received: %v", ctx.Err())
37+
} else {
38+
t.Fatalf("unexpected command execution error received: %v", err)
39+
}
40+
}
41+
}

docs/tasks.md

+19
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ weight: 1
1212
- [Defining `Steps`](#defining-steps)
1313
- [Reserved directories](#reserved-directories)
1414
- [Running scripts within `Steps`](#running-scripts-within-steps)
15+
- [Specifying a timeout](#specifying-a-timeout)
1516
- [Specifying `Parameters`](#specifying-parameters)
1617
- [Specifying `Resources`](#specifying-resources)
1718
- [Specifying `Workspaces`](#specifying-workspaces)
@@ -241,7 +242,25 @@ steps:
241242
#!/usr/bin/env bash
242243
/bin/my-binary
243244
```
245+
#### Specifying a timeout
244246

247+
A `Step` can specify a `timeout` field. If the `Step` execution time exceeds the specified timeout, this `Step` and any subsequent `Steps` are canceled.
248+
Specifically, the running process spawned by the `Step` is killed.
249+
An accompanying log is output under the `status.conditions.message` field of the `TaskRun` YAML.
250+
251+
The format for a timeout is a duration string as specified in the [Go time package](https://golang.org/pkg/time/#ParseDuration) (e.g. 1s or 1ms).
252+
253+
The example `Step` below is supposed to sleep for 60 seconds but will be canceled by the specified 5 second timeout.
254+
```yaml
255+
steps:
256+
- name: willTimeout
257+
image: ubuntu
258+
script: |
259+
#!/usr/bin/env bash
260+
echo "I am supposed to sleep for 60 seconds!"
261+
sleep 60
262+
timeout: 5s
263+
```
245264
### Specifying `Parameters`
246265

247266
You can specify parameters, such as compilation flags or artifact names, that you want to supply to the `Task` at execution time.

internal/builder/v1beta1/pod.go

+3
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ type PodSpecOp func(*corev1.PodSpec)
3333
// PodStatusOp is an operation which modifies a PodStatus struct.
3434
type PodStatusOp func(status *corev1.PodStatus)
3535

36+
// PodContainerStatusOp is an operation which modifies a ContainerStatus struct.
37+
type PodContainerStatusOp func(status *corev1.ContainerStatus)
38+
3639
// Pod creates a Pod with default values.
3740
// Any number of Pod modifiers can be passed to transform it.
3841
func Pod(name string, ops ...PodOp) *corev1.Pod {

pkg/apis/pipeline/v1beta1/task_types.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,11 @@ type Step struct {
125125
//
126126
// If Script is not empty, the Step cannot have an Command or Args.
127127
Script string `json:"script,omitempty"`
128+
// If step times out after Timeout, pod is terminated
129+
Timeout string `json:"timeout,omitempty"`
128130
}
129131

130-
// Sidecar embeds the Container type, which allows it to include fields not
131-
// provided by Container.
132+
// Sidecar has nearly the same data structure as Step, consisting of a Container and an optional Script, but does not have the ability to timeout.
132133
type Sidecar struct {
133134
corev1.Container `json:",inline"`
134135

pkg/apis/pipeline/v1beta1/task_validation.go

+7
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"fmt"
2222
"path/filepath"
2323
"strings"
24+
"time"
2425

2526
"github.com/tektoncd/pipeline/pkg/apis/validate"
2627
"github.com/tektoncd/pipeline/pkg/substitution"
@@ -189,6 +190,12 @@ func validateSteps(steps []Step) *apis.FieldError {
189190
names.Insert(s.Name)
190191
}
191192

193+
if s.Timeout != "" {
194+
if _, err := time.ParseDuration(s.Timeout); err != nil {
195+
return apis.ErrInvalidValue(s.Timeout, "timeout")
196+
}
197+
}
198+
192199
for _, vm := range s.VolumeMounts {
193200
if strings.HasPrefix(vm.MountPath, "/tekton/") &&
194201
!strings.HasPrefix(vm.MountPath, "/tekton/home") {

pkg/entrypoint/entrypointer.go

+11-3
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package entrypoint
1818

1919
import (
20+
"context"
2021
"fmt"
2122
"io/ioutil"
2223
"os"
@@ -73,7 +74,7 @@ type Waiter interface {
7374

7475
// Runner encapsulates running commands.
7576
type Runner interface {
76-
Run(args ...string) error
77+
Run(ctx context.Context, args ...string) error
7778
}
7879

7980
// PostWriter encapsulates writing a file when complete.
@@ -84,7 +85,7 @@ type PostWriter interface {
8485

8586
// Go optionally waits for a file, runs the command, and writes a
8687
// post file.
87-
func (e Entrypointer) Go() error {
88+
func (e Entrypointer) Go(ctx context.Context) error {
8889
prod, _ := zap.NewProduction()
8990
logger := prod.Sugar()
9091

@@ -120,7 +121,14 @@ func (e Entrypointer) Go() error {
120121
ResultType: v1beta1.InternalTektonResultType,
121122
})
122123

123-
err := e.Runner.Run(e.Args...)
124+
err := e.Runner.Run(ctx, e.Args...)
125+
if ctx.Err() == context.DeadlineExceeded {
126+
output = append(output, v1beta1.PipelineResourceResult{
127+
Key: "Reason",
128+
Value: "TimeoutExceeded",
129+
ResultType: v1beta1.InternalTektonResultType,
130+
})
131+
}
124132

125133
// Write the post file *no matter what*
126134
e.WritePostFile(e.PostFile, err)

pkg/entrypoint/entrypointer_test.go

+5-4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package entrypoint
1818

1919
import (
20+
"context"
2021
"encoding/json"
2122
"errors"
2223
"io/ioutil"
@@ -76,7 +77,7 @@ func TestEntrypointerFailures(t *testing.T) {
7677
Runner: fr,
7778
PostWriter: fpw,
7879
TerminationPath: "termination",
79-
}.Go()
80+
}.Go(context.Background())
8081
if err == nil {
8182
t.Fatalf("Entrypointer didn't fail")
8283
}
@@ -139,7 +140,7 @@ func TestEntrypointer(t *testing.T) {
139140
Runner: fr,
140141
PostWriter: fpw,
141142
TerminationPath: "termination",
142-
}.Go()
143+
}.Go(context.Background())
143144
if err != nil {
144145
t.Fatalf("Entrypointer failed: %v", err)
145146
}
@@ -214,7 +215,7 @@ func (f *fakeWaiter) Wait(file string, _ bool) error {
214215

215216
type fakeRunner struct{ args *[]string }
216217

217-
func (f *fakeRunner) Run(args ...string) error {
218+
func (f *fakeRunner) Run(ctx context.Context, args ...string) error {
218219
f.args = &args
219220
return nil
220221
}
@@ -232,7 +233,7 @@ func (f *fakeErrorWaiter) Wait(file string, expectContent bool) error {
232233

233234
type fakeErrorRunner struct{ args *[]string }
234235

235-
func (f *fakeErrorRunner) Run(args ...string) error {
236+
func (f *fakeErrorRunner) Run(ctx context.Context, args ...string) error {
236237
f.args = &args
237238
return errors.New("runner failed")
238239
}

pkg/pod/entrypoint.go

+1-3
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,6 @@ var (
8484
// Containers must have Command specified; if the user didn't specify a
8585
// command, we must have fetched the image's ENTRYPOINT before calling this
8686
// method, using entrypoint_lookup.go.
87-
//
88-
// TODO(#1605): Also use entrypoint injection to order sidecar start/stop.
8987
func orderContainers(entrypointImage string, extraEntrypointArgs []string, steps []corev1.Container, results []v1beta1.TaskResult) (corev1.Container, []corev1.Container, error) {
9088
initContainer := corev1.Container{
9189
Name: "place-tools",
@@ -236,6 +234,6 @@ func isContainerSidecar(name string) bool { return strings.HasPrefix(name, sidec
236234
// trimStepPrefix returns the container name, stripped of its step prefix.
237235
func trimStepPrefix(name string) string { return strings.TrimPrefix(name, stepPrefix) }
238236

239-
// trimSidecarPrefix returns the container name, stripped of its sidecar
237+
// TrimSidecarPrefix returns the container name, stripped of its sidecar
240238
// prefix.
241239
func TrimSidecarPrefix(name string) string { return strings.TrimPrefix(name, sidecarPrefix) }

pkg/pod/entrypoint_test.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ func TestOrderContainers(t *testing.T) {
8787
VolumeMounts: []corev1.VolumeMount{toolsMount},
8888
TerminationMessagePath: "/tekton/termination",
8989
}}
90-
gotInit, got, err := orderContainers(images.EntrypointImage, []string{}, steps, nil)
90+
gotInit, got, err := orderContainers(images.EntrypointImage, []string{}, steps, make([]string, len(steps)), nil)
9191
if err != nil {
9292
t.Fatalf("orderContainers: %v", err)
9393
}
@@ -171,7 +171,7 @@ func TestEntryPointResults(t *testing.T) {
171171
VolumeMounts: []corev1.VolumeMount{toolsMount},
172172
TerminationMessagePath: "/tekton/termination",
173173
}}
174-
_, got, err := orderContainers(images.EntrypointImage, []string{}, steps, results)
174+
_, got, err := orderContainers(images.EntrypointImage, []string{}, steps, make([]string, len(steps)), results)
175175
if err != nil {
176176
t.Fatalf("orderContainers: %v", err)
177177
}
@@ -209,7 +209,7 @@ func TestEntryPointResultsSingleStep(t *testing.T) {
209209
VolumeMounts: []corev1.VolumeMount{toolsMount, downwardMount},
210210
TerminationMessagePath: "/tekton/termination",
211211
}}
212-
_, got, err := orderContainers(images.EntrypointImage, []string{}, steps, results)
212+
_, got, err := orderContainers(images.EntrypointImage, []string{}, steps, make([]string, len(steps)), results)
213213
if err != nil {
214214
t.Fatalf("orderContainers: %v", err)
215215
}
@@ -243,7 +243,7 @@ func TestEntryPointSingleResultsSingleStep(t *testing.T) {
243243
VolumeMounts: []corev1.VolumeMount{toolsMount, downwardMount},
244244
TerminationMessagePath: "/tekton/termination",
245245
}}
246-
_, got, err := orderContainers(images.EntrypointImage, []string{}, steps, results)
246+
_, got, err := orderContainers(images.EntrypointImage, []string{}, steps, make([]string, len(steps)), results)
247247
if err != nil {
248248
t.Fatalf("orderContainers: %v", err)
249249
}

pkg/pod/pod.go

+11
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ func (b *Builder) Build(ctx context.Context, taskRun *v1beta1.TaskRun, taskSpec
139139
return nil, err
140140
}
141141

142+
stepContainers = setStepTimeouts(taskSpec.Steps, stepContainers)
143+
142144
// Rewrite steps with entrypoint binary. Append the entrypoint init
143145
// container to place the entrypoint binary.
144146
entrypointInit, stepContainers, err := orderContainers(b.Images.EntrypointImage, credEntrypointArgs, stepContainers, taskSpec.Results)
@@ -398,3 +400,12 @@ func shouldAddReadyAnnotationOnPodCreate(ctx context.Context, sidecars []v1beta1
398400
cfg := config.FromContextOrDefaults(ctx)
399401
return !cfg.FeatureFlags.RunningInEnvWithInjectedSidecars
400402
}
403+
404+
func setStepTimeouts(steps []v1beta1.Step, stepContainers []corev1.Container) []corev1.Container {
405+
for i := range stepContainers {
406+
if steps[i].Timeout != "" {
407+
stepContainers[i].Args = append(stepContainers[i].Args, steps[i].Timeout)
408+
}
409+
}
410+
return stepContainers
411+
}

pkg/pod/pod_test.go

+48-1
Original file line numberDiff line numberDiff line change
@@ -1085,7 +1085,54 @@ script-heredoc-randomly-generated-78c5n
10851085
Resources: corev1.ResourceRequirements{Requests: allZeroQty()},
10861086
TerminationMessagePath: "/tekton/termination",
10871087
}},
1088-
}}} {
1088+
},
1089+
}, {
1090+
desc: "step-with-timeout",
1091+
ts: v1beta1.TaskSpec{
1092+
Steps: []v1beta1.Step{{Container: corev1.Container{
1093+
Name: "name",
1094+
Image: "image",
1095+
Command: []string{"cmd"}, // avoid entrypoint lookup.
1096+
},
1097+
Timeout: "1s",
1098+
}},
1099+
},
1100+
want: &corev1.PodSpec{
1101+
RestartPolicy: corev1.RestartPolicyNever,
1102+
InitContainers: []corev1.Container{placeToolsInit},
1103+
Containers: []corev1.Container{{
1104+
Name: "step-name",
1105+
Image: "image",
1106+
Command: []string{"/tekton/tools/entrypoint"},
1107+
Args: []string{
1108+
"-wait_file",
1109+
"/tekton/downward/ready",
1110+
"-wait_file_content",
1111+
"-post_file",
1112+
"/tekton/tools/0",
1113+
"-termination_path",
1114+
"/tekton/termination",
1115+
"-timeout",
1116+
"1s",
1117+
"-entrypoint",
1118+
"cmd",
1119+
"--",
1120+
},
1121+
Env: implicitEnvVars,
1122+
VolumeMounts: append([]corev1.VolumeMount{toolsMount, downwardMount, {
1123+
Name: "tekton-creds-init-home-9l9zj",
1124+
MountPath: "/tekton/creds",
1125+
}}, implicitVolumeMounts...),
1126+
WorkingDir: pipeline.WorkspaceDir,
1127+
Resources: corev1.ResourceRequirements{Requests: allZeroQty()},
1128+
TerminationMessagePath: "/tekton/termination",
1129+
}},
1130+
Volumes: append(implicitVolumes, toolsVolume, downwardVolume, corev1.Volume{
1131+
Name: "tekton-creds-init-home-9l9zj",
1132+
VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{Medium: corev1.StorageMediumMemory}},
1133+
}),
1134+
},
1135+
}} {
10891136
t.Run(c.desc, func(t *testing.T) {
10901137
names.TestingSeed()
10911138
store := config.NewStore(logtesting.TestLogger(t))

0 commit comments

Comments
 (0)