internal/control/admin.go

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