Commit ad0bf9770a

ad0bf9770a27e5204e6c40e0b4f2787c4d7e406e

parent: de7be35a4e

Verified · cmc

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

control, cli: repo runner add, list, remove

Ref #184
cmd/gitbay/main.go +5
@@ -439,6 +439,11 @@ func repoCmd() *cobra.Command {
439439 pass("list", "list deploy keys", passOpts{server: []string{"repo", "deploy-key", "list"}, needsRepo: true}),
440440 pass("remove", "remove a deploy key: <fingerprint>", passOpts{server: []string{"repo", "deploy-key", "remove"}, needsRepo: true}),
441441 ),
442 group("runner", "runners attached to a repository",
443 pass("add", "attach a runner's public key: < key.pub", passOpts{server: []string{"repo", "runner", "add"}, needsRepo: true, alwaysStdin: true, stdinWhat: "an SSH public key"}),
444 pass("list", "list attached runners", passOpts{server: []string{"repo", "runner", "list"}, needsRepo: true}),
445 pass("remove", "detach a runner: <fingerprint>", passOpts{server: []string{"repo", "runner", "remove"}, needsRepo: true}),
446 ),
442447 group("mirror", "sync with a foreign remote",
443448 pass("add", "add a mirror: <https-url> --direction push|pull [--username <u>] [--token-stdin]",
444449 passOpts{server: []string{"repo", "mirror", "add"}, needsRepo: true, stdinOK: true}),
e2e/buildcancelweb_test.go +6 −8
@@ -19,19 +19,17 @@ func TestBuildCancelWeb(t *testing.T) {
1919 inst.admin(t, "admin", "user", "create", "alice",
2020 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
2121
22 // ci is an ordinary account; its runner key is self-added with
23 // --scope runner, which confines it to the runner protocol and
24 // read-only git rather than reaching for admin.
25 ciKey := inst.newKey(t, "ci")
26 inst.admin(t, "admin", "user", "create", "ci", "--key", ciKey+".pub")
2722 runnerKey := inst.newKey(t, "ci-runner")
2823 pub, _ := os.ReadFile(runnerKey + ".pub")
29 if _, errOut, code := inst.ssh(t, ciKey, string(pub), "keys", "add", "--scope", "runner"); code != 0 {
30 t.Fatalf("keys add --scope runner: %s", errOut)
31 }
3224 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
3325 t.Fatalf("repo create: %s", errOut)
3426 }
27 // The runner key is attached by alice through repo runner add, which
28 // registers it on her account with scope runner, confining it to the
29 // runner protocol and read-only git rather than reaching for admin.
30 if _, errOut, code := inst.ssh(t, aliceKey, string(pub), "repo", "runner", "add", "alice/app"); code != 0 {
31 t.Fatalf("repo runner add: %s", errOut)
32 }
3533 work := t.TempDir()
3634 env := inst.gitEnv(aliceKey)
3735 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
internal/control/runnerrepo.go added +127
@@ -0,0 +1,127 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7
8 "golang.org/x/crypto/ssh"
9
10 "gitbay.org/gitbay/internal/policy"
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15// Runners attached to a repository (#184). A runner key claims builds only
16// for the repositories it is attached to; a repository admin attaches it
17// by pasting the runner's public key. The key lands on the admin's own
18// account with scope runner, which confines it to the runner protocol and
19// read-only git.
20func init() {
21 register(Command{Path: []string{"repo", "runner", "add"},
22 Summary: "attach a runner's public key to a repository",
23 Usage: "repo runner add <owner/name> < key.pub",
24 ReadsStdin: true, Run: runRepoRunnerAdd})
25 register(Command{Path: []string{"repo", "runner", "list"},
26 Summary: "list the runners attached to a repository",
27 Usage: "repo runner list <owner/name>", ReadOnly: true, Run: runRepoRunnerList})
28 register(Command{Path: []string{"repo", "runner", "remove"},
29 Summary: "detach a runner from a repository",
30 Usage: "repo runner remove <owner/name> <fingerprint>", Run: runRepoRunnerRemove})
31}
32
33func runRepoRunnerAdd(c *Ctx, args []string) int {
34 f, err := parseFlags(args, flagSpec{MaxPos: 1, Usage: "repo runner add <owner/name> < key.pub"})
35 if err != nil || len(f.Pos) != 1 {
36 return c.fail(protocol.ExitUsage, "usage: repo runner add <owner/name> < key.pub")
37 }
38 repo, code := resolveRepo(c, f.Pos[0], policy.CanAdmin)
39 if code >= 0 {
40 return code
41 }
42 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
43 if err != nil {
44 return c.fail(protocol.ExitFailure, "reading key: %v", err)
45 }
46 pub, _, _, _, err := ssh.ParseAuthorizedKey(raw)
47 if err != nil {
48 return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err)
49 }
50 fp := ssh.FingerprintSHA256(pub)
51 key, err := c.Store.SSHKeyByFingerprint(fp)
52 switch {
53 case errors.Is(err, store.ErrNotFound):
54 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), "runner"); err != nil {
55 return c.fail(protocol.ExitFailure, "adding key: %v", err)
56 }
57 if key, err = c.Store.SSHKeyByFingerprint(fp); err != nil {
58 return c.fail(protocol.ExitFailure, "%v", err)
59 }
60 case err != nil:
61 return c.fail(protocol.ExitFailure, "%v", err)
62 case key.Scope != "runner":
63 // A full key would let a build step administer the account; a
64 // deploy key is bound elsewhere. A runner gets a key of its own.
65 return c.fail(protocol.ExitDenied, "%s is a %s key, not a runner key; give the runner a key of its own", fp, key.Scope)
66 case key.UserID != c.User.ID && !c.User.IsAdmin:
67 return c.fail(protocol.ExitDenied, "%s belongs to another account", fp)
68 }
69 if err := c.Store.AttachRunner(key.ID, repo.ID); err != nil {
70 return c.fail(protocol.ExitFailure, "%v", err)
71 }
72 c.Store.Audit(c.User.ID, "repo.runner.add", map[string]any{"repo": repo.Path(), "fingerprint": fp})
73 d := map[string]string{"fingerprint": fp, "repo": repo.Path()}
74 return c.emit(d, func(w io.Writer) {
75 fmt.Fprintf(w, "runner %s attached to %s\n", fp, repo.Path())
76 })
77}
78
79func runRepoRunnerList(c *Ctx, args []string) int {
80 if len(args) != 1 {
81 return c.fail(protocol.ExitUsage, "usage: repo runner list <owner/name>")
82 }
83 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
84 if code >= 0 {
85 return code
86 }
87 runners, err := c.Store.ListRepoRunners(repo.ID)
88 if err != nil {
89 return c.fail(protocol.ExitFailure, "%v", err)
90 }
91 if runners == nil {
92 runners = []store.RepoRunner{}
93 }
94 return c.emit(runners, func(w io.Writer) {
95 for _, r := range runners {
96 seen := r.LastSeen
97 if seen == "" {
98 seen = "never"
99 }
100 held := "idle"
101 if r.BuildNumber != 0 {
102 held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt)
103 }
104 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Fingerprint, r.Algo, r.Username, seen, held)
105 }
106 })
107}
108
109func runRepoRunnerRemove(c *Ctx, args []string) int {
110 if len(args) != 2 {
111 return c.fail(protocol.ExitUsage, "usage: repo runner remove <owner/name> <fingerprint>")
112 }
113 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
114 if code >= 0 {
115 return code
116 }
117 if err := c.Store.DetachRunner(repo.ID, args[1]); err != nil {
118 if errors.Is(err, store.ErrNotFound) {
119 return c.fail(protocol.ExitNotFound, "no runner %s on %s", args[1], repo.Path())
120 }
121 return c.fail(protocol.ExitFailure, "%v", err)
122 }
123 c.Store.Audit(c.User.ID, "repo.runner.remove", map[string]any{"repo": repo.Path(), "fingerprint": args[1]})
124 return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
125 fmt.Fprintf(w, "runner %s detached from %s\n", args[1], repo.Path())
126 })
127}
internal/control/runnerrepo_test.go added +107
@@ -0,0 +1,107 @@
1package control
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/config"
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13// Generated once with ssh-keygen -t ed25519; a valid authorized_keys line.
14const testRunnerPub = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILAr2r82jFsCJwsEyrEf2wgKy9Dv45xYYici6Ii7NyCS runner@test\n"
15
16func repoRunnerCtx(t *testing.T, st *store.Store, uid int64, admin bool, stdin string) (*Ctx, *bytes.Buffer) {
17 t.Helper()
18 var out bytes.Buffer
19 return &Ctx{
20 User: store.User{ID: uid, Username: "alice", IsAdmin: admin},
21 Scope: "full",
22 Source: "SHA256:session",
23 Store: st,
24 Cfg: config.Config{Server: config.Server{SiteURL: "https://x.test"}},
25 Stdin: strings.NewReader(stdin),
26 Stdout: &out,
27 Stderr: &out,
28 JSON: true,
29 }, &out
30}
31
32// A fresh key is registered on the caller's account with scope runner and
33// attached; a second add is a no-op; list shows it; remove detaches and
34// leaves the key on the account.
35func TestRepoRunnerAddListRemove(t *testing.T) {
36 st, repo, uid := newQueueTestRepo(t)
37 c, out := repoRunnerCtx(t, st, uid, false, testRunnerPub)
38 if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitOK {
39 t.Fatalf("add: exit %d %s", code, out.String())
40 }
41 if !strings.Contains(out.String(), `"fingerprint":"SHA256:`) {
42 t.Fatalf("add output: %s", out.String())
43 }
44 keys, _ := st.ListSSHKeys(uid)
45 if len(keys) != 1 || keys[0].Scope != "runner" {
46 t.Fatalf("key not registered as runner: %+v", keys)
47 }
48 fp := keys[0].Fingerprint
49 c, out = repoRunnerCtx(t, st, uid, false, testRunnerPub)
50 if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitOK {
51 t.Fatalf("second add: exit %d %s", code, out.String())
52 }
53 c, out = repoRunnerCtx(t, st, uid, false, "")
54 if code := runRepoRunnerList(c, []string{repo.Path()}); code != protocol.ExitOK || strings.Count(out.String(), fp) != 1 {
55 t.Fatalf("list: exit %d %s", code, out.String())
56 }
57 c, out = repoRunnerCtx(t, st, uid, false, "")
58 if code := runRepoRunnerRemove(c, []string{repo.Path(), fp}); code != protocol.ExitOK {
59 t.Fatalf("remove: exit %d %s", code, out.String())
60 }
61 if ok, _ := st.RunnerAttached(keys[0].ID, repo.ID); ok {
62 t.Fatal("still attached after remove")
63 }
64 if keys, _ = st.ListSSHKeys(uid); len(keys) != 1 {
65 t.Fatal("remove dropped the key from the account")
66 }
67 c, out = repoRunnerCtx(t, st, uid, false, "")
68 if code := runRepoRunnerRemove(c, []string{repo.Path(), fp}); code != protocol.ExitNotFound {
69 t.Fatalf("remove twice: exit %d, want %d", code, protocol.ExitNotFound)
70 }
71}
72
73// A key that already exists with another scope is never promoted, and
74// another account's runner key is refused unless the caller is an admin.
75func TestRepoRunnerAddRefusesWrongKeys(t *testing.T) {
76 st, repo, uid := newQueueTestRepo(t)
77 c, _ := repoRunnerCtx(t, st, uid, false, testRunnerPub)
78 // Register the same key as a full key first.
79 if code := runKeysAdd(c, nil); code != protocol.ExitOK {
80 t.Fatal("keys add failed")
81 }
82 c, out := repoRunnerCtx(t, st, uid, false, testRunnerPub)
83 if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitDenied {
84 t.Fatalf("full key accepted as runner: exit %d %s", code, out.String())
85 }
86 keys, _ := st.ListSSHKeys(uid)
87 if keys[0].Scope != "full" {
88 t.Fatalf("scope changed to %s", keys[0].Scope)
89 }
90 // Someone else's runner key.
91 bob, _ := st.CreateUser("bob", false)
92 if err := st.AddSSHKey(bob, "SHA256:bobrunner", "ssh-ed25519", []byte("x"), "runner"); err != nil {
93 t.Fatal(err)
94 }
95 st.RemoveSSHKey(uid, keys[0].Fingerprint)
96 if err := st.AddSSHKey(bob, keys[0].Fingerprint, "ssh-ed25519", keys[0].Blob, "runner"); err != nil {
97 t.Fatal(err)
98 }
99 c, out = repoRunnerCtx(t, st, uid, false, testRunnerPub)
100 if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitDenied {
101 t.Fatalf("another account's key attached by a non-admin: exit %d %s", code, out.String())
102 }
103 c, out = repoRunnerCtx(t, st, uid, true, testRunnerPub)
104 if code := runRepoRunnerAdd(c, []string{repo.Path()}); code != protocol.ExitOK {
105 t.Fatalf("admin could not attach another account's runner key: exit %d %s", code, out.String())
106 }
107}