package control import ( "errors" "fmt" "io" "slices" "strconv" "strings" "time" "gitbay.org/gitbay/internal/gitutil" "gitbay.org/gitbay/internal/protocol" "gitbay.org/gitbay/internal/store" ) func init() { register(Command{Path: []string{"admin", "user", "list"}, Summary: "list accounts (instance admins)", Usage: "admin user list [--state active|pending|disabled|admin] [--limit ] [--cursor ]", Flags: []Flag{ {"--state", "active|pending|disabled|admin", "which accounts", ""}, {"--limit", "", "rows per page", ""}, {"--cursor", "", "continue from the previous page", ""}, }, Examples: []string{"admin user list --state pending"}, ReadOnly: true, Run: runAdminUserList}) register(Command{Path: []string{"admin", "user", "show"}, Summary: "show an account: keys, emails, orgs, tokens, sessions (instance admins)", Usage: "admin user show ", Examples: []string{"admin user show alice"}, ReadOnly: true, Run: runAdminUserShow}) register(Command{Path: []string{"admin", "user", "promote"}, NeedsRecentSignIn: true, Summary: "make an account an instance admin", Usage: "admin user promote ", Examples: []string{"admin user promote alice"}, Run: runAdminUserPromote}) register(Command{Path: []string{"admin", "user", "demote"}, Summary: "remove instance admin from an account (never the last one)", Usage: "admin user demote ", Examples: []string{"admin user demote alice"}, Run: runAdminUserDemote}) register(Command{Path: []string{"admin", "runners"}, Summary: "the build queue and runner accounts: last poll, scope, the build each holds (instance admins)", Usage: "admin runners", Examples: []string{"admin runners"}, ReadOnly: true, Run: runAdminRunners}) register(Command{Path: []string{"admin", "runners", "remove"}, Summary: "drop a key's runner heartbeat row, e.g. one that polled once by mistake (instance admins)", Usage: "admin runners remove ", Examples: []string{"admin runners remove SHA256:abcd1234"}, Run: runAdminRunnersForget}) // forget is the name this shipped under in v1.18; remove is the verb // every other noun uses. Both stay for one release. register(Command{Path: []string{"admin", "runners", "forget"}, Summary: "alias of admin runners remove", Usage: "admin runners forget ", Examples: []string{"admin runners forget SHA256:abcd1234"}, Run: runAdminRunnersForget}) register(Command{Path: []string{"admin", "repo", "list"}, Summary: "list every repository with size and last push (instance admins)", Usage: "admin repo list [--owner ] [--visibility public|private] [--limit ] [--cursor ]", Flags: []Flag{ {"--owner", "", "only this owner's repositories", ""}, {"--visibility", "public|private", "which repositories", ""}, {"--limit", "", "rows per page", ""}, {"--cursor", "", "continue from the previous page", ""}, }, Examples: []string{"admin repo list --owner alice"}, ReadOnly: true, Run: runAdminRepoList}) register(Command{Path: []string{"admin", "repo", "archive"}, Summary: "archive any repository (instance admins; audited)", Usage: "admin repo archive ", Examples: []string{"admin repo archive alice/old-project"}, Run: runAdminRepoArchive}) register(Command{Path: []string{"admin", "repo", "unarchive"}, Summary: "unarchive any repository (instance admins; audited)", Usage: "admin repo unarchive ", Examples: []string{"admin repo unarchive alice/old-project"}, Run: runAdminRepoUnarchive}) register(Command{Path: []string{"admin", "repo", "visibility"}, NeedsRecentSignIn: true, Summary: "set any repository's visibility (instance admins; audited)", Usage: "admin repo visibility public|private", Examples: []string{"admin repo visibility alice/secret private"}, Run: runAdminRepoVisibility}) register(Command{Path: []string{"admin", "repo", "delete"}, Summary: "delete any repository (instance admins; audited)", Usage: "admin repo delete --yes", Flags: []Flag{ {"--yes", "", "confirm the permanent delete", ""}, }, Examples: []string{"admin repo delete alice/spam --yes"}, Run: runAdminRepoDelete}) register(Command{Path: []string{"admin", "mr", "prune"}, Summary: "drop merged or closed MRs' head refs and the objects only they kept, e.g. after a history rewrite (instance admins; audited)", Usage: "admin mr prune [...] --yes", Flags: []Flag{ {"--yes", "", "confirm the permanent prune", ""}, }, Examples: []string{"admin mr prune krz/gitbay 12 13 --yes"}, Run: runAdminMRPrune}) } // requireInstanceAdmin gates the admin noun. -1 means proceed. func requireInstanceAdmin(c *Ctx) int { if !c.User.IsAdmin { return c.fail(protocol.ExitDenied, "admin commands are for instance admins; ask one") } return -1 } // adminUserOut is one account row, shared by list and show. type adminUserOut struct { Username string `json:"username"` State string `json:"state"` // active | pending | disabled Admin bool `json:"admin"` CreatedAt string `json:"created_at"` LastSeen string `json:"last_seen,omitempty"` } func adminUserRow(u store.AdminUser) adminUserOut { state := "active" switch { case u.Disabled: state = "disabled" case u.Pending: state = "pending" } return adminUserOut{u.Username, state, u.IsAdmin, u.CreatedAt, u.LastSeen} } func runAdminUserList(c *Ctx, args []string) int { if code := requireInstanceAdmin(c); code >= 0 { return code } args, p, code := parsePageFlags(c, args, "admin-user", false) if code >= 0 { return code } f, err := c.parseArgs(args, flagSpec{Values: []string{"--state"}, MaxPos: 0, Usage: "admin user list [--state active|pending|disabled|admin] [--limit ] [--cursor ]"}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } state := f.Value("--state") switch state { case "", "active", "pending", "disabled", "admin": default: return c.fail(protocol.ExitUsage, "--state requires active|pending|disabled|admin") } users, err := c.Store.ListUsers(state, p.queryLimit(), p.key) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } users, next := trimPage(p, users, "admin-user", func(u store.AdminUser) string { return u.Username }) var ds []adminUserOut for _, u := range users { ds = append(ds, adminUserRow(u)) } return c.emitPageView(p, ds, next, func(w io.Writer) { tb := c.table(w, "USERNAME", "STATE", "ADMIN", "CREATED", "LAST SEEN") for _, d := range ds { mark := "" if d.Admin { mark = "admin" } tb.row(cRef(d.Username), cState(d.State), cText(mark), cAge(d.CreatedAt), cAge(d.LastSeen)) } tb.flush() }, func() screen { rows := make([]row, len(ds)) for i, d := range ds { lead := cGlyph("") if d.State == "pending" { lead = cYou() } admin, seen := "", "" if d.Admin { admin = "admin" } if d.LastSeen != "" { seen = "seen " + relAge(d.LastSeen, termNow()) } rows[i] = rowOf(cRef(d.Username), lead, cState(d.State), cMeta(admin, seen)) } return listScreen("Accounts", rows, action{"Filter", []string{"admin", "user", "list", "--state", "pending"}}, ) }) } func runAdminUserShow(c *Ctx, args []string) int { if code := requireInstanceAdmin(c); code >= 0 { return code } if len(args) != 1 { return c.usage() } name := args[0] u, err := c.Store.UserByUsername(name) if errors.Is(err, store.ErrNotFound) { return c.fail(protocol.ExitNotFound, "no user %q", name) } else if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } row, err := c.Store.AdminUserByName(name) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } type keyOut struct { Fingerprint string `json:"fingerprint"` Algo string `json:"algo"` Scope string `json:"scope"` Label string `json:"label"` CreatedAt string `json:"created_at"` LastUsedAt string `json:"last_used_at,omitempty"` } type emailOut struct { Address string `json:"address"` Verified bool `json:"verified"` VerifiedBy string `json:"verified_by,omitempty"` // smtp | admin Primary bool `json:"primary"` } type pgpOut struct { Fingerprint string `json:"fingerprint"` ExpiresAt *time.Time `json:"expires_at,omitempty"` RevokedAt *time.Time `json:"revoked_at,omitempty"` } type orgOut struct { Org string `json:"org"` Role string `json:"role"` } type tokenOut struct { Name string `json:"name"` Scope string `json:"scope"` CreatedAt string `json:"created_at"` ExpiresAt *time.Time `json:"expires_at,omitempty"` LastUsedAt *time.Time `json:"last_used_at,omitempty"` } type out struct { adminUserOut Keys []keyOut `json:"keys"` Emails []emailOut `json:"emails"` PGPKeys []pgpOut `json:"pgp_keys"` Orgs []orgOut `json:"orgs"` Repos int64 `json:"repos"` RepoLimit int64 `json:"repo_limit"` // 0 unlimited ByteLimit int64 `json:"byte_limit"` // 0 unlimited APITokens []tokenOut `json:"api_tokens"` WebSessions int64 `json:"web_sessions"` DeleteAfter string `json:"delete_after,omitempty"` // a scheduled self-deletion } d := out{adminUserOut: adminUserRow(row), Keys: []keyOut{}, Emails: []emailOut{}, PGPKeys: []pgpOut{}, Orgs: []orgOut{}, APITokens: []tokenOut{}} keys, err := c.Store.ListSSHKeys(u.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } for _, k := range keys { d.Keys = append(d.Keys, keyOut{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedAt, k.LastUsedAt}) } emails, err := c.Store.ListEmails(u.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } for _, e := range emails { d.Emails = append(d.Emails, emailOut{e.Address, e.Verified, e.VerifiedBy, e.Primary}) } pgp, err := c.Store.ListPGPKeys(u.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } for _, k := range pgp { d.PGPKeys = append(d.PGPKeys, pgpOut{k.Fingerprint, k.ExpiresAt, k.RevokedAt}) } orgs, err := c.Store.ListOrgsForUser(u.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } for _, m := range orgs { d.Orgs = append(d.Orgs, orgOut{m.Username, m.Role}) } if d.Repos, err = c.Store.OwnedRepoCount("user", u.ID); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } d.RepoLimit = RepoLimit(c.Store, limitsOf(c), "user", u.ID) d.ByteLimit = ByteLimit(c.Store, limitsOf(c), "user", u.ID) tokens, err := c.Store.ListAPITokens(u.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } for _, t := range tokens { d.APITokens = append(d.APITokens, tokenOut{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt}) } if d.WebSessions, err = c.Store.WebSessionCount(u.ID); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } d.DeleteAfter = u.DeleteAfter return c.emitView(d, func(w io.Writer) { admin := "" if d.Admin { admin = "yes" } v := c.view(w) v.title(d.Username, "", d.State) v.fields( "admin", admin, "created", c.when(d.CreatedAt), "last seen", c.when(d.LastSeen), "repos", fmt.Sprintf("%d", d.Repos), "web sessions", fmt.Sprintf("%d", d.WebSessions), "deletes at", c.when(d.DeleteAfter), ) if len(d.Keys) > 0 { v.section("keys") tk := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LAST USED") for _, k := range d.Keys { tk.row(cFlex(k.Fingerprint), cText(k.Algo), cState(k.Scope), cAge(k.LastUsedAt)) } tk.flush() } if len(d.Emails) > 0 { v.section("emails") te := c.table(w, "ADDRESS", "STATE") for _, e := range d.Emails { state := "unverified" if e.Verified { state = "verified by " + e.VerifiedBy } cells := []cell{cRef(e.Address), cState(state)} if e.Primary { cells = c.note(cells, 1, "primary", "primary") } te.row(cells...) } te.flush() } if len(d.PGPKeys) > 0 { v.section("pgp keys") tp := c.table(w, "FINGERPRINT") for _, k := range d.PGPKeys { tp.row(cFlex(k.Fingerprint)) } tp.flush() } if len(d.Orgs) > 0 { v.section("orgs") to := c.table(w, "ORG", "ROLE") for _, o := range d.Orgs { to.row(cRef(o.Org), cState(o.Role)) } to.flush() } if len(d.APITokens) > 0 { v.section("api tokens") tt := c.table(w, "NAME", "SCOPE", "LAST USED") for _, t := range d.APITokens { used := "" if t.LastUsedAt != nil { used = t.LastUsedAt.UTC().Format(time.RFC3339Nano) } tt.row(cRef(t.Name), cState(t.Scope), cAge(used)) } tt.flush() } }, func() screen { s := screen{} user := []cell{cRef(d.Username), cState(d.State)} if d.Admin { user = append(user, cMeta("admin")) } seen := "never seen" if d.LastSeen != "" { seen = "seen " + relAge(d.LastSeen, termNow()) } s.fields = []field{ {"User", user}, {"Seen", []cell{cText(seen), cMeta("created " + relAge(d.CreatedAt, termNow()))}}, {"Repos", []cell{cText(strconv.FormatInt(d.Repos, 10))}}, {"Sessions", []cell{cText(strconv.FormatInt(d.WebSessions, 10))}}, } keys := section{title: "Keys", n: len(d.Keys)} for _, k := range d.Keys { keys.rows = append(keys.rows, rowOf(cFlexRef(k.Fingerprint), cState(k.Scope), cMeta(k.Algo, "used "+relAge(k.LastUsedAt, termNow())))) } emails := section{title: "Emails", n: len(d.Emails)} for _, e := range d.Emails { state, primary := "unverified", "" if e.Verified { state = "verified" } if e.Primary { primary = "primary" } emails.rows = append(emails.rows, rowOf(cRef(e.Address), cState(state), cMeta(primary, e.VerifiedBy))) } pgp := section{title: "PGP keys", n: len(d.PGPKeys)} for _, k := range d.PGPKeys { pgp.rows = append(pgp.rows, rowOf(cFlexRef(k.Fingerprint))) } orgs := section{title: "Orgs", n: len(d.Orgs)} for _, o := range d.Orgs { orgs.rows = append(orgs.rows, rowOf(cLink(o.Org, c.siteURL(o.Org)), cState(o.Role))) } tokens := section{title: "API tokens", n: len(d.APITokens)} for _, tk := range d.APITokens { used := "" if tk.LastUsedAt != nil { used = "used " + relAge(tk.LastUsedAt.UTC().Format(time.RFC3339Nano), termNow()) } tokens.rows = append(tokens.rows, rowOf(cRef(tk.Name), cState(tk.Scope), cMeta(used))) } s.sections = []section{keys, emails, pgp, orgs, tokens} role, state := "promote", "disable" if d.Admin { role = "demote" } if d.State == "disabled" { state = "enable" } s.actions = []action{ {"Manage", []string{"admin", "user", role, d.Username}}, {"Manage", []string{"admin", "user", state, d.Username}}, } return s }) } func runAdminUserPromote(c *Ctx, args []string) int { return setAdmin(c, args, true) } func runAdminUserDemote(c *Ctx, args []string) int { return setAdmin(c, args, false) } func setAdmin(c *Ctx, args []string, admin bool) int { if code := requireInstanceAdmin(c); code >= 0 { return code } verb := "demote" if admin { verb = "promote" } if len(args) != 1 { return c.usage() } u, err := c.Store.UserByUsername(args[0]) if errors.Is(err, store.ErrNotFound) { return c.fail(protocol.ExitNotFound, "no user %q", args[0]) } else if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if u.IsAdmin == admin { return c.fail(protocol.ExitUsage, "%s is already %s", u.Username, map[bool]string{true: "an admin", false: "not an admin"}[admin]) } if admin && (u.Pending || u.Disabled) { return c.fail(protocol.ExitUsage, "%s is %s; only an active account can be an admin", u.Username, map[bool]string{true: "disabled", false: "pending"}[u.Disabled]) } if err := c.Store.SetUserAdmin(u.ID, admin); err != nil { if errors.Is(err, store.ErrLastAdmin) { return c.failErr(err) } return c.fail(protocol.ExitFailure, "%v", err) } c.Store.Audit(c.User.ID, "admin user."+verb+"d", map[string]any{"user": u.Username}) return c.emit(map[string]any{"user": u.Username, "admin": admin}, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, u.Username) }) } // adminRepo loads a repository for an admin override. Instance admin // carries no implicit read right, so policy is not consulted; the only // refusal is a path that does not exist. Every caller audits what it does. func adminRepo(c *Ctx, path string) (store.Repo, int) { if code := requireInstanceAdmin(c); code >= 0 { return store.Repo{}, code } repo, err := c.Store.RepoByPath(path) if errors.Is(err, store.ErrNotFound) { return repo, c.fail(protocol.ExitNotFound, "repository %s not found", path) } else if err != nil { return repo, c.fail(protocol.ExitFailure, "loading repository: %v", err) } return repo, -1 } func runAdminRepoList(c *Ctx, args []string) int { if code := requireInstanceAdmin(c); code >= 0 { return code } args, p, code := parsePageFlags(c, args, "admin-repo", false) if code >= 0 { return code } f, err := c.parseArgs(args, flagSpec{Values: []string{"--owner", "--visibility"}, MaxPos: 0, Usage: "admin repo list [--owner ] [--visibility public|private] [--limit ] [--cursor ]"}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } owner, visibility := f.Value("--owner"), f.Value("--visibility") if visibility != "" && visibility != "public" && visibility != "private" { return c.fail(protocol.ExitUsage, "--visibility requires public|private") } repos, err := c.Store.ListReposAdmin(owner, visibility, p.queryLimit(), p.key) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } repos, next := trimPage(p, repos, "admin-repo", func(r store.AdminRepo) string { return r.Path }) type out struct { Path string `json:"path"` Visibility string `json:"visibility"` Archived bool `json:"archived,omitempty"` CreatedAt string `json:"created_at"` LastPush string `json:"last_push,omitempty"` Bytes int64 `json:"bytes"` } var ds []out for _, r := range repos { size := gitutil.DirSize(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) ds = append(ds, out{r.Path, r.Visibility, r.Archived, r.CreatedAt, r.LastPush, size}) } return c.emitPageView(p, ds, next, func(w io.Writer) { tb := c.table(w, "PATH", "VISIBILITY", "BYTES", "CREATED", "LAST PUSH") for _, d := range ds { cells := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cSize(d.Bytes), cAge(d.CreatedAt), cAge(d.LastPush)} if d.Archived { cells = c.note(cells, 1, "[archived]", "archived") } tb.row(cells...) } tb.flush() }, func() screen { rows := make([]row, len(ds)) for i, d := range ds { state := d.Visibility if d.Archived { state += ", archived" } pushed := "" if d.LastPush != "" { pushed = "pushed " + relAge(d.LastPush, termNow()) } rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cState(state), cSize(d.Bytes), cMeta(pushed)) } return listScreen("Repositories", rows, action{"Filter", []string{"admin", "repo", "list", "--visibility", "private"}}, ) }) } func runAdminRepoArchive(c *Ctx, args []string) int { return adminArchive(c, args, true) } func runAdminRepoUnarchive(c *Ctx, args []string) int { return adminArchive(c, args, false) } func adminArchive(c *Ctx, args []string, archived bool) int { verb := "archive" if !archived { verb = "unarchive" } if len(args) != 1 { return c.usage() } repo, code := adminRepo(c, args[0]) if code >= 0 { return code } if code := archiveRepo(c, repo, archived); code != protocol.ExitOK { return code } c.Store.Audit(c.User.ID, "admin repo."+verb, map[string]any{"repo": repo.Path()}) return protocol.ExitOK } func runAdminRepoVisibility(c *Ctx, args []string) int { if len(args) != 2 || (args[1] != "public" && args[1] != "private") { return c.usage() } repo, code := adminRepo(c, args[0]) if code >= 0 { return code } if code := setRepoVisibility(c, repo, args[1]); code != protocol.ExitOK { return code } c.Store.Audit(c.User.ID, "admin repo.visibility", map[string]any{"repo": repo.Path(), "visibility": args[1]}) return protocol.ExitOK } func runAdminRepoDelete(c *Ctx, args []string) int { var path string var yes bool for _, a := range args { if a == "--yes" { yes = true } else if path == "" { path = a } else { return c.usage() } } if path == "" { return c.usage() } repo, code := adminRepo(c, path) if code >= 0 { return code } if !yes { return c.fail(protocol.ExitUsage, "admin repo delete is permanent; re-run with --yes") } if code := deleteRepo(c, repo); code != protocol.ExitOK { return code } c.Store.Audit(c.User.ID, "admin repo.delete", map[string]any{"repo": repo.Path()}) return protocol.ExitOK } func runAdminRunnersForget(c *Ctx, args []string) int { if code := requireInstanceAdmin(c); code >= 0 { return code } if len(args) != 1 { return c.usage() } if err := c.Store.ForgetRunner(args[0]); err != nil { if errors.Is(err, store.ErrNotFound) { return c.fail(protocol.ExitNotFound, "no runner has polled with %s", args[0]) } return c.fail(protocol.ExitFailure, "%v", err) } c.Store.Audit(c.User.ID, "admin runners.forget", map[string]any{"fingerprint": args[0]}) return c.emit(map[string]string{"forgot": args[0]}, func(w io.Writer) { fmt.Fprintf(w, "forgot runner %s\n", args[0]) }) } func runAdminRunners(c *Ctx, args []string) int { if code := requireInstanceAdmin(c); code >= 0 { return code } if len(args) != 0 { return c.usage() } runners, err := c.Store.ListRunners() if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } queue, err := c.Store.QueueStats() if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if runners == nil { runners = []store.Runner{} } // The scope column is what the key may claim, not what it asked for. A // runner key is confined to its attachments, so they replace whatever // -repos it polled with, and none of them means none. Any other key // keeps the repositories it asked for, or the whole instance. for i := range runners { key, err := c.Store.SSHKeyByID(runners[i].KeyID) if err != nil || key.Scope != "runner" { continue } paths, err := c.Store.RunnerRepoPaths(runners[i].KeyID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } runners[i].Scope = "none" if len(paths) > 0 { runners[i].Scope = strings.Join(paths, ",") } } d := map[string]any{"queue": queue, "runners": runners} return c.emitView(d, func(w io.Writer) { v := c.view(w) v.fields( "pending", fmt.Sprintf("%d", queue.Pending), "claimed 24h", fmt.Sprintf("%d", queue.Claimed24h), "wait avg", c.Term.dur(queue.ClaimWaitAvgS), "wait max", c.Term.dur(queue.ClaimWaitMaxS), "reaped 24h", fmt.Sprintf("%d", queue.Reaped24h), ) if len(runners) > 0 { v.section("runners") } tb := c.table(w, "USER", "FINGERPRINT", "LAST SEEN", "SCOPE", "HELD") for _, r := range runners { scope := r.Scope if scope == "" { scope = "any" } held := "idle" if r.BuildNumber != 0 { held = fmt.Sprintf("%s #%d %s since %s", r.BuildRepo, r.BuildNumber, r.BuildJob, r.StartedAt) } tb.row(cText(r.Username), cText(r.Fingerprint), cAge(r.LastSeen), cFlex(scope), cText(held)) } tb.flush() }, func() screen { s := screen{fields: []field{{"Queue", []cell{ cText(fmt.Sprintf("%d pending", queue.Pending)), cMeta(fmt.Sprintf("%d claimed in 24h", queue.Claimed24h), "wait avg "+c.Term.dur(queue.ClaimWaitAvgS), "max "+c.Term.dur(queue.ClaimWaitMaxS), fmt.Sprintf("%d reaped", queue.Reaped24h)), }}}} runnersSec := section{title: "Runners", n: len(runners)} idle := "" for _, r := range runners { scope := r.Scope if scope == "" { scope = "any" } lead, held := cGlyph(""), "idle" if r.BuildNumber != 0 { lead, held = cGlyph("running"), fmt.Sprintf("building %s #%d %s", r.BuildRepo, r.BuildNumber, r.BuildJob) } else if idle == "" { idle = r.Fingerprint } runnersSec.rows = append(runnersSec.rows, rowOf(cRef(r.Username), lead, cFlex(scope), cMeta("seen "+relAge(r.LastSeen, termNow()), held))) } s.sections = []section{runnersSec} if idle != "" { s.actions = []action{{"Prune", []string{"admin", "runners", "remove", idle}}} } return s }) } type mrPruneOut struct { Number int64 `json:"number"` Head string `json:"head_sha"` // what the ref pointed at; empty if it was already gone } // runAdminMRPrune deletes refs/merge-requests//head for the named MRs // and prunes the repository at once, so commits a history rewrite left // reachable only through them stop being fetchable. Nothing drops a head // ref on its own: an open or source-gone MR is merged through it, and a // merged or closed one keeps its diff readable through it. Every check // runs before the first write. func runAdminMRPrune(c *Ctx, args []string) int { var path string var yes bool var numbers []int64 for _, a := range args { switch { case a == "--yes": yes = true case path == "": path = a default: n, err := strconv.ParseInt(a, 10, 64) if err != nil || n <= 0 { return c.usage() } if !slices.Contains(numbers, n) { numbers = append(numbers, n) } } } if path == "" || len(numbers) == 0 { return c.usage() } repo, code := adminRepo(c, path) if code >= 0 { return code } if !yes { return c.fail(protocol.ExitUsage, "admin mr prune drops the commits for good; re-run with --yes") } mrs := make([]store.MR, 0, len(numbers)) for _, n := range numbers { mr, err := c.Store.MRByNumber(repo.ID, n) if errors.Is(err, store.ErrNotFound) { return c.fail(protocol.ExitNotFound, "MR !%d not found in %s", n, repo.Path()) } else if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if mr.State != "merged" && mr.State != "closed" { return c.fail(protocol.ExitFailure, "!%d is still mergeable and its head is what makes it so; merge or close it first", n) } mrs = append(mrs, mr) } // The prune would remove objects a running full backup has listed // and not yet read. release, code := holdOffBackup(c) if code >= 0 { return code } defer release() // The record is written as each ref goes, not after the gc: a failure // past this point leaves refs deleted, and the audit log and the MR // thread must say so. Re-running the same command finishes the job. dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) rows := make([]mrPruneOut, 0, len(mrs)) for _, mr := range mrs { ref := mrHeadRef(mr.Number) row := mrPruneOut{Number: mr.Number} if gitutil.RefExists(dir, ref) { row.Head, _ = gitutil.ResolveRef(dir, ref) if err := gitutil.DeleteRef(dir, ref); err != nil { c.Store.Audit(c.User.ID, "admin mr.prune", map[string]any{"repo": repo.Path(), "numbers": numbers, "failed": err.Error()}) return c.fail(protocol.ExitFailure, "%v; the refs before !%d are deleted and not yet pruned; re-run the same command", err, mr.Number) } } c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("head ref pruned by %s; the diff is no longer available", c.User.Username)) rows = append(rows, row) } c.Store.Audit(c.User.ID, "admin mr.prune", map[string]any{"repo": repo.Path(), "numbers": numbers}) if err := gitutil.PruneNow(dir); err != nil { return c.fail(protocol.ExitFailure, "%v; the head refs are deleted but the objects are not yet pruned; re-run the same command", err) } return c.emitView(rows, func(w io.Writer) { tb := c.table(w, "!", "HEAD") for _, r := range rows { if r.Head == "" { tb.row(cRef(fmt.Sprintf("!%d", r.Number)), cText("already gone")) continue } tb.row(cRef(fmt.Sprintf("!%d", r.Number)), cRef(r.Head)) } tb.flush() }, func() screen { rs := make([]row, len(rows)) for i, r := range rows { n := strconv.FormatInt(r.Number, 10) ref := cLink("!"+n, c.siteURL(repo.Path(), "mrs", n)) if r.Head == "" { rs[i] = rowOf(ref, cGlyph("skipped"), cMeta("already gone")) continue } rs[i] = rowOf(ref, cGlyph("ok"), cRef(fmt.Sprintf("%.10s", r.Head))) } s := listScreen("Pruned", rs) if len(rows) > 0 { s.actions = []action{{"Read", []string{"mr", "show", repo.Path(), strconv.FormatInt(rows[0].Number, 10)}}} } return s }) }