internal/control/admin.go
701 lines · 23646 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 = append(cells, cText("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{cRef(d.Path), cState(d.Visibility), cNum(d.Bytes), cAge(d.CreatedAt), cAge(d.LastPush)}
446 if d.Archived {
447 cells = append(cells, cText("[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", fmt.Sprintf("%ds", queue.ClaimWaitAvgS),
583 "wait max", fmt.Sprintf("%ds", 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), cFlex(r.Fingerprint), cAge(r.LastSeen), cText(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}