e2e/runner_scope_test.go
114 lines · 4911 bytes
1package e2e
2
3import (
4 "os"
5 "os/exec"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// A runner names the repositories it will take builds for. Without that, any
12// runner claims whatever is next in the global queue, so a runner on a machine
13// that should only build one project ends up executing every repository's
14// steps — including those of a repository it has nothing to do with.
15func TestRunnerNextScopedToRepos(t *testing.T) {
16 inst := startInstance(t)
17 aliceKey := inst.newKey(t, "alice")
18 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
19 runnerKey := inst.newKey(t, "ci")
20 inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin")
21
22 // Two repositories, each with a build queued. "other" is pushed first, so
23 // an unscoped claim would take it.
24 for _, name := range []string{"other", "site"} {
25 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/"+name); code != 0 {
26 t.Fatalf("repo create %s: %s", name, errOut)
27 }
28 work := t.TempDir()
29 env := inst.gitEnv(aliceKey)
30 mustGit(t, work, env, "clone", inst.sshURL("alice/"+name), "w")
31 dir := filepath.Join(work, "w")
32 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
33 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
34 "jobs:\n "+name+":\n steps:\n - echo hi\n"), 0o644)
35 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
36 mustGit(t, dir, env, "add", ".")
37 mustGit(t, dir, env, "commit", "-q", "-m", "base")
38 mustGit(t, dir, env, "push", "-q", "origin", "main")
39 }
40
41 // Scoped to alice/site: takes the site build, not the older other build.
42 out, errOut, code := inst.ssh(t, runnerKey, "", "runner", "next", "alice/site", "--json")
43 if code != 0 {
44 t.Fatalf("runner next: %s", errOut)
45 }
46 if !strings.Contains(out, `"repo":"alice/site"`) {
47 t.Fatalf("scoped claim took the wrong repo:\n%s", out)
48 }
49
50 // That scope is now empty, though alice/other is still pending.
51 out, _, code = inst.ssh(t, runnerKey, "", "runner", "next", "alice/site", "--json")
52 if code != 0 || strings.Contains(out, `"repo":`) {
53 t.Fatalf("scoped claim took a build outside its scope:\n%s", out)
54 }
55
56 // An unscoped runner still takes it, so the default is unchanged.
57 out, _, code = inst.ssh(t, runnerKey, "", "runner", "next", "--json")
58 if code != 0 || !strings.Contains(out, `"repo":"alice/other"`) {
59 t.Fatalf("unscoped claim did not take the remaining build:\n%s", out)
60 }
61}
62
63// A runner host holds a key added with --scope runner: it can claim and
64// report builds and clone what it builds, and nothing else. Neither the
65// same account's full key nor the runner key reaches the wrong side, so a
66// key stolen from a build step cannot administer the instance (#92).
67func TestRunnerScopedKey(t *testing.T) {
68 inst := startInstance(t)
69 aliceKey := inst.newKey(t, "alice")
70 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
71 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
72 t.Fatalf("repo create: %s", errOut)
73 }
74
75 // ci is an ordinary account. Its runner key is self-added.
76 ciKey := inst.newKey(t, "ci")
77 inst.admin(t, "admin", "user", "create", "ci", "--key", ciKey+".pub")
78 runnerKey := inst.newKey(t, "ci-runner")
79 pub, _ := os.ReadFile(runnerKey + ".pub")
80 if _, errOut, code := inst.ssh(t, ciKey, string(pub), "keys", "add", "--scope", "runner"); code != 0 {
81 t.Fatalf("keys add --scope runner: %s", errOut)
82 }
83
84 // The runner key claims (nothing is queued, which is a success).
85 out, errOut, code := inst.ssh(t, runnerKey, "", "runner", "next", "--json")
86 if code != 0 || !strings.Contains(out, "{}") {
87 t.Fatalf("runner key cannot claim: exit %d\n%s%s", code, out, errOut)
88 }
89 // The runner key reaches no other command, whoami included.
90 for _, argv := range [][]string{{"whoami"}, {"repo", "create", "ci/evil"}, {"admin", "user", "list"}} {
91 if _, _, code := inst.ssh(t, runnerKey, "", argv...); code != 4 {
92 t.Fatalf("runner key ran %v: exit %d, want 4", argv, code)
93 }
94 }
95 // The account's full key is not a runner.
96 if _, _, code := inst.ssh(t, ciKey, "", "runner", "next"); code != 4 {
97 t.Fatalf("full key of a non-admin claimed a build: exit %d, want 4", code)
98 }
99
100 // Git: the runner key clones and cannot push.
101 work := t.TempDir()
102 env := inst.gitEnv(runnerKey)
103 mustGit(t, work, env, "clone", "-q", inst.sshURL("alice/app"), "w")
104 dir := filepath.Join(work, "w")
105 os.WriteFile(filepath.Join(dir, "f"), []byte("x\n"), 0o644)
106 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
107 mustGit(t, dir, env, "add", ".")
108 mustGit(t, dir, env, "commit", "-q", "-m", "x")
109 push := exec.Command("git", "push", "-q", "origin", "main")
110 push.Dir, push.Env = dir, env
111 if pushOut, err := push.CombinedOutput(); err == nil || !strings.Contains(string(pushOut), "scope") {
112 t.Fatalf("runner key pushed, or was refused for another reason: err=%v\n%s", err, pushOut)
113 }
114}