e2e/ci_test.go

6b01ef3788b21e3a4c027a2afe7e7a5bda9ca000
gitbay/e2e/ci_test.go history · blame · raw

227 lines · 9865 bytes

  1package e2e
  2
  3import (
  4	"fmt"
  5	"os"
  6	"os/exec"
  7	"path/filepath"
  8	"strings"
  9	"testing"
 10)
 11
 12func buildRunner(t *testing.T) string {
 13	t.Helper()
 14	bin := filepath.Join(t.TempDir(), "gitbay-runner")
 15	cmd := exec.Command("go", "build", "-o", bin, "gitbay.org/gitbay/cmd/gitbay-runner")
 16	cmd.Dir = ".."
 17	if out, err := cmd.CombinedOutput(); err != nil {
 18		t.Fatalf("build gitbay-runner: %v\n%s", err, out)
 19	}
 20	return bin
 21}
 22
 23// runnerOnce processes at most one pending build with the given key.
 24func (i *instance) runnerOnce(t *testing.T, key string) string {
 25	t.Helper()
 26	opts := fmt.Sprintf("-p %d -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes",
 27		i.port, key, filepath.Join(i.sshDir, "known_hosts"))
 28	cmd := exec.Command(i.runner, "-once",
 29		"-remote", "git@127.0.0.1",
 30		"-ssh-opts", opts,
 31		"-clone-base", fmt.Sprintf("ssh://git@127.0.0.1:%d", i.port),
 32		"-workdir", t.TempDir())
 33	cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
 34	out, err := cmd.CombinedOutput()
 35	if err != nil {
 36		t.Fatalf("runner: %v\n%s", err, out)
 37	}
 38	return string(out)
 39}
 40
 41func TestCI(t *testing.T) {
 42	inst := startInstance(t)
 43	inst.runner = buildRunner(t)
 44	aliceKey := inst.newKey(t, "alice")
 45	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
 46	runnerKey := inst.newKey(t, "ci")
 47	inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin")
 48
 49	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
 50		t.Fatalf("repo create: %s", errOut)
 51	}
 52	work := t.TempDir()
 53	env := inst.gitEnv(aliceKey)
 54	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
 55	dir := filepath.Join(work, "w")
 56	os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
 57	os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
 58		"jobs:\n  ok:\n    steps:\n      - echo hello from $GITBAY_JOB\n  broken:\n    steps:\n      - \"false\"\n"), 0o644)
 59	os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x\n"), 0o644)
 60	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
 61	mustGit(t, dir, env, "add", ".")
 62	mustGit(t, dir, env, "commit", "-q", "-m", "base")
 63	mustGit(t, dir, env, "push", "-q", "origin", "main")
 64	sha := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
 65
 66	// The push queued one pending build per job, with pending statuses.
 67	out, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
 68	if !strings.Contains(out, "broken\tpending") || !strings.Contains(out, "ok\tpending") {
 69		t.Fatalf("builds not queued:\n%s", out)
 70	}
 71	out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha)
 72	if !strings.Contains(out, "ci/ok") || !strings.Contains(out, "pending") {
 73		t.Fatalf("pending statuses missing:\n%s", out)
 74	}
 75
 76	// Non-admins cannot claim jobs.
 77	if _, _, code := inst.ssh(t, aliceKey, "", "runner", "next"); code != 4 {
 78		t.Fatalf("non-admin claimed a build: exit %d", code)
 79	}
 80
 81	// The runner processes both jobs ("broken" sorts first).
 82	inst.runnerOnce(t, runnerKey)
 83	inst.runnerOnce(t, runnerKey)
 84
 85	out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
 86	if !strings.Contains(out, "ok\tsuccess") || !strings.Contains(out, "broken\tfailure") {
 87		t.Fatalf("build outcomes wrong:\n%s", out)
 88	}
 89	// Logs captured the step output and the failure.
 90	var okN, brokenN string
 91	for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
 92		f := strings.Split(l, "\t")
 93		if f[1] == "ok" {
 94			okN = f[0]
 95		} else {
 96			brokenN = f[0]
 97		}
 98	}
 99	out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", okN)
100	if !strings.Contains(out, "hello from ok") {
101		t.Fatalf("ok log:\n%s", out)
102	}
103	out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", brokenN)
104	if !strings.Contains(out, "step failed") {
105		t.Fatalf("broken log:\n%s", out)
106	}
107	// Statuses resolved, with target URLs pointing at the build pages.
108	out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha, "--json")
109	if !strings.Contains(out, `"ci/ok","state":"success"`) && !strings.Contains(out, `"state":"success"`) {
110		t.Fatalf("status not success:\n%s", out)
111	}
112	if !strings.Contains(out, "/alice/app/builds/") {
113		t.Fatalf("status target url missing:\n%s", out)
114	}
115
116	// Web: list page and log page.
117	status, body := inst.get(t, "/alice/app/builds")
118	if status != 200 || !strings.Contains(body, "ok") || !strings.Contains(body, "failure") {
119		t.Fatalf("builds page: %d\n%s", status, body)
120	}
121	if _, body = inst.get(t, "/alice/app/builds/"+okN); !strings.Contains(body, "hello from ok") {
122		t.Fatalf("build log page:\n%s", body)
123	}
124
125	// --- secrets: stdin in, names-only out, injected into the build env ---
126	if _, errOut, code := inst.ssh(t, aliceKey, "hunter2\n", "repo", "secret", "set", "alice/app", "MY_TOKEN"); code != 0 {
127		t.Fatalf("secret set: %s", errOut)
128	}
129	if _, _, code := inst.ssh(t, aliceKey, "x\n", "repo", "secret", "set", "alice/app", "bad-name"); code != 2 {
130		t.Fatal("bad secret name accepted")
131	}
132	out, _, _ = inst.ssh(t, aliceKey, "", "repo", "secret", "list", "alice/app")
133	if !strings.Contains(out, "MY_TOKEN") || strings.Contains(out, "hunter2") {
134		t.Fatalf("secret list leaked or missed: %s", out)
135	}
136
137	// --- schedules and manual trigger ---
138	os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
139		"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)
140	mustGit(t, dir, env, "add", ".")
141	mustGit(t, dir, env, "commit", "-q", "-m", "secrets and schedule")
142	mustGit(t, dir, env, "push", "-q", "origin", "main")
143
144	// The push queued only the unscheduled job.
145	out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
146	if !strings.Contains(out, "usesecret\tpending") || strings.Contains(out, "nightly") {
147		t.Fatalf("scheduled job queued on push:\n%s", out)
148	}
149	inst.runnerOnce(t, runnerKey)
150	out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
151	usecretN := strings.Split(out, "\t")[0]
152	out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", usecretN)
153	if !strings.Contains(out, "token=hunter2") {
154		t.Fatalf("secret not injected:\n%s", out)
155	}
156	// The scheduled job runs on demand via trigger.
157	if _, errOut, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "nightly"); code != 0 {
158		t.Fatalf("trigger: %s", errOut)
159	}
160	if _, _, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "nosuch"); code != 3 {
161		t.Fatal("triggered a job that does not exist")
162	}
163	inst.runnerOnce(t, runnerKey)
164	out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
165	if !strings.Contains(out, "nightly\tsuccess") {
166		t.Fatalf("triggered build did not run:\n%s", out)
167	}
168	// Removing the secret stops injection.
169	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "secret", "remove", "alice/app", "MY_TOKEN"); code != 0 {
170		t.Fatal("secret remove failed")
171	}
172
173	// --- tag-triggered jobs ---
174	os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
175		"jobs:\n  test:\n    steps:\n      - echo branch build\n  publish:\n    tags: \"v*\"\n    steps:\n      - echo publishing $GITBAY_REF\n"), 0o644)
176	mustGit(t, dir, env, "add", ".")
177	mustGit(t, dir, env, "commit", "-q", "-m", "tag job")
178	mustGit(t, dir, env, "push", "-q", "origin", "main")
179	// The branch push queued only the branch job.
180	out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
181	if strings.Contains(out, "publish") {
182		t.Fatalf("tag job queued on branch push:\n%s", out)
183	}
184	// An annotated tag queues the tag job, with the peeled commit as sha.
185	mustGit(t, dir, env, "tag", "-a", "-m", "rel", "v1.0.0")
186	mustGit(t, dir, env, "push", "-q", "origin", "v1.0.0")
187	headSHA := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
188	out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
189	if !strings.Contains(out, "publish\tpending\t"+headSHA[:10]) || !strings.Contains(out, "v1.0.0") {
190		t.Fatalf("tag build missing or unpeeled:\n%s", out)
191	}
192	// A non-matching tag queues nothing.
193	mustGit(t, dir, env, "tag", "nightly-1")
194	mustGit(t, dir, env, "push", "-q", "origin", "nightly-1")
195	out2, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
196	if strings.Count(out2, "publish") != strings.Count(out, "publish") {
197		t.Fatalf("non-matching tag queued a build:\n%s", out2)
198	}
199	inst.runnerOnce(t, runnerKey) // branch "test" job
200	inst.runnerOnce(t, runnerKey) // tag "publish" job
201	out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
202	if !strings.Contains(out, "publish\tsuccess") {
203		t.Fatalf("tag build did not run:\n%s", out)
204	}
205	// schedule and tags together are refused.
206	os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
207		"jobs:\n  both:\n    schedule: \"0 6 * * *\"\n    tags: \"v*\"\n    steps: [echo x]\n"), 0o644)
208	mustGit(t, dir, env, "add", ".")
209	mustGit(t, dir, env, "commit", "-q", "-m", "both triggers")
210	mustGit(t, dir, env, "push", "-q", "origin", "main")
211	shaBoth := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
212	out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", shaBoth)
213	if !strings.Contains(out, "ci/config") || !strings.Contains(out, "failure") {
214		t.Fatalf("mutually exclusive triggers not refused:\n%s", out)
215	}
216
217	// A broken ci.yml surfaces as a failed ci/config status.
218	os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs: {bad name: {steps: [x]}}\n"), 0o644)
219	mustGit(t, dir, env, "add", ".")
220	mustGit(t, dir, env, "commit", "-q", "-m", "break config")
221	mustGit(t, dir, env, "push", "-q", "origin", "main")
222	sha2 := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
223	out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha2)
224	if !strings.Contains(out, "ci/config") || !strings.Contains(out, "failure") {
225		t.Fatalf("config failure status missing:\n%s", out)
226	}
227}