Commit 00fb4320ce
Verified · cmc
cmd/gitbayd/main.go +2 −11
| @@ -242,15 +242,6 @@ func adminCmd() *cobra.Command { | ||
| 242 | 242 | Use: "admin", |
| 243 | 243 | Short: "host-local administration", |
| 244 | 244 | } |
| 245 | notImplemented := func(use, short string) *cobra.Command { | |
| 246 | return &cobra.Command{ | |
| 247 | Use: use, | |
| 248 | Short: short, | |
| 249 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 250 | return fmt.Errorf("not implemented") | |
| 251 | }, | |
| 252 | } | |
| 253 | } | |
| 254 | 245 | userCmd := &cobra.Command{Use: "user", Short: "manage users"} |
| 255 | 246 | userCmd.AddCommand(adminUserCreateCmd()) |
| 256 | 247 | emailCmd := &cobra.Command{Use: "email", Short: "manage user emails"} |
| @@ -260,8 +251,8 @@ func adminCmd() *cobra.Command { | ||
| 260 | 251 | emailCmd, |
| 261 | 252 | adminInviteCmd(), |
| 262 | 253 | backupCmd(), |
| 263 | notImplemented("gc", "run git gc across repositories"), | |
| 264 | notImplemented("stats", "instance statistics"), | |
| 254 | gcCmd(), | |
| 255 | statsCmd(), | |
| 265 | 256 | ) |
| 266 | 257 | return admin |
| 267 | 258 | } |
cmd/gitbayd/maint.go added +159
| @@ -0,0 +1,159 @@ | ||
| 1 | package main | |
| 2 | ||
| 3 | import ( | |
| 4 | "encoding/json" | |
| 5 | "fmt" | |
| 6 | "io/fs" | |
| 7 | "os" | |
| 8 | "os/exec" | |
| 9 | "path/filepath" | |
| 10 | "text/tabwriter" | |
| 11 | ||
| 12 | "github.com/spf13/cobra" | |
| 13 | ||
| 14 | "gitbay.org/gitbay/internal/config" | |
| 15 | "gitbay.org/gitbay/internal/control" | |
| 16 | "gitbay.org/gitbay/internal/store" | |
| 17 | ) | |
| 18 | ||
| 19 | func gcCmd() *cobra.Command { | |
| 20 | var repoPath string | |
| 21 | var aggressive bool | |
| 22 | cmd := &cobra.Command{ | |
| 23 | Use: "gc", | |
| 24 | Short: "repack and prune repositories (git gc)", | |
| 25 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 26 | cfg, err := config.Load(configPath) | |
| 27 | if err != nil { | |
| 28 | return err | |
| 29 | } | |
| 30 | st, err := openStore(cfg) | |
| 31 | if err != nil { | |
| 32 | return err | |
| 33 | } | |
| 34 | defer st.Close() | |
| 35 | ||
| 36 | var repos []store.Repo | |
| 37 | if repoPath != "" { | |
| 38 | r, err := st.RepoByPath(repoPath) | |
| 39 | if err != nil { | |
| 40 | return fmt.Errorf("no repository %q", repoPath) | |
| 41 | } | |
| 42 | repos = []store.Repo{r} | |
| 43 | } else if repos, err = st.ListAllRepos(); err != nil { | |
| 44 | return err | |
| 45 | } | |
| 46 | ||
| 47 | var before, after int64 | |
| 48 | for _, r := range repos { | |
| 49 | dir := control.RepoDir(cfg.Server.Root, r.OwnerName, r.Name) | |
| 50 | b := duDir(dir) | |
| 51 | gcArgs := []string{"-C", dir, "gc", "--quiet"} | |
| 52 | if aggressive { | |
| 53 | gcArgs = append(gcArgs, "--aggressive") | |
| 54 | } | |
| 55 | if out, err := exec.Command("git", gcArgs...).CombinedOutput(); err != nil { | |
| 56 | fmt.Fprintf(os.Stderr, "%s: gc failed: %v\n%s", r.Path(), err, out) | |
| 57 | continue | |
| 58 | } | |
| 59 | a := duDir(dir) | |
| 60 | before, after = before+b, after+a | |
| 61 | fmt.Printf("%s\t%s -> %s\n", r.Path(), human(b), human(a)) | |
| 62 | } | |
| 63 | fmt.Printf("total\t%s -> %s (freed %s)\n", human(before), human(after), human(before-after)) | |
| 64 | return nil | |
| 65 | }, | |
| 66 | } | |
| 67 | cmd.Flags().StringVar(&repoPath, "repo", "", "one repository (owner/name) instead of all") | |
| 68 | cmd.Flags().BoolVar(&aggressive, "aggressive", false, "more thorough repack (slow; rarely needed)") | |
| 69 | return cmd | |
| 70 | } | |
| 71 | ||
| 72 | func statsCmd() *cobra.Command { | |
| 73 | var asJSON bool | |
| 74 | cmd := &cobra.Command{ | |
| 75 | Use: "stats", | |
| 76 | Short: "instance statistics: counts and per-repository disk usage", | |
| 77 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 78 | cfg, err := config.Load(configPath) | |
| 79 | if err != nil { | |
| 80 | return err | |
| 81 | } | |
| 82 | st, err := openStore(cfg) | |
| 83 | if err != nil { | |
| 84 | return err | |
| 85 | } | |
| 86 | defer st.Close() | |
| 87 | ||
| 88 | counts, err := st.InstanceCounts() | |
| 89 | if err != nil { | |
| 90 | return err | |
| 91 | } | |
| 92 | repos, err := st.ListAllRepos() | |
| 93 | if err != nil { | |
| 94 | return err | |
| 95 | } | |
| 96 | type repoDisk struct { | |
| 97 | Path string `json:"path"` | |
| 98 | Bytes int64 `json:"bytes"` | |
| 99 | } | |
| 100 | var disks []repoDisk | |
| 101 | var totalDisk int64 | |
| 102 | for _, r := range repos { | |
| 103 | b := duDir(control.RepoDir(cfg.Server.Root, r.OwnerName, r.Name)) | |
| 104 | disks = append(disks, repoDisk{r.Path(), b}) | |
| 105 | totalDisk += b | |
| 106 | } | |
| 107 | var dbBytes int64 | |
| 108 | if fi, err := os.Stat(cfg.Server.Root + "/gitbay.db"); err == nil { | |
| 109 | dbBytes = fi.Size() | |
| 110 | } | |
| 111 | ||
| 112 | if asJSON { | |
| 113 | return json.NewEncoder(os.Stdout).Encode(map[string]any{ | |
| 114 | "counts": counts, "db_bytes": dbBytes, | |
| 115 | "repo_bytes": totalDisk, "repos": disks, | |
| 116 | }) | |
| 117 | } | |
| 118 | fmt.Printf("users %d · orgs %d · repos %d · issues %d (%d open) · MRs %d (%d open)\n", | |
| 119 | counts.Users, counts.Orgs, counts.Repos, | |
| 120 | counts.Issues, counts.OpenIssues, counts.MRs, counts.OpenMRs) | |
| 121 | fmt.Printf("database %s · repositories %s\n\n", human(dbBytes), human(totalDisk)) | |
| 122 | w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) | |
| 123 | for _, d := range disks { | |
| 124 | fmt.Fprintf(w, "%s\t%s\n", d.Path, human(d.Bytes)) | |
| 125 | } | |
| 126 | return w.Flush() | |
| 127 | }, | |
| 128 | } | |
| 129 | cmd.Flags().BoolVar(&asJSON, "json", false, "machine-readable output") | |
| 130 | return cmd | |
| 131 | } | |
| 132 | ||
| 133 | // duDir sums file sizes under dir; errors count as zero. | |
| 134 | func duDir(dir string) int64 { | |
| 135 | var total int64 | |
| 136 | filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error { | |
| 137 | if err != nil || d.IsDir() { | |
| 138 | return nil | |
| 139 | } | |
| 140 | if fi, err := d.Info(); err == nil { | |
| 141 | total += fi.Size() | |
| 142 | } | |
| 143 | return nil | |
| 144 | }) | |
| 145 | return total | |
| 146 | } | |
| 147 | ||
| 148 | func human(b int64) string { | |
| 149 | switch { | |
| 150 | case b >= 1<<30: | |
| 151 | return fmt.Sprintf("%.1f GiB", float64(b)/(1<<30)) | |
| 152 | case b >= 1<<20: | |
| 153 | return fmt.Sprintf("%.1f MiB", float64(b)/(1<<20)) | |
| 154 | case b >= 1<<10: | |
| 155 | return fmt.Sprintf("%.1f KiB", float64(b)/(1<<10)) | |
| 156 | default: | |
| 157 | return fmt.Sprintf("%d B", b) | |
| 158 | } | |
| 159 | } | |
deploy/cloud-init.yaml +22 −2
| @@ -115,6 +115,26 @@ write_files: | ||
| 115 | 115 | [Install] |
| 116 | 116 | WantedBy=timers.target |
| 117 | 117 | |
| 118 | - path: /etc/systemd/system/gitbay-gc.service | |
| 119 | content: | | |
| 120 | [Unit] | |
| 121 | Description=gitbay weekly repository maintenance | |
| 122 | [Service] | |
| 123 | Type=oneshot | |
| 124 | User=gitbay | |
| 125 | ExecStart=/usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin gc | |
| 126 | ||
| 127 | - path: /etc/systemd/system/gitbay-gc.timer | |
| 128 | content: | | |
| 129 | [Unit] | |
| 130 | Description=gitbay weekly repository maintenance | |
| 131 | [Timer] | |
| 132 | OnCalendar=Sun *-*-* 07:00:00 UTC | |
| 133 | RandomizedDelaySec=30m | |
| 134 | Persistent=true | |
| 135 | [Install] | |
| 136 | WantedBy=timers.target | |
| 137 | ||
| 118 | 138 | runcmd: |
| 119 | 139 | - adduser --system --group --home /var/lib/gitbay --shell /usr/sbin/nologin gitbay |
| 120 | 140 | - install -d -o gitbay -g gitbay -m 750 /var/lib/gitbay /var/backups/gitbay |
| @@ -126,5 +146,5 @@ runcmd: | ||
| 126 | 146 | - ufw --force enable |
| 127 | 147 | - systemctl daemon-reload |
| 128 | 148 | - systemctl restart ssh.socket || systemctl restart ssh |
| 129 | - systemctl enable gitbayd gitbay-backup.timer | |
| 130 | - systemctl start gitbay-backup.timer | |
| 149 | - systemctl enable gitbayd gitbay-backup.timer gitbay-gc.timer | |
| 150 | - systemctl start gitbay-backup.timer gitbay-gc.timer | |
docs/admin.org +13
| @@ -121,6 +121,19 @@ gitbayd admin invite --email b@example.org # mails a code; prints it if no | ||
| 121 | 121 | records which. Verified emails are what make commit signatures |
| 122 | 122 | meaningful — an unverified address never produces a =verified= badge. |
| 123 | 123 | |
| 124 | * Maintenance | |
| 125 | ||
| 126 | #+begin_src sh | |
| 127 | gitbayd admin stats [--json] # counts, database size, per-repo disk | |
| 128 | gitbayd admin gc [--repo owner/name] # git gc: repack and prune; per-repo sizes | |
| 129 | gitbayd admin gc --aggressive # thorough repack; slow, rarely needed | |
| 130 | #+end_src | |
| 131 | ||
| 132 | =deploy/cloud-init.yaml= ships a =gitbay-gc.timer= that runs =admin gc= | |
| 133 | weekly (Sunday 07:00 UTC). Imported repositories keep whatever pack | |
| 134 | layout the source sent, so a first manual =admin gc= after a bulk | |
| 135 | import is worthwhile. | |
| 136 | ||
| 124 | 137 | * Backup and restore |
| 125 | 138 | |
| 126 | 139 | #+begin_src sh |
e2e/maint_test.go added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "os" | |
| 6 | "path/filepath" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | func TestAdminGCAndStats(t *testing.T) { | |
| 12 | inst := startInstance(t) | |
| 13 | aliceKey := inst.newKey(t, "alice") | |
| 14 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 15 | ||
| 16 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 17 | t.Fatalf("repo create: %s", errOut) | |
| 18 | } | |
| 19 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/other"); code != 0 { | |
| 20 | t.Fatal("second repo create failed") | |
| 21 | } | |
| 22 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'x'"); code != 0 { | |
| 23 | t.Fatal("issue create failed") | |
| 24 | } | |
| 25 | ||
| 26 | // Push several commits so the repo has loose objects worth packing. | |
| 27 | work := t.TempDir() | |
| 28 | env := inst.gitEnv(aliceKey) | |
| 29 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 30 | dir := filepath.Join(work, "w") | |
| 31 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 32 | for i := 0; i < 10; i++ { | |
| 33 | os.WriteFile(filepath.Join(dir, "f.txt"), []byte(strings.Repeat(fmt.Sprint(i), 2000)+"\n"), 0o644) | |
| 34 | mustGit(t, dir, env, "add", ".") | |
| 35 | mustGit(t, dir, env, "commit", "-q", "-m", fmt.Sprintf("c%d", i)) | |
| 36 | } | |
| 37 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 38 | ||
| 39 | // Stats: counts and per-repo disk usage. | |
| 40 | out := inst.admin(t, "admin", "stats") | |
| 41 | if !strings.Contains(out, "repos 2") || !strings.Contains(out, "issues 1 (1 open)") || | |
| 42 | !strings.Contains(out, "alice/app") || !strings.Contains(out, "alice/other") { | |
| 43 | t.Fatalf("stats output: %s", out) | |
| 44 | } | |
| 45 | if out = inst.admin(t, "admin", "stats", "--json"); !strings.Contains(out, `"repos":2`) || | |
| 46 | !strings.Contains(out, `"path":"alice/app"`) { | |
| 47 | t.Fatalf("stats json: %s", out) | |
| 48 | } | |
| 49 | ||
| 50 | // GC one repo, then all; the repo must survive (clone still works). | |
| 51 | out = inst.admin(t, "admin", "gc", "--repo", "alice/app") | |
| 52 | if !strings.Contains(out, "alice/app") || !strings.Contains(out, "total") || | |
| 53 | strings.Contains(out, "alice/other") { | |
| 54 | t.Fatalf("scoped gc: %s", out) | |
| 55 | } | |
| 56 | if out = inst.admin(t, "admin", "gc"); !strings.Contains(out, "alice/other") { | |
| 57 | t.Fatalf("full gc: %s", out) | |
| 58 | } | |
| 59 | if out = inst.forgedAdminErr(t, "admin", "gc", "--repo", "alice/nope"); !strings.Contains(out, "no repository") { | |
| 60 | t.Fatalf("bogus repo: %s", out) | |
| 61 | } | |
| 62 | work2 := t.TempDir() | |
| 63 | mustGit(t, work2, env, "clone", "-q", inst.sshURL("alice/app"), "r") | |
| 64 | if data, err := os.ReadFile(filepath.Join(work2, "r", "f.txt")); err != nil || len(data) == 0 { | |
| 65 | t.Fatal("repo damaged by gc") | |
| 66 | } | |
| 67 | } | |
internal/store/stats.go added +50
| @@ -0,0 +1,50 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | // ListAllRepos returns every repository, for host-local admin tooling. | |
| 4 | func (s *Store) ListAllRepos() ([]Repo, error) { | |
| 5 | rows, err := s.DB.Query(repoSelect + " ORDER BY 4, r.name") | |
| 6 | if err != nil { | |
| 7 | return nil, err | |
| 8 | } | |
| 9 | defer rows.Close() | |
| 10 | var out []Repo | |
| 11 | for rows.Next() { | |
| 12 | r, err := scanRepo(rows) | |
| 13 | if err != nil { | |
| 14 | return nil, err | |
| 15 | } | |
| 16 | out = append(out, r) | |
| 17 | } | |
| 18 | return out, rows.Err() | |
| 19 | } | |
| 20 | ||
| 21 | type Counts struct { | |
| 22 | Users int64 `json:"users"` | |
| 23 | Orgs int64 `json:"orgs"` | |
| 24 | Repos int64 `json:"repos"` | |
| 25 | Issues int64 `json:"issues"` | |
| 26 | OpenIssues int64 `json:"open_issues"` | |
| 27 | MRs int64 `json:"mrs"` | |
| 28 | OpenMRs int64 `json:"open_mrs"` | |
| 29 | } | |
| 30 | ||
| 31 | func (s *Store) InstanceCounts() (Counts, error) { | |
| 32 | var c Counts | |
| 33 | for _, q := range []struct { | |
| 34 | dst *int64 | |
| 35 | query string | |
| 36 | }{ | |
| 37 | {&c.Users, "SELECT COUNT(*) FROM users"}, | |
| 38 | {&c.Orgs, "SELECT COUNT(*) FROM orgs"}, | |
| 39 | {&c.Repos, "SELECT COUNT(*) FROM repos"}, | |
| 40 | {&c.Issues, "SELECT COUNT(*) FROM issues"}, | |
| 41 | {&c.OpenIssues, "SELECT COUNT(*) FROM issues WHERE state = 'open'"}, | |
| 42 | {&c.MRs, "SELECT COUNT(*) FROM merge_requests"}, | |
| 43 | {&c.OpenMRs, "SELECT COUNT(*) FROM merge_requests WHERE state IN ('open','source_gone')"}, | |
| 44 | } { | |
| 45 | if err := s.DB.QueryRow(q.query).Scan(q.dst); err != nil { | |
| 46 | return c, err | |
| 47 | } | |
| 48 | } | |
| 49 | return c, nil | |
| 50 | } | |