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}