internal/control/admin.go
691 lines · 23344 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 := c.parseArgs(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 := c.parseArgs(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}