internal/control/admin.go

841 lines · 28449 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"slices"
  8	"strconv"
  9	"strings"
 10	"time"
 11
 12	"gitbay.org/gitbay/internal/gitutil"
 13	"gitbay.org/gitbay/internal/protocol"
 14	"gitbay.org/gitbay/internal/store"
 15)
 16
 17func init() {
 18	register(Command{Path: []string{"admin", "user", "list"},
 19		Summary: "list accounts (instance admins)",
 20		Usage:   "admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]",
 21		Flags: []Flag{
 22			{"--state", "active|pending|disabled|admin", "which accounts", ""},
 23			{"--limit", "<n>", "rows per page", ""},
 24			{"--cursor", "<c>", "continue from the previous page", ""},
 25		},
 26		Examples: []string{"admin user list --state pending"},
 27		ReadOnly: true, Run: runAdminUserList})
 28	register(Command{Path: []string{"admin", "user", "show"},
 29		Summary:  "show an account: keys, emails, orgs, tokens, sessions (instance admins)",
 30		Usage:    "admin user show <username>",
 31		Examples: []string{"admin user show alice"},
 32		ReadOnly: true, Run: runAdminUserShow})
 33	register(Command{Path: []string{"admin", "user", "promote"},
 34		NeedsRecentSignIn: true,
 35		Summary:           "make an account an instance admin",
 36		Usage:             "admin user promote <username>",
 37		Examples:          []string{"admin user promote alice"},
 38		Run:               runAdminUserPromote})
 39	register(Command{Path: []string{"admin", "user", "demote"},
 40		Summary:  "remove instance admin from an account (never the last one)",
 41		Usage:    "admin user demote <username>",
 42		Examples: []string{"admin user demote alice"},
 43		Run:      runAdminUserDemote})
 44	register(Command{Path: []string{"admin", "runners"},
 45		Summary:  "the build queue and runner accounts: last poll, scope, the build each holds (instance admins)",
 46		Usage:    "admin runners",
 47		Examples: []string{"admin runners"},
 48		ReadOnly: true, Run: runAdminRunners})
 49	register(Command{Path: []string{"admin", "runners", "remove"},
 50		Summary:  "drop a key's runner heartbeat row, e.g. one that polled once by mistake (instance admins)",
 51		Usage:    "admin runners remove <fingerprint>",
 52		Examples: []string{"admin runners remove SHA256:abcd1234"},
 53		Run:      runAdminRunnersForget})
 54	// forget is the name this shipped under in v1.18; remove is the verb
 55	// every other noun uses. Both stay for one release.
 56	register(Command{Path: []string{"admin", "runners", "forget"},
 57		Summary:  "alias of admin runners remove",
 58		Usage:    "admin runners forget <fingerprint>",
 59		Examples: []string{"admin runners forget SHA256:abcd1234"},
 60		Run:      runAdminRunnersForget})
 61	register(Command{Path: []string{"admin", "repo", "list"},
 62		Summary: "list every repository with size and last push (instance admins)",
 63		Usage:   "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]",
 64		Flags: []Flag{
 65			{"--owner", "<name>", "only this owner's repositories", ""},
 66			{"--visibility", "public|private", "which repositories", ""},
 67			{"--limit", "<n>", "rows per page", ""},
 68			{"--cursor", "<c>", "continue from the previous page", ""},
 69		},
 70		Examples: []string{"admin repo list --owner alice"},
 71		ReadOnly: true, Run: runAdminRepoList})
 72	register(Command{Path: []string{"admin", "repo", "archive"},
 73		Summary:  "archive any repository (instance admins; audited)",
 74		Usage:    "admin repo archive <owner/name>",
 75		Examples: []string{"admin repo archive alice/old-project"},
 76		Run:      runAdminRepoArchive})
 77	register(Command{Path: []string{"admin", "repo", "unarchive"},
 78		Summary:  "unarchive any repository (instance admins; audited)",
 79		Usage:    "admin repo unarchive <owner/name>",
 80		Examples: []string{"admin repo unarchive alice/old-project"},
 81		Run:      runAdminRepoUnarchive})
 82	register(Command{Path: []string{"admin", "repo", "visibility"},
 83		NeedsRecentSignIn: true,
 84		Summary:           "set any repository's visibility (instance admins; audited)",
 85		Usage:             "admin repo visibility <owner/name> public|private",
 86		Examples:          []string{"admin repo visibility alice/secret private"},
 87		Run:               runAdminRepoVisibility})
 88	register(Command{Path: []string{"admin", "repo", "delete"},
 89		Summary: "delete any repository (instance admins; audited)",
 90		Usage:   "admin repo delete <owner/name> --yes",
 91		Flags: []Flag{
 92			{"--yes", "", "confirm the permanent delete", ""},
 93		},
 94		Examples: []string{"admin repo delete alice/spam --yes"},
 95		Run:      runAdminRepoDelete})
 96	register(Command{Path: []string{"admin", "mr", "prune"},
 97		Summary: "drop merged or closed MRs' head refs and the objects only they kept, e.g. after a history rewrite (instance admins; audited)",
 98		Usage:   "admin mr prune <owner/name> <n> [<n>...] --yes",
 99		Flags: []Flag{
100			{"--yes", "", "confirm the permanent prune", ""},
101		},
102		Examples: []string{"admin mr prune krz/gitbay 12 13 --yes"},
103		Run:      runAdminMRPrune})
104}
105
106// requireInstanceAdmin gates the admin noun. -1 means proceed.
107func requireInstanceAdmin(c *Ctx) int {
108	if !c.User.IsAdmin {
109		return c.fail(protocol.ExitDenied, "admin commands are for instance admins; ask one")
110	}
111	return -1
112}
113
114// adminUserOut is one account row, shared by list and show.
115type adminUserOut struct {
116	Username  string `json:"username"`
117	State     string `json:"state"` // active | pending | disabled
118	Admin     bool   `json:"admin"`
119	CreatedAt string `json:"created_at"`
120	LastSeen  string `json:"last_seen,omitempty"`
121}
122
123func adminUserRow(u store.AdminUser) adminUserOut {
124	state := "active"
125	switch {
126	case u.Disabled:
127		state = "disabled"
128	case u.Pending:
129		state = "pending"
130	}
131	return adminUserOut{u.Username, state, u.IsAdmin, u.CreatedAt, u.LastSeen}
132}
133
134func runAdminUserList(c *Ctx, args []string) int {
135	if code := requireInstanceAdmin(c); code >= 0 {
136		return code
137	}
138	args, p, code := parsePageFlags(c, args, "admin-user", false)
139	if code >= 0 {
140		return code
141	}
142	f, err := c.parseArgs(args, flagSpec{Values: []string{"--state"}, MaxPos: 0,
143		Usage: "admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]"})
144	if err != nil {
145		return c.fail(protocol.ExitUsage, "%v", err)
146	}
147	state := f.Value("--state")
148	switch state {
149	case "", "active", "pending", "disabled", "admin":
150	default:
151		return c.fail(protocol.ExitUsage, "--state requires active|pending|disabled|admin")
152	}
153	users, err := c.Store.ListUsers(state, p.queryLimit(), p.key)
154	if err != nil {
155		return c.fail(protocol.ExitFailure, "%v", err)
156	}
157	users, next := trimPage(p, users, "admin-user", func(u store.AdminUser) string { return u.Username })
158	var ds []adminUserOut
159	for _, u := range users {
160		ds = append(ds, adminUserRow(u))
161	}
162	return c.emitPageView(p, ds, next, func(w io.Writer) {
163		tb := c.table(w, "USERNAME", "STATE", "ADMIN", "CREATED", "LAST SEEN")
164		for _, d := range ds {
165			mark := ""
166			if d.Admin {
167				mark = "admin"
168			}
169			tb.row(cRef(d.Username), cState(d.State), cText(mark), cAge(d.CreatedAt), cAge(d.LastSeen))
170		}
171		tb.flush()
172	}, func() screen {
173		rows := make([]row, len(ds))
174		for i, d := range ds {
175			lead := cGlyph("")
176			if d.State == "pending" {
177				lead = cYou()
178			}
179			admin, seen := "", ""
180			if d.Admin {
181				admin = "admin"
182			}
183			if d.LastSeen != "" {
184				seen = "seen " + relAge(d.LastSeen, termNow())
185			}
186			rows[i] = rowOf(cRef(d.Username), lead, cState(d.State), cMeta(admin, seen))
187		}
188		return listScreen("Accounts", rows,
189			action{"Filter", []string{"admin", "user", "list", "--state", "pending"}},
190		)
191	})
192}
193
194func runAdminUserShow(c *Ctx, args []string) int {
195	if code := requireInstanceAdmin(c); code >= 0 {
196		return code
197	}
198	if len(args) != 1 {
199		return c.usage()
200	}
201	name := args[0]
202	u, err := c.Store.UserByUsername(name)
203	if errors.Is(err, store.ErrNotFound) {
204		return c.fail(protocol.ExitNotFound, "no user %q", name)
205	} else if err != nil {
206		return c.fail(protocol.ExitFailure, "%v", err)
207	}
208	row, err := c.Store.AdminUserByName(name)
209	if err != nil {
210		return c.fail(protocol.ExitFailure, "%v", err)
211	}
212
213	type keyOut struct {
214		Fingerprint string `json:"fingerprint"`
215		Algo        string `json:"algo"`
216		Scope       string `json:"scope"`
217		Label       string `json:"label"`
218		CreatedAt   string `json:"created_at"`
219		LastUsedAt  string `json:"last_used_at,omitempty"`
220	}
221	type emailOut struct {
222		Address    string `json:"address"`
223		Verified   bool   `json:"verified"`
224		VerifiedBy string `json:"verified_by,omitempty"` // smtp | admin
225		Primary    bool   `json:"primary"`
226	}
227	type pgpOut struct {
228		Fingerprint string     `json:"fingerprint"`
229		ExpiresAt   *time.Time `json:"expires_at,omitempty"`
230		RevokedAt   *time.Time `json:"revoked_at,omitempty"`
231	}
232	type orgOut struct {
233		Org  string `json:"org"`
234		Role string `json:"role"`
235	}
236	type tokenOut struct {
237		Name       string     `json:"name"`
238		Scope      string     `json:"scope"`
239		CreatedAt  string     `json:"created_at"`
240		ExpiresAt  *time.Time `json:"expires_at,omitempty"`
241		LastUsedAt *time.Time `json:"last_used_at,omitempty"`
242	}
243	type out struct {
244		adminUserOut
245		Keys        []keyOut   `json:"keys"`
246		Emails      []emailOut `json:"emails"`
247		PGPKeys     []pgpOut   `json:"pgp_keys"`
248		Orgs        []orgOut   `json:"orgs"`
249		Repos       int64      `json:"repos"`
250		RepoLimit   int64      `json:"repo_limit"` // 0 unlimited
251		ByteLimit   int64      `json:"byte_limit"` // 0 unlimited
252		APITokens   []tokenOut `json:"api_tokens"`
253		WebSessions int64      `json:"web_sessions"`
254		DeleteAfter string     `json:"delete_after,omitempty"` // a scheduled self-deletion
255	}
256	d := out{adminUserOut: adminUserRow(row),
257		Keys: []keyOut{}, Emails: []emailOut{}, PGPKeys: []pgpOut{}, Orgs: []orgOut{}, APITokens: []tokenOut{}}
258
259	keys, err := c.Store.ListSSHKeys(u.ID)
260	if err != nil {
261		return c.fail(protocol.ExitFailure, "%v", err)
262	}
263	for _, k := range keys {
264		d.Keys = append(d.Keys, keyOut{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedAt, k.LastUsedAt})
265	}
266	emails, err := c.Store.ListEmails(u.ID)
267	if err != nil {
268		return c.fail(protocol.ExitFailure, "%v", err)
269	}
270	for _, e := range emails {
271		d.Emails = append(d.Emails, emailOut{e.Address, e.Verified, e.VerifiedBy, e.Primary})
272	}
273	pgp, err := c.Store.ListPGPKeys(u.ID)
274	if err != nil {
275		return c.fail(protocol.ExitFailure, "%v", err)
276	}
277	for _, k := range pgp {
278		d.PGPKeys = append(d.PGPKeys, pgpOut{k.Fingerprint, k.ExpiresAt, k.RevokedAt})
279	}
280	orgs, err := c.Store.ListOrgsForUser(u.ID)
281	if err != nil {
282		return c.fail(protocol.ExitFailure, "%v", err)
283	}
284	for _, m := range orgs {
285		d.Orgs = append(d.Orgs, orgOut{m.Username, m.Role})
286	}
287	if d.Repos, err = c.Store.OwnedRepoCount("user", u.ID); err != nil {
288		return c.fail(protocol.ExitFailure, "%v", err)
289	}
290	d.RepoLimit = RepoLimit(c.Store, limitsOf(c), "user", u.ID)
291	d.ByteLimit = ByteLimit(c.Store, limitsOf(c), "user", u.ID)
292	tokens, err := c.Store.ListAPITokens(u.ID)
293	if err != nil {
294		return c.fail(protocol.ExitFailure, "%v", err)
295	}
296	for _, t := range tokens {
297		d.APITokens = append(d.APITokens, tokenOut{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt})
298	}
299	if d.WebSessions, err = c.Store.WebSessionCount(u.ID); err != nil {
300		return c.fail(protocol.ExitFailure, "%v", err)
301	}
302	d.DeleteAfter = u.DeleteAfter
303
304	return c.emitView(d, func(w io.Writer) {
305		admin := ""
306		if d.Admin {
307			admin = "yes"
308		}
309		v := c.view(w)
310		v.title(d.Username, "", d.State)
311		v.fields(
312			"admin", admin,
313			"created", c.when(d.CreatedAt),
314			"last seen", c.when(d.LastSeen),
315			"repos", fmt.Sprintf("%d", d.Repos),
316			"web sessions", fmt.Sprintf("%d", d.WebSessions),
317			"deletes at", c.when(d.DeleteAfter),
318		)
319		if len(d.Keys) > 0 {
320			v.section("keys")
321			tk := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LAST USED")
322			for _, k := range d.Keys {
323				tk.row(cFlex(k.Fingerprint), cText(k.Algo), cState(k.Scope), cAge(k.LastUsedAt))
324			}
325			tk.flush()
326		}
327		if len(d.Emails) > 0 {
328			v.section("emails")
329			te := c.table(w, "ADDRESS", "STATE")
330			for _, e := range d.Emails {
331				state := "unverified"
332				if e.Verified {
333					state = "verified by " + e.VerifiedBy
334				}
335				cells := []cell{cRef(e.Address), cState(state)}
336				if e.Primary {
337					cells = c.note(cells, 1, "primary", "primary")
338				}
339				te.row(cells...)
340			}
341			te.flush()
342		}
343		if len(d.PGPKeys) > 0 {
344			v.section("pgp keys")
345			tp := c.table(w, "FINGERPRINT")
346			for _, k := range d.PGPKeys {
347				tp.row(cFlex(k.Fingerprint))
348			}
349			tp.flush()
350		}
351		if len(d.Orgs) > 0 {
352			v.section("orgs")
353			to := c.table(w, "ORG", "ROLE")
354			for _, o := range d.Orgs {
355				to.row(cRef(o.Org), cState(o.Role))
356			}
357			to.flush()
358		}
359		if len(d.APITokens) > 0 {
360			v.section("api tokens")
361			tt := c.table(w, "NAME", "SCOPE", "LAST USED")
362			for _, t := range d.APITokens {
363				used := ""
364				if t.LastUsedAt != nil {
365					used = t.LastUsedAt.UTC().Format(time.RFC3339Nano)
366				}
367				tt.row(cRef(t.Name), cState(t.Scope), cAge(used))
368			}
369			tt.flush()
370		}
371	}, func() screen {
372		s := screen{}
373		user := []cell{cRef(d.Username), cState(d.State)}
374		if d.Admin {
375			user = append(user, cMeta("admin"))
376		}
377		seen := "never seen"
378		if d.LastSeen != "" {
379			seen = "seen " + relAge(d.LastSeen, termNow())
380		}
381		s.fields = []field{
382			{"User", user},
383			{"Seen", []cell{cText(seen), cMeta("created " + relAge(d.CreatedAt, termNow()))}},
384			{"Repos", []cell{cText(strconv.FormatInt(d.Repos, 10))}},
385			{"Sessions", []cell{cText(strconv.FormatInt(d.WebSessions, 10))}},
386		}
387		keys := section{title: "Keys", n: len(d.Keys)}
388		for _, k := range d.Keys {
389			keys.rows = append(keys.rows, rowOf(cFlexRef(k.Fingerprint), cState(k.Scope), cMeta(k.Algo, "used "+relAge(k.LastUsedAt, termNow()))))
390		}
391		emails := section{title: "Emails", n: len(d.Emails)}
392		for _, e := range d.Emails {
393			state, primary := "unverified", ""
394			if e.Verified {
395				state = "verified"
396			}
397			if e.Primary {
398				primary = "primary"
399			}
400			emails.rows = append(emails.rows, rowOf(cRef(e.Address), cState(state), cMeta(primary, e.VerifiedBy)))
401		}
402		pgp := section{title: "PGP keys", n: len(d.PGPKeys)}
403		for _, k := range d.PGPKeys {
404			pgp.rows = append(pgp.rows, rowOf(cFlexRef(k.Fingerprint)))
405		}
406		orgs := section{title: "Orgs", n: len(d.Orgs)}
407		for _, o := range d.Orgs {
408			orgs.rows = append(orgs.rows, rowOf(cLink(o.Org, c.siteURL(o.Org)), cState(o.Role)))
409		}
410		tokens := section{title: "API tokens", n: len(d.APITokens)}
411		for _, tk := range d.APITokens {
412			used := ""
413			if tk.LastUsedAt != nil {
414				used = "used " + relAge(tk.LastUsedAt.UTC().Format(time.RFC3339Nano), termNow())
415			}
416			tokens.rows = append(tokens.rows, rowOf(cRef(tk.Name), cState(tk.Scope), cMeta(used)))
417		}
418		s.sections = []section{keys, emails, pgp, orgs, tokens}
419		role, state := "promote", "disable"
420		if d.Admin {
421			role = "demote"
422		}
423		if d.State == "disabled" {
424			state = "enable"
425		}
426		s.actions = []action{
427			{"Manage", []string{"admin", "user", role, d.Username}},
428			{"Manage", []string{"admin", "user", state, d.Username}},
429		}
430		return s
431	})
432}
433
434func runAdminUserPromote(c *Ctx, args []string) int { return setAdmin(c, args, true) }
435func runAdminUserDemote(c *Ctx, args []string) int  { return setAdmin(c, args, false) }
436
437func setAdmin(c *Ctx, args []string, admin bool) int {
438	if code := requireInstanceAdmin(c); code >= 0 {
439		return code
440	}
441	verb := "demote"
442	if admin {
443		verb = "promote"
444	}
445	if len(args) != 1 {
446		return c.usage()
447	}
448	u, err := c.Store.UserByUsername(args[0])
449	if errors.Is(err, store.ErrNotFound) {
450		return c.fail(protocol.ExitNotFound, "no user %q", args[0])
451	} else if err != nil {
452		return c.fail(protocol.ExitFailure, "%v", err)
453	}
454	if u.IsAdmin == admin {
455		return c.fail(protocol.ExitUsage, "%s is already %s", u.Username, map[bool]string{true: "an admin", false: "not an admin"}[admin])
456	}
457	if admin && (u.Pending || u.Disabled) {
458		return c.fail(protocol.ExitUsage, "%s is %s; only an active account can be an admin", u.Username,
459			map[bool]string{true: "disabled", false: "pending"}[u.Disabled])
460	}
461	if err := c.Store.SetUserAdmin(u.ID, admin); err != nil {
462		if errors.Is(err, store.ErrLastAdmin) {
463			return c.failErr(err)
464		}
465		return c.fail(protocol.ExitFailure, "%v", err)
466	}
467	c.Store.Audit(c.User.ID, "admin user."+verb+"d", map[string]any{"user": u.Username})
468	return c.emit(map[string]any{"user": u.Username, "admin": admin}, func(w io.Writer) {
469		fmt.Fprintf(w, "%sd %s\n", verb, u.Username)
470	})
471}
472
473// adminRepo loads a repository for an admin override. Instance admin
474// carries no implicit read right, so policy is not consulted; the only
475// refusal is a path that does not exist. Every caller audits what it does.
476func adminRepo(c *Ctx, path string) (store.Repo, int) {
477	if code := requireInstanceAdmin(c); code >= 0 {
478		return store.Repo{}, code
479	}
480	repo, err := c.Store.RepoByPath(path)
481	if errors.Is(err, store.ErrNotFound) {
482		return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path)
483	} else if err != nil {
484		return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err)
485	}
486	return repo, -1
487}
488
489func runAdminRepoList(c *Ctx, args []string) int {
490	if code := requireInstanceAdmin(c); code >= 0 {
491		return code
492	}
493	args, p, code := parsePageFlags(c, args, "admin-repo", false)
494	if code >= 0 {
495		return code
496	}
497	f, err := c.parseArgs(args, flagSpec{Values: []string{"--owner", "--visibility"}, MaxPos: 0,
498		Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]"})
499	if err != nil {
500		return c.fail(protocol.ExitUsage, "%v", err)
501	}
502	owner, visibility := f.Value("--owner"), f.Value("--visibility")
503	if visibility != "" && visibility != "public" && visibility != "private" {
504		return c.fail(protocol.ExitUsage, "--visibility requires public|private")
505	}
506	repos, err := c.Store.ListReposAdmin(owner, visibility, p.queryLimit(), p.key)
507	if err != nil {
508		return c.fail(protocol.ExitFailure, "%v", err)
509	}
510	repos, next := trimPage(p, repos, "admin-repo", func(r store.AdminRepo) string { return r.Path })
511	type out struct {
512		Path       string `json:"path"`
513		Visibility string `json:"visibility"`
514		Archived   bool   `json:"archived,omitempty"`
515		CreatedAt  string `json:"created_at"`
516		LastPush   string `json:"last_push,omitempty"`
517		Bytes      int64  `json:"bytes"`
518	}
519	var ds []out
520	for _, r := range repos {
521		size := gitutil.DirSize(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
522		ds = append(ds, out{r.Path, r.Visibility, r.Archived, r.CreatedAt, r.LastPush, size})
523	}
524	return c.emitPageView(p, ds, next, func(w io.Writer) {
525		tb := c.table(w, "PATH", "VISIBILITY", "BYTES", "CREATED", "LAST PUSH")
526		for _, d := range ds {
527			cells := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cSize(d.Bytes), cAge(d.CreatedAt), cAge(d.LastPush)}
528			if d.Archived {
529				cells = c.note(cells, 1, "[archived]", "archived")
530			}
531			tb.row(cells...)
532		}
533		tb.flush()
534	}, func() screen {
535		rows := make([]row, len(ds))
536		for i, d := range ds {
537			state := d.Visibility
538			if d.Archived {
539				state += ", archived"
540			}
541			pushed := ""
542			if d.LastPush != "" {
543				pushed = "pushed " + relAge(d.LastPush, termNow())
544			}
545			rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cState(state), cSize(d.Bytes), cMeta(pushed))
546		}
547		return listScreen("Repositories", rows,
548			action{"Filter", []string{"admin", "repo", "list", "--visibility", "private"}},
549		)
550	})
551}
552
553func runAdminRepoArchive(c *Ctx, args []string) int   { return adminArchive(c, args, true) }
554func runAdminRepoUnarchive(c *Ctx, args []string) int { return adminArchive(c, args, false) }
555
556func adminArchive(c *Ctx, args []string, archived bool) int {
557	verb := "archive"
558	if !archived {
559		verb = "unarchive"
560	}
561	if len(args) != 1 {
562		return c.usage()
563	}
564	repo, code := adminRepo(c, args[0])
565	if code >= 0 {
566		return code
567	}
568	if code := archiveRepo(c, repo, archived); code != protocol.ExitOK {
569		return code
570	}
571	c.Store.Audit(c.User.ID, "admin repo."+verb, map[string]any{"repo": repo.Path()})
572	return protocol.ExitOK
573}
574
575func runAdminRepoVisibility(c *Ctx, args []string) int {
576	if len(args) != 2 || (args[1] != "public" && args[1] != "private") {
577		return c.usage()
578	}
579	repo, code := adminRepo(c, args[0])
580	if code >= 0 {
581		return code
582	}
583	if code := setRepoVisibility(c, repo, args[1]); code != protocol.ExitOK {
584		return code
585	}
586	c.Store.Audit(c.User.ID, "admin repo.visibility", map[string]any{"repo": repo.Path(), "visibility": args[1]})
587	return protocol.ExitOK
588}
589
590func runAdminRepoDelete(c *Ctx, args []string) int {
591	var path string
592	var yes bool
593	for _, a := range args {
594		if a == "--yes" {
595			yes = true
596		} else if path == "" {
597			path = a
598		} else {
599			return c.usage()
600		}
601	}
602	if path == "" {
603		return c.usage()
604	}
605	repo, code := adminRepo(c, path)
606	if code >= 0 {
607		return code
608	}
609	if !yes {
610		return c.fail(protocol.ExitUsage, "admin repo delete is permanent; re-run with --yes")
611	}
612	if code := deleteRepo(c, repo); code != protocol.ExitOK {
613		return code
614	}
615	c.Store.Audit(c.User.ID, "admin repo.delete", map[string]any{"repo": repo.Path()})
616	return protocol.ExitOK
617}
618
619func runAdminRunnersForget(c *Ctx, args []string) int {
620	if code := requireInstanceAdmin(c); code >= 0 {
621		return code
622	}
623	if len(args) != 1 {
624		return c.usage()
625	}
626	if err := c.Store.ForgetRunner(args[0]); err != nil {
627		if errors.Is(err, store.ErrNotFound) {
628			return c.fail(protocol.ExitNotFound, "no runner has polled with %s", args[0])
629		}
630		return c.fail(protocol.ExitFailure, "%v", err)
631	}
632	c.Store.Audit(c.User.ID, "admin runners.forget", map[string]any{"fingerprint": args[0]})
633	return c.emit(map[string]string{"forgot": args[0]}, func(w io.Writer) {
634		fmt.Fprintf(w, "forgot runner %s\n", args[0])
635	})
636}
637
638func runAdminRunners(c *Ctx, args []string) int {
639	if code := requireInstanceAdmin(c); code >= 0 {
640		return code
641	}
642	if len(args) != 0 {
643		return c.usage()
644	}
645	runners, err := c.Store.ListRunners()
646	if err != nil {
647		return c.fail(protocol.ExitFailure, "%v", err)
648	}
649	queue, err := c.Store.QueueStats()
650	if err != nil {
651		return c.fail(protocol.ExitFailure, "%v", err)
652	}
653	if runners == nil {
654		runners = []store.Runner{}
655	}
656	// The scope column is what the key may claim, not what it asked for. A
657	// runner key is confined to its attachments, so they replace whatever
658	// -repos it polled with, and none of them means none. Any other key
659	// keeps the repositories it asked for, or the whole instance.
660	for i := range runners {
661		key, err := c.Store.SSHKeyByID(runners[i].KeyID)
662		if err != nil || key.Scope != "runner" {
663			continue
664		}
665		paths, err := c.Store.RunnerRepoPaths(runners[i].KeyID)
666		if err != nil {
667			return c.fail(protocol.ExitFailure, "%v", err)
668		}
669		runners[i].Scope = "none"
670		if len(paths) > 0 {
671			runners[i].Scope = strings.Join(paths, ",")
672		}
673	}
674	d := map[string]any{"queue": queue, "runners": runners}
675	return c.emitView(d, func(w io.Writer) {
676		v := c.view(w)
677		v.fields(
678			"pending", fmt.Sprintf("%d", queue.Pending),
679			"claimed 24h", fmt.Sprintf("%d", queue.Claimed24h),
680			"wait avg", c.Term.dur(queue.ClaimWaitAvgS),
681			"wait max", c.Term.dur(queue.ClaimWaitMaxS),
682			"reaped 24h", fmt.Sprintf("%d", queue.Reaped24h),
683		)
684		if len(runners) > 0 {
685			v.section("runners")
686		}
687		tb := c.table(w, "USER", "FINGERPRINT", "LAST SEEN", "SCOPE", "HELD")
688		for _, r := range runners {
689			scope := r.Scope
690			if scope == "" {
691				scope = "any"
692			}
693			held := "idle"
694			if r.BuildNumber != 0 {
695				held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt)
696			}
697			tb.row(cText(r.Username), cText(r.Fingerprint), cAge(r.LastSeen), cFlex(scope), cText(held))
698		}
699		tb.flush()
700	}, func() screen {
701		s := screen{fields: []field{{"Queue", []cell{
702			cText(fmt.Sprintf("%d pending", queue.Pending)),
703			cMeta(fmt.Sprintf("%d claimed in 24h", queue.Claimed24h), "wait avg "+c.Term.dur(queue.ClaimWaitAvgS),
704				"max "+c.Term.dur(queue.ClaimWaitMaxS), fmt.Sprintf("%d reaped", queue.Reaped24h)),
705		}}}}
706		runnersSec := section{title: "Runners", n: len(runners)}
707		idle := ""
708		for _, r := range runners {
709			scope := r.Scope
710			if scope == "" {
711				scope = "any"
712			}
713			lead, held := cGlyph(""), "idle"
714			if r.BuildNumber != 0 {
715				lead, held = cGlyph("running"), fmt.Sprintf("building %s #%d %s", r.BuildRepo, r.BuildNumber, r.BuildJob)
716			} else if idle == "" {
717				idle = r.Fingerprint
718			}
719			runnersSec.rows = append(runnersSec.rows, rowOf(cRef(r.Username), lead, cFlex(scope), cMeta("seen "+relAge(r.LastSeen, termNow()), held)))
720		}
721		s.sections = []section{runnersSec}
722		if idle != "" {
723			s.actions = []action{{"Prune", []string{"admin", "runners", "remove", idle}}}
724		}
725		return s
726	})
727}
728
729type mrPruneOut struct {
730	Number int64  `json:"number"`
731	Head   string `json:"head_sha"` // what the ref pointed at; empty if it was already gone
732}
733
734// runAdminMRPrune deletes refs/merge-requests/<n>/head for the named MRs
735// and prunes the repository at once, so commits a history rewrite left
736// reachable only through them stop being fetchable. Nothing drops a head
737// ref on its own: an open or source-gone MR is merged through it, and a
738// merged or closed one keeps its diff readable through it. Every check
739// runs before the first write.
740func runAdminMRPrune(c *Ctx, args []string) int {
741	var path string
742	var yes bool
743	var numbers []int64
744	for _, a := range args {
745		switch {
746		case a == "--yes":
747			yes = true
748		case path == "":
749			path = a
750		default:
751			n, err := strconv.ParseInt(a, 10, 64)
752			if err != nil || n <= 0 {
753				return c.usage()
754			}
755			if !slices.Contains(numbers, n) {
756				numbers = append(numbers, n)
757			}
758		}
759	}
760	if path == "" || len(numbers) == 0 {
761		return c.usage()
762	}
763	repo, code := adminRepo(c, path)
764	if code >= 0 {
765		return code
766	}
767	if !yes {
768		return c.fail(protocol.ExitUsage, "admin mr prune drops the commits for good; re-run with --yes")
769	}
770	mrs := make([]store.MR, 0, len(numbers))
771	for _, n := range numbers {
772		mr, err := c.Store.MRByNumber(repo.ID, n)
773		if errors.Is(err, store.ErrNotFound) {
774			return c.fail(protocol.ExitNotFound, "MR !%d not found in %s", n, repo.Path())
775		} else if err != nil {
776			return c.fail(protocol.ExitFailure, "%v", err)
777		}
778		if mr.State != "merged" && mr.State != "closed" {
779			return c.fail(protocol.ExitFailure, "!%d is still mergeable and its head is what makes it so; merge or close it first", n)
780		}
781		mrs = append(mrs, mr)
782	}
783
784	// The prune would remove objects a running full backup has listed
785	// and not yet read.
786	release, code := holdOffBackup(c)
787	if code >= 0 {
788		return code
789	}
790	defer release()
791
792	// The record is written as each ref goes, not after the gc: a failure
793	// past this point leaves refs deleted, and the audit log and the MR
794	// thread must say so. Re-running the same command finishes the job.
795	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
796	rows := make([]mrPruneOut, 0, len(mrs))
797	for _, mr := range mrs {
798		ref := mrHeadRef(mr.Number)
799		row := mrPruneOut{Number: mr.Number}
800		if gitutil.RefExists(dir, ref) {
801			row.Head, _ = gitutil.ResolveRef(dir, ref)
802			if err := gitutil.DeleteRef(dir, ref); err != nil {
803				c.Store.Audit(c.User.ID, "admin mr.prune", map[string]any{"repo": repo.Path(), "numbers": numbers, "failed": err.Error()})
804				return c.fail(protocol.ExitFailure, "%v; the refs before !%d are deleted and not yet pruned; re-run the same command", err, mr.Number)
805			}
806		}
807		c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("head ref pruned by %s; the diff is no longer available", c.User.Username))
808		rows = append(rows, row)
809	}
810	c.Store.Audit(c.User.ID, "admin mr.prune", map[string]any{"repo": repo.Path(), "numbers": numbers})
811	if err := gitutil.PruneNow(dir); err != nil {
812		return c.fail(protocol.ExitFailure, "%v; the head refs are deleted but the objects are not yet pruned; re-run the same command", err)
813	}
814	return c.emitView(rows, func(w io.Writer) {
815		tb := c.table(w, "!", "HEAD")
816		for _, r := range rows {
817			if r.Head == "" {
818				tb.row(cRef(fmt.Sprintf("!%d", r.Number)), cText("already gone"))
819				continue
820			}
821			tb.row(cRef(fmt.Sprintf("!%d", r.Number)), cRef(r.Head))
822		}
823		tb.flush()
824	}, func() screen {
825		rs := make([]row, len(rows))
826		for i, r := range rows {
827			n := strconv.FormatInt(r.Number, 10)
828			ref := cLink("!"+n, c.siteURL(repo.Path(), "mrs", n))
829			if r.Head == "" {
830				rs[i] = rowOf(ref, cGlyph("skipped"), cMeta("already gone"))
831				continue
832			}
833			rs[i] = rowOf(ref, cGlyph("ok"), cRef(fmt.Sprintf("%.10s", r.Head)))
834		}
835		s := listScreen("Pruned", rs)
836		if len(rows) > 0 {
837			s.actions = []action{{"Read", []string{"mr", "show", repo.Path(), strconv.FormatInt(rows[0].Number, 10)}}}
838		}
839		return s
840	})
841}