engine_test.go
1package engine
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "slices"
8 "strings"
9 "testing"
10 "time"
11
12 "git.kilimanjaro.io/ci/internal/lex"
13 "git.kilimanjaro.io/ci/internal/manifest"
14 "git.kilimanjaro.io/ci/internal/runlog"
15)
16
17type fixture struct {
18 t *testing.T
19 root string
20 dir Dir
21 m *manifest.Manifest
22 opts Options
23}
24
25func newFixture(t *testing.T, wf string) *fixture {
26 t.Helper()
27 m, err := manifest.Parse("w", []byte(wf))
28 if err != nil {
29 t.Fatal(err)
30 }
31 tmp := t.TempDir()
32 root := filepath.Join(tmp, "repo")
33 if err := os.MkdirAll(root, 0o755); err != nil {
34 t.Fatal(err)
35 }
36 return &fixture{t: t, root: root, dir: Dir(filepath.Join(tmp, "run")), m: m}
37}
38
39func (f *fixture) run(from string) (*State, []lex.RunEvent) {
40 f.t.Helper()
41 e, err := Create(f.m, f.dir, Meta{Run: "r1", Workflow: "w", Repo: "repo", Sha: "abc", Ref: "refs/heads/main", Kind: "manual", Host: "local", Root: f.root}, Bash{}, f.opts)
42 if err != nil {
43 f.t.Fatal(err)
44 }
45 var st *State
46 if from == "" {
47 st, err = e.Run(context.Background())
48 } else {
49 st, err = e.RunFrom(context.Background(), from)
50 }
51 e.Log.Close()
52 if err != nil {
53 f.t.Fatal(err)
54 }
55 b, err := os.Open(f.dir.Events())
56 if err != nil {
57 f.t.Fatal(err)
58 }
59 defer b.Close()
60 evs, err := runlog.Read(b)
61 if err != nil {
62 f.t.Fatal(err)
63 }
64 return st, evs
65}
66
67func executed(st *State) []string {
68 var out []string
69 for _, x := range st.History {
70 out = append(out, x.Action)
71 }
72 return out
73}
74
75// wf builds a manifest with block-scalar commands (YAML folds newlines in
76// quoted strings). Each action is "name: command"; extra is appended to the
77// action's mapping (e.g. "dir: sub").
78func wf(steps string, actions ...string) string {
79 var b strings.Builder
80 b.WriteString("engine: devenv\nsteps: [" + steps + "]\nactions:\n")
81 for _, a := range actions {
82 name, rest, _ := strings.Cut(a, ": ")
83 cmd, extra, _ := strings.Cut(rest, "\x00")
84 b.WriteString(" " + name + ":\n")
85 if extra != "" {
86 b.WriteString(" " + extra + "\n")
87 }
88 b.WriteString(" command: |\n")
89 for _, l := range strings.Split(cmd, "\n") {
90 b.WriteString(" " + l + "\n")
91 }
92 }
93 return b.String()
94}
95
96// result writes a result JSON to $CI_RESULT from a shell action.
97func result(json string) string {
98 return "cat > \"$CI_RESULT\" <<'EOF'\n" + json + "\nEOF"
99}
100
101func TestRunHappyPathAndEnv(t *testing.T) {
102 f := newFixture(t, "environment: { GREETING: hi }\n"+wf("a, b",
103 `a: echo "$GREETING $CI_ACTION $CI_ATTEMPT $CI_SHA $CI_HOST $TANGLED_REF_NAME $CI"
104echo oops >&2
105pwd`,
106 "b: test -d \"$CI_STATE\" && test -d \"$CI_ARTIFACTS\" && test \"$(cat \"$CI_INPUTS\")\" = \"{}\" && test \"$(basename \"$PWD\")\" = sub\x00dir: sub",
107 ))
108 if err := os.MkdirAll(filepath.Join(f.root, "sub"), 0o755); err != nil {
109 t.Fatal(err)
110 }
111 st, evs := f.run("")
112 if st.Status != StatusSuccess || !slices.Equal(executed(st), []string{"a", "b"}) {
113 t.Fatalf("status %s, ran %v, end %+v", st.Status, executed(st), st.End)
114 }
115 var out, errs []string
116 for _, e := range evs {
117 if e.Kind == lex.KindLog && e.Action == "a" {
118 if e.Stream == "stdout" {
119 out = append(out, e.Content)
120 } else {
121 errs = append(errs, e.Content)
122 }
123 }
124 }
125 // stdout and stderr interleave nondeterministically; each is ordered
126 if !slices.Equal(out, []string{"hi a 1 abc local main true", f.root}) || !slices.Equal(errs, []string{"oops"}) {
127 t.Errorf("stdout %q stderr %q", out, errs)
128 }
129 disk, err := Load(f.dir)
130 if err != nil || disk.Status != StatusSuccess || disk.Cursor != 2 {
131 t.Errorf("disk state %+v %v", disk, err)
132 }
133}
134
135func TestNoResultFileFallsBackToExitCode(t *testing.T) {
136 f := newFixture(t, `
137engine: devenv
138steps: [a, b]
139actions: { a: { command: "exit 3" }, b: { command: "true" } }
140`)
141 st, evs := f.run("")
142 if st.Status != StatusFailure || !slices.Equal(executed(st), []string{"a"}) || st.History[0].ExitCode != 3 {
143 t.Fatalf("%+v", st)
144 }
145 var end lex.RunEvent
146 for _, e := range evs {
147 if e.Kind == lex.KindEnd {
148 end = e
149 }
150 }
151 if end.ExitCode == nil || *end.ExitCode != 3 || end.Status != lex.Failure {
152 t.Errorf("end event %+v", end)
153 }
154}
155
156func TestRoutingThroughResults(t *testing.T) {
157 f := newFixture(t, wf("test, plan, record",
158 "test: "+result(`{"version":1,"status":"success","next":{"action":"extra","then":"continue","inputs":{"n":7}},"outputs":{"x":1}}`),
159 `extra: test "$(jq -r .n "$CI_INPUTS")" = 7 && test -s "$CI_TRIGGER_RESULT"`,
160 "plan: "+result(`{"version":1,"status":"warning","summary":"careful","outputs":{"y":2}}`),
161 `record: test "$(jq -c . "$CI_STATE/outputs.json")" = '{"x":1,"y":2}'`,
162 ))
163 st, _ := f.run("")
164 if st.Status != StatusSuccess || !slices.Equal(executed(st), []string{"test", "extra", "plan", "record"}) {
165 t.Fatalf("status %s ran %v end %+v", st.Status, executed(st), st.End)
166 }
167}
168
169func TestDivertAndStop(t *testing.T) {
170 f := newFixture(t, wf("deploy, health, record",
171 "deploy: true",
172 "health: "+result(`{"version":1,"status":"failure","next":"undo"}`),
173 "undo: true",
174 "record: true",
175 ))
176 st, _ := f.run("")
177 if st.Status != StatusFailure || !slices.Equal(executed(st), []string{"deploy", "health", "undo"}) {
178 t.Fatalf("status %s ran %v", st.Status, executed(st))
179 }
180 if !strings.Contains(st.End.Detail, "before record") {
181 t.Errorf("detail %q", st.End.Detail)
182 }
183}
184
185func TestContractErrors(t *testing.T) {
186 cases := map[string]string{
187 "unknown field": result(`{"version":1,"status":"success","summray":"x"}`),
188 "undeclared next": result(`{"version":1,"status":"success","next":"nope"}`),
189 "bad version": result(`{"version":2,"status":"success"}`),
190 "exit 1 claims ok": result(`{"version":1,"status":"success"}`) + "\nexit 1",
191 "missing attach": result(`{"version":1,"status":"success","attachments":[{"path":"none.txt"}]}`),
192 "escaping attach": "touch \"$CI_ARTIFACTS/../x\"\n" + result(`{"version":1,"status":"success","attachments":[{"path":"../x"}]}`),
193 "outputs not obj": result(`{"version":1,"status":"success","outputs":[1]}`),
194 "not json": "echo nope > \"$CI_RESULT\"",
195 "bad then": result(`{"version":1,"status":"success","next":{"action":"b","then":"later"}}`),
196 }
197 for name, cmd := range cases {
198 t.Run(name, func(t *testing.T) {
199 f := newFixture(t, wf("a, b", "a: "+cmd, "b: true"))
200 st, evs := f.run("")
201 if st.Status != StatusFailure || len(st.History) != 1 {
202 t.Fatalf("status %s ran %v", st.Status, executed(st))
203 }
204 var r *lex.Result
205 for _, e := range evs {
206 if e.Kind == lex.KindResult {
207 r = e.Result
208 }
209 }
210 if r == nil || !strings.HasPrefix(r.Summary, "contract error") {
211 t.Errorf("result %+v", r)
212 }
213 })
214 }
215}
216
217func TestAttachmentOK(t *testing.T) {
218 f := newFixture(t, wf("a",
219 "a: mkdir -p \"$CI_ARTIFACTS/d\" && echo x > \"$CI_ARTIFACTS/d/p.ndjson\"\n"+
220 result(`{"version":1,"status":"success","attachments":[{"label":"probes","path":"d/p.ndjson","type":"ndjson"}]}`),
221 ))
222 st, _ := f.run("")
223 if st.Status != StatusSuccess {
224 t.Fatalf("%+v", st.End)
225 }
226}
227
228func TestLoopGuard(t *testing.T) {
229 f := newFixture(t, wf("a", "a: "+result(`{"version":1,"status":"success","next":"a"}`)))
230 f.opts.MaxTransitions = 5
231 st, _ := f.run("")
232 if st.Status != StatusFailure || st.End.Reason != lex.ReasonLimit || len(st.History) != 5 {
233 t.Fatalf("status %s end %+v execs %d", st.Status, st.End, len(st.History))
234 }
235}
236
237func TestStubUntilNoFollow(t *testing.T) {
238 wf := `
239engine: devenv
240steps: [test, deploy, health]
241actions:
242 test: { command: "true" }
243 deploy: { command: "touch $CI_RUN_DIR/deployed" }
244 health: { command: "exit 1" }
245 undo: { command: "true" }
246`
247 // --stub replaces the action: health "fails" via a fixture that routes to undo
248 f := newFixture(t, wf)
249 stub := filepath.Join(t.TempDir(), "degraded.json")
250 if err := os.WriteFile(stub, []byte(`{"version":1,"status":"failure","next":"undo"}`), 0o644); err != nil {
251 t.Fatal(err)
252 }
253 f.opts.Stubs = map[string]string{"health": stub}
254 st, _ := f.run("")
255 if !slices.Equal(executed(st), []string{"test", "deploy", "health", "undo"}) || !st.History[2].Stubbed || st.History[2].ExitCode != 1 {
256 t.Fatalf("ran %v %+v", executed(st), st.History)
257 }
258
259 // --until stops before deploy
260 f = newFixture(t, wf)
261 f.opts.Until = "deploy"
262 st, _ = f.run("")
263 if st.Status != StatusCancelled || !slices.Equal(executed(st), []string{"test"}) {
264 t.Fatalf("until: %s %v", st.Status, executed(st))
265 }
266 if _, err := os.Stat(filepath.Join(f.dir.Path(), "deployed")); err == nil {
267 t.Error("deploy ran")
268 }
269
270 // --no-follow runs one action and says what would happen
271 f = newFixture(t, wf)
272 f.opts.NoFollow = true
273 st, _ = f.run("test")
274 if !slices.Equal(executed(st), []string{"test"}) || !strings.Contains(st.End.Detail, "would run deploy") {
275 t.Fatalf("no-follow: %v %+v", executed(st), st.End)
276 }
277}
278
279func TestRunFromOffPathAction(t *testing.T) {
280 f := newFixture(t, `
281engine: devenv
282steps: [a, b]
283actions: { a: { command: "true" }, b: { command: "true" }, c: { command: "true" } }
284`)
285 st, _ := f.run("c")
286 // an off-path action started by hand stops after itself
287 if !slices.Equal(executed(st), []string{"c"}) || st.Status != StatusFailure {
288 t.Fatalf("%v %s %+v", executed(st), st.Status, st.End)
289 }
290 f = newFixture(t, `
291engine: devenv
292steps: [a, b]
293actions: { a: { command: "true" }, b: { command: "true" } }
294`)
295 st, _ = f.run("b")
296 if !slices.Equal(executed(st), []string{"b"}) || st.Status != StatusSuccess {
297 t.Fatalf("%v %s", executed(st), st.Status)
298 }
299}
300
301func TestCancel(t *testing.T) {
302 f := newFixture(t, `
303engine: devenv
304steps: [a, b]
305actions: { a: { command: "sleep 30" }, b: { command: "true" } }
306`)
307 e, err := Create(f.m, f.dir, Meta{Run: "r1", Workflow: "w", Repo: "repo", Kind: "manual", Host: "local", Root: f.root}, Bash{}, f.opts)
308 if err != nil {
309 t.Fatal(err)
310 }
311 defer e.Log.Close()
312 ctx, cancel := context.WithCancel(context.Background())
313 go func() {
314 for {
315 if s, _ := Load(f.dir); s != nil && len(s.History) == 1 {
316 cancel()
317 return
318 }
319 time.Sleep(5 * time.Millisecond)
320 }
321 }()
322 st, err := e.Run(ctx)
323 if err != nil {
324 t.Fatal(err)
325 }
326 if st.Status != StatusCancelled {
327 t.Fatalf("status %s", st.Status)
328 }
329}