Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Bound what one repository and one account can cost

9e8cc1553e7b4479e0d9a3d7afd044a0755423ba · Keenan Nemetz · 13d ago · parent 701232cf

Bound what one repository and one account can cost

A repository had no quotas. A contributor could add branches in their own
namespace without limit, any writer could add issues and comments, a
maintainer could add tags, and every page that listed or parsed them grew
with the count. The instance's only answer was an admin deleting things
by hand.

Repository quotas now run in pre-receive, so a refused push moves no
refs, and they describe the repository the push would leave rather than
the one it found:

  repo_max_refs              branches and tags, counted with the refs the
                             push creates and deletes
  repo_max_metadata_entries  documents on the metadata branch: issues,
                             requests, comments, approvals, runs, releases
  repo_max_metadata_bytes    their total size
  repo_max_bytes             git objects on disk; a repository already
                             over it refuses further pushes, so growth is
                             the cap plus one permitted push
  repo_max_lfs_bytes         LFS objects, logs and artifacts, enforced on
                             the upload where the bytes arrive, with 413

The values reach the hook the same way the blob limit does: from the
instance config through the SSH server's environment, so a quota is one
setting rather than a second policy.

Browser writes are budgeted per account (`account_writes_per_hour`, 300).
The limiter is keyed by the account rather than the address, because the
account is what a write is attributed to; a person discussing never
reaches it.

Reads that a repository's own writers control are capped: for-each-ref
listings (branches, tags) return at most 500, while the quota check uses
a count that sees all of them. Activity feeds and state changes were
already bounded.

Open registration's baseline is unchanged (off, role none) and its role
is now restricted to none, read or contribute: a stranger who typed a
name must not be able to move a shared branch or approve a review.

Tests: refs (a new ref is counted, moving one is not), metadata entries
and bytes (a tree with two issues refuses at one, and passes at a
generous limit), repository size, and the LFS quota against a real store;
the transfer refuses a 413 upload over the object cap; the account write
budget; and the registration role refusal.

server/hook/quotas.go +161

@@ -0,0 +1,161 @@
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
20 "github.com/rodent-software/vole/core/git"
21 "github.com/rodent-software/vole/internal/format"
22 "github.com/rodent-software/vole/metadata"
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{
136 metadata.IssuesDir, metadata.RequestsDir, metadata.RunsDir, metadata.ReleasesDir,
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 }