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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/919-per-account-storage-limit-repository-and → main 4 commits fork 11 threads · 11 unresolved

  • Merged. scout/919-per-account-storage-limit-repository-and was merged into main as a03528c.

Review

Sign in to review this change.
10 files changed +570 −33
README.md +1 −1
@@ -1140,7 +1140,7 @@ A forge admin sees every declared flag at `/settings/flags` (Settings → Flags)
11401140
11411141 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.
11421142
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.
11441144
11451145 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.
11461146
src/actions/repos.ts +12 −1
@@ -10,6 +10,7 @@ import { canSeeSealed } from "../core/sealed.ts";
1010 import { capacityValue } from "../core/capacity.ts";
1111 import { NEW_FOR_MS } from "./accounts.ts";
1212 import { repositoryLimitSentence } from "../settings/quotas.ts";
13 import { storageChanged, storageRefusal, storageSentence } from "../core/storage.ts";
1314 import {
1415 createLabel as insertLabel,
1516 deleteLabel as removeLabel,
@@ -89,7 +90,11 @@ export function repositoryLimitRefusal(app: App, user: User, ownerId = user.id):
8990 * between the refusal and now, it still says the limit was the reason.
9091 */
9192 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"

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.

97 );
9398 }
9499
95100 /**
@@ -140,6 +145,7 @@ export async function createRepo(
140145 if (ownerId === undefined) return err("forbidden_owner");
141146 if (ownerId === user.value.id && repositoryLimitRefusal(app, user.value) !== undefined)
142147 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 repository_limit, so an API client cannot tell "too many repositories" from "out of space" without reading the message. The message is right, because repositoryLimitMessage falls back to storageSentence. A separate storage_limit code would be clearer in a later change.

143149 const created = await initRepo(app.db, app.reposRoot, {
144150 ownerId,
145151 name: input.name,
@@ -147,6 +153,7 @@ export async function createRepo(
147153 description: input.description ?? "",
148154 });
149155 if (!created.ok) return created;
156 storageChanged(app, ownerId);
150157 app.bus.append({
151158 kind: "repo.created",
152159 repoId: created.value.id,
@@ -179,6 +186,8 @@ export async function forkRepo(
179186 const guarded = requireNewAccountGuard(app, user.value);
180187 if (!guarded.ok) return guarded;
181188 if (repositoryLimitRefusal(app, user.value) !== undefined) return err("repository_limit");
outdated

This checks the cached figure, but after cloneRepo succeeds nothing calls storageChanged(app, user.value.id). Repeated forks all pass against the figure from before them. The same applies to createRepo at line 148. Add a test: forks fill the limit, then the next fork is refused.

189 if ((await storageRefusal(app, user.value, user.value.id)) !== undefined)
190 return err("repository_limit");
182191 const forked = await cloneRepo(
183192 app.db,
184193 app.reposRoot,
@@ -193,6 +202,7 @@ export async function forkRepo(
193202 },
194203 );
195204 if (!forked.ok) return forked;
205 storageChanged(app, user.value.id);
196206 app.bus.append({
197207 kind: "repo.forked",
198208 repoId: forked.value.id,
@@ -397,6 +407,7 @@ export async function deleteRepo(
397407 if (!admin.ok) return admin;
398408 const deleted = await destroyRepo(app.db, app.reposRoot, repo);
399409 if (!deleted.ok) return deleted;
410 storageChanged(app, repo.ownerId);
400411 // The row is gone (events cascade with it), so this event carries no repo.
401412 app.bus.append({ kind: "repo.deleted", actorId: admin.value.id });
402413 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 GET info/refs advertisement. Sequential requests each walk the account's whole tree. Concurrent requests share the in-flight walk, but back-to-back requests do not. Bound it, e.g. by skipping the re-walk when the last walk finished less than N seconds ago, so a locked-out client cannot turn retries into disk scans.

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 {
4848 } from "./limits.ts";
4949 import { canRead, canWrite, getRepo, getRepoById } from "../core/repos.ts";
5050 import { trackPullHead } from "../core/pulls.ts";
51 import { createRepo } from "../actions/repos.ts";
51 import { createRepo, repositoryLimitMessage, resolveOwner } from "../actions/repos.ts";
5252 import { sessionOf } from "../core/sessions.ts";
5353 import { scopeAllows, verifyToken } from "../core/tokens.ts";
5454 import { forgeHost } from "../core/commit_identity.ts";
@@ -62,6 +62,7 @@ import { edgeIdentity } from "./edge.ts";
6262 import { liveResponse, readyResponse } from "./health.ts";
6363 import { runGit } from "../git/env.ts";
6464 import { noteRefusedInternal } from "../kernel/internal_refused.ts";
65 import { pushCap, storageChanged, storageRefusal, storageRefusedText } from "../core/storage.ts";
6566 import { hookEnv, ZERO_OID, type HookRequest } from "../git/hooks.ts";
6667 import { repoDir } from "../git/repos.ts";
6768 import {
@@ -934,14 +935,17 @@ export function buildRouter(): Router<Handler> {
934935 ),
935936 );
936937 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) =>
938939 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 },
945949 }),
946950 ),
947951 );
@@ -1291,7 +1295,11 @@ export function buildApiRouter(): Router<Handler> {
12911295 async function withGitAccess(
12921296 ctx: Ctx,
12931297 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>,
12951303 ): Promise<Response> {
12961304 let repo = getRepo(ctx.app.db, ctx.params.owner!, ctx.params.name!);
12971305 const write = service === "git-receive-pack";
@@ -1310,6 +1318,11 @@ async function withGitAccess(
13101318 visibility: "private",
13111319 });
13121320 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 );
13131326 }
13141327
13151328 // Anonymous callers get a challenge wherever credentials could help;
@@ -1327,7 +1340,63 @@ async function withGitAccess(
13271340 if (!canRead(ctx.app.db, repo, ctx.auth.user))
13281341 return ctx.auth.user ? notFound() : gitChallenge();
13291342 }
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 });
13311400 }
13321401
13331402 // ---- limits and replays ----
src/origins/mirror.ts +3
@@ -18,6 +18,7 @@ import {
1818 rememberOriginRepo,
1919 type Origin,
2020 } from "../core/origins.ts";
21 import { storageChanged, storageRefusal } from "../core/storage.ts";
2122 import { createRepo, getRepoById, type Repo } from "../core/repos.ts";
2223 import type { User } from "../core/users.ts";
2324 import { isSafeName, isSafeRef } from "../git/guard.ts";
@@ -124,6 +125,7 @@ export async function mirrorRepo(
124125 // A mirror is a repository the account owns: the repositories limit asks
125126 // here as it does at create and fork (#2093).
126127 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 repository_limit and leaves nothing on disk.

127129
128130 const created = await createRepo(app.db, app.reposRoot, {
129131 ownerId: actor.id,
@@ -133,6 +135,7 @@ export async function mirrorRepo(
133135 defaultBranch,
134136 });
135137 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 syncOrigin, and neither that first fetch nor any later sync marks the figure stale. storageRefusal re-walks only once the cached figure is already at the limit, so an account under its limit can mirror any number of large repositories. Fix: call storageChanged after the first sync, and after each later sync fetch, finishes writing.

136139
137140 linkRepoToOrigin(app.db, created.value.id, {
138141 originId: origin.id,
src/settings/quotas.ts +9 −3
@@ -4,14 +4,16 @@
44 * `forge_setting_change` (who, when, from what to what). The default for
55 * each is no limit (`null`), and creating an app asks the apps limit
66 * (`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.
810 */
911
1012 import type { Database } from "bun:sqlite";
1113 import { err, ok, type Result } from "../kernel/result.ts";
1214 import { writeSetting } from "./limits.ts";
1315
14 export const QUOTA_KEYS = ["repositories", "apps"] as const;
16 export const QUOTA_KEYS = ["repositories", "apps", "storage"] as const;
1517 export type QuotaKey = (typeof QUOTA_KEYS)[number];
1618 export type Quotas = Record<QuotaKey, number | null>;
1719
@@ -46,7 +48,11 @@ export function getQuota(db: Database, key: QuotaKey): number | null {
4648 }
4749
4850 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 };
5056 }
5157
5258 /** 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";
1616 import { findKeyByFingerprint, touchKey } from "../core/keys.ts";
1717 import { canRead, canWrite, getRepo } from "../core/repos.ts";
1818 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";
2020 import { hookEnv } from "../git/hooks.ts";
2121 import { repoDir } from "../git/repos.ts";
22 import { pushCap, storageChanged, storageRefusal, storageRefusedText } from "../core/storage.ts";
2223 import { parseGitCommand, runGitService } from "../git/smart-ssh.ts";
2324 import { serveSsh, type SshServer, type SshSession } from "../ssh/server.ts";
2425 import { err, ok, type Result } from "../kernel/result.ts";
@@ -230,7 +231,7 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void {
230231 * private repo when the target does not exist yet.
231232 * A repo you cannot see is "not found", the same answer a missing one gets.
232233 */
233 async function serveGitCommand(
234 export async function serveGitCommand(
234235 forge: Forge,
235236 session: SshSession,
236237 user: User | undefined,
@@ -256,6 +257,13 @@ async function serveGitCommand(
256257 visibility: "private",
257258 });
258259 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 }
259267 }
260268
261269 const allowed =
@@ -272,13 +280,26 @@ async function serveGitCommand(
272280 }
273281
274282 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 }
275293 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 }
282303 : {};
283304 // The client asks for protocol v2 by sending GIT_PROTOCOL through the
284305 // channel's `env` requests; forward it inside git's own grammar only.
@@ -287,6 +308,7 @@ async function serveGitCommand(
287308 env.GIT_PROTOCOL = protocol;
288309
289310 await runGitService(dir, parsed.service, session, { env });
311 if (write) storageChanged(forge, repo.ownerId);
290312 }
291313
292314 export interface ForgeSshOptions {
src/web/pages/admin-limits.ts +3 −3
@@ -86,12 +86,12 @@ export function limitsPage(ctx: Ctx): Response {
8686 const quotaCard = card(
8787 html`<div class="card-body">
8888 <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.
9191 </p>
9292 <form class="row-actions" method="post" action="/admin/quotas">
9393 ${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 (src/tui/screens/quotas.ts) shows just storage, so an admin there could type bytes. It would be worth showing the same unit in the terminal.

9595 <input
9696 class="input mono"
9797 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", () => {
6464
6565 test("limits start unset, an admin sets one and clears it, each change is kept", async () => {
6666 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 });
6868 expect(await (await api(t, "GET")).json()).toMatchObject({
6969 quotas: { repositories: null, apps: null },
7070 changes: [],
@@ -72,11 +72,11 @@ describe("quotas", () => {
7272
7373 const set = await api(t, "PATCH", { quotas: { repositories: 5, apps: "3" } });
7474 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 });
7676
7777 const cleared = await api(t, "PATCH", { quotas: { repositories: null, apps: "" } });
7878 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 });
8080
8181 const changes = app.db
8282 .query<{ n: number }, []>(
@@ -122,7 +122,7 @@ describe("quotas", () => {
122122 const bobAdmin = createToken(app.db, bob.id, "t", "admin").token;
123123 expect((await rpc(bobAdmin, "quotas")).error).toContain("no such tool");
124124
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 });
126126 const dry = await rpc(admin, "quotas", { key: "apps", value: "9" });
127127 expect(dry.value.says).toContain("Would set");
128128 expect(getQuotas(app.db).apps).toBeNull();
@@ -157,7 +157,7 @@ describe("quotas", () => {
157157 }
158158 expect((await api(t, "PATCH", { quotas: { nope: 1 } })).status).toBe(422);
159159 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 });
161161 });
162162
163163 test("the admin page form sets a quota and refuses a bad one", async () => {
@@ -180,9 +180,9 @@ describe("quotas", () => {
180180 body: new URLSearchParams(form),
181181 });
182182 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 });
184184 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 });
186186 const page = await fetch(`${servers.url}/admin/limits`, { headers: { cookie } });
187187 const text = await page.text();
188188 expect(text).toContain('name="repositories"');

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs