internal/control/runnerrepo.go
144 lines · 5127 bytes
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.usage()
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, comment, _, _, 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 label, _ := keyLabel(comment)
55 if err := c.Store.AddSSHKey(c.User.ID, fp, pub.Type(), pub.Marshal(), "runner", label); err != nil {
56 return c.fail(protocol.ExitFailure, "adding key: %v", err)
57 }
58 if key, err = c.Store.SSHKeyByFingerprint(fp); err != nil {
59 return c.fail(protocol.ExitFailure, "%v", err)
60 }
61 case err != nil:
62 return c.fail(protocol.ExitFailure, "%v", err)
63 case key.Scope != "runner":
64 // A full key would let a build step administer the account; a
65 // deploy key is bound elsewhere. A runner gets a key of its own.
66 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)
67 case key.UserID != c.User.ID && !c.User.IsAdmin:
68 return c.fail(protocol.ExitDenied, "%s belongs to another account", fp)
69 }
70 // The runner clones what it builds, so the key's account must be able
71 // to read the repository. The caller's own key needs no check: they
72 // hold admin on the repository to get here.
73 if key.UserID != c.User.ID {
74 owner, err := c.Store.UserByID(key.UserID)
75 if err != nil {
76 return c.fail(protocol.ExitFailure, "%v", err)
77 }
78 grant, err := c.Store.AccessRole(repo.ID, owner.ID)
79 if err != nil {
80 return c.fail(protocol.ExitFailure, "%v", err)
81 }
82 if !policy.CanRead(owner, repo, grant) {
83 return c.fail(protocol.ExitDenied, "%s belongs to %s, who cannot read %s", fp, owner.Username, repo.Path())
84 }
85 }
86 if err := c.Store.AttachRunner(key.ID, repo.ID); err != nil {
87 return c.fail(protocol.ExitFailure, "%v", err)
88 }
89 c.Store.Audit(c.User.ID, "repo.runner.add", map[string]any{"repo": repo.Path(), "fingerprint": fp})
90 d := map[string]string{"fingerprint": fp, "repo": repo.Path()}
91 return c.emit(d, func(w io.Writer) {
92 fmt.Fprintf(w, "runner %s attached to %s\n", fp, repo.Path())
93 })
94}
95
96func runRepoRunnerList(c *Ctx, args []string) int {
97 if len(args) != 1 {
98 return c.usage()
99 }
100 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
101 if code >= 0 {
102 return code
103 }
104 runners, err := c.Store.ListRepoRunners(repo.ID)
105 if err != nil {
106 return c.fail(protocol.ExitFailure, "%v", err)
107 }
108 if runners == nil {
109 runners = []store.RepoRunner{}
110 }
111 return c.emit(runners, func(w io.Writer) {
112 for _, r := range runners {
113 seen := r.LastSeen
114 if seen == "" {
115 seen = "never"
116 }
117 held := "idle"
118 if r.BuildNumber != 0 {
119 held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt)
120 }
121 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Fingerprint, r.Algo, r.Username, seen, held)
122 }
123 })
124}
125
126func runRepoRunnerRemove(c *Ctx, args []string) int {
127 if len(args) != 2 {
128 return c.usage()
129 }
130 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
131 if code >= 0 {
132 return code
133 }
134 if err := c.Store.DetachRunner(repo.ID, args[1]); err != nil {
135 if errors.Is(err, store.ErrNotFound) {
136 return c.fail(protocol.ExitNotFound, "no runner %s on %s", args[1], repo.Path())
137 }
138 return c.fail(protocol.ExitFailure, "%v", err)
139 }
140 c.Store.Audit(c.User.ID, "repo.runner.remove", map[string]any{"repo": repo.Path(), "fingerprint": args[1]})
141 return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
142 fmt.Fprintf(w, "runner %s detached from %s\n", args[1], repo.Path())
143 })
144}