Commit aaf2234b85
Verified · cmc ci/build: success ci/test: success
Layout: unified · split
.gitbay/wiki/Admin.org +10 −4
| @@ -358,10 +358,15 @@ push=. | |||
| 358 | - =max_snippet_bytes= (1MB) — cap per snippet file. | 358 | - =max_snippet_bytes= (1MB) — cap per snippet file. |
| 359 | - =max_snippets_per_user= (0, unlimited) — snippets an account may own. | 359 | - =max_snippets_per_user= (0, unlimited) — snippets an account may own. |
| 360 | - =max_repos_per_user= (0, unlimited) — repositories an account may own | 360 | - =max_repos_per_user= (0, unlimited) — repositories an account may own |
| 361 | directly; =repo create=, =fork= and =import= refuse past it. | 361 | directly; =repo create=, =fork=, =import= and =repo transfer= refuse |
| 362 | Organizations are not capped. | 362 | past it. |
| 363 | - =max_bytes_per_user= (0, unlimited) — disk the account's own | 363 | - =max_bytes_per_user= (0, unlimited) — disk the account's own |
| 364 | repositories may take; a push may be no larger than what is left. | 364 | repositories may take; a push may be no larger than what is left, and |
| 365 | a transfer in must fit. | ||
| 366 | - =max_orgs_per_user= (0, unlimited) — organizations an account may | ||
| 367 | create. Membership in an org someone else created does not count. | ||
| 368 | - =max_repos_per_org=, =max_bytes_per_org= (0, unlimited) — the same two | ||
| 369 | caps for each organization. | ||
| 365 | - =pack_concurrency= (3), =pack_per_principal= (2), =pack_queue= (32), | 370 | - =pack_concurrency= (3), =pack_per_principal= (2), =pack_queue= (32), |
| 366 | =pack_queue_wait= (="60s"=) — git pack generation (clones, fetches, | 371 | =pack_queue_wait= (="60s"=) — git pack generation (clones, fetches, |
| 367 | =git archive --remote=, web archive downloads, =repo download= over | 372 | =git archive --remote=, web archive downloads, =repo download= over |
| @@ -520,7 +525,8 @@ gitbayd admin audit verify # check the hash chain; exit 1 names the fi | |||
| 520 | ssh git@<host> audit ... # the same, from an admin session | 525 | ssh git@<host> audit ... # the same, from an admin session |
| 521 | ssh git@<host> admin user list [--state active|pending|disabled|admin] | 526 | ssh git@<host> admin user list [--state active|pending|disabled|admin] |
| 522 | ssh git@<host> admin user show <name> # keys, emails, orgs, tokens, sessions | 527 | ssh git@<host> admin user show <name> # keys, emails, orgs, tokens, sessions |
| 523 | ssh git@<host> admin user limits <name> [--repos n|default] [--bytes n|default] # per-account caps | 528 | ssh git@<host> admin user limits <name> [--repos n|default] [--bytes n|default] [--orgs n|default] # per-account caps |
| 529 | ssh git@<host> admin org limits <org> [--repos n|default] [--bytes n|default] # per-org caps | ||
| 524 | ssh git@<host> admin user promote <name> # grant instance admin | 530 | ssh git@<host> admin user promote <name> # grant instance admin |
| 525 | ssh git@<host> admin user demote <name> # remove it; the last admin is refused | 531 | ssh git@<host> admin user demote <name> # remove it; the last admin is refused |
| 526 | gitbayd admin user promote <name> # host-local: recovery when no admin key is reachable | 532 | gitbayd admin user promote <name> # host-local: recovery when no admin key is reachable |
.gitbay/wiki/Parity.org +1
| @@ -478,6 +478,7 @@ when there is none. | |||
| 478 | | disable, enable | yes | yes | no | | 478 | | disable, enable | yes | yes | no | |
| 479 | | account create, delete | yes | no | no | | 479 | | account create, delete | yes | no | no | |
| 480 | | invite | yes | no | no | | 480 | | invite | yes | no | no | |
| 481 | | account and org quotas | yes | no | no | | ||
| 481 | | worker queues | yes | yes | no | | 482 | | worker queues | yes | yes | no | |
| 482 | | runners | yes | no | no | | 483 | | runners | yes | no | no | |
| 483 | | repository list, archive, visibility | yes | no | no | | 484 | | repository list, archive, visibility | yes | no | no | |
cmd/gitbay/main.go +3
| @@ -129,6 +129,9 @@ func newRoot() *cobra.Command { | |||
| 129 | pass("delete", passOpts{server: []string{"admin", "user", "delete"}}), | 129 | pass("delete", passOpts{server: []string{"admin", "user", "delete"}}), |
| 130 | pass("limits", passOpts{server: []string{"admin", "user", "limits"}}), | 130 | pass("limits", passOpts{server: []string{"admin", "user", "limits"}}), |
| 131 | ), | 131 | ), |
| 132 | group("org", "any organization", | ||
| 133 | pass("limits", passOpts{server: []string{"admin", "org", "limits"}}), | ||
| 134 | ), | ||
| 132 | group("email", "addresses on any account", | 135 | group("email", "addresses on any account", |
| 133 | pass("verify", passOpts{server: []string{"admin", "email", "verify"}}), | 136 | pass("verify", passOpts{server: []string{"admin", "email", "verify"}}), |
| 134 | ), | 137 | ), |
cmd/gitbay/summaries_gen.go +2 −1
| @@ -9,6 +9,7 @@ var summaries = map[string]string{ | |||
| 9 | "admin invite": "issue a registration invite and mail its code", | 9 | "admin invite": "issue a registration invite and mail its code", |
| 10 | "admin mail inbound check": "connect to the reply mailbox read-only and report what is waiting", | 10 | "admin mail inbound check": "connect to the reply mailbox read-only and report what is waiting", |
| 11 | "admin mr prune": "drop merged or closed MRs' head refs and the objects only they kept, e.g. after a history rewrite (instance admins; audited)", | 11 | "admin mr prune": "drop merged or closed MRs' head refs and the objects only they kept, e.g. after a history rewrite (instance admins; audited)", |
| 12 | "admin org limits": "show or set an organization's repository and storage caps (instance admins)", | ||
| 12 | "admin repo archive": "archive any repository (instance admins; audited)", | 13 | "admin repo archive": "archive any repository (instance admins; audited)", |
| 13 | "admin repo delete": "delete any repository (instance admins; audited)", | 14 | "admin repo delete": "delete any repository (instance admins; audited)", |
| 14 | "admin repo list": "list every repository with size and last push (instance admins)", | 15 | "admin repo list": "list every repository with size and last push (instance admins)", |
| @@ -24,7 +25,7 @@ var summaries = map[string]string{ | |||
| 24 | "admin user demote": "remove instance admin from an account (never the last one)", | 25 | "admin user demote": "remove instance admin from an account (never the last one)", |
| 25 | "admin user disable": "suspend an account: SSH, web sessions and API tokens refused until re-enabled", | 26 | "admin user disable": "suspend an account: SSH, web sessions and API tokens refused until re-enabled", |
| 26 | "admin user enable": "restore a suspended account", | 27 | "admin user enable": "restore a suspended account", |
| 27 | "admin user limits": "show or set an account's repository and storage caps (instance admins)", | 28 | "admin user limits": "show or set an account's repository, storage and organization caps (instance admins)", |
| 28 | "admin user list": "list accounts (instance admins)", | 29 | "admin user list": "list accounts (instance admins)", |
| 29 | "admin user promote": "make an account an instance admin", | 30 | "admin user promote": "make an account an instance admin", |
| 30 | "admin user show": "show an account: keys, emails, orgs, tokens, sessions (instance admins)", | 31 | "admin user show": "show an account: keys, emails, orgs, tokens, sessions (instance admins)", |
e2e/quota_test.go +32 −4
| @@ -10,14 +10,16 @@ import ( | |||
| 10 | "time" | 10 | "time" |
| 11 | ) | 11 | ) |
| 12 | 12 | ||
| 13 | // Per-account caps on repositories and storage, with the admin override, | 13 | // Per-account and per-org caps on repositories and storage, the cap on |
| 14 | // and expiry of accounts that never verified. | 14 | // orgs an account creates, the admin overrides, and expiry of accounts |
| 15 | // that never verified. | ||
| 15 | func TestQuotasAndPendingExpiry(t *testing.T) { | 16 | func TestQuotasAndPendingExpiry(t *testing.T) { |
| 16 | t.Setenv("GITBAY_REAP_TICK", "500ms") | 17 | t.Setenv("GITBAY_REAP_TICK", "500ms") |
| 17 | smtp := startFakeSMTP(t) | 18 | smtp := startFakeSMTP(t) |
| 18 | inst := startInstanceWith(t, fmt.Sprintf( | 19 | inst := startInstanceWith(t, fmt.Sprintf( |
| 19 | "[registration]\nmode = \"open\"\npending_expiry = \"2s\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n"+ | 20 | "[registration]\nmode = \"open\"\npending_expiry = \"2s\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n"+ |
| 20 | "[limits]\nmax_repos_per_user = 2\nmax_bytes_per_user = 300000\n", smtp.addr)) | 21 | "[limits]\nmax_repos_per_user = 2\nmax_bytes_per_user = 300000\n"+ |
| 22 | "max_orgs_per_user = 1\nmax_repos_per_org = 1\n", smtp.addr)) | ||
| 21 | rootKey := inst.newKey(t, "root") | 23 | rootKey := inst.newKey(t, "root") |
| 22 | aliceKey := inst.newKey(t, "alice") | 24 | aliceKey := inst.newKey(t, "alice") |
| 23 | inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin") | 25 | inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin") |
| @@ -35,13 +37,32 @@ func TestQuotasAndPendingExpiry(t *testing.T) { | |||
| 35 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "fork", "alice/one", "--name", "onefork"); code != 4 { | 37 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "fork", "alice/one", "--name", "onefork"); code != 4 { |
| 36 | t.Fatal("fork slipped past the cap") | 38 | t.Fatal("fork slipped past the cap") |
| 37 | } | 39 | } |
| 38 | // An org is not capped. | 40 | // One org fits; the second is refused. |
| 39 | if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "acme"); code != 0 { | 41 | if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "acme"); code != 0 { |
| 40 | t.Fatal("org create failed") | 42 | t.Fatal("org create failed") |
| 41 | } | 43 | } |
| 44 | if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "create", "acme2"); code != 4 || !strings.Contains(errOut, "1 of the 1 organizations") { | ||
| 45 | t.Fatalf("second org: exit %d %s", code, errOut) | ||
| 46 | } | ||
| 47 | // The org has its own repository cap, which the admin raises per org. | ||
| 42 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "acme/lib"); code != 0 { | 48 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "acme/lib"); code != 0 { |
| 43 | t.Fatalf("org repo: %s", errOut) | 49 | t.Fatalf("org repo: %s", errOut) |
| 44 | } | 50 | } |
| 51 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "acme/lib2"); code != 4 || !strings.Contains(errOut, "1 of the 1 repositories") { | ||
| 52 | t.Fatalf("second org repo: exit %d %s", code, errOut) | ||
| 53 | } | ||
| 54 | if out, _, code := inst.ssh(t, rootKey, "", "admin", "org", "limits", "acme", "--repos", "2"); code != 0 || !strings.Contains(out, "repos 1 of 2") { | ||
| 55 | t.Fatalf("org limits: exit %d %s", code, out) | ||
| 56 | } | ||
| 57 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "acme/lib2"); code != 0 { | ||
| 58 | t.Fatalf("second org repo after raise: %s", errOut) | ||
| 59 | } | ||
| 60 | if out, _, code := inst.ssh(t, rootKey, "", "admin", "user", "limits", "alice", "--orgs", "2"); code != 0 || !strings.Contains(out, "orgs 1 of 2") { | ||
| 61 | t.Fatalf("user org limit: exit %d %s", code, out) | ||
| 62 | } | ||
| 63 | if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "create", "acme2"); code != 0 { | ||
| 64 | t.Fatalf("second org after raise: %s", errOut) | ||
| 65 | } | ||
| 45 | // The admin raises the cap for this account; the third fits. | 66 | // The admin raises the cap for this account; the third fits. |
| 46 | if out, _, code := inst.ssh(t, rootKey, "", "admin", "user", "limits", "alice", "--repos", "3"); code != 0 || !strings.Contains(out, "repos 2 of 3") { | 67 | if out, _, code := inst.ssh(t, rootKey, "", "admin", "user", "limits", "alice", "--repos", "3"); code != 0 || !strings.Contains(out, "repos 2 of 3") { |
| 47 | t.Fatalf("limits: exit %d %s", code, out) | 68 | t.Fatalf("limits: exit %d %s", code, out) |
| @@ -55,6 +76,13 @@ func TestQuotasAndPendingExpiry(t *testing.T) { | |||
| 55 | if out, _, _ := inst.ssh(t, rootKey, "", "admin", "user", "limits", "alice", "--repos", "default"); !strings.Contains(out, "of 2") { | 76 | if out, _, _ := inst.ssh(t, rootKey, "", "admin", "user", "limits", "alice", "--repos", "default"); !strings.Contains(out, "of 2") { |
| 56 | t.Fatalf("limits back to default:\n%s", out) | 77 | t.Fatalf("limits back to default:\n%s", out) |
| 57 | } | 78 | } |
| 79 | // A transfer in counts as a create for the receiving owner. | ||
| 80 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "transfer", "acme/lib2", "alice"); code != 4 || !strings.Contains(errOut, "3 of the 2 repositories") { | ||
| 81 | t.Fatalf("transfer past the cap: exit %d %s", code, errOut) | ||
| 82 | } | ||
| 83 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "transfer", "alice/three", "acme2"); code != 0 { | ||
| 84 | t.Fatalf("transfer into an org with room: %s", errOut) | ||
| 85 | } | ||
| 58 | 86 | ||
| 59 | // Storage: a push past what the account has left is refused. | 87 | // Storage: a push past what the account has left is refused. |
| 60 | work := t.TempDir() | 88 | work := t.TempDir() |
internal/config/config.go +9 −5
| @@ -239,11 +239,14 @@ type Limits struct { | |||
| 239 | // counted in the dispatcher so every surface shares one budget. 0 uses | 239 | // counted in the dispatcher so every surface shares one budget. 0 uses |
| 240 | // the default; a negative value turns the limit off. | 240 | // the default; a negative value turns the limit off. |
| 241 | WriteRate int `toml:"write_rate"` | 241 | WriteRate int `toml:"write_rate"` |
| 242 | // Per-account quotas on what a user owns directly (organizations are | 242 | // Quotas on what a user or an org owns directly, and on the orgs an |
| 243 | // not capped). 0 means unlimited; admin user limits overrides per | 243 | // account creates. 0 means unlimited; admin user limits and admin org |
| 244 | // account. | 244 | // limits override per owner. |
| 245 | MaxReposPerUser int `toml:"max_repos_per_user"` | 245 | MaxReposPerUser int `toml:"max_repos_per_user"` |
| 246 | MaxBytesPerUser int64 `toml:"max_bytes_per_user"` | 246 | MaxBytesPerUser int64 `toml:"max_bytes_per_user"` |
| 247 | MaxOrgsPerUser int `toml:"max_orgs_per_user"` | ||
| 248 | MaxReposPerOrg int `toml:"max_repos_per_org"` | ||
| 249 | MaxBytesPerOrg int64 `toml:"max_bytes_per_org"` | ||
| 247 | // PackConcurrency caps git pack generation (upload-pack and | 250 | // PackConcurrency caps git pack generation (upload-pack and |
| 248 | // upload-archive) running at once across SSH, smart HTTP and git://. | 251 | // upload-archive) running at once across SSH, smart HTTP and git://. |
| 249 | // PackPerPrincipal caps it per account, or per client address on the | 252 | // PackPerPrincipal caps it per account, or per client address on the |
| @@ -675,8 +678,9 @@ func (c Config) Validate() error { | |||
| 675 | errs = append(errs, fmt.Errorf("registration.pending_expiry %q must be a positive duration such as 168h", c.Registration.PendingExpiry)) | 678 | errs = append(errs, fmt.Errorf("registration.pending_expiry %q must be a positive duration such as 168h", c.Registration.PendingExpiry)) |
| 676 | } | 679 | } |
| 677 | } | 680 | } |
| 678 | if c.Limits.MaxReposPerUser < 0 || c.Limits.MaxBytesPerUser < 0 || c.Limits.MaxSnippetsPerUser < 0 { | 681 | if c.Limits.MaxReposPerUser < 0 || c.Limits.MaxBytesPerUser < 0 || c.Limits.MaxSnippetsPerUser < 0 || |
| 679 | errs = append(errs, errors.New("limits.max_repos_per_user, max_bytes_per_user and max_snippets_per_user must not be negative")) | 682 | c.Limits.MaxOrgsPerUser < 0 || c.Limits.MaxReposPerOrg < 0 || c.Limits.MaxBytesPerOrg < 0 { |
| 683 | errs = append(errs, errors.New("limits.max_repos_per_user, max_bytes_per_user, max_snippets_per_user, max_orgs_per_user, max_repos_per_org and max_bytes_per_org must not be negative")) | ||
| 680 | } | 684 | } |
| 681 | for _, w := range []struct{ name, val string }{ | 685 | for _, w := range []struct{ name, val string }{ |
| 682 | {"pack_queue_wait", c.Limits.PackQueueWait}, | 686 | {"pack_queue_wait", c.Limits.PackQueueWait}, |
internal/control/admin.go +3 −3
| @@ -283,11 +283,11 @@ func runAdminUserShow(c *Ctx, args []string) int { | |||
| 283 | for _, m := range orgs { | 283 | for _, m := range orgs { |
| 284 | d.Orgs = append(d.Orgs, orgOut{m.Username, m.Role}) | 284 | d.Orgs = append(d.Orgs, orgOut{m.Username, m.Role}) |
| 285 | } | 285 | } |
| 286 | if d.Repos, err = c.Store.OwnedRepoCount(u.ID); err != nil { | 286 | if d.Repos, err = c.Store.OwnedRepoCount("user", u.ID); err != nil { |
| 287 | return c.fail(protocol.ExitFailure, "%v", err) | 287 | return c.fail(protocol.ExitFailure, "%v", err) |
| 288 | } | 288 | } |
| 289 | d.RepoLimit = RepoLimit(c.Store, limitsOf(c), u.ID) | 289 | d.RepoLimit = RepoLimit(c.Store, limitsOf(c), "user", u.ID) |
| 290 | d.ByteLimit = ByteLimit(c.Store, limitsOf(c), u.ID) | 290 | d.ByteLimit = ByteLimit(c.Store, limitsOf(c), "user", u.ID) |
| 291 | tokens, err := c.Store.ListAPITokens(u.ID) | 291 | tokens, err := c.Store.ListAPITokens(u.ID) |
| 292 | if err != nil { | 292 | if err != nil { |
| 293 | return c.fail(protocol.ExitFailure, "%v", err) | 293 | return c.fail(protocol.ExitFailure, "%v", err) |
internal/control/import.go +5 −9
| @@ -76,10 +76,8 @@ func runRepoImport(c *Ctx, args []string) int { | |||
| 76 | } | 76 | } |
| 77 | ownerKind, ownerID = "org", org.ID | 77 | ownerKind, ownerID = "org", org.ID |
| 78 | } | 78 | } |
| 79 | if ownerKind == "user" { | 79 | if code := checkRepoQuota(c, ownerKind, ownerID); code >= 0 { |
| 80 | if code := checkRepoQuota(c); code >= 0 { | 80 | return code |
| 81 | return code | ||
| 82 | } | ||
| 83 | } | 81 | } |
| 84 | 82 | ||
| 85 | // http and https only. git:// has no equivalent of curl's resolve | 83 | // http and https only. git:// has no equivalent of curl's resolve |
| @@ -138,11 +136,9 @@ func runRepoImport(c *Ctx, args []string) int { | |||
| 138 | // The early check above fails fast; this one holds the lock across | 136 | // The early check above fails fast; this one holds the lock across |
| 139 | // the insert so a concurrent create cannot slip past the count. | 137 | // the insert so a concurrent create cannot slip past the count. |
| 140 | repoCreateMu.Lock() | 138 | repoCreateMu.Lock() |
| 141 | if ownerKind == "user" { | 139 | if code := checkRepoQuota(c, ownerKind, ownerID); code >= 0 { |
| 142 | if code := checkRepoQuota(c); code >= 0 { | 140 | repoCreateMu.Unlock() |
| 143 | repoCreateMu.Unlock() | 141 | return code |
| 144 | return code | ||
| 145 | } | ||
| 146 | } | 142 | } |
| 147 | id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility) | 143 | id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility) |
| 148 | repoCreateMu.Unlock() | 144 | repoCreateMu.Unlock() |
internal/control/mr.go +3 −7
| @@ -244,13 +244,9 @@ func runRepoFork(c *Ctx, args []string) int { | |||
| 244 | return code | 244 | return code |
| 245 | } | 245 | } |
| 246 | repoCreateMu.Lock() | 246 | repoCreateMu.Lock() |
| 247 | // An organization's repositories are not counted against the quota, | 247 | if code := checkRepoQuota(c, ownerKind, ownerID); code >= 0 { |
| 248 | // the same as repo create. | 248 | repoCreateMu.Unlock() |
| 249 | if ownerKind == "user" { | 249 | return code |
| 250 | if code := checkRepoQuota(c); code >= 0 { | ||
| 251 | repoCreateMu.Unlock() | ||
| 252 | return code | ||
| 253 | } | ||
| 254 | } | 250 | } |
| 255 | id, err := c.Store.CreateFork(ownerKind, ownerID, name, src.Visibility, src.ID) | 251 | id, err := c.Store.CreateFork(ownerKind, ownerID, name, src.Visibility, src.ID) |
| 256 | repoCreateMu.Unlock() | 252 | repoCreateMu.Unlock() |
internal/control/org.go +8 −1
| @@ -80,7 +80,14 @@ func runOrgCreate(c *Ctx, args []string) int { | |||
| 80 | if err := policy.ValidateOwnerName(args[0]); err != nil { | 80 | if err := policy.ValidateOwnerName(args[0]); err != nil { |
| 81 | return c.failInput(err) | 81 | return c.failInput(err) |
| 82 | } | 82 | } |
| 83 | if _, err := c.Store.CreateOrg(args[0], c.User.ID); err != nil { | 83 | orgCreateMu.Lock() |
| 84 | if code := checkOrgQuota(c); code >= 0 { | ||
| 85 | orgCreateMu.Unlock() | ||
| 86 | return code | ||
| 87 | } | ||
| 88 | _, err := c.Store.CreateOrg(args[0], c.User.ID) | ||
| 89 | orgCreateMu.Unlock() | ||
| 90 | if err != nil { | ||
| 84 | return c.fail(protocol.ExitFailure, "%v", err) | 91 | return c.fail(protocol.ExitFailure, "%v", err) |
| 85 | } | 92 | } |
| 86 | return c.emit(map[string]string{"org": args[0], "role": "admin"}, func(w io.Writer) { | 93 | return c.emit(map[string]string{"org": args[0], "role": "admin"}, func(w io.Writer) { |
internal/control/quota.go +180 −65
| @@ -12,28 +12,43 @@ import ( | |||
| 12 | "gitbay.org/gitbay/internal/store" | 12 | "gitbay.org/gitbay/internal/store" |
| 13 | ) | 13 | ) |
| 14 | 14 | ||
| 15 | // Quotas cap what one account owns directly. The limit is the account's | 15 | // Quotas cap what a user or an org owns directly, and how many orgs an |
| 16 | // override when set, else the configured default; 0 is unlimited. | 16 | // account creates. The limit is the owner's override when set, else the |
| 17 | // configured default; 0 is unlimited. | ||
| 17 | 18 | ||
| 18 | // RepoLimit is the account's repository cap, 0 for none. | 19 | // RepoLimit is the owner's repository cap, 0 for none. |
| 19 | func RepoLimit(st *store.Store, cfg configLimits, userID int64) int64 { | 20 | func RepoLimit(st *store.Store, cfg configLimits, kind string, id int64) int64 { |
| 20 | if l, err := st.UserLimits(userID); err == nil && l.Repos != nil { | 21 | if l, err := st.OwnerLimits(kind, id); err == nil && l.Repos != nil { |
| 21 | return *l.Repos | 22 | return *l.Repos |
| 22 | } | 23 | } |
| 24 | if kind == "org" { | ||
| 25 | return int64(cfg.MaxReposPerOrg) | ||
| 26 | } | ||
| 23 | return int64(cfg.MaxReposPerUser) | 27 | return int64(cfg.MaxReposPerUser) |
| 24 | } | 28 | } |
| 25 | 29 | ||
| 26 | // ByteLimit is the account's storage cap in bytes, 0 for none. | 30 | // ByteLimit is the owner's storage cap in bytes, 0 for none. |
| 27 | func ByteLimit(st *store.Store, cfg configLimits, userID int64) int64 { | 31 | func ByteLimit(st *store.Store, cfg configLimits, kind string, id int64) int64 { |
| 28 | if l, err := st.UserLimits(userID); err == nil && l.Bytes != nil { | 32 | if l, err := st.OwnerLimits(kind, id); err == nil && l.Bytes != nil { |
| 29 | return *l.Bytes | 33 | return *l.Bytes |
| 30 | } | 34 | } |
| 35 | if kind == "org" { | ||
| 36 | return cfg.MaxBytesPerOrg | ||
| 37 | } | ||
| 31 | return cfg.MaxBytesPerUser | 38 | return cfg.MaxBytesPerUser |
| 32 | } | 39 | } |
| 33 | 40 | ||
| 34 | // OwnedBytes is the disk taken by the repositories a user owns directly. | 41 | // OrgLimit is the account's cap on organizations it creates, 0 for none. |
| 35 | func OwnedBytes(st *store.Store, root string, userID int64) int64 { | 42 | func OrgLimit(st *store.Store, cfg configLimits, userID int64) int64 { |
| 36 | repos, err := st.ListReposForOwner("user", userID) | 43 | if l, err := st.OwnerLimits("user", userID); err == nil && l.Orgs != nil { |
| 44 | return *l.Orgs | ||
| 45 | } | ||
| 46 | return int64(cfg.MaxOrgsPerUser) | ||
| 47 | } | ||
| 48 | |||
| 49 | // OwnedBytes is the disk taken by the repositories an owner holds directly. | ||
| 50 | func OwnedBytes(st *store.Store, root, kind string, id int64) int64 { | ||
| 51 | repos, err := st.ListReposForOwner(kind, id) | ||
| 37 | if err != nil { | 52 | if err != nil { |
| 38 | return 0 | 53 | return 0 |
| 39 | } | 54 | } |
| @@ -49,33 +64,38 @@ func OwnedBytes(st *store.Store, root string, userID int64) int64 { | |||
| 49 | type configLimits struct { | 64 | type configLimits struct { |
| 50 | MaxReposPerUser int | 65 | MaxReposPerUser int |
| 51 | MaxBytesPerUser int64 | 66 | MaxBytesPerUser int64 |
| 67 | MaxOrgsPerUser int | ||
| 68 | MaxReposPerOrg int | ||
| 69 | MaxBytesPerOrg int64 | ||
| 52 | } | 70 | } |
| 53 | 71 | ||
| 54 | // QuotaConfig is what sshd passes: the limits section of the config. | 72 | // QuotaConfig is what sshd passes: the limits section of the config. |
| 55 | func QuotaConfig(cfg config.Config) configLimits { | 73 | func QuotaConfig(cfg config.Config) configLimits { |
| 56 | return configLimits{cfg.Limits.MaxReposPerUser, cfg.Limits.MaxBytesPerUser} | 74 | l := cfg.Limits |
| 75 | return configLimits{l.MaxReposPerUser, l.MaxBytesPerUser, l.MaxOrgsPerUser, l.MaxReposPerOrg, l.MaxBytesPerOrg} | ||
| 57 | } | 76 | } |
| 58 | 77 | ||
| 59 | func limitsOf(c *Ctx) configLimits { | 78 | func limitsOf(c *Ctx) configLimits { return QuotaConfig(c.Cfg) } |
| 60 | return configLimits{c.Cfg.Limits.MaxReposPerUser, c.Cfg.Limits.MaxBytesPerUser} | ||
| 61 | } | ||
| 62 | 79 | ||
| 63 | // checkRepoQuota refuses a new user-owned repository past the cap. | 80 | // checkRepoQuota refuses one more repository for the owner past its cap. |
| 64 | // repoCreateMu serialises the quota check with the insert that follows | 81 | // repoCreateMu serialises the quota check with the insert that follows |
| 65 | // it, so two concurrent creates cannot both pass the count (#108). One | 82 | // it, so two concurrent creates cannot both pass the count (#108). One |
| 66 | // process serves the instance, so a process-wide lock is the whole story. | 83 | // process serves the instance, so a process-wide lock is the whole story. |
| 67 | var repoCreateMu sync.Mutex | 84 | var repoCreateMu sync.Mutex |
| 68 | 85 | ||
| 69 | func checkRepoQuota(c *Ctx) int { | 86 | func checkRepoQuota(c *Ctx, kind string, id int64) int { |
| 70 | limit := RepoLimit(c.Store, limitsOf(c), c.User.ID) | 87 | limit := RepoLimit(c.Store, limitsOf(c), kind, id) |
| 71 | if limit == 0 { | 88 | if limit == 0 { |
| 72 | return -1 | 89 | return -1 |
| 73 | } | 90 | } |
| 74 | n, err := c.Store.OwnedRepoCount(c.User.ID) | 91 | n, err := c.Store.OwnedRepoCount(kind, id) |
| 75 | if err != nil { | 92 | if err != nil { |
| 76 | return c.fail(protocol.ExitFailure, "%v", err) | 93 | return c.fail(protocol.ExitFailure, "%v", err) |
| 77 | } | 94 | } |
| 78 | if n >= limit { | 95 | if n >= limit { |
| 96 | if kind == "org" { | ||
| 97 | return c.fail(protocol.ExitDenied, "the organization owns %d of the %d repositories it may hold; delete or transfer one, or ask an admin to raise the limit", n, limit) | ||
| 98 | } | ||
| 79 | return c.fail(protocol.ExitDenied, "you own %d of the %d repositories your account may hold; delete or transfer one, or ask an admin to raise the limit", n, limit) | 99 | return c.fail(protocol.ExitDenied, "you own %d of the %d repositories your account may hold; delete or transfer one, or ask an admin to raise the limit", n, limit) |
| 80 | } | 100 | } |
| 81 | return -1 | 101 | return -1 |
| @@ -83,102 +103,197 @@ func checkRepoQuota(c *Ctx) int { | |||
| 83 | 103 | ||
| 84 | // checkStorageQuota refuses a server-side write into repo once its | 104 | // checkStorageQuota refuses a server-side write into repo once its |
| 85 | // owner's storage quota is used up, the check sshd makes before a push. | 105 | // owner's storage quota is used up, the check sshd makes before a push. |
| 86 | // Repositories an org owns have no quota. | ||
| 87 | func checkStorageQuota(c *Ctx, repo store.Repo) int { | 106 | func checkStorageQuota(c *Ctx, repo store.Repo) int { |
| 88 | if repo.OwnerKind != "user" { | 107 | return checkBytesLeft(c, repo.OwnerKind, repo.OwnerID, repo.OwnerName, 0) |
| 89 | return -1 | 108 | } |
| 90 | } | 109 | |
| 91 | limit := ByteLimit(c.Store, limitsOf(c), repo.OwnerID) | 110 | // checkBytesLeft refuses when the owner's storage plus adding exceeds |
| 111 | // its cap (with adding 0, once the cap is used up). | ||
| 112 | func checkBytesLeft(c *Ctx, kind string, id int64, name string, adding int64) int { | ||
| 113 | limit := ByteLimit(c.Store, limitsOf(c), kind, id) | ||
| 92 | if limit <= 0 { | 114 | if limit <= 0 { |
| 93 | return -1 | 115 | return -1 |
| 94 | } | 116 | } |
| 95 | if used := OwnedBytes(c.Store, c.Cfg.Server.Root, repo.OwnerID); used >= limit { | 117 | used := OwnedBytes(c.Store, c.Cfg.Server.Root, kind, id) |
| 118 | if adding == 0 && used >= limit { | ||
| 96 | return c.fail(protocol.ExitDenied, | 119 | return c.fail(protocol.ExitDenied, |
| 97 | "%s's storage quota is used up (%d of %d bytes); delete something, or ask an admin to raise the limit", | 120 | "%s's storage quota is used up (%d of %d bytes); delete something, or ask an admin to raise the limit", |
| 98 | repo.OwnerName, used, limit) | 121 | name, used, limit) |
| 122 | } | ||
| 123 | if adding > 0 && used+adding > limit { | ||
| 124 | return c.fail(protocol.ExitDenied, | ||
| 125 | "%s's storage quota cannot take %d more bytes (%d of %d used); delete something, or ask an admin to raise the limit", | ||
| 126 | name, adding, used, limit) | ||
| 127 | } | ||
| 128 | return -1 | ||
| 129 | } | ||
| 130 | |||
| 131 | // orgCreateMu serialises the org cap check with the insert, as | ||
| 132 | // repoCreateMu does for repositories. | ||
| 133 | var orgCreateMu sync.Mutex | ||
| 134 | |||
| 135 | func checkOrgQuota(c *Ctx) int { | ||
| 136 | limit := OrgLimit(c.Store, limitsOf(c), c.User.ID) | ||
| 137 | if limit == 0 { | ||
| 138 | return -1 | ||
| 139 | } | ||
| 140 | n, err := c.Store.CreatedOrgCount(c.User.ID) | ||
| 141 | if err != nil { | ||
| 142 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 143 | } | ||
| 144 | if n >= limit { | ||
| 145 | return c.fail(protocol.ExitDenied, "you have created %d of the %d organizations your account may create; delete one, or ask an admin to raise the limit", n, limit) | ||
| 99 | } | 146 | } |
| 100 | return -1 | 147 | return -1 |
| 101 | } | 148 | } |
| 102 | 149 | ||
| 103 | func init() { | 150 | func init() { |
| 104 | register(Command{Path: []string{"admin", "user", "limits"}, | 151 | register(Command{Path: []string{"admin", "user", "limits"}, |
| 105 | Summary: "show or set an account's repository and storage caps (instance admins)", | 152 | Summary: "show or set an account's repository, storage and organization caps (instance admins)", |
| 106 | Usage: "admin user limits <username> [--repos <n>|default] [--bytes <n>|default]", | 153 | Usage: "admin user limits <username> [--repos <n>|default] [--bytes <n>|default] [--orgs <n>|default]", |
| 107 | Flags: []Flag{ | 154 | Flags: []Flag{ |
| 108 | {"--repos", "<n>|default", "the account's repository cap", ""}, | 155 | {"--repos", "<n>|default", "the account's repository cap", ""}, |
| 109 | {"--bytes", "<n>|default", "the account's storage cap", ""}, | 156 | {"--bytes", "<n>|default", "the account's storage cap", ""}, |
| 157 | {"--orgs", "<n>|default", "the account's cap on organizations it creates", ""}, | ||
| 110 | }, | 158 | }, |
| 111 | Examples: []string{"admin user limits alice", "admin user limits alice --repos 50"}, | 159 | Examples: []string{"admin user limits alice", "admin user limits alice --repos 50"}, |
| 112 | Run: runAdminUserLimits}) | 160 | Run: runAdminUserLimits}) |
| 161 | register(Command{Path: []string{"admin", "org", "limits"}, | ||
| 162 | Summary: "show or set an organization's repository and storage caps (instance admins)", | ||
| 163 | Usage: "admin org limits <org> [--repos <n>|default] [--bytes <n>|default]", | ||
| 164 | Flags: []Flag{ | ||
| 165 | {"--repos", "<n>|default", "the organization's repository cap", ""}, | ||
| 166 | {"--bytes", "<n>|default", "the organization's storage cap", ""}, | ||
| 167 | }, | ||
| 168 | Examples: []string{"admin org limits krz", "admin org limits krz --bytes 0"}, | ||
| 169 | Run: runAdminOrgLimits}) | ||
| 113 | } | 170 | } |
| 114 | 171 | ||
| 115 | func runAdminUserLimits(c *Ctx, args []string) int { | 172 | // applyLimitFlags reads --repos/--bytes (and --orgs when orgs is true) |
| 116 | if code := requireInstanceAdmin(c); code >= 0 { | 173 | // into l. set reports whether any flag was given. |
| 117 | return code | 174 | func applyLimitFlags(c *Ctx, args []string, l *store.Limits, orgs bool) (set bool, code int) { |
| 118 | } | 175 | for i := 0; i < len(args); i++ { |
| 119 | if len(args) < 1 { | ||
| 120 | return c.usage() | ||
| 121 | } | ||
| 122 | u, err := c.Store.UserByUsername(args[0]) | ||
| 123 | if err != nil { | ||
| 124 | return c.fail(protocol.ExitNotFound, "no user %q", args[0]) | ||
| 125 | } | ||
| 126 | l, err := c.Store.UserLimits(u.ID) | ||
| 127 | if err != nil { | ||
| 128 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 129 | } | ||
| 130 | set := false | ||
| 131 | for i := 1; i < len(args); i++ { | ||
| 132 | if i+1 >= len(args) { | 176 | if i+1 >= len(args) { |
| 133 | return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) | 177 | return false, c.fail(protocol.ExitUsage, "%s requires a value", args[i]) |
| 134 | } | 178 | } |
| 135 | v := args[i+1] | 179 | v := args[i+1] |
| 136 | var target **int64 | 180 | var target **int64 |
| 137 | switch args[i] { | 181 | switch { |
| 138 | case "--repos": | 182 | case args[i] == "--repos": |
| 139 | target = &l.Repos | 183 | target = &l.Repos |
| 140 | case "--bytes": | 184 | case args[i] == "--bytes": |
| 141 | target = &l.Bytes | 185 | target = &l.Bytes |
| 186 | case args[i] == "--orgs" && orgs: | ||
| 187 | target = &l.Orgs | ||
| 142 | default: | 188 | default: |
| 143 | return c.usage() | 189 | return false, c.usage() |
| 144 | } | 190 | } |
| 145 | if v == "default" { | 191 | if v == "default" { |
| 146 | *target = nil | 192 | *target = nil |
| 147 | } else { | 193 | } else { |
| 148 | n, err := strconv.ParseInt(v, 10, 64) | 194 | n, err := strconv.ParseInt(v, 10, 64) |
| 149 | if err != nil || n < 0 { | 195 | if err != nil || n < 0 { |
| 150 | return c.fail(protocol.ExitUsage, "%s takes a non-negative number or default", args[i]) | 196 | return false, c.fail(protocol.ExitUsage, "%s takes a non-negative number or default", args[i]) |
| 151 | } | 197 | } |
| 152 | *target = &n | 198 | *target = &n |
| 153 | } | 199 | } |
| 154 | set = true | 200 | set = true |
| 155 | i++ | 201 | i++ |
| 156 | } | 202 | } |
| 203 | return set, -1 | ||
| 204 | } | ||
| 205 | |||
| 206 | func capText(n int64) string { | ||
| 207 | if n == 0 { | ||
| 208 | return "unlimited" | ||
| 209 | } | ||
| 210 | return strconv.FormatInt(n, 10) | ||
| 211 | } | ||
| 212 | |||
| 213 | func runAdminUserLimits(c *Ctx, args []string) int { | ||
| 214 | if code := requireInstanceAdmin(c); code >= 0 { | ||
| 215 | return code | ||
| 216 | } | ||
| 217 | if len(args) < 1 { | ||
| 218 | return c.usage() | ||
| 219 | } | ||
| 220 | u, err := c.Store.UserByUsername(args[0]) | ||
| 221 | if err != nil { | ||
| 222 | return c.fail(protocol.ExitNotFound, "no user %q", args[0]) | ||
| 223 | } | ||
| 224 | l, err := c.Store.OwnerLimits("user", u.ID) | ||
| 225 | if err != nil { | ||
| 226 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 227 | } | ||
| 228 | set, code := applyLimitFlags(c, args[1:], &l, true) | ||
| 229 | if code >= 0 { | ||
| 230 | return code | ||
| 231 | } | ||
| 157 | if set { | 232 | if set { |
| 158 | if err := c.Store.SetUserLimits(u.ID, l); err != nil { | 233 | if err := c.Store.SetOwnerLimits("user", u.ID, l); err != nil { |
| 159 | return c.fail(protocol.ExitFailure, "%v", err) | 234 | return c.fail(protocol.ExitFailure, "%v", err) |
| 160 | } | 235 | } |
| 161 | c.Store.Audit(c.User.ID, "admin user.limits", map[string]any{"user": u.Username, "repos": l.Repos, "bytes": l.Bytes}) | 236 | c.Store.Audit(c.User.ID, "admin user.limits", map[string]any{"user": u.Username, "repos": l.Repos, "bytes": l.Bytes, "orgs": l.Orgs}) |
| 162 | } | 237 | } |
| 163 | type out struct { | 238 | type out struct { |
| 164 | User string `json:"user"` | 239 | User string `json:"user"` |
| 240 | Repos int64 `json:"repos"` // effective cap, 0 unlimited | ||
| 241 | Bytes int64 `json:"bytes"` // effective cap, 0 unlimited | ||
| 242 | Orgs int64 `json:"orgs"` // effective cap, 0 unlimited | ||
| 243 | ReposOwned int64 `json:"repos_owned"` | ||
| 244 | BytesOwned int64 `json:"bytes_owned"` | ||
| 245 | OrgsCreated int64 `json:"orgs_created"` | ||
| 246 | Override bool `json:"override"` // any per-account value set | ||
| 247 | } | ||
| 248 | d := out{User: u.Username, Repos: RepoLimit(c.Store, limitsOf(c), "user", u.ID), Bytes: ByteLimit(c.Store, limitsOf(c), "user", u.ID), | ||
| 249 | Orgs: OrgLimit(c.Store, limitsOf(c), u.ID), Override: l.Repos != nil || l.Bytes != nil || l.Orgs != nil} | ||
| 250 | d.ReposOwned, _ = c.Store.OwnedRepoCount("user", u.ID) | ||
| 251 | d.BytesOwned = OwnedBytes(c.Store, c.Cfg.Server.Root, "user", u.ID) | ||
| 252 | d.OrgsCreated, _ = c.Store.CreatedOrgCount(u.ID) | ||
| 253 | return c.emit(d, func(w io.Writer) { | ||
| 254 | fmt.Fprintf(w, "%s\trepos %d of %s\tbytes %d of %s\torgs %d of %s\n", d.User, | ||
| 255 | d.ReposOwned, capText(d.Repos), d.BytesOwned, capText(d.Bytes), d.OrgsCreated, capText(d.Orgs)) | ||
| 256 | }) | ||
| 257 | } | ||
| 258 | |||
| 259 | func runAdminOrgLimits(c *Ctx, args []string) int { | ||
| 260 | if code := requireInstanceAdmin(c); code >= 0 { | ||
| 261 | return code | ||
| 262 | } | ||
| 263 | if len(args) < 1 { | ||
| 264 | return c.usage() | ||
| 265 | } | ||
| 266 | org, err := c.Store.OrgByName(args[0]) | ||
| 267 | if err != nil { | ||
| 268 | return c.fail(protocol.ExitNotFound, "no organization %q", args[0]) | ||
| 269 | } | ||
| 270 | l, err := c.Store.OwnerLimits("org", org.ID) | ||
| 271 | if err != nil { | ||
| 272 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 273 | } | ||
| 274 | set, code := applyLimitFlags(c, args[1:], &l, false) | ||
| 275 | if code >= 0 { | ||
| 276 | return code | ||
| 277 | } | ||
| 278 | if set { | ||
| 279 | if err := c.Store.SetOwnerLimits("org", org.ID, l); err != nil { | ||
| 280 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 281 | } | ||
| 282 | c.Store.Audit(c.User.ID, "admin org.limits", map[string]any{"org": org.Name, "repos": l.Repos, "bytes": l.Bytes}) | ||
| 283 | } | ||
| 284 | type out struct { | ||
| 285 | Org string `json:"org"` | ||
| 165 | Repos int64 `json:"repos"` // effective cap, 0 unlimited | 286 | Repos int64 `json:"repos"` // effective cap, 0 unlimited |
| 166 | Bytes int64 `json:"bytes"` // effective cap, 0 unlimited | 287 | Bytes int64 `json:"bytes"` // effective cap, 0 unlimited |
| 167 | ReposOwned int64 `json:"repos_owned"` | 288 | ReposOwned int64 `json:"repos_owned"` |
| 168 | BytesOwned int64 `json:"bytes_owned"` | 289 | BytesOwned int64 `json:"bytes_owned"` |
| 169 | Override bool `json:"override"` // any per-account value set | 290 | Override bool `json:"override"` // any per-org value set |
| 170 | } | 291 | } |
| 171 | d := out{User: u.Username, Repos: RepoLimit(c.Store, limitsOf(c), u.ID), Bytes: ByteLimit(c.Store, limitsOf(c), u.ID), | 292 | d := out{Org: org.Name, Repos: RepoLimit(c.Store, limitsOf(c), "org", org.ID), Bytes: ByteLimit(c.Store, limitsOf(c), "org", org.ID), |
| 172 | Override: l.Repos != nil || l.Bytes != nil} | 293 | Override: l.Repos != nil || l.Bytes != nil} |
| 173 | d.ReposOwned, _ = c.Store.OwnedRepoCount(u.ID) | 294 | d.ReposOwned, _ = c.Store.OwnedRepoCount("org", org.ID) |
| 174 | d.BytesOwned = OwnedBytes(c.Store, c.Cfg.Server.Root, u.ID) | 295 | d.BytesOwned = OwnedBytes(c.Store, c.Cfg.Server.Root, "org", org.ID) |
| 175 | return c.emit(d, func(w io.Writer) { | 296 | return c.emit(d, func(w io.Writer) { |
| 176 | cap := func(n int64) string { | 297 | fmt.Fprintf(w, "%s\trepos %d of %s\tbytes %d of %s\n", d.Org, d.ReposOwned, capText(d.Repos), d.BytesOwned, capText(d.Bytes)) |
| 177 | if n == 0 { | ||
| 178 | return "unlimited" | ||
| 179 | } | ||
| 180 | return strconv.FormatInt(n, 10) | ||
| 181 | } | ||
| 182 | fmt.Fprintf(w, "%s\trepos %d of %s\tbytes %d of %s\n", d.User, d.ReposOwned, cap(d.Repos), d.BytesOwned, cap(d.Bytes)) | ||
| 183 | }) | 298 | }) |
| 184 | } | 299 | } |
internal/control/repo.go +13 −5
| @@ -276,11 +276,9 @@ func runRepoCreate(c *Ctx, args []string) int { | |||
| 276 | return code | 276 | return code |
| 277 | } | 277 | } |
| 278 | repoCreateMu.Lock() | 278 | repoCreateMu.Lock() |
| 279 | if ownerKind == "user" { | 279 | if code := checkRepoQuota(c, ownerKind, ownerID); code >= 0 { |
| 280 | if code := checkRepoQuota(c); code >= 0 { | 280 | repoCreateMu.Unlock() |
| 281 | repoCreateMu.Unlock() | 281 | return code |
| 282 | return code | ||
| 283 | } | ||
| 284 | } | 282 | } |
| 285 | id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility) | 283 | id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility) |
| 286 | repoCreateMu.Unlock() | 284 | repoCreateMu.Unlock() |
| @@ -581,6 +579,16 @@ func runRepoTransfer(c *Ctx, args []string) int { | |||
| 581 | 579 | ||
| 582 | oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | 580 | oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) |
| 583 | newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name) | 581 | newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name) |
| 582 | // The receiving owner's caps apply as if the repository were created | ||
| 583 | // there. The lock covers the move so two transfers cannot both pass. | ||
| 584 | repoCreateMu.Lock() | ||
| 585 | defer repoCreateMu.Unlock() | ||
| 586 | if code := checkRepoQuota(c, newKind, newID); code >= 0 { | ||
| 587 | return code | ||
| 588 | } | ||
| 589 | if code := checkBytesLeft(c, newKind, newID, newOwner, gitutil.DirSize(oldDir)); code >= 0 { | ||
| 590 | return code | ||
| 591 | } | ||
| 584 | if _, err := os.Stat(newDir); err == nil { | 592 | if _, err := os.Stat(newDir); err == nil { |
| 585 | return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name) | 593 | return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name) |
| 586 | } | 594 | } |
internal/sshd/sshd.go +3 −3
| @@ -586,9 +586,9 @@ func runGit(cfg config.Config, st *store.Store, packs, pushes *packlimit.Limiter | |||
| 586 | // A storage quota on the owner rides the same mechanism as the pack | 586 | // A storage quota on the owner rides the same mechanism as the pack |
| 587 | // cap: the pack may be no larger than what the owner has left. | 587 | // cap: the pack may be no larger than what the owner has left. |
| 588 | maxPack := cfg.Limits.MaxPackBytes | 588 | maxPack := cfg.Limits.MaxPackBytes |
| 589 | if write && repo.OwnerKind == "user" { | 589 | if write { |
| 590 | if limit := control.ByteLimit(st, control.QuotaConfig(cfg), repo.OwnerID); limit > 0 { | 590 | if limit := control.ByteLimit(st, control.QuotaConfig(cfg), repo.OwnerKind, repo.OwnerID); limit > 0 { |
| 591 | used := control.OwnedBytes(st, cfg.Server.Root, repo.OwnerID) | 591 | used := control.OwnedBytes(st, cfg.Server.Root, repo.OwnerKind, repo.OwnerID) |
| 592 | left := limit - used | 592 | left := limit - used |
| 593 | if left <= 0 { | 593 | if left <= 0 { |
| 594 | fmt.Fprintf(stderr, "%s's storage quota is used up (%d of %d bytes); delete something, or ask an admin to raise the limit\n", repo.OwnerName, used, limit) | 594 | fmt.Fprintf(stderr, "%s's storage quota is used up (%d of %d bytes); delete something, or ask an admin to raise the limit\n", repo.OwnerName, used, limit) |
internal/store/adminusers.go +3 −4
| @@ -111,11 +111,10 @@ func (s *Store) AdminMailAddresses() ([]string, error) { | |||
| 111 | return out, rows.Err() | 111 | return out, rows.Err() |
| 112 | } | 112 | } |
| 113 | 113 | ||
| 114 | // OwnedRepoCount counts repositories the user owns directly, not through | 114 | // OwnedRepoCount counts repositories a user or an org owns directly. |
| 115 | // an org. | 115 | func (s *Store) OwnedRepoCount(kind string, id int64) (int64, error) { |
| 116 | func (s *Store) OwnedRepoCount(userID int64) (int64, error) { | ||
| 117 | var n int64 | 116 | var n int64 |
| 118 | err := s.DB.QueryRow("SELECT COUNT(*) FROM repos WHERE owner_kind = 'user' AND owner_id = ?", userID).Scan(&n) | 117 | err := s.DB.QueryRow("SELECT COUNT(*) FROM repos WHERE owner_kind = ? AND owner_id = ?", kind, id).Scan(&n) |
| 119 | return n, err | 118 | return n, err |
| 120 | } | 119 | } |
| 121 | 120 | ||
internal/store/migrations/0076_org_limits.down.sql added +5
| @@ -0,0 +1,5 @@ | |||
| 1 | DROP INDEX orgs_created_by; | ||
| 2 | ALTER TABLE users DROP COLUMN org_limit; | ||
| 3 | ALTER TABLE orgs DROP COLUMN byte_limit; | ||
| 4 | ALTER TABLE orgs DROP COLUMN repo_limit; | ||
| 5 | ALTER TABLE orgs DROP COLUMN created_by; | ||
internal/store/migrations/0076_org_limits.up.sql added +14
| @@ -0,0 +1,14 @@ | |||
| 1 | -- Quotas for organizations: limits.max_orgs_per_user counts orgs by the | ||
| 2 | -- account that created them; max_repos_per_org and max_bytes_per_org cap | ||
| 3 | -- each org, with per-org overrides. NULL means the configured default. | ||
| 4 | -- created_by carries no foreign key: ids are never reused (#306), and an | ||
| 5 | -- org outlives the account that made it. | ||
| 6 | ALTER TABLE orgs ADD COLUMN created_by INTEGER; | ||
| 7 | ALTER TABLE orgs ADD COLUMN repo_limit INTEGER; | ||
| 8 | ALTER TABLE orgs ADD COLUMN byte_limit INTEGER; | ||
| 9 | ALTER TABLE users ADD COLUMN org_limit INTEGER; | ||
| 10 | UPDATE orgs SET created_by = ( | ||
| 11 | SELECT m.user_id FROM org_members m | ||
| 12 | WHERE m.org_id = orgs.id AND m.role = 'admin' | ||
| 13 | ORDER BY m.rowid LIMIT 1); | ||
| 14 | CREATE INDEX orgs_created_by ON orgs(created_by); | ||
internal/store/orgs.go +1 −1
| @@ -43,7 +43,7 @@ func (s *Store) CreateOrg(name string, creatorID int64) (int64, error) { | |||
| 43 | if taken { | 43 | if taken { |
| 44 | return 0, fmt.Errorf("the name %q is taken", name) | 44 | return 0, fmt.Errorf("the name %q is taken", name) |
| 45 | } | 45 | } |
| 46 | res, err := tx.Exec("INSERT INTO orgs (name) VALUES (?)", name) | 46 | res, err := tx.Exec("INSERT INTO orgs (name, created_by) VALUES (?, ?)", name, creatorID) |
| 47 | if err != nil { | 47 | if err != nil { |
| 48 | return 0, err | 48 | return 0, err |
| 49 | } | 49 | } |
internal/store/quotas.go +46 −23
| @@ -5,39 +5,54 @@ import ( | |||
| 5 | "time" | 5 | "time" |
| 6 | ) | 6 | ) |
| 7 | 7 | ||
| 8 | // UserLimits is an account's quota overrides; nil means the configured | 8 | // Limits is an owner's quota overrides; nil means the configured default |
| 9 | // default applies. | 9 | // applies. Orgs is the account's cap on organizations it creates and is |
| 10 | type UserLimits struct { | 10 | // always nil for an org. |
| 11 | type Limits struct { | ||
| 11 | Repos *int64 | 12 | Repos *int64 |
| 12 | Bytes *int64 | 13 | Bytes *int64 |
| 14 | Orgs *int64 | ||
| 13 | } | 15 | } |
| 14 | 16 | ||
| 15 | func (s *Store) UserLimits(userID int64) (UserLimits, error) { | 17 | func nullable(n sql.NullInt64) *int64 { |
| 16 | var repos, bytes sql.NullInt64 | 18 | if !n.Valid { |
| 17 | err := s.DB.QueryRow("SELECT repo_limit, byte_limit FROM users WHERE id = ?", userID).Scan(&repos, &bytes) | 19 | return nil |
| 18 | if err != nil { | ||
| 19 | return UserLimits{}, err | ||
| 20 | } | ||
| 21 | var l UserLimits | ||
| 22 | if repos.Valid { | ||
| 23 | l.Repos = &repos.Int64 | ||
| 24 | } | 20 | } |
| 25 | if bytes.Valid { | 21 | return &n.Int64 |
| 26 | l.Bytes = &bytes.Int64 | 22 | } |
| 23 | |||
| 24 | func orNull(p *int64) any { | ||
| 25 | if p == nil { | ||
| 26 | return nil | ||
| 27 | } | 27 | } |
| 28 | return l, nil | 28 | return *p |
| 29 | } | 29 | } |
| 30 | 30 | ||
| 31 | // SetUserLimits writes the overrides; a nil field clears back to default. | 31 | // OwnerLimits reads the overrides of a user or an org. |
| 32 | func (s *Store) SetUserLimits(userID int64, l UserLimits) error { | 32 | func (s *Store) OwnerLimits(kind string, id int64) (Limits, error) { |
| 33 | var repos, bytes any | 33 | var repos, bytes, orgs sql.NullInt64 |
| 34 | if l.Repos != nil { | 34 | var err error |
| 35 | repos = *l.Repos | 35 | if kind == "org" { |
| 36 | err = s.DB.QueryRow("SELECT repo_limit, byte_limit FROM orgs WHERE id = ?", id).Scan(&repos, &bytes) | ||
| 37 | } else { | ||
| 38 | err = s.DB.QueryRow("SELECT repo_limit, byte_limit, org_limit FROM users WHERE id = ?", id).Scan(&repos, &bytes, &orgs) | ||
| 39 | } | ||
| 40 | if err != nil { | ||
| 41 | return Limits{}, err | ||
| 36 | } | 42 | } |
| 37 | if l.Bytes != nil { | 43 | return Limits{nullable(repos), nullable(bytes), nullable(orgs)}, nil |
| 38 | bytes = *l.Bytes | 44 | } |
| 45 | |||
| 46 | // SetOwnerLimits writes the overrides; a nil field clears back to default. | ||
| 47 | func (s *Store) SetOwnerLimits(kind string, id int64, l Limits) error { | ||
| 48 | var res sql.Result | ||
| 49 | var err error | ||
| 50 | if kind == "org" { | ||
| 51 | res, err = s.DB.Exec("UPDATE orgs SET repo_limit = ?, byte_limit = ? WHERE id = ?", orNull(l.Repos), orNull(l.Bytes), id) | ||
| 52 | } else { | ||
| 53 | res, err = s.DB.Exec("UPDATE users SET repo_limit = ?, byte_limit = ?, org_limit = ? WHERE id = ?", | ||
| 54 | orNull(l.Repos), orNull(l.Bytes), orNull(l.Orgs), id) | ||
| 39 | } | 55 | } |
| 40 | res, err := s.DB.Exec("UPDATE users SET repo_limit = ?, byte_limit = ? WHERE id = ?", repos, bytes, userID) | ||
| 41 | if err != nil { | 56 | if err != nil { |
| 42 | return err | 57 | return err |
| 43 | } | 58 | } |
| @@ -47,6 +62,14 @@ func (s *Store) SetUserLimits(userID int64, l UserLimits) error { | |||
| 47 | return nil | 62 | return nil |
| 48 | } | 63 | } |
| 49 | 64 | ||
| 65 | // CreatedOrgCount counts the organizations an account created and that | ||
| 66 | // still exist. | ||
| 67 | func (s *Store) CreatedOrgCount(userID int64) (int64, error) { | ||
| 68 | var n int64 | ||
| 69 | err := s.DB.QueryRow("SELECT COUNT(*) FROM orgs WHERE created_by = ?", userID).Scan(&n) | ||
| 70 | return n, err | ||
| 71 | } | ||
| 72 | |||
| 50 | // ReapPendingUsers deletes self-registered accounts still unverified | 73 | // ReapPendingUsers deletes self-registered accounts still unverified |
| 51 | // after maxAge. A pending account owns nothing (it cannot create a | 74 | // after maxAge. A pending account owns nothing (it cannot create a |
| 52 | // repository before verifying), so DeleteUser has nothing to refuse; an | 75 | // repository before verifying), so DeleteUser has nothing to refuse; an |