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