internal/control/runnerattach_test.go
253 lines · 9449 bytes
1package control
2
3import (
4 "bytes"
5 "strconv"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/protocol"
11 "gitbay.org/gitbay/internal/store"
12)
13
14// attachFixture: alice (not admin) owns alice/app with a build queued;
15// mallory (not admin) owns mallory/evil with an older build queued. Each
16// has a runner-scoped key. The Ctx polls as the given user with the given
17// key, which is what the SSH listener produces.
18type attachFixture struct {
19 st *store.Store
20 alice, mallory int64
21 aliceKey, malloryKey store.SSHKey
22 app, evil store.Repo
23 appBuild, evilBuild int64
24}
25
26func newAttachFixture(t *testing.T) attachFixture {
27 t.Helper()
28 st, err := store.Open(":memory:")
29 if err != nil {
30 t.Fatal(err)
31 }
32 t.Cleanup(func() { st.Close() })
33 if err := st.MigrateUp(); err != nil {
34 t.Fatal(err)
35 }
36 var f attachFixture
37 f.st = st
38 mk := func(name, fp string) (int64, store.SSHKey, store.Repo, string) {
39 uid, err := st.CreateUser(name, false)
40 if err != nil {
41 t.Fatal(err)
42 }
43 if err := st.AddSSHKey(uid, fp, "ssh-ed25519", []byte(fp), "runner"); err != nil {
44 t.Fatal(err)
45 }
46 k, _ := st.SSHKeyByFingerprint(fp)
47 repoName := map[string]string{"alice": "app", "mallory": "evil"}[name]
48 rid, err := st.CreateRepo("user", uid, repoName, "public")
49 if err != nil {
50 t.Fatal(err)
51 }
52 repo, _ := st.RepoByID(rid)
53 return uid, k, repo, repoName
54 }
55 f.mallory, f.malloryKey, f.evil, _ = mk("mallory", "SHA256:mallory")
56 f.alice, f.aliceKey, f.app, _ = mk("alice", "SHA256:alice")
57 // mallory's build is older, so an unrestricted claim would take it.
58 f.evilBuild, err = st.CreateBuild(f.evil.ID, "unit", "aaa111", "main", "[]", "", "", true)
59 if err != nil {
60 t.Fatal(err)
61 }
62 f.appBuild, err = st.CreateBuild(f.app.ID, "unit", "bbb222", "main", "[]", "", "", true)
63 if err != nil {
64 t.Fatal(err)
65 }
66 return f
67}
68
69func (f attachFixture) ctx(uid int64, key store.SSHKey, admin bool) (*Ctx, *bytes.Buffer) {
70 var out bytes.Buffer
71 name := "alice"
72 if uid == f.mallory {
73 name = "mallory"
74 }
75 return &Ctx{
76 User: store.User{ID: uid, Username: name, IsAdmin: admin},
77 Scope: key.Scope,
78 Source: key.Fingerprint,
79 Store: f.st,
80 Cfg: config.Config{Server: config.Server{Root: "/nonexistent", SiteURL: "https://x.test"}},
81 Stdin: strings.NewReader(""),
82 Stdout: &out,
83 Stderr: &out,
84 }, &out
85}
86
87// A runner key with no attachment claims nothing, whatever is queued.
88func TestRunnerNextUnattachedClaimsNothing(t *testing.T) {
89 f := newAttachFixture(t)
90 c, out := f.ctx(f.alice, f.aliceKey, false)
91 if code := runRunnerNext(c, nil); code != protocol.ExitOK || !strings.Contains(out.String(), "no pending builds") {
92 t.Fatalf("exit %d: %s", code, out.String())
93 }
94 b, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild)
95 if b.Status != "pending" {
96 t.Fatalf("unattached key claimed a build: %s", b.Status)
97 }
98}
99
100// An attached key claims its repository's build and not the older one
101// queued elsewhere; naming a repository outside the attachments is refused.
102func TestRunnerNextAttachedClaimsOwnRepoOnly(t *testing.T) {
103 f := newAttachFixture(t)
104 if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil {
105 t.Fatal(err)
106 }
107 c, out := f.ctx(f.alice, f.aliceKey, false)
108 if code := runRunnerNext(c, nil); code != protocol.ExitOK || !strings.Contains(out.String(), "alice/app") {
109 t.Fatalf("exit %d: %s", code, out.String())
110 }
111 if b, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild); b.Status != "pending" {
112 t.Fatalf("mallory's build was touched: %s", b.Status)
113 }
114 c, out = f.ctx(f.alice, f.aliceKey, false)
115 if code := runRunnerNext(c, []string{"mallory/evil"}); code != protocol.ExitDenied {
116 t.Fatalf("naming an unattached repo: exit %d, want %d: %s", code, protocol.ExitDenied, out.String())
117 }
118}
119
120// The heartbeat is recorded against the key, and admin runners shows it
121// with its fingerprint and attachments. The column is the attachments even
122// when the key polled with a narrower -repos, and none when it has no
123// attachment at all.
124func TestAdminRunnersShowsKeyAndAttachments(t *testing.T) {
125 f := newAttachFixture(t)
126 for _, id := range []int64{f.app.ID, f.evil.ID} {
127 if err := f.st.AttachRunner(f.aliceKey.ID, id); err != nil {
128 t.Fatal(err)
129 }
130 }
131 c, _ := f.ctx(f.alice, f.aliceKey, false)
132 runRunnerNext(c, []string{"alice/app"})
133 c, _ = f.ctx(f.mallory, f.malloryKey, false)
134 runRunnerNext(c, nil)
135 admin, out := f.ctx(f.alice, f.aliceKey, true)
136 admin.Scope = "full"
137 if code := runAdminRunners(admin, nil); code != protocol.ExitOK {
138 t.Fatalf("admin runners: exit %d: %s", code, out.String())
139 }
140 if !strings.Contains(out.String(), "alice\tSHA256:alice\t") ||
141 !strings.Contains(out.String(), "\talice/app,mallory/evil\t") {
142 t.Fatalf("row lacks fingerprint or attachments:\n%s", out.String())
143 }
144 if !strings.Contains(out.String(), "\tnone\t") {
145 t.Fatalf("mallory's unattached runner key is not none:\n%s", out.String())
146 }
147}
148
149// The instance-admin bypass is the key, not the account: a runner-scoped
150// key on an admin account claims only what it is attached to.
151func TestRunnerNextAdminAccountRunnerKeyIsConfined(t *testing.T) {
152 f := newAttachFixture(t)
153 c, out := f.ctx(f.alice, f.aliceKey, true)
154 if code := runRunnerNext(c, nil); code != protocol.ExitOK || !strings.Contains(out.String(), "no pending builds") {
155 t.Fatalf("exit %d: %s", code, out.String())
156 }
157 for _, b := range []struct {
158 repo store.Repo
159 number int64
160 }{{f.app, f.appBuild}, {f.evil, f.evilBuild}} {
161 if got, _ := f.st.BuildByNumber(b.repo.ID, b.number); got.Status != "pending" {
162 t.Fatalf("%s claimed by an unattached runner key: %s", b.repo.Path(), got.Status)
163 }
164 }
165 if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil {
166 t.Fatal(err)
167 }
168 c, out = f.ctx(f.alice, f.aliceKey, true)
169 if code := runRunnerNext(c, nil); code != protocol.ExitOK || !strings.Contains(out.String(), "alice/app") {
170 t.Fatalf("attached claim: exit %d: %s", code, out.String())
171 }
172 if got, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild); got.Status != "pending" {
173 t.Fatalf("mallory's build was claimed: %s", got.Status)
174 }
175}
176
177// Untrusted builds are skipped unless the runner asks.
178func TestRunnerNextUntrustedFlag(t *testing.T) {
179 f := newAttachFixture(t)
180 if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil {
181 t.Fatal(err)
182 }
183 c, _ := f.ctx(f.alice, f.aliceKey, false)
184 runRunnerNext(c, nil) // takes the trusted build
185 fork, err := f.st.CreateBuild(f.app.ID, "unit", "ccc333", "refs/merge-requests/1/head", "[]", "", "", false)
186 if err != nil {
187 t.Fatal(err)
188 }
189 c, out := f.ctx(f.alice, f.aliceKey, false)
190 runRunnerNext(c, nil)
191 if !strings.Contains(out.String(), "no pending builds") {
192 t.Fatalf("fork head claimed without --untrusted: %s", out.String())
193 }
194 c, out = f.ctx(f.alice, f.aliceKey, false)
195 if code := runRunnerNext(c, []string{"--untrusted"}); code != protocol.ExitOK || !strings.Contains(out.String(), "alice/app") {
196 t.Fatalf("--untrusted did not claim the fork head: exit %d %s", code, out.String())
197 }
198 if b, _ := f.st.BuildByNumber(f.app.ID, fork); b.Status != "running" {
199 t.Fatalf("fork build is %s, want running", b.Status)
200 }
201}
202
203// runner done and runner log on a build whose repository is not attached
204// to the key are refused.
205func TestRunnerDoneRefusedForUnattachedBuild(t *testing.T) {
206 f := newAttachFixture(t)
207 if err := f.st.AttachRunner(f.malloryKey.ID, f.evil.ID); err != nil {
208 t.Fatal(err)
209 }
210 c, _ := f.ctx(f.mallory, f.malloryKey, false)
211 runRunnerNext(c, nil) // mallory holds her own build
212 evil, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild)
213 c, out := f.ctx(f.alice, f.aliceKey, false)
214 id := strconv.FormatInt(evil.ID, 10)
215 if code := runRunnerDone(c, []string{id, "success"}); code != protocol.ExitDenied {
216 t.Fatalf("done on an unattached build: exit %d, want %d: %s", code, protocol.ExitDenied, out.String())
217 }
218 c, out = f.ctx(f.alice, f.aliceKey, false)
219 if code := runRunnerLog(c, []string{id}); code != protocol.ExitDenied {
220 t.Fatalf("log on an unattached build: exit %d, want %d: %s", code, protocol.ExitDenied, out.String())
221 }
222 if b, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild); b.Status != "running" {
223 t.Fatalf("build was finished by a foreign key: %s", b.Status)
224 }
225}
226
227// admin runners forget drops one heartbeat row by fingerprint.
228func TestAdminRunnersForget(t *testing.T) {
229 f := newAttachFixture(t)
230 c, _ := f.ctx(f.alice, f.aliceKey, false)
231 runRunnerNext(c, nil)
232 admin, out := f.ctx(f.alice, f.aliceKey, true)
233 admin.Scope = "full"
234 if code := runAdminRunnersForget(admin, []string{"SHA256:nobody"}); code != protocol.ExitNotFound {
235 t.Fatalf("unknown fingerprint: exit %d, want %d: %s", code, protocol.ExitNotFound, out.String())
236 }
237 admin, out = f.ctx(f.alice, f.aliceKey, true)
238 admin.Scope = "full"
239 if code := runAdminRunnersForget(admin, []string{f.aliceKey.Fingerprint}); code != protocol.ExitOK {
240 t.Fatalf("forget: exit %d: %s", code, out.String())
241 }
242 admin, out = f.ctx(f.alice, f.aliceKey, true)
243 admin.Scope = "full"
244 runAdminRunners(admin, nil)
245 if strings.Contains(out.String(), f.aliceKey.Fingerprint) {
246 t.Fatalf("row still listed after forget:\n%s", out.String())
247 }
248 user, out := f.ctx(f.alice, f.aliceKey, false)
249 user.Scope = "full"
250 if code := runAdminRunnersForget(user, []string{f.aliceKey.Fingerprint}); code != protocol.ExitDenied {
251 t.Fatalf("non-admin forgot a runner: exit %d: %s", code, out.String())
252 }
253}