runner_test.go

  1package runner
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"io"
  7	"net/http"
  8	"net/http/httptest"
  9	"os"
 10	"os/exec"
 11	"path/filepath"
 12	"strings"
 13	"testing"
 14	"time"
 15
 16	"github.com/gorilla/websocket"
 17
 18	"git.kilimanjaro.io/ci/internal/engine"
 19	"git.kilimanjaro.io/ci/internal/eventlog"
 20	"git.kilimanjaro.io/ci/internal/ident"
 21	"git.kilimanjaro.io/ci/internal/lex"
 22)
 23
 24var ciBinary string
 25
 26func TestMain(m *testing.M) {
 27	dir, err := os.MkdirTemp("", "ci-runner-test")
 28	if err != nil {
 29		panic(err)
 30	}
 31	ciBinary = filepath.Join(dir, "ci")
 32	if out, err := exec.Command("go", "build", "-o", ciBinary, "../../cmd/ci").CombinedOutput(); err != nil {
 33		panic(string(out))
 34	}
 35	os.Symlink("ci", filepath.Join(dir, "ci-result"))
 36	code := m.Run()
 37	os.RemoveAll(dir)
 38	os.Exit(code)
 39}
 40
 41func sh(t *testing.T, dir string, args ...string) string {
 42	t.Helper()
 43	cmd := exec.Command(args[0], args[1:]...)
 44	cmd.Dir = dir
 45	cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t",
 46		"GIT_COMMITTER_EMAIL=t@t", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
 47	out, err := cmd.CombinedOutput()
 48	if err != nil {
 49		t.Fatalf("%v: %v\n%s", args, err, out)
 50	}
 51	return strings.TrimSpace(string(out))
 52}
 53
 54const deployWF = `
 55when: [{ event: push, branch: main }]
 56engine: devenv
 57steps: [build, deploy, check]
 58actions:
 59  build:  { command: "echo building; echo out > $CI_STATE/built" }
 60  deploy: { command: "echo deploying", gate: { prompt: "Deploy demo?" } }
 61  check:  { command: "echo checking" }
 62  undo:   { command: "cat $CI_STATE/built", manual: true }
 63`
 64
 65type fixture struct {
 66	t     *testing.T
 67	r     *Runner
 68	forge string
 69	sha   string
 70}
 71
 72func newFixture(t *testing.T, files map[string]string) *fixture {
 73	t.Helper()
 74	t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null")
 75	forge := t.TempDir()
 76	work := filepath.Join(t.TempDir(), "demo")
 77	os.MkdirAll(work, 0o755)
 78	sh(t, work, "git", "init", "-q", "-b", "main")
 79	for p, c := range files {
 80		os.MkdirAll(filepath.Dir(filepath.Join(work, p)), 0o755)
 81		os.WriteFile(filepath.Join(work, p), []byte(c), 0o644)
 82	}
 83	sh(t, work, "git", "add", "-A")
 84	sh(t, work, "git", "commit", "-qm", "init")
 85	sha := sh(t, work, "git", "rev-parse", "HEAD")
 86	sh(t, "", "git", "clone", "-q", "--bare", work, filepath.Join(forge, "demo.git"))
 87
 88	state := t.TempDir()
 89	log, err := eventlog.Open(filepath.Join(state, "runner.db"))
 90	if err != nil {
 91		t.Fatal(err)
 92	}
 93	t.Cleanup(func() { log.Close() })
 94	cfg := Config{RunsDir: filepath.Join(state, "runs"), CacheDir: filepath.Join(state, "cache"),
 95		Knot: "git.test", OwnerDid: "did:plc:me", CloneURL: filepath.Join(forge, "{repo}.git")}
 96	l := &Local{Engine: ciBinary, Shell: "bash", LocksDir: filepath.Join(state, "locks")}
 97	r, err := New(cfg, log, l, nil)
 98	if err != nil {
 99		t.Fatal(err)
100	}
101	return &fixture{t: t, r: r, forge: forge, sha: sha}
102}
103
104func (f *fixture) push(ref string, opts ...string) error {
105	return f.r.OnRefUpdate(context.Background(), lex.GitRefUpdate{
106		Repo: ident.RepoDID("git.test", "demo"), Ref: ref, OldSha: strings.Repeat("0", 40), NewSha: f.sha, PushOptions: opts,
107	})
108}
109
110// waitStatus polls until run reaches status.
111func (f *fixture) waitStatus(id, status string) Run {
112	f.t.Helper()
113	deadline := time.Now().Add(30 * time.Second)
114	for time.Now().Before(deadline) {
115		x, err := f.r.GetRun(id)
116		if err == nil && x.Status == status {
117			f.r.mu.Lock()
118			busy := f.r.tailing[id]
119			f.r.mu.Unlock()
120			if !busy {
121				return x
122			}
123		}
124		time.Sleep(50 * time.Millisecond)
125	}
126	x, _ := f.r.GetRun(id)
127	b, _ := os.ReadFile(filepath.Join(x.Dir, "segment-1.log"))
128	f.t.Fatalf("run %s: status %q, want %q\n%s", id, x.Status, status, b)
129	return Run{}
130}
131
132func (f *fixture) records() map[string][]json.RawMessage {
133	evs, _ := f.r.Log.List(0, 1000)
134	out := map[string][]json.RawMessage{}
135	for _, e := range evs {
136		out[e.Nsid] = append(out[e.Nsid], e.Event)
137	}
138	return out
139}
140
141func statuses(t *testing.T, recs []json.RawMessage, workflow string) []string {
142	t.Helper()
143	var out []string
144	for _, b := range recs {
145		var s lex.PipelineStatus
146		json.Unmarshal(b, &s)
147		if s.Workflow == workflow {
148			out = append(out, s.Status)
149		}
150	}
151	return out
152}
153
154func TestPushGateApproveAndLogs(t *testing.T) {
155	f := newFixture(t, map[string]string{
156		".tangled/workflows/deploy.yml": deployWF,
157		".tangled/workflows/bad.yml":    "engine: devenv\nsteps: [nope]\nactions: {}\n",
158		".tangled/workflows/other.yml":  "engine: nixery\nsteps: []\n",
159	})
160	if err := f.push("refs/heads/main"); err != nil {
161		t.Fatal(err)
162	}
163	runs, _ := f.r.ListRuns("demo", 10)
164	if len(runs) != 1 {
165		t.Fatalf("runs %+v", runs)
166	}
167	id := runs[0].ID
168	f.waitStatus(id, engine.StatusPaused)
169
170	recs := f.records()
171	var p lex.Pipeline
172	json.Unmarshal(recs[lex.PipelineNSID][0], &p)
173	if len(p.Workflows) != 2 || p.TriggerMetadata.Push.NewSha != f.sha || *p.TriggerMetadata.Repo.RepoDid != "did:web:git.test:demo" {
174		t.Errorf("pipeline %+v", p)
175	}
176	if got := statuses(t, recs[lex.PipelineStatusNSID], "bad"); len(got) != 1 || got[0] != lex.StatusFailed {
177		t.Errorf("bad workflow statuses %v", got)
178	}
179	if len(recs[lex.GateNSID]) != 1 || !strings.Contains(string(recs[lex.GateNSID][0]), "Deploy demo?") {
180		t.Errorf("gate records %s", recs[lex.GateNSID])
181	}
182
183	x, _ := f.r.GetRun(id)
184	if err := f.r.AnswerGate(context.Background(), x, gateAnswer{Action: "deploy", Actor: "did:plc:me"}); err != nil {
185		t.Fatal(err)
186	}
187	f.waitStatus(id, engine.StatusSuccess)
188	if got := statuses(t, f.records()[lex.PipelineStatusNSID], "deploy"); strings.Join(got, ",") != "pending,running,success" {
189		t.Errorf("deploy statuses %v", got)
190	}
191
192	// API: log.txt and /logs in upstream's LogLine format
193	srv := httptest.NewServer(f.r.Handler("tok"))
194	defer srv.Close()
195	req, _ := http.NewRequest("GET", srv.URL+"/api/runs/"+id+"/log.txt", nil)
196	req.Header.Set("Authorization", "Bearer tok")
197	resp, err := http.DefaultClient.Do(req)
198	if err != nil {
199		t.Fatal(err)
200	}
201	txt, _ := io.ReadAll(resp.Body)
202	resp.Body.Close()
203	for _, want := range []string{"== build (#1)", "building", "== gate approved", "checking", "== run success"} {
204		if !strings.Contains(string(txt), want) {
205			t.Errorf("log.txt lacks %q:\n%s", want, txt)
206		}
207	}
208	h := http.Header{"Authorization": {"Bearer tok"}}
209	conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/logs/git.test/"+x.Rkey+"/deploy", h)
210	if err != nil {
211		t.Fatal(err)
212	}
213	var lines []lex.LogLine
214	for {
215		_, msg, err := conn.ReadMessage()
216		if err != nil {
217			break
218		}
219		var l lex.LogLine
220		json.Unmarshal(msg, &l)
221		lines = append(lines, l)
222	}
223	conn.Close()
224	if len(lines) < 9 || lines[0].Kind != "control" || lines[0].StepStatus != "start" || lines[0].Content != "build" ||
225		lines[1].Kind != "data" || lines[1].Content != "building" || lines[1].StepId != 1 {
226		t.Errorf("log lines %+v", lines)
227	}
228
229	// unauthenticated API calls are refused
230	if resp, _ := http.Get(srv.URL + "/api/runs"); resp.StatusCode != 401 {
231		t.Errorf("unauthenticated: %d", resp.StatusCode)
232	}
233
234	// a manual action run against the finished run sees its $CI_STATE
235	child, err := f.r.ManualAction(context.Background(), x, "undo", "did:plc:me")
236	if err != nil {
237		t.Fatal(err)
238	}
239	cx := f.waitStatus(child, engine.StatusSuccess)
240	b, _ := os.ReadFile(engine.Dir(cx.Dir).Events())
241	if !strings.Contains(string(b), `"content":"out"`) {
242		t.Errorf("child did not read the parent's state:\n%s", b)
243	}
244	if _, err := f.r.ManualAction(context.Background(), x, "check", ""); err == nil {
245		t.Error("non-manual action accepted")
246	}
247	// the child's run doesn't touch the workflow's Tangled status
248	if got := statuses(t, f.records()[lex.PipelineStatusNSID], "deploy"); len(got) != 3 {
249		t.Errorf("child run published statuses: %v", got)
250	}
251}
252
253func TestPushFilters(t *testing.T) {
254	f := newFixture(t, map[string]string{".tangled/workflows/deploy.yml": deployWF})
255	f.push("refs/heads/feature")
256	f.push("refs/heads/main", "skip-ci")
257	f.r.OnRefUpdate(context.Background(), lex.GitRefUpdate{Repo: ident.RepoDID("git.test", "demo"), Ref: "refs/heads/main", NewSha: strings.Repeat("0", 40)})
258	if runs, _ := f.r.ListRuns("", 10); len(runs) != 0 {
259		t.Errorf("runs started: %+v", runs)
260	}
261	if err := f.r.OnRefUpdate(context.Background(), lex.GitRefUpdate{Repo: ident.RepoDID("other.host", "demo"), Ref: "refs/heads/main", NewSha: f.sha}); err == nil {
262		t.Error("accepted a repo on another forge")
263	}
264}
265
266func TestReconcile(t *testing.T) {
267	f := newFixture(t, map[string]string{".tangled/workflows/deploy.yml": "engine: devenv\nsteps: [a]\nactions: { a: { command: 'true' } }\n"})
268	if err := f.push("refs/heads/main"); err != nil {
269		t.Fatal(err)
270	}
271	runs, _ := f.r.ListRuns("", 10)
272	done := f.waitStatus(runs[0].ID, engine.StatusSuccess)
273
274	// a run the runner lost track of mid-way: its state says running, its
275	// process is gone. After a restart it is settled as failed.
276	lost := done
277	lost.ID, lost.Dir = "lost", filepath.Join(f.r.Cfg.RunsDir, "lost")
278	if err := os.CopyFS(lost.Dir, os.DirFS(done.Dir)); err != nil {
279		t.Fatal(err)
280	}
281	st, _ := engine.Load(engine.Dir(lost.Dir))
282	st.Status, st.End = engine.StatusRunning, nil
283	st.Meta.Run = "lost"
284	st.Save(engine.Dir(lost.Dir))
285	f.r.insertRun(Run{ID: "lost", Pipeline: done.Pipeline, Rkey: done.Rkey, Workflow: "lostwf", Repo: "demo", Dir: lost.Dir, Status: "running", Created: now(), Updated: now()})
286	f.r.updateRun("lost", `handle = ?, segment = 1`, "pid:999999999")
287
288	if err := f.r.Reconcile(context.Background()); err != nil {
289		t.Fatal(err)
290	}
291	x := f.waitStatus("lost", engine.StatusFailure)
292	st, _ = engine.Load(engine.Dir(x.Dir))
293	if !strings.Contains(st.End.Detail, "ended without finishing") {
294		t.Errorf("end %+v", st.End)
295	}
296	if got := statuses(t, f.records()[lex.PipelineStatusNSID], "lostwf"); len(got) != 1 || got[0] != lex.StatusFailed {
297		t.Errorf("lost statuses %v", got)
298	}
299}
300
301func TestSystemdCommand(t *testing.T) {
302	s := &Systemd{Engine: "/bin/ci", LocksDir: "/var/lib/ci/locks", CacheDir: "/var/lib/ci/cache",
303		Credentials: map[string]string{"age-identity": "/run/keys/ci-age", "ssh-key": "/run/keys/ci-ssh"}, Pasta: "/bin/pasta", Properties: []string{"MemoryMax=8G"}, Env: []string{"CI_CLONE_URL=git@h:{repo}"}}
304	argv := strings.Join(s.Command(Job{Run: "r1-deploy", Dir: "/var/lib/ci/runs/r1-deploy", Repo: "sovrn", Segment: 2, Args: []string{"--choose", "1"}}), " ")
305	for _, want := range []string{
306		"--unit=ci-r1-deploy-2", "--slice=ci.slice", "--uid=ci", "ProtectSystem=strict", "PrivateTmp=yes",
307		"ReadWritePaths=/var/lib/ci/runs/r1-deploy /var/lib/ci/cache/home/sovrn /var/lib/ci/locks /nix/var/nix/daemon-socket",
308		"LoadCredential=age-identity:/run/keys/ci-age", "LoadCredential=ssh-key:/run/keys/ci-ssh", "--setenv=HOME=/var/lib/ci/cache/home/sovrn", "MemoryMax=8G",
309		"-- /bin/pasta --config-net --quiet -t none -u none -T none -U none -- /bin/ci runner-exec --shell devenv --locks-dir /var/lib/ci/locks --choose 1 /var/lib/ci/runs/r1-deploy",
310	} {
311		if !strings.Contains(argv, want) {
312			t.Errorf("command lacks %q:\n%s", want, argv)
313		}
314	}
315	s.PastaHostPorts = []int{23231, 22}
316	if p := strings.Join(s.PastaCommand(), " "); p != "/bin/pasta --config-net --quiet -t none -u none -T 23231,22 -U none" {
317		t.Errorf("pasta: %s", p)
318	}
319}