1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "context"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15)
16
17// GO-006: the image setup of a local composite action (pull, retry sleep) stops
18// with the step.
19func TestLocalCompositeSetupIsCancellable(t *testing.T) {
20 needTools(t, "bash")
21 dir := t.TempDir()
22 pulls := filepath.Join(dir, "pulls")
23 script := "#!/bin/sh\necho x >> " + pulls + "\necho ready\nexit 1\n"
24 if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil {
25 t.Fatal(err)
26 }
27 c := NewCanceller()
28 started := make(chan struct{})
29 go func() {
30 <-started
31 c.Cancel(CancelUser)
32 }()
33 home := t.TempDir()
34 layout := NewLayout(home, "hello", home+"/tc")
35 writeFiles(t, layout.Workspace, files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: docker://alpine:3\n"})
36 rec := newRecorder()
37 rep := &signalReporter{recorder: rec, started: started, match: "ready"}
38 done := make(chan Result, 1)
39 go func() {
40 done <- Run(c, Config{
41 WorkflowYAML: []byte(wfHead + " steps:\n - uses: ./c\n - if: always()\n run: echo cleanup-ran\n"),
42 JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rep,
43 BaseEnv: []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home},
44 TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, WaitDelay: 500 * time.Millisecond,
45 Actions: ActionsConfig{DockerPath: filepath.Join(dir, "docker"), Backoff: func() time.Duration { return time.Hour }},
46 })
47 }()
48 select {
49 case res := <-done:
50 if res.Conclusion != ConclusionCancelled {
51 t.Fatalf("%+v", res)
52 }
53 case <-time.After(20 * time.Second):
54 t.Fatal("the retry sleep of the pull ignored the cancellation")
55 }
56 if b, _ := os.ReadFile(pulls); strings.Count(string(b), "x") != 1 {
57 t.Errorf("no retry after the cancellation: %q", b)
58 }
59 if got := rec.step(t, "Run ./c").Conclusion; got != ConclusionCancelled {
60 t.Errorf("step %s", got)
61 }
62 if got := rec.step(t, "Run echo cleanup-ran").Conclusion; got != ConclusionSuccess {
63 t.Errorf("always() step %s", got)
64 }
65}
66
67// GO-014: what the timeout or the cancellation of a step breaks while a local
68// composite action is prepared (a resolution, a download, an image pull and its
69// retry sleep) ends the step as the cancellation of the step does: the timeout
70// message and a failed step for timeout-minutes, a cancelled step for a user
71// cancel, and no stray "The operation was canceled.".
72func TestPreparationInterruptedByTheStepTimeout(t *testing.T) {
73 skew := -(60*time.Second - 1500*time.Millisecond)
74 leaf := files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: o/leaf@v1\n"}
75 docker := files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: docker://alpine:3\n"}
76 hang := make(chan struct{})
77 slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
78 select {
79 case <-r.Context().Done():
80 case <-hang:
81 }
82 }))
83 defer slow.Close()
84 defer close(hang)
85 failDocker := filepath.Join(t.TempDir(), "docker")
86 writeFiles(t, filepath.Dir(failDocker), files{"docker": "#!/bin/sh\necho pull failed\nexit 1\n"})
87
88 for name, tc := range map[string]struct {
89 tree files
90 tweak func(*Config)
91 }{
92 "blocked resolver": {leaf, func(c *Config) {
93 c.Actions.Resolver = func(ctx context.Context, _ []string) ([]ResolvedAction, error) {
94 <-ctx.Done()
95 return nil, ctx.Err()
96 }
97 }},
98 "blocked download": {leaf, func(c *Config) {
99 c.Actions.Resolver = func(context.Context, []string) ([]ResolvedAction, error) {
100 return []ResolvedAction{{Ref: "o/leaf@v1", NameWithOwner: "o/leaf", ResolvedSHA: strings.Repeat("a", 40), TarballURL: slow.URL + "/t"}}, nil
101 }
102 }},
103 "docker pull retry sleep": {docker, func(c *Config) {
104 c.Actions.DockerPath = failDocker
105 c.Actions.Backoff = func() time.Duration { return time.Hour }
106 }},
107 } {
108 t.Run(name, func(t *testing.T) {
109 start := time.Now()
110 rr, res := actionResults(t, tc.tree, " - name: comp\n timeout-minutes: 1\n uses: ./c\n - name: after\n if: always()\n run: echo after-ran\n", func(c *Config) {
111 c.StepTimeoutSkew = skew
112 tc.tweak(c)
113 })
114 if time.Since(start) > 15*time.Second {
115 t.Fatalf("the preparation was not interrupted: %v", time.Since(start))
116 }
117 comp := rr.step(t, "comp")
118 log := logOf(comp)
119 if res.Conclusion != ConclusionFailure || comp.Conclusion != ConclusionFailure {
120 t.Fatalf("job %s, step %s\n%s", res.Conclusion, comp.Conclusion, log)
121 }
122 if !strings.Contains(log, "##[error]The action 'comp' has timed out after 1 minutes.") {
123 t.Errorf("no timeout message:\n%s", log)
124 }
125 if strings.Contains(log, "The operation was canceled.") {
126 t.Errorf("a stray cancel text:\n%s", log)
127 }
128 if rr.step(t, "after").Conclusion != ConclusionSuccess {
129 t.Error("the job is not cancelled by a step timeout")
130 }
131 })
132 }
133}
134
135func TestPreparationInterruptedByAUserCancel(t *testing.T) {
136 needTools(t, "bash")
137 entered := make(chan struct{})
138 var once sync.Once
139 home := t.TempDir()
140 layout := NewLayout(home, "hello", home+"/tc")
141 writeFiles(t, layout.Workspace, files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: o/leaf@v1\n"})
142 c := NewCanceller()
143 go func() {
144 <-entered
145 c.Cancel(CancelUser)
146 }()
147 rec := newRecorder()
148 done := make(chan Result, 1)
149 go func() {
150 done <- Run(c, Config{
151 WorkflowYAML: []byte(wfHead + " steps:\n - name: comp\n uses: ./c\n - name: cleanup\n if: always()\n run: echo cleanup-ran\n"),
152 JobKey: "j", ContextsJSON: testGitHubContext("j"), Layout: &layout, Reporter: rec,
153 BaseEnv: []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home},
154 TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, WaitDelay: 500 * time.Millisecond,
155 Actions: ActionsConfig{Resolver: func(ctx context.Context, _ []string) ([]ResolvedAction, error) {
156 once.Do(func() { close(entered) })
157 <-ctx.Done()
158 return nil, ctx.Err()
159 }},
160 })
161 }()
162 select {
163 case res := <-done:
164 if res.Conclusion != ConclusionCancelled {
165 t.Fatalf("%+v", res)
166 }
167 case <-time.After(20 * time.Second):
168 t.Fatal("the resolution ignored the cancellation")
169 }
170 if got := rec.step(t, "comp").Conclusion; got != ConclusionCancelled {
171 t.Errorf("step %s, want cancelled", got)
172 }
173 if got := rec.step(t, "cleanup").Conclusion; got != ConclusionSuccess {
174 t.Errorf("always() step %s", got)
175 }
176}
177
178// A genuine error of the preparation still fails the step.
179func TestPreparationErrorStillFailsTheStep(t *testing.T) {
180 tree := files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: o/leaf@v1\n"}
181 rr, res := actionResults(t, tree, " - name: comp\n uses: ./c\n", func(c *Config) {
182 c.Actions.Resolver = func(context.Context, []string) ([]ResolvedAction, error) {
183 return []ResolvedAction{{Ref: "o/leaf@v1", Error: "Unable to resolve action `o/leaf@v1`, repository not found: `o/leaf`"}}, nil
184 }
185 })
186 comp := rr.step(t, "comp")
187 if res.Conclusion != ConclusionFailure || comp.Conclusion != ConclusionFailure || !strings.Contains(logOf(comp), "##[error]Unable to resolve action `o/leaf@v1`, repository not found: `o/leaf`") {
188 t.Errorf("%s %s\n%s", res.Conclusion, comp.Conclusion, logOf(comp))
189 }
190}