internal/control/runnerrepo.go

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

152 lines · 5502 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, 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.emit(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	})
132}
133
134func runRepoRunnerRemove(c *Ctx, args []string) int {
135	if len(args) != 2 {
136		return c.usage()
137	}
138	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
139	if code >= 0 {
140		return code
141	}
142	if err := c.Store.DetachRunner(repo.ID, args[1]); err != nil {
143		if errors.Is(err, store.ErrNotFound) {
144			return c.fail(protocol.ExitNotFound, "no runner %s on %s", args[1], repo.Path())
145		}
146		return c.fail(protocol.ExitFailure, "%v", err)
147	}
148	c.Store.Audit(c.User.ID, "repo.runner.remove", map[string]any{"repo": repo.Path(), "fingerprint": args[1]})
149	return c.emit(map[string]string{"removed": args[1]}, func(w io.Writer) {
150		fmt.Fprintf(w, "runner %s detached from %s\n", args[1], repo.Path())
151	})
152}