internal/control/admin.go

dd06e80a071d451ebb7b083363e0580550481114
gitbay/internal/control/admin.go history · blame · raw

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