Commit de7be35a4e

de7be35a4e87f4beda3dc6b3f9bc42772f6f0cc4

parent: 67fca35eea

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-09 02:47 UTC

control: a runner key claims only the repositories it is attached to

runner next takes --untrusted; without it fork heads are skipped. runner
log and runner done refuse a build outside the key's attachments. The
heartbeat and admin runners are per key.

Ref #184
e2e/mrbuilds_test.go +1 −1
@@ -92,7 +92,7 @@ func TestForkMRHeadIsBuilt(t *testing.T) {
9292 }
9393
9494 // The claim carries no secrets for a head from another repository.
95 out, errOut, code := inst.ssh(t, runnerKey, "", "runner", "next", "alice/app", "--json")
95 out, errOut, code := inst.ssh(t, runnerKey, "", "runner", "next", "--untrusted", "alice/app", "--json")
9696 if code != 0 {
9797 t.Fatalf("runner next: %s", errOut)
9898 }
internal/control/admin.go +15 −1
@@ -459,6 +459,20 @@ func runAdminRunners(c *Ctx, args []string) int {
459459 if runners == nil {
460460 runners = []store.Runner{}
461461 }
462 for i := range runners {
463 if runners[i].Scope != "" {
464 continue
465 }
466 key, err := c.Store.SSHKeyByID(runners[i].KeyID)
467 if err != nil || key.Scope != "runner" {
468 continue // an admin key with no -repos: any
469 }
470 paths, err := c.Store.RunnerRepoPaths(runners[i].KeyID)
471 if err != nil {
472 return c.fail(protocol.ExitFailure, "%v", err)
473 }
474 runners[i].Scope = strings.Join(paths, ",")
475 }
462476 d := map[string]any{"queue": queue, "runners": runners}
463477 return c.emit(d, func(w io.Writer) {
464478 fmt.Fprintf(w, "queue: %d pending; last 24h: %d claimed, wait avg %ds max %ds, %d reaped\n",
@@ -472,7 +486,7 @@ func runAdminRunners(c *Ctx, args []string) int {
472486 if r.BuildNumber != 0 {
473487 held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt)
474488 }
475 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Username, r.LastSeen, scope, held)
489 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Username, r.Fingerprint, r.LastSeen, scope, held)
476490 }
477491 })
478492}
internal/control/build.go +75 −17
@@ -59,8 +59,8 @@ func init() {
5959 // dispatcher confines to these three commands and read-only git, or
6060 // an admin key, which a runner host should not hold (#92).
6161 register(Command{Path: []string{"runner", "next"},
62 Summary: "claim the oldest pending build (runner protocol)",
63 Usage: "runner next [<owner/name>...]", SSHOnly: true, Run: runRunnerNext})
62 Summary: "claim the oldest pending build this key may run (runner protocol)",
63 Usage: "runner next [--untrusted] [<owner/name>...]", SSHOnly: true, Run: runRunnerNext})
6464 register(Command{Path: []string{"runner", "log"},
6565 Summary: "append a build's log from stdin",
6666 Usage: "runner log <build-id>", SSHOnly: true, ReadsStdin: true, Run: runRunnerLog})
@@ -309,11 +309,29 @@ func runSecretList(c *Ctx, args []string) int {
309309 })
310310}
311311
312func requireRunner(c *Ctx) int {
312// runnerSession resolves the key behind a runner-protocol session. The
313// runner commands are SSHOnly, so Source is the key's fingerprint. An
314// admin key is accepted so an operator can rotate at their own pace; a
315// runner host should hold a key added with --scope runner.
316func runnerSession(c *Ctx) (store.SSHKey, int) {
313317 if c.Scope != "runner" && !c.User.IsAdmin {
314 return c.fail(protocol.ExitDenied, "runner commands need a key added with --scope runner")
318 return store.SSHKey{}, c.fail(protocol.ExitDenied, "runner commands need a key added with --scope runner")
315319 }
316 return -1
320 key, err := c.Store.SSHKeyByFingerprint(c.Source)
321 if err != nil {
322 return store.SSHKey{}, c.fail(protocol.ExitDenied, "runner commands need an SSH key session")
323 }
324 return key, -1
325}
326
327// runnerMayBuild reports whether a runner session may act on a
328// repository's builds: an admin user may on any, a runner key on the
329// repositories it is attached to (#184).
330func runnerMayBuild(c *Ctx, key store.SSHKey, repoID int64) (bool, error) {
331 if c.User.IsAdmin {
332 return true, nil
333 }
334 return c.Store.RunnerAttached(key.ID, repoID)
317335}
318336
319337// maxOrphanSkip bounds how many claimed builds runRunnerNext will find
@@ -327,26 +345,52 @@ func requireRunner(c *Ctx) int {
327345const maxOrphanSkip = 50
328346
329347func runRunnerNext(c *Ctx, args []string) int {
330 if code := requireRunner(c); code >= 0 {
348 key, code := runnerSession(c)
349 if code >= 0 {
331350 return code
332351 }
333 // A runner may limit itself to named repositories. The operator chooses
334 // what a given runner executes by how they start it; this is scoping the
335 // runner asks for, not an ACL the server holds over it.
352 f, err := parseFlags(args, flagSpec{Bools: []string{"--untrusted"}, MaxPos: -1,
353 Usage: "runner next [--untrusted] [<owner/name>...]"})
354 if err != nil {
355 return c.fail(protocol.ExitUsage, "%v", err)
356 }
357 // The candidate set. An admin key claims from any repository, narrowed
358 // by the names given. A runner key claims from the repositories it is
359 // attached to; a name outside them is refused, not ignored, so a
360 // misconfigured runner says so instead of idling.
336361 var repoIDs []int64
337 for _, arg := range args {
362 for _, arg := range f.Pos {
338363 repo, code := resolveRepo(c, arg, policy.CanRead)
339364 if code >= 0 {
340365 return code
341366 }
367 ok, err := runnerMayBuild(c, key, repo.ID)
368 if err != nil {
369 return c.fail(protocol.ExitFailure, "%v", err)
370 }
371 if !ok {
372 return c.fail(protocol.ExitDenied, "this key is not attached to %s", repo.Path())
373 }
342374 repoIDs = append(repoIDs, repo.ID)
343375 }
376 if !c.User.IsAdmin && len(repoIDs) == 0 {
377 repoIDs, err = c.Store.RunnerRepoIDs(key.ID)
378 if err != nil {
379 return c.fail(protocol.ExitFailure, "%v", err)
380 }
381 if len(repoIDs) == 0 {
382 // Nothing attached: nothing to claim. Still a heartbeat, so
383 // admin runners shows the key polling.
384 c.Store.TouchRunner(key.ID, c.User.ID, "", 0)
385 return c.emit(map[string]any{}, func(w io.Writer) { fmt.Fprintln(w, "no pending builds") })
386 }
387 }
388 untrusted := f.Has("--untrusted")
344389 var b store.Build
345390 var repo store.Repo
346391 var ok bool
347 var err error
348392 for attempt := 0; attempt < maxOrphanSkip; attempt++ {
349 b, ok, err = c.Store.ClaimBuild(repoIDs)
393 b, ok, err = c.Store.ClaimBuild(repoIDs, untrusted)
350394 if err != nil {
351395 return c.fail(protocol.ExitFailure, "%v", err)
352396 }
@@ -376,7 +420,7 @@ func runRunnerNext(c *Ctx, args []string) int {
376420 b, ok = store.Build{}, false
377421 }
378422 // The poll itself is the runner's heartbeat: admin runners reads it.
379 c.Store.TouchRunner(c.User.ID, strings.Join(args, ","), b.ID)
423 c.Store.TouchRunner(key.ID, c.User.ID, strings.Join(f.Pos, ","), b.ID)
380424 if !ok {
381425 return c.emit(map[string]any{}, func(w io.Writer) { fmt.Fprintln(w, "no pending builds") })
382426 }
@@ -408,7 +452,8 @@ func runRunnerNext(c *Ctx, args []string) int {
408452}
409453
410454func runRunnerLog(c *Ctx, args []string) int {
411 if code := requireRunner(c); code >= 0 {
455 key, code := runnerSession(c)
456 if code >= 0 {
412457 return code
413458 }
414459 if len(args) != 1 {
@@ -418,6 +463,13 @@ func runRunnerLog(c *Ctx, args []string) int {
418463 if err != nil {
419464 return c.fail(protocol.ExitUsage, "bad build id %q", args[0])
420465 }
466 if b, err := c.Store.BuildByID(id); err != nil {
467 return c.fail(protocol.ExitNotFound, "no build %d", id)
468 } else if ok, err := runnerMayBuild(c, key, b.RepoID); err != nil {
469 return c.fail(protocol.ExitFailure, "%v", err)
470 } else if !ok {
471 return c.fail(protocol.ExitDenied, "this key is not attached to the build's repository")
472 }
421473 // Stream stdin into the log in chunks so long builds appear live. An
422474 // append that fails drops its chunk and the loop keeps draining: ending
423475 // the session here breaks the runner's pipe, and a broken pipe is how a
@@ -478,7 +530,8 @@ func runRunnerLog(c *Ctx, args []string) int {
478530}
479531
480532func runRunnerDone(c *Ctx, args []string) int {
481 if code := requireRunner(c); code >= 0 {
533 key, code := runnerSession(c)
534 if code >= 0 {
482535 return code
483536 }
484537 if len(args) != 2 || (args[1] != "success" && args[1] != "failure") {
@@ -492,10 +545,15 @@ func runRunnerDone(c *Ctx, args []string) int {
492545 if err != nil {
493546 return c.fail(protocol.ExitNotFound, "no build %d", id)
494547 }
548 if ok, err := runnerMayBuild(c, key, b.RepoID); err != nil {
549 return c.fail(protocol.ExitFailure, "%v", err)
550 } else if !ok {
551 return c.fail(protocol.ExitDenied, "this key is not attached to the build's repository")
552 }
495553 // Cancelled underneath the runner: its report is late, not wrong.
496554 // The row, the status and the log were settled by the cancel.
497555 if b.Status == "cancelled" {
498 c.Store.RunnerDone(c.User.ID)
556 c.Store.RunnerDone(key.ID)
499557 return c.emit(map[string]any{"build": b.Number, "status": "cancelled"}, func(w io.Writer) {
500558 fmt.Fprintf(w, "build %d was cancelled\n", b.Number)
501559 })
@@ -503,7 +561,7 @@ func runRunnerDone(c *Ctx, args []string) int {
503561 if err := c.Store.FinishBuild(id, args[1]); err != nil {
504562 return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err)
505563 }
506 c.Store.RunnerDone(c.User.ID)
564 c.Store.RunnerDone(key.ID)
507565 repo, err := c.Store.RepoByID(b.RepoID)
508566 if err != nil {
509567 return c.fail(protocol.ExitFailure, "%v", err)
internal/control/build_test.go +1 −1
@@ -582,7 +582,7 @@ func TestQueueBranchBuildsSameTreeReusesSuccess(t *testing.T) {
582582 if len(builds) != 1 {
583583 t.Fatalf("first commit queued %d builds, want 1", len(builds))
584584 }
585 if _, ok, err := st.ClaimBuild([]int64{repo.ID}); err != nil || !ok {
585 if _, ok, err := st.ClaimBuild([]int64{repo.ID}, false); err != nil || !ok {
586586 t.Fatalf("claim: ok=%v err=%v", ok, err)
587587 }
588588 if err := st.FinishBuild(builds[0].ID, "success"); err != nil {
internal/control/runnerattach_test.go added +187
@@ -0,0 +1,187 @@
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.
122func TestAdminRunnersShowsKeyAndAttachments(t *testing.T) {
123 f := newAttachFixture(t)
124 if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil {
125 t.Fatal(err)
126 }
127 c, _ := f.ctx(f.alice, f.aliceKey, false)
128 runRunnerNext(c, nil)
129 admin, out := f.ctx(f.alice, f.aliceKey, true)
130 admin.Scope = "full"
131 if code := runAdminRunners(admin, nil); code != protocol.ExitOK {
132 t.Fatalf("admin runners: exit %d: %s", code, out.String())
133 }
134 if !strings.Contains(out.String(), "alice\tSHA256:alice\t") || !strings.Contains(out.String(), "\talice/app\t") {
135 t.Fatalf("row lacks fingerprint or attachments:\n%s", out.String())
136 }
137}
138
139// Untrusted builds are skipped unless the runner asks.
140func TestRunnerNextUntrustedFlag(t *testing.T) {
141 f := newAttachFixture(t)
142 if err := f.st.AttachRunner(f.aliceKey.ID, f.app.ID); err != nil {
143 t.Fatal(err)
144 }
145 c, _ := f.ctx(f.alice, f.aliceKey, false)
146 runRunnerNext(c, nil) // takes the trusted build
147 fork, err := f.st.CreateBuild(f.app.ID, "unit", "ccc333", "refs/merge-requests/1/head", "[]", "", "", false)
148 if err != nil {
149 t.Fatal(err)
150 }
151 c, out := f.ctx(f.alice, f.aliceKey, false)
152 runRunnerNext(c, nil)
153 if !strings.Contains(out.String(), "no pending builds") {
154 t.Fatalf("fork head claimed without --untrusted: %s", out.String())
155 }
156 c, out = f.ctx(f.alice, f.aliceKey, false)
157 if code := runRunnerNext(c, []string{"--untrusted"}); code != protocol.ExitOK || !strings.Contains(out.String(), "alice/app") {
158 t.Fatalf("--untrusted did not claim the fork head: exit %d %s", code, out.String())
159 }
160 if b, _ := f.st.BuildByNumber(f.app.ID, fork); b.Status != "running" {
161 t.Fatalf("fork build is %s, want running", b.Status)
162 }
163}
164
165// runner done and runner log on a build whose repository is not attached
166// to the key are refused.
167func TestRunnerDoneRefusedForUnattachedBuild(t *testing.T) {
168 f := newAttachFixture(t)
169 if err := f.st.AttachRunner(f.malloryKey.ID, f.evil.ID); err != nil {
170 t.Fatal(err)
171 }
172 c, _ := f.ctx(f.mallory, f.malloryKey, false)
173 runRunnerNext(c, nil) // mallory holds her own build
174 evil, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild)
175 c, out := f.ctx(f.alice, f.aliceKey, false)
176 id := strconv.FormatInt(evil.ID, 10)
177 if code := runRunnerDone(c, []string{id, "success"}); code != protocol.ExitDenied {
178 t.Fatalf("done on an unattached build: exit %d, want %d: %s", code, protocol.ExitDenied, out.String())
179 }
180 c, out = f.ctx(f.alice, f.aliceKey, false)
181 if code := runRunnerLog(c, []string{id}); code != protocol.ExitDenied {
182 t.Fatalf("log on an unattached build: exit %d, want %d: %s", code, protocol.ExitDenied, out.String())
183 }
184 if b, _ := f.st.BuildByNumber(f.evil.ID, f.evilBuild); b.Status != "running" {
185 t.Fatalf("build was finished by a foreign key: %s", b.Status)
186 }
187}
internal/control/runnernext_test.go +6 −2
@@ -14,12 +14,16 @@ import (
1414)
1515
1616// runnerCtx builds a Ctx good enough to run runRunnerNext directly: an
17// admin user (requireRunner accepts admin as well as scope "runner"), a
17// admin user (runnerSession accepts admin as well as scope "runner"), a
1818// server root that matches where the test's bare repo lives.
1919func runnerCtx(st *store.Store, uid int64, root string) (*Ctx, *bytes.Buffer) {
2020 var out bytes.Buffer
21 fp := fmt.Sprintf("SHA256:runner-%d", uid)
22 st.AddSSHKey(uid, fp, "ssh-ed25519", []byte(fp), "full") // ErrDuplicateKey on reuse is fine
2123 c := &Ctx{
2224 User: store.User{ID: uid, Username: "ci", IsAdmin: true},
25 Scope: "full",
26 Source: fp,
2327 Store: st,
2428 Cfg: config.Config{Server: config.Server{Root: root, SiteURL: "https://x.test"}},
2529 Stdin: strings.NewReader(""),
@@ -224,7 +228,7 @@ func TestRunnerLogMarksStreamClosed(t *testing.T) {
224228 if _, err := st.CreateBuild(repo.ID, "unit", strings.Repeat("a", 40), "main", "[]", "", "", true); err != nil {
225229 t.Fatal(err)
226230 }
227 b, ok, err := st.ClaimBuild([]int64{repo.ID})
231 b, ok, err := st.ClaimBuild([]int64{repo.ID}, false)
228232 if err != nil || !ok {
229233 t.Fatalf("claim: %v", err)
230234 }