internal/control/runnerrepo.go
168 lines · 6127 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 Examples: []string{"repo runner add krz/gitbay < key.pub"},
25 ReadsStdin: true,
26 MintsCredential: true, NeedsRecentSignIn: true, Run: runRepoRunnerAdd})
27 register(Command{Path: []string{"repo", "runner", "list"},
28 Summary: "list the runners attached to a repository",
29 Usage: "repo runner list <owner/name>",
30 Examples: []string{"repo runner list krz/gitbay"},
31 ReadOnly: true, Run: runRepoRunnerList})
32 register(Command{Path: []string{"repo", "runner", "remove"},
33 Summary: "detach a runner from a repository",
34 Usage: "repo runner remove <owner/name> <fingerprint>",
35 Examples: []string{"repo runner remove krz/gitbay SHA256:abcd1234"},
36 Run: runRepoRunnerRemove})
37}
38
39func runRepoRunnerAdd(c *Ctx, args []string) int {
40 f, err := c.parseArgs(args, flagSpec{MaxPos: 1, Usage: "repo runner add <owner/name> < key.pub"})
41 if err != nil || len(f.Pos) != 1 {
42 return c.usage()
43 }
44 repo, code := resolveRepo(c, f.Pos[0], policy.CanAdmin)
45 if code >= 0 {
46 return code
47 }
48 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
49 if err != nil {
50 return c.fail(protocol.ExitFailure, "reading key: %v", err)
51 }
52 pub, comment, _, _, err := ssh.ParseAuthorizedKey(raw)
53 if err != nil {
54 return c.fail(protocol.ExitUsage, "not a valid public key in authorized_keys format: %v", err)
55 }
56 fp := ssh.FingerprintSHA256(pub)
57 key, err := c.Store.SSHKeyByFingerprint(fp)
58 switch {
59 case errors.Is(err, store.ErrNotFound):
60 label, _ := keyLabel(comment)
61 if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), "runner", label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
62 return c.fail(protocol.ExitFailure, "adding key: %v", err)
63 }
64 if key, err = c.Store.SSHKeyByFingerprint(fp); err != nil {
65 return c.fail(protocol.ExitFailure, "%v", err)
66 }
67 case err != nil:
68 return c.fail(protocol.ExitFailure, "%v", err)
69 case key.Scope != "runner":
70 // A full key would let a build step administer the account; a
71 // deploy key is bound elsewhere. A runner gets a key of its own.
72 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)
73 case key.UserID != c.User.ID && !c.User.IsAdmin:
74 return c.fail(protocol.ExitDenied, "%s belongs to another account", fp)
75 }
76 // The runner clones what it builds, so the key's account must be able
77 // to read the repository. The caller's own key needs no check: they
78 // hold admin on the repository to get here.
79 if key.UserID != c.User.ID {
80 owner, err := c.Store.UserByID(key.UserID)
81 if err != nil {
82 return c.fail(protocol.ExitFailure, "%v", err)
83 }
84 grant, err := c.Store.AccessRole(repo.ID, owner.ID)
85 if err != nil {
86 return c.fail(protocol.ExitFailure, "%v", err)
87 }
88 if !policy.CanRead(owner, repo, grant) {
89 return c.fail(protocol.ExitDenied, "%s belongs to %s, who cannot read %s", fp, owner.Username, repo.Path())
90 }
91 }
92 if err := c.Store.AttachRunner(key.ID, repo.ID); err != nil {
93 return c.fail(protocol.ExitFailure, "%v", err)
94 }
95 c.Store.Audit(c.User.ID, "repo.runner.add", map[string]any{"repo": repo.Path(), "fingerprint": fp})
96 d := map[string]string{"fingerprint": fp, "repo": repo.Path()}
97 return c.emit(d, func(w io.Writer) {
98 fmt.Fprintf(w, "runner %s attached to %s\n", fp, repo.Path())
99 })
100}
101
102func runRepoRunnerList(c *Ctx, args []string) int {
103 if len(args) != 1 {
104 return c.usage()
105 }
106 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
107 if code >= 0 {
108 return code
109 }
110 runners, err := c.Store.ListRepoRunners(repo.ID)
111 if err != nil {
112 return c.fail(protocol.ExitFailure, "%v", err)
113 }
114 if runners == nil {
115 runners = []store.RepoRunner{}
116 }
117 return c.emitView(runners, func(w io.Writer) {
118 tb := c.table(w, "FINGERPRINT", "ALGO", "USER", "SEEN", "HELD")
119 for _, r := range runners {
120 seen := r.LastSeen
121 if seen == "" {
122 seen = "never"
123 }
124 held := "idle"
125 if r.BuildNumber != 0 {
126 held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt)
127 }
128 tb.row(cRef(r.Fingerprint), cText(r.Algo), cText(r.Username), cAge(seen), cText(held))
129 }
130 tb.flush()
131 }, func() screen {
132 rows := make([]row, len(runners))
133 for i, r := range runners {
134 lead, held := cGlyph(""), ""
135 if r.BuildNumber != 0 {
136 lead, held = cGlyph("running"), fmt.Sprintf("building %s #%d %s", r.BuildRepo, r.BuildNumber, r.BuildJob)
137 }
138 seen := "never seen"
139 if r.LastSeen != "" {
140 seen = "seen " + relAge(r.LastSeen, termNow())
141 }
142 rows[i] = rowOf(cFlexRef(r.Fingerprint), lead, cText(r.Username), cMeta(r.Algo, seen, held))
143 }
144 return listScreen("Runners", rows,
145 action{"Runners", []string{"repo", "runner", "remove", repo.Path(), "<fingerprint>"}},
146 )
147 })
148}
149
150func runRepoRunnerRemove(c *Ctx, args []string) int {
151 if len(args) != 2 {
152 return c.usage()
153 }
154 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
155 if code >= 0 {
156 return code
157 }
158 if err := c.Store.DetachRunner(repo.ID, args[1]); err != nil {
159 if errors.Is(err, store.ErrNotFound) {
160 return c.fail(protocol.ExitNotFound, "no runner %s on %s", args[1], repo.Path())
161 }
162 return c.fail(protocol.ExitFailure, "%v", err)
163 }
164 c.Store.Audit(c.User.ID, "repo.runner.remove", map[string]any{"repo": repo.Path(), "fingerprint": args[1]})
165 return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
166 fmt.Fprintf(w, "runner %s detached from %s\n", args[1], repo.Path())
167 })
168}