internal/control/build.go

160fc0ec2bbbb4367ce5db1814c1a7404b656048
gitbay/internal/control/build.go history · blame · raw

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}