Public: anyone can read this; the crew can change it.
Per-account storage limit: repository and app-data bytes measured, and a push past the limit refused before it is written #2183
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/919-per-account-storage-limit-repository-and → main 4 commits fork 11 threads · 11 unresolved
README.md +1 −1
| @@ -1140,7 +1140,7 @@ A forge admin sees every declared flag at `/settings/flags` (Settings → Flags) | ||
| 1140 | 1140 | |
| 1141 | 1141 | Every limit the owner can hit is declared in `src/settings/limits.ts` with its default, bounds and reason, and changed live by a forge admin at `/admin/limits`, `PUT /api/v1/admin/limits/:key` or the `limits` MCP tool (never by an agent). Each change is kept with who made it. |
| 1142 | 1142 | |
| 1143 | Per-account quotas (`src/settings/quotas.ts`) — how many `repositories` and `apps` one account may own — are set the same way: **Account quotas** on `/admin/limits`, `GET`/`PATCH /api/v1/admin/settings` (`{"quotas": {"repositories": 5, "apps": null}}`) or the `quotas` MCP tool (dry by default, admin scope, never an agent). A value is a positive whole number, or empty/`null` for no limit, which is the default; anything else is a 422. Every change is kept with who made it and listed beside the form. The `apps` limit is enforced (`src/actions/app_limit.ts`): with one set, an account owning that many apps is refused one more, unless the actor is a forge admin; with none set nothing is asked. The check and the insert are separate statements, so two concurrent creates can pass by one. The refusal reads "This account is at its limit: 3 of 3 apps. Remove one to make room." and each door answers it its own way: the API (`POST /api/v1/apps` and `POST /api/v1/repos/:owner/:name/app`) a 403 `{"error": "app_limit", "message", "limit", "count"}`; the new-app page and the app settings form a 422 with the sentence on the form; make-it-yours the same sentence as an invalid error, before the fork, so no repository is left behind. The `repositories` limit is enforced too (`src/actions/repos.ts`): with one set, an account owning that many repositories (sealed forks are not counted) is refused one more by create, fork, make-it-yours, mirror, migrate and new app, before anything is made on disk. Forge admins and agents are exempt, and creating under an organisation is not counted: the quota is per account, so only a repository the actor owns itself is asked. The refusal reads "2 of 2 repositories"; the API answers 403 `{"error": "repository_limit", "message"}`, the pages say "Not made: 2 of 2 repositories." on the form (a 422), and the terminal shows it as a toast. The check and the insert are separate statements here as well, so two concurrent creates can pass by one. In the terminal the same admin opens the command palette (`ctrl+k`), picks Account quotas, and sees every key with its limit and last change; enter sets one (empty for no limit), `x` clears it, both through the same validation. | |
| 1143 | Per-account quotas (`src/settings/quotas.ts`) — how many `repositories` and `apps` one account may own — are set the same way: **Account quotas** on `/admin/limits`, `GET`/`PATCH /api/v1/admin/settings` (`{"quotas": {"repositories": 5, "apps": null}}`) or the `quotas` MCP tool (dry by default, admin scope, never an agent). A value is a positive whole number, or empty/`null` for no limit, which is the default; anything else is a 422. Every change is kept with who made it and listed beside the form. The `apps` limit is enforced (`src/actions/app_limit.ts`): with one set, an account owning that many apps is refused one more, unless the actor is a forge admin; with none set nothing is asked. The check and the insert are separate statements, so two concurrent creates can pass by one. The refusal reads "This account is at its limit: 3 of 3 apps. Remove one to make room." and each door answers it its own way: the API (`POST /api/v1/apps` and `POST /api/v1/repos/:owner/:name/app`) a 403 `{"error": "app_limit", "message", "limit", "count"}`; the new-app page and the app settings form a 422 with the sentence on the form; make-it-yours the same sentence as an invalid error, before the fork, so no repository is left behind. The `repositories` limit is enforced too (`src/actions/repos.ts`): with one set, an account owning that many repositories (sealed forks are not counted) is refused one more by create, fork, make-it-yours, mirror, migrate and new app, before anything is made on disk. Forge admins and agents are exempt, and creating under an organisation is not counted: the quota is per account, so only a repository the actor owns itself is asked. The refusal reads "2 of 2 repositories"; the API answers 403 `{"error": "repository_limit", "message"}`, the pages say "Not made: 2 of 2 repositories." on the form (a 422), and the terminal shows it as a toast. The check and the insert are separate statements here as well, so two concurrent creates can pass by one. The `storage` limit (megabytes, `src/core/storage.ts`) bounds the bare repositories an account owns plus its apps' data: a push, create or fork by a non-admin, non-agent account at the limit is refused before git runs, as a git error over HTTP and SSH ("storage limit reached: 1.9 GB of 2 GB"), and a push that would pass it is capped inside git (`receive.maxInputSize` = the room left) so it is stopped in quarantine and the repository is as it was. The figure is cached per account in memory and re-measured in the background after each push, walked on the request path only when it must be: the first look at an account waits for one walk, and so does the first check after a push, create, fork, mirror or delete changed the disk (a walk already under way is joined, not repeated). An account found at its limit is measured again before it is refused, so a deletion frees it with no restart, but at most once every 5 seconds, so retries by a locked-out client do not scan the disk. Two pushes at once can each pass. In the terminal the same admin opens the command palette (`ctrl+k`), picks Account quotas, and sees every key with its limit and last change; enter sets one (empty for no limit), `x` clears it, both through the same validation. | |
| 1144 | 1144 | |
| 1145 | 1145 | The forge setting `signup` (`src/settings/signup.ts`) is `open`, `invite` or `closed`, and `open` is the default. A forge admin who is a person changes it live — **Signup** on `/admin/limits`, `PATCH /api/v1/admin/settings` (`{"signup":"closed"}`) or the `signup` MCP tool (dry by default, admin scope, never an agent) — with no restart; any other value is a 422. `FORGE_SIGNUP` sets it at boot, and a wrong value stops the boot with a plain message; while it stays set, every restart or deploy writes it again over a change made on the page (kept as a change by `boot`), so unset it to let the page decide. Every change is kept with who made it and when, and listed beside the form. Nothing reads it yet: no sign-in or signup path asks it. |
| 1146 | 1146 |
src/actions/repos.ts +12 −1
| @@ -10,6 +10,7 @@ import { canSeeSealed } from "../core/sealed.ts"; | ||
| 10 | 10 | import { capacityValue } from "../core/capacity.ts"; |
| 11 | 11 | import { NEW_FOR_MS } from "./accounts.ts"; |
| 12 | 12 | import { repositoryLimitSentence } from "../settings/quotas.ts"; |
| 13 | import { storageChanged, storageRefusal, storageSentence } from "../core/storage.ts"; | |
| 13 | 14 | import { |
| 14 | 15 | createLabel as insertLabel, |
| 15 | 16 | deleteLabel as removeLabel, |
| @@ -89,7 +90,11 @@ export function repositoryLimitRefusal(app: App, user: User, ownerId = user.id): | ||
| 89 | 90 | * between the refusal and now, it still says the limit was the reason. |
| 90 | 91 | */ |
| 91 | 92 | export function repositoryLimitMessage(app: App, user: User, ownerId = user.id): string { |
| 92 | return repositoryLimitRefusal(app, user, ownerId) ?? "at the repositories limit"; | |
| 93 | return ( | |
| 94 | repositoryLimitRefusal(app, user, ownerId) ?? | |
| 95 | storageSentence(app, ownerId) ?? | |
| 96 | "at the repositories limit" | |
| 97 | ); | |
| 93 | 98 | } |
| 94 | 99 | |
| 95 | 100 | /** |
| @@ -140,6 +145,7 @@ export async function createRepo( | ||
| 140 | 145 | if (ownerId === undefined) return err("forbidden_owner"); |
| 141 | 146 | if (ownerId === user.value.id && repositoryLimitRefusal(app, user.value) !== undefined) |
| 142 | 147 | return err("repository_limit"); |
| 148 | if ((await storageRefusal(app, user.value, ownerId)) !== undefined) return err("repository_limit"); | |
Not blocking: a storage refusal comes back as |
||
| 143 | 149 | const created = await initRepo(app.db, app.reposRoot, { |
| 144 | 150 | ownerId, |
| 145 | 151 | name: input.name, |
| @@ -147,6 +153,7 @@ export async function createRepo( | ||
| 147 | 153 | description: input.description ?? "", |
| 148 | 154 | }); |
| 149 | 155 | if (!created.ok) return created; |
| 156 | storageChanged(app, ownerId); | |
| 150 | 157 | app.bus.append({ |
| 151 | 158 | kind: "repo.created", |
| 152 | 159 | repoId: created.value.id, |
| @@ -179,6 +186,8 @@ export async function forkRepo( | ||
| 179 | 186 | const guarded = requireNewAccountGuard(app, user.value); |
| 180 | 187 | if (!guarded.ok) return guarded; |
| 181 | 188 | if (repositoryLimitRefusal(app, user.value) !== undefined) return err("repository_limit"); |
outdated
This checks the cached figure, but after |
||
| 189 | if ((await storageRefusal(app, user.value, user.value.id)) !== undefined) | |
| 190 | return err("repository_limit"); | |
| 182 | 191 | const forked = await cloneRepo( |
| 183 | 192 | app.db, |
| 184 | 193 | app.reposRoot, |
| @@ -193,6 +202,7 @@ export async function forkRepo( | ||
| 193 | 202 | }, |
| 194 | 203 | ); |
| 195 | 204 | if (!forked.ok) return forked; |
| 205 | storageChanged(app, user.value.id); | |
| 196 | 206 | app.bus.append({ |
| 197 | 207 | kind: "repo.forked", |
| 198 | 208 | repoId: forked.value.id, |
| @@ -397,6 +407,7 @@ export async function deleteRepo( | ||
| 397 | 407 | if (!admin.ok) return admin; |
| 398 | 408 | const deleted = await destroyRepo(app.db, app.reposRoot, repo); |
| 399 | 409 | if (!deleted.ok) return deleted; |
| 410 | storageChanged(app, repo.ownerId); | |
| 400 | 411 | // The row is gone (events cascade with it), so this event carries no repo. |
| 401 | 412 | app.bus.append({ kind: "repo.deleted", actorId: admin.value.id }); |
| 402 | 413 | return ok(); |
src/core/storage.ts added +189
| @@ -0,0 +1,189 @@ | ||
| 1 | /** | |
| 2 | * What one account stores, and whether it may store more (the storage | |
| 3 | * quota, `settings/quotas.ts`): the bytes of the bare repositories it owns | |
| 4 | * plus the data of the apps built on them. Walking a large repository is | |
| 5 | * slow, so the figure is cached per account. A change (push, create, fork, | |
| 6 | * mirror, delete) marks it stale and starts a walk in the background; the | |
| 7 | * next check joins that walk rather than judge on a number the disk has | |
| 8 | * left, so only a first look or a check after a change waits. An account | |
| 9 | * found at its limit is measured once more before it is refused, at most | |
| 10 | * every `REWALK_MS`. The cache is memory, not state: git | |
| 11 | * and the data directory are the truth, and a restart measures again. | |
| 12 | */ | |
| 13 | ||
| 14 | import { join } from "node:path"; | |
| 15 | import type { App } from "../app.ts"; | |
| 16 | import { appsRoot } from "../apps/data.ts"; | |
| 17 | import { dirBytes } from "../apps/bytes.ts"; | |
| 18 | import { repoDir } from "../git/repos.ts"; | |
| 19 | import { getQuota } from "../settings/quotas.ts"; | |
| 20 | import type { User } from "./users.ts"; | |
| 21 | ||
| 22 | /** The limit is set in megabytes (decimal), the unit people say it in. */ | |
| 23 | export const BYTES_PER_MB = 1_000_000; | |
| 24 | ||
| 25 | interface Entry { | |
| 26 | bytes: number; | |
| 27 | stale: boolean; | |
| 28 | /** When the last walk finished, in ms. */ | |
| 29 | walkedAt?: number; | |
| 30 | walking?: Promise<number>; | |
| 31 | } | |
| 32 | ||
| 33 | const caches = new WeakMap<App, Map<string, Entry>>(); | |
| 34 | ||
| 35 | function cacheOf(app: App): Map<string, Entry> { | |
| 36 | let cache = caches.get(app); | |
| 37 | if (cache === undefined) caches.set(app, (cache = new Map())); | |
| 38 | return cache; | |
| 39 | } | |
| 40 | ||
| 41 | /** Walk the disk for one account: repositories it owns plus its apps' data. */ | |
| 42 | export async function measureAccount(app: App, ownerId: string): Promise<number> { | |
| 43 | const repos = app.db | |
| 44 | .query<{ name: string; owner: string }, [string]>( | |
| 45 | `SELECT repo.name AS name, user.username AS owner | |
| 46 | FROM repo JOIN user ON user.id = repo.owner_id WHERE repo.owner_id = ?1`, | |
| 47 | ) | |
| 48 | .all(ownerId); | |
| 49 | const apps = app.db | |
| 50 | .query<{ id: string }, [string]>( | |
| 51 | "SELECT app.id AS id FROM app JOIN repo ON repo.id = app.repo_id WHERE repo.owner_id = ?1", | |
| 52 | ) | |
| 53 | .all(ownerId); | |
| 54 | let total = 0; | |
| 55 | for (const r of repos) total += await dirBytes(repoDir(app.reposRoot, r.owner, r.name)); | |
| 56 | for (const a of apps) total += await dirBytes(join(appsRoot(app.config.dataDir), a.id)); | |
| 57 | return total; | |
| 58 | } | |
| 59 | ||
| 60 | function walk(app: App, ownerId: string, entry: Entry): Promise<number> { | |
| 61 | entry.walking ??= measureAccount(app, ownerId) | |
| 62 | .then((bytes) => { | |
| 63 | entry.bytes = bytes; | |
| 64 | entry.stale = false; | |
| 65 | entry.walkedAt = Date.now(); | |
| 66 | return bytes; | |
| 67 | }) | |
| 68 | .finally(() => { | |
| 69 | delete entry.walking; | |
| 70 | }); | |
| 71 | return entry.walking; | |
| 72 | } | |
| 73 | ||
| 74 | /** The account's bytes: the cached figure, or the first walk. */ | |
| 75 | export async function accountBytes(app: App, ownerId: string): Promise<number> { | |
| 76 | const cache = cacheOf(app); | |
| 77 | let entry = cache.get(ownerId); | |
| 78 | if (entry === undefined) { | |
| 79 | entry = { bytes: 0, stale: true }; | |
| 80 | cache.set(ownerId, entry); | |
| 81 | } | |
| 82 | // A figure marked stale is never judged on: the walk a change began in the | |
| 83 | // background is joined, so a refusal never rests on a number the disk has left. | |
| 84 | return entry.stale ? walk(app, ownerId, entry) : entry.bytes; | |
| 85 | } | |
| 86 | ||
| 87 | /** The least time between two re-walks made only because an account is at its limit. */ | |
| 88 | export const REWALK_MS = 5_000; | |
| 89 | ||
| 90 | /** | |
outdated
High: the only callers are the push paths. Repository delete, create, fork and mirror never mark the figure stale. An account at the limit that deletes a repository is still refused (its next push stops at info/refs, so this never runs), and once a figure is cached, forks are checked against the old number. Call this from every action that changes the account's bytes, and re-measure when a check finds the account at or over the limit. |
||
| 91 | * Measure again, whatever the cache holds — unless a walk finished within | |
| 92 | * `REWALK_MS`, so a locked-out client retrying cannot turn each retry into a | |
| 93 | * disk scan. A change (`storageChanged`) always walks, whatever the age. | |
| 94 | */ | |
| 95 | async function freshBytes(app: App, ownerId: string): Promise<number> { | |
| 96 | const entry = cacheOf(app).get(ownerId); | |
| 97 | if (entry !== undefined) { | |
| 98 | if (entry.walkedAt !== undefined && Date.now() - entry.walkedAt < REWALK_MS) | |
| 99 | return entry.bytes; | |
| 100 | entry.stale = true; | |
| 101 | } | |
| 102 | return accountBytes(app, ownerId); | |
| 103 | } | |
| 104 | ||
| 105 | /** The last known figure, without waiting for anything. */ | |
| 106 | export function cachedBytes(app: App, ownerId: string): number | undefined { | |
| 107 | return cacheOf(app).get(ownerId)?.bytes; | |
| 108 | } | |
| 109 | ||
| 110 | /** A push, create, fork, mirror or delete changed the disk: measure again, off the request path. */ | |
| 111 | export function storageChanged(app: App, ownerId: string): void { | |
| 112 | const cache = cacheOf(app); | |
| 113 | const entry = cache.get(ownerId); | |
| 114 | if (entry === undefined) return; | |
| 115 | entry.stale = true; | |
| 116 | void walk(app, ownerId, entry).catch(() => {}); | |
| 117 | } | |
| 118 | ||
outdated
No test shows an agent is exempt. Add one: an agent pushing past the limit succeeds. |
||
| 119 | /** Bytes the way a person says them: `1.9 GB`, `340 MB`, `12 kB`. */ | |
| 120 | export function formatBytes(bytes: number): string { | |
| 121 | if (bytes >= 1e9) return `${+(bytes / 1e9).toFixed(1)} GB`; | |
| 122 | if (bytes >= 1e6) return `${+(bytes / 1e6).toFixed(1)} MB`; | |
| 123 | if (bytes >= 1e3) return `${Math.round(bytes / 1e3)} kB`; | |
| 124 | return `${bytes} B`; | |
| 125 | } | |
| 126 | ||
| 127 | /** The storage limit in bytes, or none. */ | |
| 128 | export function storageLimitBytes(app: App): number | undefined { | |
| 129 | const mb = getQuota(app.db, "storage"); | |
| 130 | return mb === null ? undefined : mb * BYTES_PER_MB; | |
| 131 | } | |
| 132 | ||
| 133 | /** Admins and agents are not held to the storage limit. */ | |
| 134 | export const storageExempt = (user: User): boolean => user.isAdmin || user.kind === "agent"; | |
| 135 | ||
| 136 | const sentence = (used: number, limit: number): string => | |
| 137 | `${formatBytes(used)} of ${formatBytes(limit)}`; | |
| 138 | ||
outdated
Medium: every push attempt by an account at its limit runs a full disk walk, including the |
||
| 139 | /** | |
| 140 | * The refusal a store past the limit gets, e.g. "1.9 GB of 2 GB", or nothing | |
| 141 | * when `user` may store more under `ownerId`'s account. A soft quota: two | |
| 142 | * pushes at once can each pass. | |
| 143 | */ | |
| 144 | export async function storageRefusal( | |
| 145 | app: App, | |
| 146 | user: User, | |
| 147 | ownerId: string, | |
| 148 | ): Promise<string | undefined> { | |
| 149 | const limit = storageLimitBytes(app); | |
| 150 | if (limit === undefined || storageExempt(user)) return undefined; | |
| 151 | let used = await accountBytes(app, ownerId); | |
| 152 | // At the limit, look again: a repository deleted or data cleared since the | |
| 153 | // figure was taken must not keep an account locked out. | |
| 154 | if (used >= limit) used = await freshBytes(app, ownerId); | |
| 155 | return used >= limit ? sentence(used, limit) : undefined; | |
| 156 | } | |
| 157 | ||
| 158 | /** What a refused push says, over HTTP and SSH alike. */ | |
| 159 | export const storageRefusedText = (used: string): string => | |
| 160 | `storage limit reached: ${used}; delete a repository or app data to push`; | |
| 161 | ||
| 162 | /** The sentence for a refusal just given, from the figure it was given on. */ | |
| 163 | export function storageSentence(app: App, ownerId: string): string | undefined { | |
| 164 | const limit = storageLimitBytes(app); | |
| 165 | const used = cachedBytes(app, ownerId); | |
| 166 | return limit === undefined || used === undefined || used < limit | |
| 167 | ? undefined | |
| 168 | : sentence(used, limit); | |
| 169 | } | |
| 170 | ||
| 171 | /** | |
| 172 | * git's own cap on the pack a push brings: the room left, so a push that | |
| 173 | * would pass the limit is stopped by git while it is still in quarantine, | |
| 174 | * and the repository is as it was. Env for the receive-pack process. | |
| 175 | */ | |
| 176 | export async function pushCap( | |
| 177 | app: App, | |
| 178 | user: User, | |
| 179 | ownerId: string, | |
| 180 | ): Promise<Record<string, string>> { | |
| 181 | const limit = storageLimitBytes(app); | |
| 182 | if (limit === undefined || storageExempt(user)) return {}; | |
| 183 | const room = Math.max(1, limit - (await accountBytes(app, ownerId))); | |
| 184 | return { | |
| 185 | GIT_CONFIG_COUNT: "1", | |
| 186 | GIT_CONFIG_KEY_0: "receive.maxInputSize", | |
| 187 | GIT_CONFIG_VALUE_0: String(room), | |
| 188 | }; | |
| 189 | } |
src/http/server.ts +79 −10
| @@ -48,7 +48,7 @@ import { | ||
| 48 | 48 | } from "./limits.ts"; |
| 49 | 49 | import { canRead, canWrite, getRepo, getRepoById } from "../core/repos.ts"; |
| 50 | 50 | import { trackPullHead } from "../core/pulls.ts"; |
| 51 | import { createRepo } from "../actions/repos.ts"; | |
| 51 | import { createRepo, repositoryLimitMessage, resolveOwner } from "../actions/repos.ts"; | |
| 52 | 52 | import { sessionOf } from "../core/sessions.ts"; |
| 53 | 53 | import { scopeAllows, verifyToken } from "../core/tokens.ts"; |
| 54 | 54 | import { forgeHost } from "../core/commit_identity.ts"; |
| @@ -62,6 +62,7 @@ import { edgeIdentity } from "./edge.ts"; | ||
| 62 | 62 | import { liveResponse, readyResponse } from "./health.ts"; |
| 63 | 63 | import { runGit } from "../git/env.ts"; |
| 64 | 64 | import { noteRefusedInternal } from "../kernel/internal_refused.ts"; |
| 65 | import { pushCap, storageChanged, storageRefusal, storageRefusedText } from "../core/storage.ts"; | |
| 65 | 66 | import { hookEnv, ZERO_OID, type HookRequest } from "../git/hooks.ts"; |
| 66 | 67 | import { repoDir } from "../git/repos.ts"; |
| 67 | 68 | import { |
| @@ -934,14 +935,17 @@ export function buildRouter(): Router<Handler> { | ||
| 934 | 935 | ), |
| 935 | 936 | ); |
| 936 | 937 | router.add("POST", "/:owner/:name.git/git-receive-pack", (ctx) => |
| 937 | withGitAccess(ctx, "git-receive-pack", (dir, repoId) => | |
| 938 | withGitAccess(ctx, "git-receive-pack", (dir, repoId, cap) => | |
| 938 | 939 | serviceRpc(dir, "git-receive-pack", ctx.request, { |
| 939 | env: hookEnv({ | |
| 940 | socketPath: ctx.app.hookAddress.current, | |
| 941 | secret: ctx.app.hookSecret, | |
| 942 | repoId, | |
| 943 | actorId: ctx.auth.user?.id ?? "", | |
| 944 | }), | |
| 940 | env: { | |
| 941 | ...hookEnv({ | |
| 942 | socketPath: ctx.app.hookAddress.current, | |
| 943 | secret: ctx.app.hookSecret, | |
| 944 | repoId, | |
| 945 | actorId: ctx.auth.user?.id ?? "", | |
| 946 | }), | |
| 947 | ...cap, | |
| 948 | }, | |
| 945 | 949 | }), |
| 946 | 950 | ), |
| 947 | 951 | ); |
| @@ -1291,7 +1295,11 @@ export function buildApiRouter(): Router<Handler> { | ||
| 1291 | 1295 | async function withGitAccess( |
| 1292 | 1296 | ctx: Ctx, |
| 1293 | 1297 | service: GitService, |
| 1294 | serve: (dir: string, repoId: string) => Response | Promise<Response>, | |
| 1298 | serve: ( | |
| 1299 | dir: string, | |
| 1300 | repoId: string, | |
| 1301 | cap: Record<string, string>, | |
| 1302 | ) => Response | Promise<Response>, | |
| 1295 | 1303 | ): Promise<Response> { |
| 1296 | 1304 | let repo = getRepo(ctx.app.db, ctx.params.owner!, ctx.params.name!); |
| 1297 | 1305 | const write = service === "git-receive-pack"; |
| @@ -1310,6 +1318,11 @@ async function withGitAccess( | ||
| 1310 | 1318 | visibility: "private", |
| 1311 | 1319 | }); |
| 1312 | 1320 | if (created.ok) repo = created.value; |
| 1321 | else if (created.error === "repository_limit") | |
| 1322 | return gitRefused( | |
| 1323 | ctx, | |
| 1324 | `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user, resolveOwner(ctx.app, ctx.auth.user, ctx.params.owner!) ?? ctx.auth.user.id)}`, | |
| 1325 | ); | |
| 1313 | 1326 | } |
| 1314 | 1327 | |
| 1315 | 1328 | // Anonymous callers get a challenge wherever credentials could help; |
| @@ -1327,7 +1340,63 @@ async function withGitAccess( | ||
| 1327 | 1340 | if (!canRead(ctx.app.db, repo, ctx.auth.user)) |
| 1328 | 1341 | return ctx.auth.user ? notFound() : gitChallenge(); |
| 1329 | 1342 | } |
| 1330 | return serve(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), repo.id); | |
| 1343 | const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name); | |
| 1344 | if (!write) return serve(dir, repo.id, {}); | |
| 1345 | // The storage limit (#919): refused before git is spawned, as a git error | |
| 1346 | // the client prints, and a push that would pass it is capped inside git. | |
| 1347 | const user = ctx.auth.user!; | |
| 1348 | const refused = await storageRefusal(ctx.app, user, repo.ownerId); | |
| 1349 | if (refused !== undefined) return gitRefused(ctx, storageRefusedText(refused)); | |
| 1350 | const cap = await pushCap(ctx.app, user, repo.ownerId); | |
| 1351 | const served = await serve(dir, repo.id, cap); | |
| 1352 | return ctx.request.method === "POST" && served.body !== null | |
| 1353 | ? new Response(afterBody(served.body, () => storageChanged(ctx.app, repo.ownerId)), served) | |
| 1354 | : served; | |
| 1355 | } | |
| 1356 | ||
| 1357 | /** `body`, unchanged, then `done` once it has ended or been given up. */ | |
| 1358 | function afterBody(body: ReadableStream<Uint8Array>, done: () => void): ReadableStream<Uint8Array> { | |
| 1359 | const reader = body.getReader(); | |
| 1360 | return new ReadableStream({ | |
| 1361 | async pull(controller) { | |
| 1362 | try { | |
| 1363 | const { done: ended, value } = await reader.read(); | |
| 1364 | if (ended) { | |
| 1365 | controller.close(); | |
| 1366 | done(); | |
| 1367 | } else controller.enqueue(value); | |
| 1368 | } catch (error) { | |
| 1369 | done(); | |
| 1370 | controller.error(error); | |
| 1371 | } | |
| 1372 | }, | |
| 1373 | cancel(reason) { | |
| 1374 | done(); | |
| 1375 | return reader.cancel(reason); | |
| 1376 | }, | |
| 1377 | }); | |
| 1378 | } | |
| 1379 | ||
| 1380 | /** | |
| 1381 | * The refusal as a git client shows it: on the advertisement an `ERR` packet | |
| 1382 | * ("fatal: remote error: …"), on the push itself a 403 with the words. | |
| 1383 | */ | |
| 1384 | function gitRefused(ctx: Ctx, message: string): Response { | |
| 1385 | if (ctx.request.method === "GET") { | |
| 1386 | const err = `ERR ${message}\n`; | |
| 1387 | const service = "# service=git-receive-pack\n"; | |
| 1388 | const pkt = (text: string): string => (text.length + 4).toString(16).padStart(4, "0") + text; | |
| 1389 | return new Response(`${pkt(service)}0000${pkt(err)}`, { | |
| 1390 | headers: { | |
| 1391 | "content-type": "application/x-git-receive-pack-advertisement", | |
| 1392 | "cache-control": "no-cache", | |
| 1393 | }, | |
| 1394 | }); | |
| 1395 | } | |
| 1396 | return new Response(`${message}\n`, { | |
| 1397 | status: 403, | |
| 1398 | headers: { "content-type": "text/plain; charset=utf-8" }, | |
| 1399 | }); | |
| 1331 | 1400 | } |
| 1332 | 1401 | |
| 1333 | 1402 | // ---- limits and replays ---- |
src/origins/mirror.ts +3
| @@ -18,6 +18,7 @@ import { | ||
| 18 | 18 | rememberOriginRepo, |
| 19 | 19 | type Origin, |
| 20 | 20 | } from "../core/origins.ts"; |
| 21 | import { storageChanged, storageRefusal } from "../core/storage.ts"; | |
| 21 | 22 | import { createRepo, getRepoById, type Repo } from "../core/repos.ts"; |
| 22 | 23 | import type { User } from "../core/users.ts"; |
| 23 | 24 | import { isSafeName, isSafeRef } from "../git/guard.ts"; |
| @@ -124,6 +125,7 @@ export async function mirrorRepo( | ||
| 124 | 125 | // A mirror is a repository the account owns: the repositories limit asks |
| 125 | 126 | // here as it does at create and fork (#2093). |
| 126 | 127 | if (repositoryLimitRefusal(app, actor) !== undefined) return err("repository_limit"); |
| 128 | if ((await storageRefusal(app, actor, actor.id)) !== undefined) return err("repository_limit"); | |
This new refusal has no test. Add one: a mirror by an account at the storage limit returns |
||
| 127 | 129 | |
| 128 | 130 | const created = await createRepo(app.db, app.reposRoot, { |
| 129 | 131 | ownerId: actor.id, |
| @@ -133,6 +135,7 @@ export async function mirrorRepo( | ||
| 133 | 135 | defaultBranch, |
| 134 | 136 | }); |
| 135 | 137 | if (!created.ok) return err(created.error); |
| 138 | storageChanged(app, actor.id); | |
Medium (#2195): this re-measure runs on the empty repository. The mirrored content arrives later in |
||
| 136 | 139 | |
| 137 | 140 | linkRepoToOrigin(app.db, created.value.id, { |
| 138 | 141 | originId: origin.id, |
src/settings/quotas.ts +9 −3
| @@ -4,14 +4,16 @@ | ||
| 4 | 4 | * `forge_setting_change` (who, when, from what to what). The default for |
| 5 | 5 | * each is no limit (`null`), and creating an app asks the apps limit |
| 6 | 6 | * (`actions/app_limit.ts`); creating, forking and copying a repository ask the |
| 7 | * repositories limit first (`actions/repos.ts`). Room for more keys is the `QUOTA_KEYS` list. | |
| 7 | * repositories limit first (`actions/repos.ts`). `storage` is megabytes | |
| 8 | * of repositories plus app data (`core/storage.ts`), asked on a push, create | |
| 9 | * and fork. Room for more keys is the `QUOTA_KEYS` list. | |
| 8 | 10 | */ |
| 9 | 11 | |
| 10 | 12 | import type { Database } from "bun:sqlite"; |
| 11 | 13 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 12 | 14 | import { writeSetting } from "./limits.ts"; |
| 13 | 15 | |
| 14 | export const QUOTA_KEYS = ["repositories", "apps"] as const; | |
| 16 | export const QUOTA_KEYS = ["repositories", "apps", "storage"] as const; | |
| 15 | 17 | export type QuotaKey = (typeof QUOTA_KEYS)[number]; |
| 16 | 18 | export type Quotas = Record<QuotaKey, number | null>; |
| 17 | 19 | |
| @@ -46,7 +48,11 @@ export function getQuota(db: Database, key: QuotaKey): number | null { | ||
| 46 | 48 | } |
| 47 | 49 | |
| 48 | 50 | export function getQuotas(db: Database): Quotas { |
| 49 | return { repositories: getQuota(db, "repositories"), apps: getQuota(db, "apps") }; | |
| 51 | return { | |
| 52 | repositories: getQuota(db, "repositories"), | |
| 53 | apps: getQuota(db, "apps"), | |
| 54 | storage: getQuota(db, "storage"), | |
| 55 | }; | |
| 50 | 56 | } |
| 51 | 57 | |
| 52 | 58 | /** Repositories an account owns, a sealed fork (nobody's to count) aside. */ |
src/tui/ssh.ts +30 −8
| @@ -16,9 +16,10 @@ import type { App as Forge } from "../app.ts"; | ||
| 16 | 16 | import { findKeyByFingerprint, touchKey } from "../core/keys.ts"; |
| 17 | 17 | import { canRead, canWrite, getRepo } from "../core/repos.ts"; |
| 18 | 18 | import { getUserById, isActive, type User } from "../core/users.ts"; |
| 19 | import { createRepo } from "../actions/repos.ts"; | |
| 19 | import { createRepo, repositoryLimitMessage, resolveOwner } from "../actions/repos.ts"; | |
| 20 | 20 | import { hookEnv } from "../git/hooks.ts"; |
| 21 | 21 | import { repoDir } from "../git/repos.ts"; |
| 22 | import { pushCap, storageChanged, storageRefusal, storageRefusedText } from "../core/storage.ts"; | |
| 22 | 23 | import { parseGitCommand, runGitService } from "../git/smart-ssh.ts"; |
| 23 | 24 | import { serveSsh, type SshServer, type SshSession } from "../ssh/server.ts"; |
| 24 | 25 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -230,7 +231,7 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void { | ||
| 230 | 231 | * private repo when the target does not exist yet. |
| 231 | 232 | * A repo you cannot see is "not found", the same answer a missing one gets. |
| 232 | 233 | */ |
| 233 | async function serveGitCommand( | |
| 234 | export async function serveGitCommand( | |
| 234 | 235 | forge: Forge, |
| 235 | 236 | session: SshSession, |
| 236 | 237 | user: User | undefined, |
| @@ -256,6 +257,13 @@ async function serveGitCommand( | ||
| 256 | 257 | visibility: "private", |
| 257 | 258 | }); |
| 258 | 259 | if (created.ok) repo = created.value; |
| 260 | else if (created.error === "repository_limit") { | |
| 261 | session.writeStderr( | |
| 262 | `forge: Not made: ${repositoryLimitMessage(forge, user, resolveOwner(forge, user, parsed.owner) ?? user.id)}${CRLF}`, | |
| 263 | ); | |
| 264 | session.exit(1); | |
| 265 | return; | |
| 266 | } | |
| 259 | 267 | } |
| 260 | 268 | |
| 261 | 269 | const allowed = |
| @@ -272,13 +280,26 @@ async function serveGitCommand( | ||
| 272 | 280 | } |
| 273 | 281 | |
| 274 | 282 | const dir = repoDir(forge.reposRoot, repo.ownerName, repo.name); |
| 283 | // The storage limit: a git error before anything is written (#919). | |
| 284 | const refused = | |
| 285 | write && user ? await storageRefusal(forge, user, repo.ownerId) : undefined; | |
| 286 | if (refused !== undefined) { | |
| 287 | session.writeStderr( | |
| 288 | `forge: ${storageRefusedText(refused)}${CRLF}`, | |
| 289 | ); | |
| 290 | session.exit(1); | |
| 291 | return; | |
| 292 | } | |
| 275 | 293 | const env: Record<string, string> = write |
| 276 | ? hookEnv({ | |
| 277 | socketPath: forge.hookAddress.current, | |
| 278 | secret: forge.hookSecret, | |
| 279 | repoId: repo.id, | |
| 280 | actorId: user?.id ?? "", | |
| 281 | }) | |
| 294 | ? { | |
| 295 | ...hookEnv({ | |
| 296 | socketPath: forge.hookAddress.current, | |
| 297 | secret: forge.hookSecret, | |
| 298 | repoId: repo.id, | |
| 299 | actorId: user?.id ?? "", | |
| 300 | }), | |
| 301 | ...(user ? await pushCap(forge, user, repo.ownerId) : {}), | |
| 302 | } | |
| 282 | 303 | : {}; |
| 283 | 304 | // The client asks for protocol v2 by sending GIT_PROTOCOL through the |
| 284 | 305 | // channel's `env` requests; forward it inside git's own grammar only. |
| @@ -287,6 +308,7 @@ async function serveGitCommand( | ||
| 287 | 308 | env.GIT_PROTOCOL = protocol; |
| 288 | 309 | |
| 289 | 310 | await runGitService(dir, parsed.service, session, { env }); |
| 311 | if (write) storageChanged(forge, repo.ownerId); | |
| 290 | 312 | } |
| 291 | 313 | |
| 292 | 314 | export interface ForgeSshOptions { |
src/web/pages/admin-limits.ts +3 −3
| @@ -86,12 +86,12 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 86 | 86 | const quotaCard = card( |
| 87 | 87 | html`<div class="card-body"> |
| 88 | 88 | <p class="muted small"> |
| 89 | What one account may own. Empty is no limit, and nothing enforces | |
| 90 | these yet. | |
| 89 | What one account may own. Empty is no limit. Storage is in | |
| 90 | megabytes: repositories plus app data. Admins are not held to these. | |
| 91 | 91 | </p> |
| 92 | 92 | <form class="row-actions" method="post" action="/admin/quotas"> |
| 93 | 93 | ${QUOTA_KEYS.map( |
| 94 | (k) => html`<label class="small">${k} | |
| 94 | (k) => html`<label class="small">${k === "storage" ? "storage (MB)" : k} | |
Not blocking: the page labels this field "storage (MB)", but the terminal's Account quotas screen ( |
||
| 95 | 95 | <input |
| 96 | 96 | class="input mono" |
| 97 | 97 | type="number" |
test/quotas-storage.test.ts added +237
| @@ -0,0 +1,237 @@ | ||
| 1 | /** | |
| 2 | * The storage quota (#919): an account with a limit is refused a push past | |
| 3 | * it with a git error, and the repository is as it was; an admin is not | |
| 4 | * held to it, and no limit set means no refusal. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { commitToBranch, createRepo, deleteRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 13 | import { signUp } from "../src/actions/users.ts"; | |
| 14 | import { createToken } from "../src/core/tokens.ts"; | |
| 15 | import { accountBytes, formatBytes, storageRefusal } from "../src/core/storage.ts"; | |
| 16 | import { getRepo } from "../src/core/repos.ts"; | |
| 17 | import { createAgent } from "../src/actions/crew.ts"; | |
| 18 | import { mirrorAndWait, newFakeState, serveFakeForge } from "./fake-origin.ts"; | |
| 19 | import { serveGitCommand } from "../src/tui/ssh.ts"; | |
| 20 | import type { SshSession } from "../src/ssh/channels.ts"; | |
| 21 | import { getUserById } from "../src/core/users.ts"; | |
| 22 | import { setQuota } from "../src/settings/quotas.ts"; | |
| 23 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 24 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 25 | import { proveAll } from "./helpers/verified.ts"; | |
| 26 | ||
| 27 | describe("storage quota", () => { | |
| 28 | let dataDir: string; | |
| 29 | let app: App; | |
| 30 | let servers: Servers; | |
| 31 | let adaId: string; | |
| 32 | let adaToken: string; | |
| 33 | let bobId: string; | |
| 34 | let bobToken: string; | |
| 35 | let carolId: string; | |
| 36 | let carolToken: string; | |
| 37 | ||
| 38 | beforeAll(async () => { | |
| 39 | dataDir = await mkdtemp(join(tmpdir(), "forge-quotas-storage-")); | |
| 40 | app = createApp({ dataDir, port: 0 }); | |
| 41 | servers = await serve(app); | |
| 42 | const ada = await signUp(app, { username: "ada", email: "[email protected]", password: "owner-passphrase" }); | |
| 43 | const bob = await signUp(app, { username: "bob", email: "[email protected]", password: "bob-passphrase" }); | |
| 44 | const carol = await signUp(app, { username: "carol", email: "[email protected]", password: "carol-passphrase" }); | |
| 45 | if (!ada.ok || !bob.ok || !carol.ok) throw new Error("signup"); | |
| 46 | carolId = carol.value.id; | |
| 47 | carolToken = createToken(app.db, carolId, "t", "admin").token; | |
| 48 | proveAll(app); | |
| 49 | expect(ada.value.isAdmin).toBe(true); | |
| 50 | expect(bob.value.isAdmin).toBe(false); | |
| 51 | adaId = ada.value.id; | |
| 52 | bobId = bob.value.id; | |
| 53 | adaToken = createToken(app.db, adaId, "t", "admin").token; | |
| 54 | bobToken = createToken(app.db, bobId, "t", "admin").token; | |
| 55 | setQuota(app.db, "storage", 1, adaId); // one megabyte | |
| 56 | }); | |
| 57 | afterAll(async () => { | |
| 58 | servers.stop(); | |
| 59 | app.close(); | |
| 60 | await rm(dataDir, { recursive: true, force: true }); | |
| 61 | }); | |
| 62 | ||
| 63 | /** Commit `bytes` of random data in a fresh clone and push it to `owner/name`. */ | |
| 64 | async function push(owner: string, token: string, name: string, bytes: number) { | |
| 65 | const work = await mkdtemp(join(tmpdir(), "forge-storage-push-")); | |
| 66 | try { | |
| 67 | const env = gitClientEnv({ | |
| 68 | GIT_AUTHOR_NAME: "T", | |
| 69 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 70 | GIT_COMMITTER_NAME: "T", | |
| 71 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 72 | }); | |
| 73 | const git = async (args: string[]) => { | |
| 74 | const proc = Bun.spawn(["git", ...args], { cwd: work, env, stdout: "pipe", stderr: "pipe" }); | |
| 75 | const [code, err] = await Promise.all([proc.exited, new Response(proc.stderr).text()]); | |
| 76 | return { code, err }; | |
| 77 | }; | |
| 78 | await git(["init", "-q", "-b", "main"]); | |
| 79 | await writeFile(join(work, "blob.bin"), crypto.getRandomValues(new Uint8Array(bytes))); | |
| 80 | await git(["add", "."]); | |
| 81 | await git(["commit", "-q", "-m", "data"]); | |
| 82 | const remote = `${servers.url.replace("http://", `http://${owner}:${token}@`)}/${owner}/${name}.git`; | |
| 83 | return await git(["push", "-q", remote, "main"]); | |
| 84 | } finally { | |
| 85 | await rm(work, { recursive: true, force: true }); | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | const refs = (owner: string, name: string): string => | |
| 90 | Bun.spawnSync(["git", "--git-dir", join(dataDir, "repos", owner, `${name}.git`), "for-each-ref"], { | |
| 91 | env: gitClientEnv({}), | |
| 92 | }).stdout.toString(); | |
| 93 | ||
| 94 | test("formats like a person says it", () => { | |
| 95 | expect(formatBytes(1_900_000_000)).toBe("1.9 GB"); | |
| 96 | expect(formatBytes(2_000_000_000)).toBe("2 GB"); | |
| 97 | expect(formatBytes(1_500_000)).toBe("1.5 MB"); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a push past the limit is refused by git and leaves the repository as it was", async () => { | |
| 101 | const pushed = await push("bob", bobToken, "big", 1_500_000); | |
| 102 | expect(pushed.code).not.toBe(0); | |
| 103 | expect(pushed.err).toContain("pack exceeds maximum allowed size"); | |
| 104 | expect(refs("bob", "big")).toBe(""); | |
| 105 | }); | |
| 106 | ||
| 107 | test("an account at the limit is refused the next push, create and fork, with its use named", async () => { | |
| 108 | setQuota(app.db, "storage", 2, adaId); | |
| 109 | const first = await push("bob", bobToken, "ok", 1_500_000); | |
| 110 | expect(first.code).toBe(0); | |
| 111 | expect(refs("bob", "ok")).not.toBe(""); | |
| 112 | // The measuring is off the request path: wait for the refresh the push began. | |
| 113 | for (let i = 0; i < 100 && (await accountBytes(app, bobId)) < 1_500_000; i++) | |
| 114 | await Bun.sleep(20); | |
| 115 | setQuota(app.db, "storage", 1, adaId); | |
| 116 | const third = await push("bob", bobToken, "ok", 10); | |
| 117 | expect(third.code).not.toBe(0); | |
| 118 | expect(third.err).toContain("MB of 1 MB"); | |
| 119 | ||
| 120 | const made = await createRepo(app, getUserById(app.db, bobId)!, { name: "more", visibility: "private" }); | |
| 121 | expect(made.ok).toBe(false); | |
| 122 | if (!made.ok) expect(made.error).toBe("repository_limit"); | |
| 123 | }); | |
| 124 | ||
| 125 | test("push-to-create over the limit is a git error naming the use, not a not-found", async () => { | |
| 126 | const pushed = await push("bob", bobToken, "fresh", 10); | |
| 127 | expect(pushed.code).not.toBe(0); | |
| 128 | expect(pushed.err).toContain("MB of 1 MB"); | |
| 129 | expect(pushed.err).not.toContain("not found"); | |
| 130 | }); | |
| 131 | ||
| 132 | test("over ssh the refusal is a git error too, before git runs", async () => { | |
| 133 | const lines: string[] = []; | |
| 134 | let exited: number | undefined; | |
| 135 | const session = { | |
| 136 | command: "git-receive-pack '/bob/ok.git'", | |
| 137 | username: "git", | |
| 138 | env: {}, | |
| 139 | writeStderr: (d: string | Uint8Array) => void lines.push(String(d)), | |
| 140 | write: () => {}, | |
| 141 | exit: (code: number) => void (exited = code), | |
| 142 | onData: () => {}, | |
| 143 | onEof: () => {}, | |
| 144 | onClose: () => {}, | |
| 145 | drain: async () => {}, | |
| 146 | } as unknown as SshSession; | |
| 147 | await serveGitCommand(app, session, getUserById(app.db, bobId)); | |
| 148 | expect(exited).toBe(1); | |
| 149 | expect(lines.join("")).toContain("storage limit reached: "); | |
| 150 | expect(lines.join("")).toContain("MB of 1 MB"); | |
| 151 | }); | |
| 152 | ||
| 153 | test("forks that fill the limit end in a refused fork", async () => { | |
| 154 | const filled = await push("carol", carolToken, "base", 900_000); | |
| 155 | expect(filled.code).toBe(0); | |
| 156 | const carol = getUserById(app.db, carolId)!; | |
| 157 | const ada = getUserById(app.db, adaId)!; | |
| 158 | let refused: string | undefined; | |
| 159 | let made = 0; | |
| 160 | for (let i = 0; i < 60 && refused === undefined; i++) { | |
| 161 | const parent = await createRepo(app, ada, { name: `src${i}`, visibility: "public" }); | |
| 162 | if (!parent.ok) throw new Error(parent.error); | |
| 163 | await commitToBranch(app, ada, parent.value, { | |
| 164 | message: "seed", | |
| 165 | files: [{ path: "a.txt", content: "a\n" }], | |
| 166 | }); | |
| 167 | const forked = await forkRepo(app, carol, parent.value); | |
| 168 | if (forked.ok) made++; | |
| 169 | else refused = forked.error; | |
| 170 | } | |
| 171 | expect(made).toBeGreaterThan(0); | |
| 172 | expect(refused).toBe("repository_limit"); | |
| 173 | }); | |
| 174 | ||
| 175 | test("an agent is not held to the limit, the account beside it is", async () => { | |
| 176 | const carol = getUserById(app.db, carolId)!; | |
| 177 | const agent = await createAgent(app, getUserById(app.db, adaId)!, { username: "worker" }); | |
| 178 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 179 | expect(await storageRefusal(app, carol, carolId)).toContain("MB of 1 MB"); | |
| 180 | expect(await storageRefusal(app, agent.value.user, carolId)).toBeUndefined(); | |
| 181 | }); | |
| 182 | ||
| 183 | test("a mirror by an account at the limit is refused and leaves nothing behind", async () => { | |
| 184 | const state = newFakeState("forgejo", "someone", "tok"); | |
| 185 | state.repos.push({ | |
| 186 | id: 1, | |
| 187 | fullName: "someone/gears", | |
| 188 | cloneUrl: "http://127.0.0.1:1/someone/gears.git", | |
| 189 | defaultBranch: "main", | |
| 190 | }); | |
| 191 | const fake = serveFakeForge(state); | |
| 192 | try { | |
| 193 | const post = (path: string, body: unknown) => | |
| 194 | fetch(`${servers.url}${path}`, { | |
| 195 | method: "POST", | |
| 196 | headers: { authorization: `Bearer ${carolToken}`, "content-type": "application/json" }, | |
| 197 | body: JSON.stringify(body), | |
| 198 | }); | |
| 199 | const connected = await post("/api/v1/user/origins", { host: fake.url, token: "tok" }); | |
| 200 | expect(connected.status).toBe(201); | |
| 201 | const originId = ((await connected.json()) as { id: string }).id; | |
| 202 | const job = await mirrorAndWait( | |
| 203 | await post(`/api/v1/user/origins/${originId}/mirror`, { fullName: "someone/gears" }), | |
| 204 | ); | |
| 205 | expect(job?.stage).toBe("failed"); | |
| 206 | expect(job?.error).toContain("Not mirrored: "); | |
| 207 | expect(job?.error).toContain("MB of 1 MB"); | |
| 208 | expect(getRepo(app.db, "carol", "gears")).toBeUndefined(); | |
| 209 | } finally { | |
| 210 | fake.stop(); | |
| 211 | } | |
| 212 | }); | |
| 213 | ||
| 214 | test("an account at its limit deletes a repository and pushes again, with no restart", async () => { | |
| 215 | const bob = getUserById(app.db, bobId)!; | |
| 216 | const refusedBefore = await push("bob", bobToken, "ok", 10); | |
| 217 | expect(refusedBefore.code).not.toBe(0); | |
| 218 | for (const name of ["ok", "big", "fresh"]) { | |
| 219 | const repo = getRepo(app.db, "bob", name); | |
| 220 | if (repo) expect((await deleteRepo(app, bob, repo)).ok).toBe(true); | |
| 221 | } | |
| 222 | const after = await push("bob", bobToken, "again", 100_000); | |
| 223 | expect(after.code).toBe(0); | |
| 224 | }); | |
| 225 | ||
| 226 | test("an admin is not held to it", async () => { | |
| 227 | const pushed = await push("ada", adaToken, "huge", 3_000_000); | |
| 228 | expect(pushed.code).toBe(0); | |
| 229 | expect(refs("ada", "huge")).not.toBe(""); | |
| 230 | }); | |
| 231 | ||
| 232 | test("with no limit set nothing is refused", async () => { | |
| 233 | setQuota(app.db, "storage", null, adaId); | |
| 234 | const pushed = await push("bob", bobToken, "free", 1_500_000); | |
| 235 | expect(pushed.code).toBe(0); | |
| 236 | }); | |
| 237 | }); |
test/quotas.test.ts +7 −7
| @@ -64,7 +64,7 @@ describe("quotas", () => { | ||
| 64 | 64 | |
| 65 | 65 | test("limits start unset, an admin sets one and clears it, each change is kept", async () => { |
| 66 | 66 | const t = createToken(app.db, alice.id, "t", "admin").token; |
| 67 | expect(getQuotas(app.db)).toEqual({ repositories: null, apps: null }); | |
| 67 | expect(getQuotas(app.db)).toEqual({ repositories: null, apps: null, storage: null }); | |
| 68 | 68 | expect(await (await api(t, "GET")).json()).toMatchObject({ |
| 69 | 69 | quotas: { repositories: null, apps: null }, |
| 70 | 70 | changes: [], |
| @@ -72,11 +72,11 @@ describe("quotas", () => { | ||
| 72 | 72 | |
| 73 | 73 | const set = await api(t, "PATCH", { quotas: { repositories: 5, apps: "3" } }); |
| 74 | 74 | expect(set.status).toBe(200); |
| 75 | expect(getQuotas(app.db)).toEqual({ repositories: 5, apps: 3 }); | |
| 75 | expect(getQuotas(app.db)).toEqual({ repositories: 5, apps: 3, storage: null }); | |
| 76 | 76 | |
| 77 | 77 | const cleared = await api(t, "PATCH", { quotas: { repositories: null, apps: "" } }); |
| 78 | 78 | expect(cleared.status).toBe(200); |
| 79 | expect(getQuotas(app.db)).toEqual({ repositories: null, apps: null }); | |
| 79 | expect(getQuotas(app.db)).toEqual({ repositories: null, apps: null, storage: null }); | |
| 80 | 80 | |
| 81 | 81 | const changes = app.db |
| 82 | 82 | .query<{ n: number }, []>( |
| @@ -122,7 +122,7 @@ describe("quotas", () => { | ||
| 122 | 122 | const bobAdmin = createToken(app.db, bob.id, "t", "admin").token; |
| 123 | 123 | expect((await rpc(bobAdmin, "quotas")).error).toContain("no such tool"); |
| 124 | 124 | |
| 125 | expect((await rpc(ro, "quotas")).value.quotas).toEqual({ repositories: null, apps: null }); | |
| 125 | expect((await rpc(ro, "quotas")).value.quotas).toEqual({ repositories: null, apps: null, storage: null }); | |
| 126 | 126 | const dry = await rpc(admin, "quotas", { key: "apps", value: "9" }); |
| 127 | 127 | expect(dry.value.says).toContain("Would set"); |
| 128 | 128 | expect(getQuotas(app.db).apps).toBeNull(); |
| @@ -157,7 +157,7 @@ describe("quotas", () => { | ||
| 157 | 157 | } |
| 158 | 158 | expect((await api(t, "PATCH", { quotas: { nope: 1 } })).status).toBe(422); |
| 159 | 159 | expect((await api(t, "PATCH", {})).status).toBe(422); |
| 160 | expect(getQuotas(app.db)).toEqual({ repositories: null, apps: null }); | |
| 160 | expect(getQuotas(app.db)).toEqual({ repositories: null, apps: null, storage: null }); | |
| 161 | 161 | }); |
| 162 | 162 | |
| 163 | 163 | test("the admin page form sets a quota and refuses a bad one", async () => { |
| @@ -180,9 +180,9 @@ describe("quotas", () => { | ||
| 180 | 180 | body: new URLSearchParams(form), |
| 181 | 181 | }); |
| 182 | 182 | await post({ repositories: "7", apps: "" }); |
| 183 | expect(getQuotas(app.db)).toEqual({ repositories: 7, apps: null }); | |
| 183 | expect(getQuotas(app.db)).toEqual({ repositories: 7, apps: null, storage: null }); | |
| 184 | 184 | await post({ repositories: "0", apps: "2" }); |
| 185 | expect(getQuotas(app.db)).toEqual({ repositories: 7, apps: null }); | |
| 185 | expect(getQuotas(app.db)).toEqual({ repositories: 7, apps: null, storage: null }); | |
| 186 | 186 | const page = await fetch(`${servers.url}/admin/limits`, { headers: { cookie } }); |
| 187 | 187 | const text = await page.text(); |
| 188 | 188 | expect(text).toContain('name="repositories"'); |
Low (#2195): a storage refusal reuses
repository_limit. If the background walk drops the figure below the limit between the refusal and this call, the message falls back to "at the repositories limit", which names the wrong limit. Fix: give storage refusals their own error,storage_limit.