e2e/ci_test.go

v1.43.0
gitbay/e2e/ci_test.go history · blame · raw

386 lines · 16714 bytes

  1package e2e
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"os"
  7	"os/exec"
  8	"path/filepath"
  9	"strings"
 10	"testing"
 11	"time"
 12)
 13
 14// runnerOnce processes at most one pending build with the given key.
 15func (i *instance) runnerOnce(t *testing.T, key string, extra ...string) string {
 16	t.Helper()
 17	opts := fmt.Sprintf("-p %d -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes",
 18		i.port, key, filepath.Join(i.sshDir, "known_hosts"))
 19	// -isolation none: these tests exercise claiming, logs, statuses and
 20	// cancellation, not the sandbox, and the suite must run on a machine
 21	// without podman. The isolation tests are in isolation_podman_test.go
 22	// and skip visibly when it is absent (#144).
 23	args := []string{"-once",
 24		"-remote", "git@127.0.0.1",
 25		"-ssh-opts", opts,
 26		"-isolation", "none",
 27		"-clone-base", fmt.Sprintf("ssh://git@127.0.0.1:%d", i.port),
 28		"-workdir", t.TempDir()}
 29	args = append(args, extra...)
 30	cmd := exec.Command(i.runner, args...)
 31	cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
 32	out, err := cmd.CombinedOutput()
 33	if err != nil {
 34		t.Fatalf("runner: %v\n%s", err, out)
 35	}
 36	return string(out)
 37}
 38
 39// A failed build mails the repo owner with the log tail; green builds
 40// stay silent.
 41func TestBuildFailureMail(t *testing.T) {
 42	t.Parallel()
 43	smtp := startFakeSMTP(t)
 44	inst := startInstanceWith(t, fmt.Sprintf(
 45		"[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr))
 46	inst.runner = buildRunner(t)
 47	aliceKey := inst.newKey(t, "alice")
 48	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub",
 49		"--email", "alice@example.test", "--verified")
 50	runnerKey := inst.newKey(t, "ci")
 51	inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin")
 52
 53	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
 54		t.Fatalf("repo create: %s", errOut)
 55	}
 56	work := t.TempDir()
 57	env := inst.gitEnv(aliceKey)
 58	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
 59	dir := filepath.Join(work, "w")
 60	os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
 61	os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
 62		"jobs:\n  ok:\n    steps:\n      - echo fine\n  broken:\n    steps:\n      - echo the dataset went stale && false\n"), 0o644)
 63	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
 64	mustGit(t, dir, env, "add", ".")
 65	mustGit(t, dir, env, "commit", "-q", "-m", "base")
 66	mustGit(t, dir, env, "push", "-q", "origin", "main")
 67
 68	inst.runnerOnce(t, runnerKey) // broken (sorts first)
 69	inst.runnerOnce(t, runnerKey) // ok
 70	mail := smtp.waitFor(t, "alice@example.test", "failed")
 71	if !strings.Contains(mail, "broken") || !strings.Contains(mail, "the dataset went stale") ||
 72		!strings.Contains(mail, "/alice/app/builds/") {
 73		t.Fatalf("failure mail missing detail:\n%s", mail)
 74	}
 75	// Only the failure mailed: no message mentions the green job.
 76	for _, m := range smtp.mailTo("alice@example.test") {
 77		if strings.Contains(m, "build") && strings.Contains(m, " ok ") && strings.Contains(m, "failed") == false {
 78			t.Fatalf("green build mailed:\n%s", m)
 79		}
 80	}
 81}
 82
 83func TestCI(t *testing.T) {
 84	t.Parallel()
 85	inst := startInstance(t)
 86	inst.runner = buildRunner(t)
 87	aliceKey := inst.newKey(t, "alice")
 88	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
 89	runnerKey := inst.newKey(t, "ci")
 90	inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin")
 91
 92	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
 93		t.Fatalf("repo create: %s", errOut)
 94	}
 95	work := t.TempDir()
 96	env := inst.gitEnv(aliceKey)
 97	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
 98	dir := filepath.Join(work, "w")
 99	os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
100	os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
101		"jobs:\n  ok:\n    steps:\n      - echo hello from $GITBAY_JOB\n  broken:\n    steps:\n      - \"false\"\n"), 0o644)
102	os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x\n"), 0o644)
103	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
104	mustGit(t, dir, env, "add", ".")
105	mustGit(t, dir, env, "commit", "-q", "-m", "base")
106	mustGit(t, dir, env, "push", "-q", "origin", "main")
107	sha := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
108
109	// The push queued one pending build per job, with pending statuses.
110	out, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
111	if !strings.Contains(out, "broken\tpending") || !strings.Contains(out, "ok\tpending") {
112		t.Fatalf("builds not queued:\n%s", out)
113	}
114	out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha)
115	if !strings.Contains(out, "ci/ok") || !strings.Contains(out, "pending") {
116		t.Fatalf("pending statuses missing:\n%s", out)
117	}
118
119	// Non-admins cannot claim jobs.
120	if _, _, code := inst.ssh(t, aliceKey, "", "runner", "next"); code != 4 {
121		t.Fatalf("non-admin claimed a build: exit %d", code)
122	}
123
124	// The runner processes both jobs ("broken" sorts first).
125	inst.runnerOnce(t, runnerKey)
126	inst.runnerOnce(t, runnerKey)
127
128	out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
129	if !strings.Contains(out, "ok\tsuccess") || !strings.Contains(out, "broken\tfailure") {
130		t.Fatalf("build outcomes wrong:\n%s", out)
131	}
132	// Logs captured the step output and the failure.
133	var okN, brokenN string
134	for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
135		f := strings.Split(l, "\t")
136		if f[1] == "ok" {
137			okN = f[0]
138		} else {
139			brokenN = f[0]
140		}
141	}
142	out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", okN)
143	if !strings.Contains(out, "hello from ok") {
144		t.Fatalf("ok log:\n%s", out)
145	}
146	out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", brokenN)
147	if !strings.Contains(out, "step 1/1 failed: exit 1") {
148		t.Fatalf("broken log:\n%s", out)
149	}
150	out, _, _ = inst.ssh(t, aliceKey, "", "build", "show", "alice/app", brokenN)
151	if !strings.Contains(out, "1/1 false (exit 1)") {
152		t.Fatalf("build show does not name the failed step:\n%s", out)
153	}
154	if out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", brokenN, "--step", "failed"); strings.Contains(out, "git clone") || !strings.Contains(out, "exit 1") {
155		t.Fatalf("build log --step failed:\n%s", out)
156	}
157	if _, body := inst.get(t, "/alice/app/builds/"+brokenN); !strings.Contains(body, `id="failed" open`) {
158		t.Fatalf("build page does not open the failed step:\n%s", body)
159	}
160	// Statuses resolved, with target URLs pointing at the build pages.
161	out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha, "--json")
162	if !strings.Contains(out, `"ci/ok","state":"success"`) && !strings.Contains(out, `"state":"success"`) {
163		t.Fatalf("status not success:\n%s", out)
164	}
165	if !strings.Contains(out, "/alice/app/builds/") {
166		t.Fatalf("status target url missing:\n%s", out)
167	}
168
169	// Web: list page and log page.
170	status, body := inst.get(t, "/alice/app/builds")
171	if status != 200 || !strings.Contains(body, "ok") || !strings.Contains(body, "failure") {
172		t.Fatalf("builds page: %d\n%s", status, body)
173	}
174	if _, body = inst.get(t, "/alice/app/builds/"+okN); !strings.Contains(body, "hello from ok") {
175		t.Fatalf("build log page:\n%s", body)
176	}
177
178	// --- secrets: stdin in, names-only out, injected into the build env ---
179	if _, errOut, code := inst.ssh(t, aliceKey, "hunter2\n", "repo", "secret", "set", "alice/app", "MY_TOKEN"); code != 0 {
180		t.Fatalf("secret set: %s", errOut)
181	}
182	if _, _, code := inst.ssh(t, aliceKey, "x\n", "repo", "secret", "set", "alice/app", "bad-name"); code != 2 {
183		t.Fatal("bad secret name accepted")
184	}
185	out, _, _ = inst.ssh(t, aliceKey, "", "repo", "secret", "list", "alice/app")
186	if !strings.Contains(out, "MY_TOKEN") || strings.Contains(out, "hunter2") {
187		t.Fatalf("secret list leaked or missed: %s", out)
188	}
189
190	// --- schedules and manual trigger ---
191	os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
192		"jobs:\n  usesecret:\n    steps:\n      - echo token=$MY_TOKEN\n  nightly:\n    schedule: \"0 6 * * 1\"\n    steps:\n      - echo scheduled ran\n"), 0o644)
193	mustGit(t, dir, env, "add", ".")
194	mustGit(t, dir, env, "commit", "-q", "-m", "secrets and schedule")
195	mustGit(t, dir, env, "push", "-q", "origin", "main")
196
197	// The push queued only the unscheduled job.
198	out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
199	if !strings.Contains(out, "usesecret\tpending") || strings.Contains(out, "nightly") {
200		t.Fatalf("scheduled job queued on push:\n%s", out)
201	}
202	inst.runnerOnce(t, runnerKey)
203	out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
204	usecretN := strings.Split(out, "\t")[0]
205	out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", usecretN)
206	if !strings.Contains(out, "token=hunter2") {
207		t.Fatalf("secret not injected:\n%s", out)
208	}
209	// The scheduled job runs on demand via trigger.
210	if _, errOut, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "nightly"); code != 0 {
211		t.Fatalf("trigger: %s", errOut)
212	}
213	if _, _, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "nosuch"); code != 3 {
214		t.Fatal("triggered a job that does not exist")
215	}
216	inst.runnerOnce(t, runnerKey)
217	out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
218	if !strings.Contains(out, "nightly\tsuccess") {
219		t.Fatalf("triggered build did not run:\n%s", out)
220	}
221	// Removing the secret stops injection.
222	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "secret", "remove", "alice/app", "MY_TOKEN"); code != 0 {
223		t.Fatal("secret remove failed")
224	}
225
226	// --- tag-triggered jobs ---
227	os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
228		"jobs:\n  test:\n    steps:\n      - echo branch build\n  publish:\n    tags: \"v*\"\n    steps:\n      - echo publishing $GITBAY_REF\n"), 0o644)
229	mustGit(t, dir, env, "add", ".")
230	mustGit(t, dir, env, "commit", "-q", "-m", "tag job")
231	mustGit(t, dir, env, "push", "-q", "origin", "main")
232	// The branch push queued only the branch job.
233	out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
234	if strings.Contains(out, "publish") {
235		t.Fatalf("tag job queued on branch push:\n%s", out)
236	}
237	// An annotated tag queues the tag job, with the peeled commit as sha.
238	mustGit(t, dir, env, "tag", "-a", "-m", "rel", "v1.0.0")
239	mustGit(t, dir, env, "push", "-q", "origin", "v1.0.0")
240	headSHA := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
241	out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
242	if !strings.Contains(out, "publish\tpending\t"+headSHA[:10]) || !strings.Contains(out, "v1.0.0") {
243		t.Fatalf("tag build missing or unpeeled:\n%s", out)
244	}
245	// A non-matching tag queues nothing.
246	mustGit(t, dir, env, "tag", "nightly-1")
247	mustGit(t, dir, env, "push", "-q", "origin", "nightly-1")
248	out2, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
249	if strings.Count(out2, "publish") != strings.Count(out, "publish") {
250		t.Fatalf("non-matching tag queued a build:\n%s", out2)
251	}
252	inst.runnerOnce(t, runnerKey) // branch "test" job
253	inst.runnerOnce(t, runnerKey) // tag "publish" job
254	out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
255	if !strings.Contains(out, "publish\tsuccess") {
256		t.Fatalf("tag build did not run:\n%s", out)
257	}
258	// schedule and tags together are refused.
259	os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
260		"jobs:\n  both:\n    schedule: \"0 6 * * *\"\n    tags: \"v*\"\n    steps: [echo x]\n"), 0o644)
261	mustGit(t, dir, env, "add", ".")
262	mustGit(t, dir, env, "commit", "-q", "-m", "both triggers")
263	mustGit(t, dir, env, "push", "-q", "origin", "main")
264	shaBoth := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
265	out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", shaBoth)
266	if !strings.Contains(out, "ci/config") || !strings.Contains(out, "failure") {
267		t.Fatalf("mutually exclusive triggers not refused:\n%s", out)
268	}
269
270	// A broken ci.yml surfaces as a failed ci/config status.
271	os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs: {bad name: {steps: [x]}}\n"), 0o644)
272	mustGit(t, dir, env, "add", ".")
273	mustGit(t, dir, env, "commit", "-q", "-m", "break config")
274	mustGit(t, dir, env, "push", "-q", "origin", "main")
275	sha2 := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
276	out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha2)
277	if !strings.Contains(out, "ci/config") || !strings.Contains(out, "failure") {
278		t.Fatalf("config failure status missing:\n%s", out)
279	}
280}
281
282// runnerJobs runs a runner with -jobs n until it has nothing left to do,
283// and returns its output. Unlike runnerOnce it is not bounded to one
284// build, so it is stopped when the queue drains.
285func (i *instance) runnerJobs(t *testing.T, key, repo string, jobs int) string {
286	t.Helper()
287	opts := fmt.Sprintf("-p %d -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes",
288		i.port, key, filepath.Join(i.sshDir, "known_hosts"))
289	cmd := exec.Command(i.runner,
290		"-jobs", fmt.Sprint(jobs),
291		"-poll", "200ms",
292		"-isolation", "none",
293		"-remote", "git@127.0.0.1",
294		"-ssh-opts", opts,
295		"-clone-base", fmt.Sprintf("ssh://git@127.0.0.1:%d", i.port),
296		"-workdir", t.TempDir())
297	cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
298	var buf bytes.Buffer
299	cmd.Stdout, cmd.Stderr = &buf, &buf
300	if err := cmd.Start(); err != nil {
301		t.Fatal(err)
302	}
303	defer func() { cmd.Process.Kill(); cmd.Wait() }()
304
305	// Wait for every queued build to leave the pending and running states.
306	deadline := time.Now().Add(60 * time.Second)
307	for time.Now().Before(deadline) {
308		out, _, code := i.ssh(t, key, "", "build", "list", repo, "--json")
309		if code == 0 && !strings.Contains(out, `"status":"pending"`) &&
310			!strings.Contains(out, `"status":"running"`) {
311			break
312		}
313		time.Sleep(200 * time.Millisecond)
314	}
315	return buf.String()
316}
317
318// -jobs N runs N builds at once. ClaimBuild has always been a single
319// transaction that selects and updates, so several workers claiming
320// together is safe; the runner simply never used more than one (#115).
321func TestRunnerConcurrentJobs(t *testing.T) {
322	t.Parallel()
323	inst := startInstance(t)
324	inst.runner = buildRunner(t)
325	aliceKey := inst.newKey(t, "alice")
326	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--admin")
327	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
328		t.Fatalf("repo create: %s", errOut)
329	}
330
331	// Four jobs, each sleeping longer than the poll interval, so serial
332	// execution and concurrent execution are distinguishable.
333	ci := "jobs:\n"
334	for _, name := range []string{"one", "two", "three", "four"} {
335		ci += fmt.Sprintf("  %s:\n    steps:\n      - sleep 1\n      - echo done-%s\n", name, name)
336	}
337	env := inst.gitEnv(aliceKey)
338	work := t.TempDir()
339	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
340	dir := filepath.Join(work, "w")
341	os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
342	os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(ci), 0o644)
343	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
344	mustGit(t, dir, env, "add", ".")
345	mustGit(t, dir, env, "commit", "-q", "-m", "ci")
346	mustGit(t, dir, env, "push", "-q", "origin", "main")
347
348	out := inst.runnerJobs(t, aliceKey, "alice/app", 4)
349
350	listing, _, code := inst.ssh(t, aliceKey, "", "build", "list", "alice/app", "--json")
351	if code != 0 {
352		t.Fatalf("build list failed:\n%s", out)
353	}
354	for _, name := range []string{"one", "two", "three", "four"} {
355		if !strings.Contains(listing, `"job":"`+name+`"`) {
356			t.Fatalf("%s never ran:\n%s\n%s", name, listing, out)
357		}
358	}
359	if strings.Contains(listing, `"status":"pending"`) || strings.Contains(listing, `"status":"running"`) {
360		t.Fatalf("builds did not finish:\n%s", listing)
361	}
362	// Overlap, asserted from the runner's own log rather than from wall
363	// clock: elapsed time also covers four concurrent clones and the
364	// polling this test does, and would make a slow machine look serial.
365	// Every build announces itself when it starts and again when it
366	// finishes, so concurrency is "a build started before the first one
367	// finished".
368	started, firstFinish := 0, -1
369	for _, line := range strings.Split(out, "\n") {
370		switch {
371		case strings.Contains(line, "alice/app"):
372			started++
373		case strings.Contains(line, ": success"), strings.Contains(line, ": failure"):
374			if firstFinish < 0 {
375				firstFinish = started
376			}
377		}
378	}
379	if started != 4 {
380		t.Fatalf("%d builds started, want 4:\n%s", started, out)
381	}
382	if firstFinish < 2 {
383		t.Errorf("only %d build(s) had started when the first finished; -jobs 4 ran them serially\n%s",
384			firstFinish, out)
385	}
386}