e2e/ci_test.go
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}