Something went wrong. Try again.
Tangled-compatible personal CI server based on Nix
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354package engine
import ( "context" "os" "path/filepath" "slices" "strings" "testing" "time"
"git.kilimanjaro.io/ci/internal/lex" "git.kilimanjaro.io/ci/internal/manifest" "git.kilimanjaro.io/ci/internal/runlog")
type fixture struct { t *testing.T root string dir Dir m *manifest.Manifest opts Options}
func newFixture(t *testing.T, wf string) *fixture { t.Helper() m, err := manifest.Parse("w", []byte(wf)) if err != nil { t.Fatal(err) } tmp := t.TempDir() root := filepath.Join(tmp, "repo") if err := os.MkdirAll(root, 0o755); err != nil { t.Fatal(err) } return &fixture{t: t, root: root, dir: Dir(filepath.Join(tmp, "run")), m: m}}
func (f *fixture) run(from string) (*State, []lex.RunEvent) { f.t.Helper() 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) if err != nil { f.t.Fatal(err) } var st *State if from == "" { st, err = e.Run(context.Background()) } else { st, err = e.RunFrom(context.Background(), from) } e.Log.Close() if err != nil { f.t.Fatal(err) } b, err := os.Open(f.dir.Events()) if err != nil { f.t.Fatal(err) } defer b.Close() evs, err := runlog.Read(b) if err != nil { f.t.Fatal(err) } return st, evs}
func executed(st *State) []string { var out []string for _, x := range st.History { out = append(out, x.Action) } return out}
// wf builds a manifest with block-scalar commands (YAML folds newlines in// quoted strings). Each action is "name: command"; extra is appended to the// action's mapping (e.g. "dir: sub").func wf(steps string, actions ...string) string { var b strings.Builder b.WriteString("engine: devenv\nsteps: [" + steps + "]\nactions:\n") for _, a := range actions { name, rest, _ := strings.Cut(a, ": ") cmd, extra, _ := strings.Cut(rest, "\x00") b.WriteString(" " + name + ":\n") if extra != "" { b.WriteString(" " + extra + "\n") } b.WriteString(" command: |\n") for _, l := range strings.Split(cmd, "\n") { b.WriteString(" " + l + "\n") } } return b.String()}
// result writes a result JSON to $CI_RESULT from a shell action.func result(json string) string { return "cat > \"$CI_RESULT\" <<'EOF'\n" + json + "\nEOF"}
func TestRunHappyPathAndEnv(t *testing.T) { f := newFixture(t, "environment: { GREETING: hi }\n"+wf("a, b", `a: echo "$GREETING $CI_ACTION $CI_ATTEMPT $CI_SHA $CI_HOST $TANGLED_REF_NAME $CI"echo oops >&2pwd`, "b: test -d \"$CI_STATE\" && test -d \"$CI_ARTIFACTS\" && test \"$(cat \"$CI_INPUTS\")\" = \"{}\" && test \"$(basename \"$PWD\")\" = sub\x00dir: sub", )) if err := os.MkdirAll(filepath.Join(f.root, "sub"), 0o755); err != nil { t.Fatal(err) } st, evs := f.run("") if st.Status != StatusSuccess || !slices.Equal(executed(st), []string{"a", "b"}) { t.Fatalf("status %s, ran %v, end %+v", st.Status, executed(st), st.End) } var out, errs []string for _, e := range evs { if e.Kind == lex.KindLog && e.Action == "a" { if e.Stream == "stdout" { out = append(out, e.Content) } else { errs = append(errs, e.Content) } } } // stdout and stderr interleave nondeterministically; each is ordered if !slices.Equal(out, []string{"hi a 1 abc local main true", f.root}) || !slices.Equal(errs, []string{"oops"}) { t.Errorf("stdout %q stderr %q", out, errs) } disk, err := Load(f.dir) if err != nil || disk.Status != StatusSuccess || disk.Cursor != 2 { t.Errorf("disk state %+v %v", disk, err) }}
func TestRunnerEnvSurvivesLeftoversDont(t *testing.T) { orig := osEnviron t.Cleanup(func() { osEnviron = orig }) osEnviron = func() []string { return append(orig(), "CI_SELF_HOST=infra.test", "CI_SSH_KNOWN_HOSTS=/etc/kh", "CI_RUN_ID=enclosing", "CI_ANSWER=/old/answer", "CI_TOKEN=secret", "TANGLED_SHA=old") } f := newFixture(t, wf("a", `a: echo "$CI_SELF_HOST $CI_SSH_KNOWN_HOSTS $CI_RUN_ID ${CI_ANSWER:-none} ${CI_TOKEN:-none} $TANGLED_SHA"`)) st, evs := f.run("") if st.Status != StatusSuccess { t.Fatalf("status %s, end %+v", st.Status, st.End) } for _, e := range evs { if e.Kind == lex.KindLog && e.Stream == "stdout" { if want := "infra.test /etc/kh r1 none none abc"; e.Content != want { t.Errorf("got %q, want %q", e.Content, want) } return } } t.Error("no output")}
func TestNoResultFileFallsBackToExitCode(t *testing.T) { f := newFixture(t, `engine: devenvsteps: [a, b]actions: { a: { command: "exit 3" }, b: { command: "true" } }`) st, evs := f.run("") if st.Status != StatusFailure || !slices.Equal(executed(st), []string{"a"}) || st.History[0].ExitCode != 3 { t.Fatalf("%+v", st) } var end lex.RunEvent for _, e := range evs { if e.Kind == lex.KindEnd { end = e } } if end.ExitCode == nil || *end.ExitCode != 3 || end.Status != lex.Failure { t.Errorf("end event %+v", end) }}
func TestRoutingThroughResults(t *testing.T) { f := newFixture(t, wf("test, plan, record", "test: "+result(`{"version":1,"status":"success","next":{"action":"extra","then":"continue","inputs":{"n":7}},"outputs":{"x":1}}`), `extra: test "$(jq -r .n "$CI_INPUTS")" = 7 && test -s "$CI_TRIGGER_RESULT"`, "plan: "+result(`{"version":1,"status":"warning","summary":"careful","outputs":{"y":2}}`), `record: test "$(jq -c . "$CI_STATE/outputs.json")" = '{"x":1,"y":2}'`, )) st, _ := f.run("") if st.Status != StatusSuccess || !slices.Equal(executed(st), []string{"test", "extra", "plan", "record"}) { t.Fatalf("status %s ran %v end %+v", st.Status, executed(st), st.End) }}
func TestDivertAndStop(t *testing.T) { f := newFixture(t, wf("deploy, health, record", "deploy: true", "health: "+result(`{"version":1,"status":"failure","next":"undo"}`), "undo: true", "record: true", )) st, _ := f.run("") if st.Status != StatusFailure || !slices.Equal(executed(st), []string{"deploy", "health", "undo"}) { t.Fatalf("status %s ran %v", st.Status, executed(st)) } if !strings.Contains(st.End.Detail, "before record") { t.Errorf("detail %q", st.End.Detail) }}
func TestContractErrors(t *testing.T) { cases := map[string]string{ "unknown field": result(`{"version":1,"status":"success","summray":"x"}`), "undeclared next": result(`{"version":1,"status":"success","next":"nope"}`), "bad version": result(`{"version":2,"status":"success"}`), "exit 1 claims ok": result(`{"version":1,"status":"success"}`) + "\nexit 1", "missing attach": result(`{"version":1,"status":"success","attachments":[{"path":"none.txt"}]}`), "escaping attach": "touch \"$CI_ARTIFACTS/../x\"\n" + result(`{"version":1,"status":"success","attachments":[{"path":"../x"}]}`), "outputs not obj": result(`{"version":1,"status":"success","outputs":[1]}`), "not json": "echo nope > \"$CI_RESULT\"", "bad then": result(`{"version":1,"status":"success","next":{"action":"b","then":"later"}}`), } for name, cmd := range cases { t.Run(name, func(t *testing.T) { f := newFixture(t, wf("a, b", "a: "+cmd, "b: true")) st, evs := f.run("") if st.Status != StatusFailure || len(st.History) != 1 { t.Fatalf("status %s ran %v", st.Status, executed(st)) } var r *lex.Result for _, e := range evs { if e.Kind == lex.KindResult { r = e.Result } } if r == nil || !strings.HasPrefix(r.Summary, "contract error") { t.Errorf("result %+v", r) } }) }}
func TestAttachmentOK(t *testing.T) { f := newFixture(t, wf("a", "a: mkdir -p \"$CI_ARTIFACTS/d\" && echo x > \"$CI_ARTIFACTS/d/p.ndjson\"\n"+ result(`{"version":1,"status":"success","attachments":[{"label":"probes","path":"d/p.ndjson","type":"ndjson"}]}`), )) st, _ := f.run("") if st.Status != StatusSuccess { t.Fatalf("%+v", st.End) }}
func TestLoopGuard(t *testing.T) { f := newFixture(t, wf("a", "a: "+result(`{"version":1,"status":"success","next":"a"}`))) f.opts.MaxTransitions = 5 st, _ := f.run("") if st.Status != StatusFailure || st.End.Reason != lex.ReasonLimit || len(st.History) != 5 { t.Fatalf("status %s end %+v execs %d", st.Status, st.End, len(st.History)) }}
func TestStubUntilNoFollow(t *testing.T) { wf := `engine: devenvsteps: [test, deploy, health]actions: test: { command: "true" } deploy: { command: "touch $CI_RUN_DIR/deployed" } health: { command: "exit 1" } undo: { command: "true" }` // --stub replaces the action: health "fails" via a fixture that routes to undo f := newFixture(t, wf) stub := filepath.Join(t.TempDir(), "degraded.json") if err := os.WriteFile(stub, []byte(`{"version":1,"status":"failure","next":"undo"}`), 0o644); err != nil { t.Fatal(err) } f.opts.Stubs = map[string]string{"health": stub} st, _ := f.run("") if !slices.Equal(executed(st), []string{"test", "deploy", "health", "undo"}) || !st.History[2].Stubbed || st.History[2].ExitCode != 1 { t.Fatalf("ran %v %+v", executed(st), st.History) }
// --until stops before deploy f = newFixture(t, wf) f.opts.Until = "deploy" st, _ = f.run("") if st.Status != StatusCancelled || !slices.Equal(executed(st), []string{"test"}) { t.Fatalf("until: %s %v", st.Status, executed(st)) } if _, err := os.Stat(filepath.Join(f.dir.Path(), "deployed")); err == nil { t.Error("deploy ran") }
// --no-follow runs one action and says what would happen f = newFixture(t, wf) f.opts.NoFollow = true st, _ = f.run("test") if !slices.Equal(executed(st), []string{"test"}) || !strings.Contains(st.End.Detail, "would run deploy") { t.Fatalf("no-follow: %v %+v", executed(st), st.End) }}
func TestRunFromOffPathAction(t *testing.T) { f := newFixture(t, `engine: devenvsteps: [a, b]actions: { a: { command: "true" }, b: { command: "true" }, c: { command: "true" } }`) st, _ := f.run("c") // an off-path action started by hand stops after itself if !slices.Equal(executed(st), []string{"c"}) || st.Status != StatusFailure { t.Fatalf("%v %s %+v", executed(st), st.Status, st.End) } f = newFixture(t, `engine: devenvsteps: [a, b]actions: { a: { command: "true" }, b: { command: "true" } }`) st, _ = f.run("b") if !slices.Equal(executed(st), []string{"b"}) || st.Status != StatusSuccess { t.Fatalf("%v %s", executed(st), st.Status) }}
func TestCancel(t *testing.T) { f := newFixture(t, `engine: devenvsteps: [a, b]actions: { a: { command: "sleep 30" }, b: { command: "true" } }`) e, err := Create(f.m, f.dir, Meta{Run: "r1", Workflow: "w", Repo: "repo", Kind: "manual", Host: "local", Root: f.root}, Bash{}, f.opts) if err != nil { t.Fatal(err) } defer e.Log.Close() ctx, cancel := context.WithCancel(context.Background()) go func() { for { if s, _ := Load(f.dir); s != nil && len(s.History) == 1 { cancel() return } time.Sleep(5 * time.Millisecond) } }() st, err := e.Run(ctx) if err != nil { t.Fatal(err) } if st.Status != StatusCancelled { t.Fatalf("status %s", st.Status) }}