internal/control/build.go
362 lines · 12281 bytes
1package control
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "regexp"
9 "strconv"
10 "strings"
11
12 "gitbay.org/gitbay/internal/ci"
13 "gitbay.org/gitbay/internal/gitutil"
14 "gitbay.org/gitbay/internal/policy"
15 "gitbay.org/gitbay/internal/protocol"
16 "gitbay.org/gitbay/internal/store"
17)
18
19func init() {
20 register(Command{Path: []string{"build", "list"},
21 Summary: "list recent builds: build list <owner/name>", ReadOnly: true, Run: runBuildList})
22 register(Command{Path: []string{"build", "show"},
23 Summary: "show one build: build show <owner/name> <n>", ReadOnly: true, Run: runBuildShow})
24 register(Command{Path: []string{"build", "log"},
25 Summary: "print a build's log: build log <owner/name> <n>", ReadOnly: true, Run: runBuildLog})
26
27 register(Command{Path: []string{"build", "trigger"},
28 Summary: "queue a job now (scheduled or not): build trigger <owner/name> <job>", Run: runBuildTrigger})
29 // Secrets: set over stdin, listed by name only, injected into the
30 // repo's builds as environment variables. Same discipline as mirror
31 // tokens — the value never appears in argv, logs, or output.
32 register(Command{Path: []string{"repo", "secret", "set"},
33 Summary: "set a build secret: repo secret set <owner/name> <NAME> (value on stdin)",
34 ReadsStdin: true, SSHOnly: true, Run: runSecretSet})
35 register(Command{Path: []string{"repo", "secret", "remove"},
36 Summary: "remove a build secret: repo secret remove <owner/name> <NAME>", Run: runSecretRemove})
37 register(Command{Path: []string{"repo", "secret", "list"},
38 Summary: "list build secret names: repo secret list <owner/name>", ReadOnly: true, Run: runSecretList})
39
40 // Runner commands: the claim/report loop for gitbay-runner. Admin-only —
41 // a runner executes arbitrary repo code, so handing out jobs is the
42 // instance operator's call.
43 register(Command{Path: []string{"runner", "next"},
44 Summary: "claim the oldest pending build (runner protocol)", SSHOnly: true, Run: runRunnerNext})
45 register(Command{Path: []string{"runner", "log"},
46 Summary: "append a build's log from stdin: runner log <build-id>", SSHOnly: true, ReadsStdin: true, Run: runRunnerLog})
47 register(Command{Path: []string{"runner", "done"},
48 Summary: "finish a build: runner done <build-id> success|failure", SSHOnly: true, Run: runRunnerDone})
49}
50
51type buildOut struct {
52 Number int64 `json:"number"`
53 Job string `json:"job"`
54 Status string `json:"status"`
55 SHA string `json:"sha"`
56 Ref string `json:"ref"`
57 CreatedAt string `json:"created_at"`
58 FinishedAt string `json:"finished_at,omitempty"`
59}
60
61func buildToOut(b store.Build) buildOut {
62 return buildOut{b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt}
63}
64
65func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) {
66 if len(args) != 2 {
67 return store.Repo{}, store.Build{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>")
68 }
69 repo, code := resolveRepo(c, args[0], policy.CanRead)
70 if code >= 0 {
71 return repo, store.Build{}, code
72 }
73 n, err := strconv.ParseInt(args[1], 10, 64)
74 if err != nil {
75 return repo, store.Build{}, c.fail(protocol.ExitUsage, "bad build number %q", args[1])
76 }
77 b, err := c.Store.BuildByNumber(repo.ID, n)
78 if err != nil {
79 return repo, b, c.fail(protocol.ExitNotFound, "no build %d on %s", n, repo.Path())
80 }
81 return repo, b, -1
82}
83
84func runBuildList(c *Ctx, args []string) int {
85 if len(args) != 1 {
86 return c.fail(protocol.ExitUsage, "usage: build list <owner/name>")
87 }
88 repo, code := resolveRepo(c, args[0], policy.CanRead)
89 if code >= 0 {
90 return code
91 }
92 builds, err := c.Store.ListBuilds(repo.ID, 50)
93 if err != nil {
94 return c.fail(protocol.ExitFailure, "%v", err)
95 }
96 var ds []buildOut
97 for _, b := range builds {
98 ds = append(ds, buildToOut(b))
99 }
100 return c.emit(ds, func(w io.Writer) {
101 for _, d := range ds {
102 fmt.Fprintf(w, "%d\t%s\t%s\t%.10s\t%s\n", d.Number, d.Job, d.Status, d.SHA, d.Ref)
103 }
104 })
105}
106
107func runBuildShow(c *Ctx, args []string) int {
108 _, b, code := buildRef(c, args)
109 if code >= 0 {
110 return code
111 }
112 d := buildToOut(b)
113 return c.emit(d, func(w io.Writer) {
114 fmt.Fprintf(w, "build %d\t%s\t%s\n%.10s on %s\nqueued %s", d.Number, d.Job, d.Status, d.SHA, d.Ref, d.CreatedAt)
115 if d.FinishedAt != "" {
116 fmt.Fprintf(w, ", finished %s", d.FinishedAt)
117 }
118 fmt.Fprintln(w)
119 })
120}
121
122func runBuildLog(c *Ctx, args []string) int {
123 _, b, code := buildRef(c, args)
124 if code >= 0 {
125 return code
126 }
127 log, err := c.Store.BuildLog(b.ID)
128 if err != nil {
129 return c.fail(protocol.ExitFailure, "%v", err)
130 }
131 c.Stdout.Write(log)
132 return protocol.ExitOK
133}
134
135func runBuildTrigger(c *Ctx, args []string) int {
136 if len(args) != 2 {
137 return c.fail(protocol.ExitUsage, "usage: build trigger <owner/name> <job>")
138 }
139 repo, code := resolveRepo(c, args[0], policy.CanWrite)
140 if code >= 0 {
141 return code
142 }
143 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
144 sha, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch)
145 if err != nil {
146 return c.fail(protocol.ExitFailure, "resolving %s: %v", repo.DefaultBranch, err)
147 }
148 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
149 if err != nil {
150 return c.fail(protocol.ExitNotFound, "%s has no %s on %s", repo.Path(), ci.ConfigPath, repo.DefaultBranch)
151 }
152 jobs, err := ci.Parse(raw)
153 if err != nil {
154 return c.fail(protocol.ExitUsage, "%v", err)
155 }
156 for _, j := range jobs {
157 if j.Name != args[1] {
158 continue
159 }
160 steps, _ := json.Marshal(j.Steps)
161 n, err := c.Store.CreateBuild(repo.ID, j.Name, sha, repo.DefaultBranch, string(steps))
162 if err != nil {
163 return c.fail(protocol.ExitFailure, "%v", err)
164 }
165 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), n)
166 c.Store.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "triggered", url, c.User.ID)
167 return c.emit(map[string]any{"build": n, "job": j.Name, "sha": sha}, func(w io.Writer) {
168 fmt.Fprintf(w, "queued build %d (%s @ %.10s)\n", n, j.Name, sha)
169 })
170 }
171 return c.fail(protocol.ExitNotFound, "no job %q in %s", args[1], ci.ConfigPath)
172}
173
174// secretName is env-var shaped: the value lands in the build environment.
175var secretName = regexp.MustCompile(`^[A-Z_][A-Z0-9_]{0,63}$`)
176
177func runSecretSet(c *Ctx, args []string) int {
178 if len(args) != 2 {
179 return c.fail(protocol.ExitUsage, "usage: repo secret set <owner/name> <NAME> (value on stdin)")
180 }
181 if !secretName.MatchString(args[1]) {
182 return c.fail(protocol.ExitUsage, "secret names are env-var shaped: uppercase letters, digits, _")
183 }
184 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
185 if code >= 0 {
186 return code
187 }
188 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
189 if err != nil {
190 return c.fail(protocol.ExitFailure, "reading secret: %v", err)
191 }
192 value := strings.TrimRight(string(raw), "\n")
193 if value == "" {
194 return c.fail(protocol.ExitUsage, "no value on stdin (pipe it: printf %%s TOKEN | ...)")
195 }
196 if err := c.Store.SetBuildSecret(repo.ID, args[1], value); err != nil {
197 return c.fail(protocol.ExitFailure, "%v", err)
198 }
199 return c.emit(map[string]string{"secret": args[1]}, func(w io.Writer) {
200 fmt.Fprintf(w, "secret %s set on %s\n", args[1], repo.Path())
201 })
202}
203
204func runSecretRemove(c *Ctx, args []string) int {
205 if len(args) != 2 {
206 return c.fail(protocol.ExitUsage, "usage: repo secret remove <owner/name> <NAME>")
207 }
208 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
209 if code >= 0 {
210 return code
211 }
212 if err := c.Store.RemoveBuildSecret(repo.ID, args[1]); err != nil {
213 if errors.Is(err, store.ErrNotFound) {
214 return c.fail(protocol.ExitNotFound, "no secret %s on %s", args[1], repo.Path())
215 }
216 return c.fail(protocol.ExitFailure, "%v", err)
217 }
218 return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
219 fmt.Fprintf(w, "removed %s\n", args[1])
220 })
221}
222
223func runSecretList(c *Ctx, args []string) int {
224 if len(args) != 1 {
225 return c.fail(protocol.ExitUsage, "usage: repo secret list <owner/name>")
226 }
227 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
228 if code >= 0 {
229 return code
230 }
231 names, err := c.Store.ListBuildSecretNames(repo.ID)
232 if err != nil {
233 return c.fail(protocol.ExitFailure, "%v", err)
234 }
235 return c.emit(names, func(w io.Writer) {
236 for _, n := range names {
237 fmt.Fprintln(w, n)
238 }
239 })
240}
241
242func requireRunner(c *Ctx) int {
243 if !c.User.IsAdmin {
244 return c.fail(protocol.ExitDenied, "runner commands are for instance-admin runner accounts")
245 }
246 return -1
247}
248
249func runRunnerNext(c *Ctx, args []string) int {
250 if code := requireRunner(c); code >= 0 {
251 return code
252 }
253 b, ok, err := c.Store.ClaimBuild()
254 if err != nil {
255 return c.fail(protocol.ExitFailure, "%v", err)
256 }
257 if !ok {
258 return c.emit(map[string]any{}, func(w io.Writer) { fmt.Fprintln(w, "no pending builds") })
259 }
260 repo, err := c.Store.RepoByID(b.RepoID)
261 if err != nil {
262 return c.fail(protocol.ExitFailure, "%v", err)
263 }
264 var steps []string
265 json.Unmarshal([]byte(b.Steps), &steps)
266 // Secrets ride the claim: this channel is admin-only and the values
267 // land in the build's environment, nowhere else.
268 secrets, err := c.Store.BuildSecrets(b.RepoID)
269 if err != nil {
270 return c.fail(protocol.ExitFailure, "%v", err)
271 }
272 d := struct {
273 ID int64 `json:"id"`
274 Repo string `json:"repo"`
275 Number int64 `json:"number"`
276 Job string `json:"job"`
277 SHA string `json:"sha"`
278 Ref string `json:"ref"`
279 Steps []string `json:"steps"`
280 Secrets map[string]string `json:"secrets,omitempty"`
281 }{b.ID, repo.Path(), b.Number, b.Job, b.SHA, b.Ref, steps, secrets}
282 return c.emit(d, func(w io.Writer) {
283 fmt.Fprintf(w, "build %d: %s %s @ %.10s\n", d.ID, d.Repo, d.Job, d.SHA)
284 })
285}
286
287func runRunnerLog(c *Ctx, args []string) int {
288 if code := requireRunner(c); code >= 0 {
289 return code
290 }
291 if len(args) != 1 {
292 return c.fail(protocol.ExitUsage, "usage: runner log <build-id> (chunk on stdin)")
293 }
294 id, err := strconv.ParseInt(args[0], 10, 64)
295 if err != nil {
296 return c.fail(protocol.ExitUsage, "bad build id %q", args[0])
297 }
298 // Stream stdin into the log in chunks so long builds appear live.
299 buf := make([]byte, 64<<10)
300 for {
301 n, rerr := c.Stdin.Read(buf)
302 if n > 0 {
303 if err := c.Store.AppendBuildLog(id, buf[:n]); err != nil {
304 return c.fail(protocol.ExitFailure, "%v", err)
305 }
306 }
307 if rerr != nil {
308 break
309 }
310 }
311 return c.emit(map[string]string{"log": "ok"}, func(w io.Writer) {})
312}
313
314func runRunnerDone(c *Ctx, args []string) int {
315 if code := requireRunner(c); code >= 0 {
316 return code
317 }
318 if len(args) != 2 || (args[1] != "success" && args[1] != "failure") {
319 return c.fail(protocol.ExitUsage, "usage: runner done <build-id> success|failure")
320 }
321 id, err := strconv.ParseInt(args[0], 10, 64)
322 if err != nil {
323 return c.fail(protocol.ExitUsage, "bad build id %q", args[0])
324 }
325 b, err := c.Store.BuildByID(id)
326 if err != nil {
327 return c.fail(protocol.ExitNotFound, "no build %d", id)
328 }
329 if err := c.Store.FinishBuild(id, args[1]); err != nil {
330 return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err)
331 }
332 repo, err := c.Store.RepoByID(b.RepoID)
333 if err != nil {
334 return c.fail(protocol.ExitFailure, "%v", err)
335 }
336 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
337 desc := "build " + args[1]
338 if err := c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, args[1], desc, url, c.User.ID); err != nil {
339 return c.fail(protocol.ExitFailure, "%v", err)
340 }
341 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+args[1],
342 fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
343 // A red build mails the repo's notify targets with the log tail — a
344 // failed scheduled job must not wait to be noticed.
345 if args[1] == "failure" {
346 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
347 tail := ""
348 if log, err := c.Store.BuildLog(id); err == nil && len(log) > 0 {
349 if len(log) > 2000 {
350 log = log[len(log)-2000:]
351 }
352 tail = string(log)
353 }
354 notifyUsers(c, targets,
355 fmt.Sprintf("[%s] build %d failed: %s on %s", repo.Path(), b.Number, b.Job, b.Ref),
356 fmt.Sprintf("job %s failed at %.10s.\n\n…%s\n\n%s\n", b.Job, b.SHA, tail, url))
357 }
358 }
359 return c.emit(map[string]any{"build": b.Number, "status": args[1]}, func(w io.Writer) {
360 fmt.Fprintf(w, "build %d %s\n", b.Number, args[1])
361 })
362}