internal/control/runnerrepo.go

f9845abbc93ead5d74684a56f2e677be71c69587
gitbay/internal/control/runnerrepo.go history · blame · raw

127 lines · 4656 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.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}