vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
9e8cc155
Keenan Nemetz
12d ago
|
1 | package hook |
| 2 | ||
| 3 | // Quotas. A repository is a place several people write to, and every one of them | |
| 4 | // can choose how much: a contributor can add branches in their own namespace, any | |
| 5 | // writer can add issues and comments, and a maintainer can add tags. Without a | |
| 6 | // bound, one account can make every anonymous page read slowly or fill the disk, | |
| 7 | // and the instance's only answer is an admin deleting things by hand. | |
| 8 | // | |
| 9 | // The checks run in pre-receive, where a refusal moves no refs. They are counts at | |
| 10 | // the *new* tip, so they describe the repository the push would leave behind, not | |
| 11 | // the one it found. The one thing that cannot be seen here is bytes still in the | |
| 12 | // quarantine; that is why a push is also bounded by receive_max_bytes, and why the | |
| 13 | // size check refuses a repository that is already over its cap: growth is bounded | |
| 14 | // by the cap plus one permitted push. | |
| 15 | ||
| 16 | import ( | |
| 17 | "fmt" | |
| 18 | "strings" | |
| 19 | ||
|
d37d301f
Keenan Nemetz
17h ago
|
20 | "rodent.software/vole/core/git" |
| 21 | "rodent.software/vole/internal/format" | |
| 22 | "rodent.software/vole/metadata" | |
|
9e8cc155
Keenan Nemetz
12d ago
|
23 | ) |
| 24 | ||
| 25 | // quotaProblems reports every limit this push would break. An empty result means | |
| 26 | // the push is inside every quota. | |
| 27 | func quotaProblems(cfg Config, g *git.Repo, changes []change) []string { | |
| 28 | var problems []string | |
| 29 | ||
| 30 | if cfg.MaxRefs > 0 { | |
| 31 | if n, err := refsAfter(g, changes); err != nil { | |
| 32 | problems = append(problems, fmt.Sprintf("cannot count refs to check the repository quota: %v", err)) | |
| 33 | } else if n > cfg.MaxRefs { | |
| 34 | problems = append(problems, fmt.Sprintf( | |
| 35 | "this push would leave %d branches and tags, over the limit of %d; delete some or raise repo_max_refs", | |
| 36 | n, cfg.MaxRefs)) | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | if cfg.MaxMetadataEntries > 0 || cfg.MaxMetadataBytes > 0 { | |
| 41 | if tip := metadataAfter(changes); tip != "" { | |
| 42 | entries, bytes, err := metadataSize(g, tip) | |
| 43 | if err != nil { | |
| 44 | problems = append(problems, fmt.Sprintf("cannot measure the metadata branch to check its quota: %v", err)) | |
| 45 | } else { | |
| 46 | if cfg.MaxMetadataEntries > 0 && entries > cfg.MaxMetadataEntries { | |
| 47 | problems = append(problems, fmt.Sprintf( | |
| 48 | "%s would hold %d issues, requests, runs and releases, over the limit of %d; raise repo_max_metadata_entries or remove some", | |
| 49 | metadata.Ref, entries, cfg.MaxMetadataEntries)) | |
| 50 | } | |
| 51 | if cfg.MaxMetadataBytes > 0 && bytes > cfg.MaxMetadataBytes { | |
| 52 | problems = append(problems, fmt.Sprintf( | |
| 53 | "%s would hold %s, over the limit of %s; raise repo_max_metadata_bytes or remove some", | |
| 54 | metadata.Ref, format.Bytes(bytes), format.Bytes(cfg.MaxMetadataBytes))) | |
| 55 | } | |
| 56 | } | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | if cfg.MaxRepoBytes > 0 { | |
| 61 | if used, err := g.DiskUsage(); err != nil { | |
| 62 | problems = append(problems, fmt.Sprintf("cannot measure the repository to check its quota: %v", err)) | |
| 63 | } else if used > cfg.MaxRepoBytes { | |
| 64 | problems = append(problems, fmt.Sprintf( | |
| 65 | "this repository holds %s, over the limit of %s; an admin can run `vole admin gc` or raise repo_max_bytes", | |
| 66 | format.Bytes(used), format.Bytes(cfg.MaxRepoBytes))) | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | if cfg.MaxLFSBytes > 0 && cfg.LFS != nil && cfg.RepoID != "" { | |
| 71 | if used, err := lfsUsage(cfg.LFS, cfg.RepoID); err != nil { | |
| 72 | problems = append(problems, fmt.Sprintf("cannot measure the object store to check its quota: %v", err)) | |
| 73 | } else if used > cfg.MaxLFSBytes { | |
| 74 | problems = append(problems, fmt.Sprintf( | |
| 75 | "this repository's LFS objects, logs and artifacts take %s, over the limit of %s; raise repo_max_lfs_bytes or remove some", | |
| 76 | format.Bytes(used), format.Bytes(cfg.MaxLFSBytes))) | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | return problems | |
| 81 | } | |
| 82 | ||
| 83 | // refsAfter is the number of branches and tags the repository would hold if this | |
| 84 | // push landed: what is there now, plus the refs the push creates, minus the ones it | |
| 85 | // deletes. Pre-receive sees the refs as they were, so the change list is what makes | |
| 86 | // the number describe the outcome. | |
| 87 | func refsAfter(g *git.Repo, changes []change) (int, error) { | |
| 88 | now, err := g.RefCount() | |
| 89 | if err != nil { | |
| 90 | return 0, err | |
| 91 | } | |
| 92 | for _, c := range changes { | |
| 93 | if !strings.HasPrefix(c.ref, "refs/heads/") && !strings.HasPrefix(c.ref, "refs/tags/") { | |
| 94 | continue | |
| 95 | } | |
| 96 | switch { | |
| 97 | case c.old == "" || c.old == git.ZeroSHA: | |
| 98 | if c.new != "" && c.new != git.ZeroSHA { | |
| 99 | now++ | |
| 100 | } | |
| 101 | case c.new == "" || c.new == git.ZeroSHA: | |
| 102 | if now > 0 { | |
| 103 | now-- | |
| 104 | } | |
| 105 | } | |
| 106 | } | |
| 107 | return now, nil | |
| 108 | } | |
| 109 | ||
| 110 | // metadataAfter is the metadata tip the push would leave, or "" when it does not | |
| 111 | // touch that branch (or deletes it). | |
| 112 | func metadataAfter(changes []change) string { | |
| 113 | for _, c := range changes { | |
| 114 | if c.ref == metadata.Ref && c.new != "" && c.new != git.ZeroSHA { | |
| 115 | return c.new | |
| 116 | } | |
| 117 | } | |
| 118 | return "" | |
| 119 | } | |
| 120 | ||
| 121 | // metadataSize counts what a page load on a repository has to parse: every issue, | |
| 122 | // request, comment and approval file, and their bytes. | |
| 123 | func metadataSize(g *git.Repo, tip string) (entries int, bytes int64, err error) { | |
| 124 | es, err := g.LsTreeSizes(tip) | |
| 125 | if err != nil { | |
| 126 | return 0, 0, err | |
| 127 | } | |
| 128 | for _, e := range es { | |
| 129 | if e.Type != "blob" { | |
| 130 | continue | |
| 131 | } | |
| 132 | // Everything the metadata branch carries as a document: issues and their | |
| 133 | // comments, requests with approvals and comments, and the run and release | |
| 134 | // records a runner or a maintainer adds. These are what a page load parses. | |
| 135 | for _, dir := range []string{ | |
|
b492b557
Keenan Nemetz
3d ago
|
136 | metadata.IssuesDir, metadata.ChangesDir, metadata.ReleasesDir, |
|
9e8cc155
Keenan Nemetz
12d ago
|
137 | } { |
| 138 | if strings.HasPrefix(e.Name, dir+"/") { | |
| 139 | entries++ | |
| 140 | bytes += e.Size | |
| 141 | break | |
| 142 | } | |
| 143 | } | |
| 144 | } | |
| 145 | return entries, bytes, nil | |
| 146 | } | |
| 147 | ||
| 148 | func lfsUsage(store lfsStore, repoID string) (int64, error) { | |
| 149 | var total int64 | |
| 150 | err := store.RepoWalk(repoID, func(_ string, size int64) error { | |
| 151 | total += size | |
| 152 | return nil | |
| 153 | }) | |
| 154 | return total, err | |
| 155 | } | |
| 156 | ||
| 157 | // lfsStore is the part of the object store the quota check needs. It is an | |
| 158 | // interface so a test can answer without a directory. | |
| 159 | type lfsStore interface { | |
| 160 | RepoWalk(repo string, fn func(oid string, size int64) error) error | |
| 161 | } |