Public: anyone can read this; the crew can change it.
Mirror, migrate and sync fetches count against the storage limit, so no account can mirror past it #2207
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2200-mirror-migrate-and-sync-fetches-count-ag → main 5 commits fork 6 threads · 6 unresolved
README.md +1 −1
Diff not shown — a line 4,879 characters wide (limit 2,000). +1 −1
View filesrc/actions/make_it_yours.ts +6 −1
| @@ -23,6 +23,7 @@ | ||
| 23 | 23 | * already made does not count. |
| 24 | 24 | */ |
| 25 | 25 | |
| 26 | import { storageLimitMessage } from "../core/storage.ts"; | |
| 26 | 27 | import type { App } from "../app.ts"; |
| 27 | 28 | import { |
| 28 | 29 | getAppByRepo, |
| @@ -71,7 +72,9 @@ export type MakeItYoursError = | ||
| 71 | 72 | | { readonly kind: "limit"; readonly resetsAt: string } |
| 72 | 73 | | { readonly kind: "invalid"; readonly message: string } |
| 73 | 74 | /** The owner's repositories limit is reached: `message` says "10 of 10 repositories". */ |
| 74 | | { readonly kind: "repository_limit"; readonly message: string }; | |
| 75 | | { readonly kind: "repository_limit"; readonly message: string } | |
| 76 | /** The account's storage limit is reached: `message` says "1.9 GB of 2 GB". */ | |
| 77 | | { readonly kind: "storage_limit"; readonly message: string }; | |
| 75 | 78 | |
| 76 | 79 | export const COPIED = ["the code", "its history"] as const; |
| 77 | 80 | export const NOT_COPIED = [ |
| @@ -232,6 +235,8 @@ export async function makeItYours( | ||
| 232 | 235 | return err({ kind: "limit", resetsAt: resetsAt(app, actor.id, now) }); |
| 233 | 236 | case "repository_limit": |
| 234 | 237 | return err({ kind: "repository_limit", message: repositoryLimitMessage(app, actor) }); |
| 238 | case "storage_limit": | |
| 239 | return err({ kind: "storage_limit", message: storageLimitMessage(app, actor.id) }); | |
| 235 | 240 | default: |
| 236 | 241 | return err({ |
| 237 | 242 | kind: "invalid", |
src/actions/migrate.ts +4
| @@ -20,6 +20,7 @@ | ||
| 20 | 20 | import { requireVerified } from "./verify.ts"; |
| 21 | 21 | import type { App } from "../app.ts"; |
| 22 | 22 | import { getAgentByName } from "../core/agents.ts"; |
| 23 | import { storageStale } from "../core/storage.ts"; | |
| 23 | 24 | import { getOriginFor } from "../core/origins.ts"; |
| 24 | 25 | import { getRepoById, type Repo } from "../core/repos.ts"; |
| 25 | 26 | import type { User } from "../core/users.ts"; |
| @@ -198,6 +199,8 @@ export function migrateMessage(error: MigrateError, limit = "at the repositories | ||
| 198 | 199 | return "New accounts cannot create repositories yet."; |
| 199 | 200 | case "repository_limit": |
| 200 | 201 | return `Not made: ${limit}.`; |
| 202 | case "storage_limit": | |
| 203 | return "Not made: this account is at the forge's storage limit. Delete a repository or app data first."; | |
| 201 | 204 | } |
| 202 | 205 | } |
| 203 | 206 | |
| @@ -399,6 +402,7 @@ async function runMigration( | ||
| 399 | 402 | advance(jobId, { stage: "fetching", detail: "every branch and tag" }); |
| 400 | 403 | const cloned = await cloneInto(app, repo, source); |
| 401 | 404 | if (!cloned.ok) return await fail(cloned.error); |
| 405 | storageStale(app, repo.ownerId); | |
| 402 | 406 | let current = getRepoById(app.db, repo.id) ?? repo; |
| 403 | 407 | |
| 404 | 408 | // The source's default branch is kept; a source that never said which |
src/actions/repos.ts +5 −4
| @@ -72,7 +72,8 @@ export type CreateRepoActionError = | ||
| 72 | 72 | | "unauthorized" |
| 73 | 73 | | "forbidden_owner" |
| 74 | 74 | | "refused" |
| 75 | | "repository_limit"; | |
| 75 | | "repository_limit" | |
| 76 | | "storage_limit"; | |
| 76 | 77 | |
| 77 | 78 | /** |
| 78 | 79 | * The repositories limit (#2093): when the owner has set one, an account at |
| @@ -145,7 +146,7 @@ export async function createRepo( | ||
| 145 | 146 | if (ownerId === undefined) return err("forbidden_owner"); |
| 146 | 147 | if (ownerId === user.value.id && repositoryLimitRefusal(app, user.value) !== undefined) |
| 147 | 148 | return err("repository_limit"); |
| 148 | if ((await storageRefusal(app, user.value, ownerId)) !== undefined) return err("repository_limit"); | |
| 149 | if ((await storageRefusal(app, user.value, ownerId)) !== undefined) return err("storage_limit"); | |
| 149 | 150 | const created = await initRepo(app.db, app.reposRoot, { |
| 150 | 151 | ownerId, |
| 151 | 152 | name: input.name, |
| @@ -178,7 +179,7 @@ export async function forkRepo( | ||
| 178 | 179 | parent: Repo, |
| 179 | 180 | name?: string, |
| 180 | 181 | options: { visibility?: "public" | "private"; now?: number } = {}, |
| 181 | ): Promise<Result<Repo, ForkRepoError | "not_found" | "unauthorized" | "unverified" | "refused" | "repository_limit">> { | |
| 182 | ): Promise<Result<Repo, ForkRepoError | "not_found" | "unauthorized" | "unverified" | "refused" | "repository_limit" | "storage_limit">> { | |
| 182 | 183 | const user = requireParticipant(app, actor, parent); |
| 183 | 184 | if (!user.ok) return user; |
| 184 | 185 | const proven = requireVerified(app, user.value); |
| @@ -187,7 +188,7 @@ export async function forkRepo( | ||
| 187 | 188 | if (!guarded.ok) return guarded; |
| 188 | 189 | if (repositoryLimitRefusal(app, user.value) !== undefined) return err("repository_limit"); |
| 189 | 190 | if ((await storageRefusal(app, user.value, user.value.id)) !== undefined) |
| 190 | return err("repository_limit"); | |
| 191 | return err("storage_limit"); | |
| 191 | 192 | const forked = await cloneRepo( |
| 192 | 193 | app.db, |
| 193 | 194 | app.reposRoot, |
src/core/storage.ts +28 −2
| @@ -25,6 +25,8 @@ export const BYTES_PER_MB = 1_000_000; | ||
| 25 | 25 | interface Entry { |
| 26 | 26 | bytes: number; |
| 27 | 27 | stale: boolean; |
| 28 | /** Bumped by every stale mark, so a walk can tell one arrived while it ran. */ | |
| 29 | marks: number; | |
| 28 | 30 | /** When the last walk finished, in ms. */ |
| 29 | 31 | walkedAt?: number; |
| 30 | 32 | walking?: Promise<number>; |
| @@ -58,10 +60,12 @@ export async function measureAccount(app: App, ownerId: string): Promise<number> | ||
| 58 | 60 | } |
| 59 | 61 | |
| 60 | 62 | function walk(app: App, ownerId: string, entry: Entry): Promise<number> { |
| 63 | const marked = entry.marks; | |
| 61 | 64 | entry.walking ??= measureAccount(app, ownerId) |
| 62 | 65 | .then((bytes) => { |
| 63 | 66 | entry.bytes = bytes; |
| 64 | entry.stale = false; | |
| 67 | // A mark that arrived mid-walk is not answered by this walk. | |
| 68 | entry.stale = entry.marks !== marked; | |
| 65 | 69 | entry.walkedAt = Date.now(); |
| 66 | 70 | return bytes; |
| 67 | 71 | }) |
| @@ -76,7 +80,7 @@ export async function accountBytes(app: App, ownerId: string): Promise<number> { | ||
| 76 | 80 | const cache = cacheOf(app); |
| 77 | 81 | let entry = cache.get(ownerId); |
| 78 | 82 | if (entry === undefined) { |
| 79 | entry = { bytes: 0, stale: true }; | |
| 83 | entry = { bytes: 0, stale: true, marks: 0 }; | |
| 80 | 84 | cache.set(ownerId, entry); |
| 81 | 85 | } |
| 82 | 86 | // A figure marked stale is never judged on: the walk a change began in the |
| @@ -98,6 +102,7 @@ async function freshBytes(app: App, ownerId: string): Promise<number> { | ||
| 98 | 102 | if (entry.walkedAt !== undefined && Date.now() - entry.walkedAt < REWALK_MS) |
| 99 | 103 | return entry.bytes; |
| 100 | 104 | entry.stale = true; |
| 105 | entry.marks++; | |
| 101 | 106 | } |
| 102 | 107 | return accountBytes(app, ownerId); |
| 103 | 108 | } |
| @@ -107,12 +112,25 @@ export function cachedBytes(app: App, ownerId: string): number | undefined { | ||
| 107 | 112 | return cacheOf(app).get(ownerId)?.bytes; |
| 108 | 113 | } |
| 109 | 114 | |
| 115 | /** | |
| 116 | * Fetched objects arrived without a push (a mirror's sync, a migrate import): | |
| 117 | * the figure is stale. Only marked: the walk is left to the next check, so a | |
| 118 | * sync never pays for walking the repository. | |
| 119 | */ | |
| 120 | export function storageStale(app: App, ownerId: string): void { | |
| 121 | const entry = cacheOf(app).get(ownerId); | |
| 122 | if (entry === undefined) return; | |
| 123 | entry.stale = true; | |
| 124 | entry.marks++; | |
| 125 | } | |
| 126 | ||
| 110 | 127 | /** A push, create, fork, mirror or delete changed the disk: measure again, off the request path. */ |
| 111 | 128 | export function storageChanged(app: App, ownerId: string): void { |
| 112 | 129 | const cache = cacheOf(app); |
| 113 | 130 | const entry = cache.get(ownerId); |
| 114 | 131 | if (entry === undefined) return; |
| 115 | 132 | entry.stale = true; |
| 133 | entry.marks++; | |
| 116 | 134 | void walk(app, ownerId, entry).catch(() => {}); |
| 117 | 135 | } |
| 118 | 136 | |
| @@ -155,6 +173,14 @@ export async function storageRefusal( | ||
| 155 | 173 | return used >= limit ? sentence(used, limit) : undefined; |
| 156 | 174 | } |
| 157 | 175 | |
| 176 | /** What a refused create, fork or mirror says, from the figure it was refused on. */ | |
| 177 | export function storageLimitMessage(app: App, ownerId: string): string { | |
| 178 | const sentence = storageSentence(app, ownerId); | |
| 179 | return sentence === undefined | |
| 180 | ? "this account is at the forge's storage limit" | |
| 181 | : `this account is at the forge's storage limit: ${sentence}`; | |
| 182 | } | |
| 183 | ||
| 158 | 184 | /** What a refused push says, over HTTP and SSH alike. */ |
| 159 | 185 | export const storageRefusedText = (used: string): string => |
| 160 | 186 | `storage limit reached: ${used}; delete a repository or app data to push`; |
src/http/api.ts +16
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * back to continue); errors are `{ error, message }`. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { storageLimitMessage } from "../core/storage.ts"; | |
| 11 | 12 | import { voteVariant } from "../actions/variants.ts"; |
| 12 | 13 | import { parentForgeFor } from "../core/parent_releases.ts"; |
| 13 | 14 | import { readFlow } from "../flow/read.ts"; |
| @@ -139,6 +140,7 @@ import { | ||
| 139 | 140 | FORKS_PER_DAY, |
| 140 | 141 | forkRepo, |
| 141 | 142 | repositoryLimitMessage, |
| 143 | resolveOwner, | |
| 142 | 144 | privateRepoForSealed, |
| 143 | 145 | updateRepoSettings, |
| 144 | 146 | } from "../actions/repos.ts"; |
| @@ -1922,6 +1924,12 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1922 | 1924 | return json({ error: "forbidden_owner" }, 403); |
| 1923 | 1925 | if (created.error === "repository_limit") |
| 1924 | 1926 | return apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!)); |
| 1927 | if (created.error === "storage_limit") | |
| 1928 | return apiError( | |
| 1929 | "storage_limit", | |
| 1930 | 403, | |
| 1931 | storageLimitMessage(ctx.app, resolveOwner(ctx.app, ctx.auth.user!, str(input.owner)) ?? ctx.auth.user!.id), | |
| 1932 | ); | |
| 1925 | 1933 | return respondAccess(created.error, 422); |
| 1926 | 1934 | } |
| 1927 | 1935 | return json(created.value, 201); |
| @@ -2034,6 +2042,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2034 | 2042 | if (!forked.ok) |
| 2035 | 2043 | return forked.error === "repository_limit" |
| 2036 | 2044 | ? apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!)) |
| 2045 | : forked.error === "storage_limit" | |
| 2046 | ? apiError("storage_limit", 403, storageLimitMessage(ctx.app, ctx.auth.user!.id)) | |
| 2037 | 2047 | : forked.error === "limit" |
| 2038 | 2048 | ? apiError("limit", 429, `at most ${FORKS_PER_DAY} forks a day: try again tomorrow`) |
| 2039 | 2049 | : respondAccess(forked.error, 422); |
| @@ -4363,6 +4373,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4363 | 4373 | ) |
| 4364 | 4374 | : made.error.kind === "repository_limit" |
| 4365 | 4375 | ? apiError("repository_limit", 403, made.error.message) |
| 4376 | : made.error.kind === "storage_limit" | |
| 4377 | ? apiError("storage_limit", 403, made.error.message) | |
| 4366 | 4378 | : made.error.kind === "invalid" |
| 4367 | 4379 | ? apiError("invalid", 422, made.error.message) |
| 4368 | 4380 | : made.error.kind === "own" |
| @@ -4494,6 +4506,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4494 | 4506 | if (!made.ok) { |
| 4495 | 4507 | if (made.error === "repository_limit") |
| 4496 | 4508 | return apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!)); |
| 4509 | if (made.error === "storage_limit") | |
| 4510 | return apiError("storage_limit", 403, storageLimitMessage(ctx.app, ctx.auth.user!.id)); | |
| 4497 | 4511 | if (made.error === "app_limit") |
| 4498 | 4512 | return appLimitResponse( |
| 4499 | 4513 | ctx, |
| @@ -4570,6 +4584,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4570 | 4584 | ? respondAccess(started.error, 401) |
| 4571 | 4585 | : started.error === "repository_limit" |
| 4572 | 4586 | ? apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!)) |
| 4587 | : started.error === "storage_limit" | |
| 4588 | ? apiError("storage_limit", 403, storageLimitMessage(ctx.app, ctx.auth.user!.id)) | |
| 4573 | 4589 | : apiError(started.error, 422, migrateMessage(started.error)); |
| 4574 | 4590 | return json( |
| 4575 | 4591 | { |
src/http/mcp.ts +2
| @@ -1547,6 +1547,8 @@ const TOOLS: readonly Tool[] = [ | ||
| 1547 | 1547 | ? bad("limit", `at most ${COPIES_PER_DAY} copies a day; another after ${made.error.resetsAt}`) |
| 1548 | 1548 | : made.error.kind === "repository_limit" |
| 1549 | 1549 | ? bad("repository_limit", made.error.message) |
| 1550 | : made.error.kind === "storage_limit" | |
| 1551 | ? bad("storage_limit", made.error.message) | |
| 1550 | 1552 | : made.error.kind === "invalid" |
| 1551 | 1553 | ? bad("invalid", made.error.message) |
| 1552 | 1554 | : made.error.kind === "own" |
src/http/server.ts +5 −1
| @@ -63,7 +63,7 @@ import { edgeIdentity } from "./edge.ts"; | ||
| 63 | 63 | import { liveResponse, readyResponse } from "./health.ts"; |
| 64 | 64 | import { runGit } from "../git/env.ts"; |
| 65 | 65 | import { noteRefusedInternal } from "../kernel/internal_refused.ts"; |
| 66 | import { pushCap, storageChanged, storageRefusal, storageRefusedText } from "../core/storage.ts"; | |
| 66 | import { pushCap, storageChanged, storageRefusal, storageRefusedText, storageSentence } from "../core/storage.ts"; | |
| 67 | 67 | import { hookEnv, ZERO_OID, type HookRequest } from "../git/hooks.ts"; |
| 68 | 68 | import { repoDir } from "../git/repos.ts"; |
| 69 | 69 | import { |
| @@ -1349,6 +1349,10 @@ async function withGitAccess( | ||
| 1349 | 1349 | visibility: "private", |
| 1350 | 1350 | }); |
| 1351 | 1351 | if (created.ok) repo = created.value; |
| 1352 | else if (created.error === "storage_limit") { | |
| 1353 | const ownerId = resolveOwner(ctx.app, ctx.auth.user, ctx.params.owner!) ?? ctx.auth.user.id; | |
| 1354 | return gitRefused(ctx, storageRefusedText(storageSentence(ctx.app, ownerId) ?? "at the limit")); | |
| 1355 | } | |
| 1352 | 1356 | else if (created.error === "repository_limit") |
| 1353 | 1357 | return gitRefused( |
| 1354 | 1358 | ctx, |
src/origins/mirror.ts +9 −5
| @@ -18,7 +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 | import { storageLimitMessage, storageRefusal } from "../core/storage.ts"; | |
| 22 | 22 | import { createRepo, getRepoById, type Repo } from "../core/repos.ts"; |
| 23 | 23 | import type { User } from "../core/users.ts"; |
| 24 | 24 | import { isSafeName, isSafeRef } from "../git/guard.ts"; |
| @@ -40,7 +40,8 @@ export type MirrorError = | ||
| 40 | 40 | | "already_mirrored" |
| 41 | 41 | | "git_init_failed" |
| 42 | 42 | | "unreachable" |
| 43 | | "repository_limit"; | |
| 43 | | "repository_limit" | |
| 44 | | "storage_limit"; | |
| 44 | 45 | |
| 45 | 46 | export interface MirrorResult { |
| 46 | 47 | readonly repo: Repo; |
| @@ -125,7 +126,7 @@ export async function mirrorRepo( | ||
| 125 | 126 | // A mirror is a repository the account owns: the repositories limit asks |
| 126 | 127 | // here as it does at create and fork (#2093). |
| 127 | 128 | if (repositoryLimitRefusal(app, actor) !== undefined) return err("repository_limit"); |
| 128 | if ((await storageRefusal(app, actor, actor.id)) !== undefined) return err("repository_limit"); | |
| 129 | if ((await storageRefusal(app, actor, actor.id)) !== undefined) return err("storage_limit"); | |
| 129 | 130 | |
| 130 | 131 | const created = await createRepo(app.db, app.reposRoot, { |
| 131 | 132 | ownerId: actor.id, |
| @@ -135,7 +136,6 @@ export async function mirrorRepo( | ||
| 135 | 136 | defaultBranch, |
| 136 | 137 | }); |
| 137 | 138 | if (!created.ok) return err(created.error); |
| 138 | storageChanged(app, actor.id); | |
| 139 | 139 | |
| 140 | 140 | linkRepoToOrigin(app.db, created.value.id, { |
| 141 | 141 | originId: origin.id, |
| @@ -240,7 +240,9 @@ export function startMirror( | ||
| 240 | 240 | error: |
| 241 | 241 | result.error === "repository_limit" |
| 242 | 242 | ? `Not mirrored: ${repositoryLimitMessage(app, actor)}.` |
| 243 | : mirrorMessage(result.error), | |
| 243 | : result.error === "storage_limit" | |
| 244 | ? `Not mirrored: ${storageLimitMessage(app, actor.id)}.` | |
| 245 | : mirrorMessage(result.error), | |
| 244 | 246 | }); |
| 245 | 247 | return; |
| 246 | 248 | } |
| @@ -298,6 +300,8 @@ export function mirrorMessage(error: MirrorError): string { | ||
| 298 | 300 | return "the origin could not be reached"; |
| 299 | 301 | case "repository_limit": |
| 300 | 302 | return "this account is at the forge's repositories limit"; |
| 303 | case "storage_limit": | |
| 304 | return "this account is at the forge's storage limit"; | |
| 301 | 305 | } |
| 302 | 306 | } |
| 303 | 307 |
src/origins/sync.ts +3
| @@ -36,6 +36,7 @@ import { | ||
| 36 | 36 | setCursor, |
| 37 | 37 | type Origin, |
| 38 | 38 | } from "../core/origins.ts"; |
| 39 | import { storageStale } from "../core/storage.ts"; | |
| 39 | 40 | import { getRepoById } from "../core/repos.ts"; |
| 40 | 41 | import { pullDiff, type Pull, getPull } from "../core/pulls.ts"; |
| 41 | 42 | import { repoDir } from "../git/repos.ts"; |
| @@ -353,6 +354,8 @@ async function syncMirrorUnguarded( | ||
| 353 | 354 | recordFetch(app.db, repo.id, { at: runStarted }); |
| 354 | 355 | out.refsMoved = fetched.value.changes.length; |
outdated
Low (cost): this marks the figure stale after every successful fetch, including a periodic sync that moved nothing. The next check on the account (any push, create or fork) then waits for a full disk walk. Move it inside |
||
| 355 | 356 | if (fetched.value.changes.length > 0) { |
| 357 | // Objects were written without a push: measure again at the next check. | |
| 358 | storageStale(app, repo.ownerId); | |
| 356 | 359 | // Shaped exactly like a native push event, plus the flag that says |
| 357 | 360 | // where it came from — so webhooks, SSE and the activity feed see |
| 358 | 361 | // upstream work without learning a new event kind. |
src/tui/screens/newrepo.ts +10 −4
| @@ -3,7 +3,8 @@ | ||
| 3 | 3 | * because the next thing anyone does is look at it. |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import { createRepo, forkRepo, repositoryLimitMessage } from "../../actions/repos.ts"; | |
| 6 | import { storageLimitMessage } from "../../core/storage.ts"; | |
| 7 | import { createRepo, forkRepo, repositoryLimitMessage, resolveOwner } from "../../actions/repos.ts"; | |
| 7 | 8 | import { listOrgsForUser } from "../../core/orgs.ts"; |
| 8 | 9 | import type { Repo } from "../../core/repos.ts"; |
| 9 | 10 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -35,15 +36,18 @@ export function newRepoScreen(f: Session, ctx: Ctx, owner?: string): Screen { | ||
| 35 | 36 | |
| 36 | 37 | const submit = (): void => { |
| 37 | 38 | runAsync(ctx, async () => { |
| 39 | const ownerName = owners[ownerIndex] ?? ""; | |
| 38 | 40 | const created = await createRepo(f.app, f.user, { |
| 39 | owner: owners[ownerIndex] ?? "", | |
| 41 | owner: ownerName, | |
| 40 | 42 | name, |
| 41 | 43 | description, |
| 42 | 44 | visibility, |
| 43 | 45 | }); |
| 44 | 46 | if (!created.ok) { |
| 45 | 47 | error = |
| 46 | created.error === "repository_limit" | |
| 48 | created.error === "storage_limit" | |
outdated
Same issue here: when the screen creates under an org, |
||
| 49 | ? `not made: ${storageLimitMessage(f.app, resolveOwner(f.app, f.user!, ownerName) ?? f.user!.id)}` | |
| 50 | : created.error === "repository_limit" | |
| 47 | 51 | ? `not made: ${repositoryLimitMessage(f.app, f.user!)}` |
| 48 | 52 | : errorText(created.error); |
| 49 | 53 | ctx.toast(error, "danger"); |
| @@ -158,7 +162,9 @@ export function forkScreen(f: Session, ctx: Ctx, parent: Repo): Screen { | ||
| 158 | 162 | const forked = await forkRepo(f.app, f.user, parent, name); |
| 159 | 163 | if (!forked.ok) { |
| 160 | 164 | error = |
| 161 | forked.error === "repository_limit" | |
| 165 | forked.error === "storage_limit" | |
| 166 | ? `not made: ${storageLimitMessage(f.app, f.user!.id)}` | |
| 167 | : forked.error === "repository_limit" | |
| 162 | 168 | ? `not made: ${repositoryLimitMessage(f.app, f.user!)}` |
| 163 | 169 | : errorText(forked.error); |
| 164 | 170 | ctx.toast(error, "danger"); |
src/tui/ssh.ts +9 −2
| @@ -19,7 +19,7 @@ import { getUserById, isActive, type User } from "../core/users.ts"; | ||
| 19 | 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 | import { pushCap, storageChanged, storageRefusal, storageRefusedText, storageSentence } from "../core/storage.ts"; | |
| 23 | 23 | import { parseGitCommand, runGitService } from "../git/smart-ssh.ts"; |
| 24 | 24 | import { serveSsh, type SshServer, type SshSession } from "../ssh/server.ts"; |
| 25 | 25 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -257,7 +257,14 @@ export async function serveGitCommand( | ||
| 257 | 257 | visibility: "private", |
| 258 | 258 | }); |
| 259 | 259 | if (created.ok) repo = created.value; |
| 260 | else if (created.error === "repository_limit") { | |
| 260 | else if (created.error === "storage_limit") { | |
| 261 | const ownerId = resolveOwner(forge, user, parsed.owner) ?? user.id; | |
| 262 | session.writeStderr( | |
| 263 | `forge: ${storageRefusedText(storageSentence(forge, ownerId) ?? "at the limit")}${CRLF}`, | |
| 264 | ); | |
| 265 | session.exit(1); | |
| 266 | return; | |
| 267 | } else if (created.error === "repository_limit") { | |
| 261 | 268 | session.writeStderr( |
| 262 | 269 | `forge: Not made: ${repositoryLimitMessage(forge, user, resolveOwner(forge, user, parsed.owner) ?? user.id)}${CRLF}`, |
| 263 | 270 | ); |
src/web/pages/apps.ts +3
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | * 404 for everyone. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | import { storageLimitMessage } from "../../core/storage.ts"; | |
| 15 | 16 | import { repositoryLimitMessage } from "../../actions/repos.ts"; |
| 16 | 17 | import { appLimitWords } from "../../actions/app_limit.ts"; |
| 17 | 18 | import { getUserByName } from "../../core/users.ts"; |
| @@ -422,6 +423,8 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 422 | 423 | values, |
| 423 | 424 | made.error === "repository_limit" |
| 424 | 425 | ? `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user!)}.` |
| 426 | : made.error === "storage_limit" | |
| 427 | ? `Not made: ${storageLimitMessage(ctx.app, ctx.auth.user!.id)}.` | |
Low: a new app created under an organisation is checked against the org's storage, but this sentence names the actor's figure ( |
||
| 425 | 428 | : made.error === "app_limit" && ownerId !== undefined |
| 426 | 429 | ? appLimitWords(ctx.app, ownerId) |
| 427 | 430 | : (NEW_APP_ERRORS[made.error] ?? "That app could not be created."), |
src/web/pages/repo.ts +3
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * path segment and can never smuggle a path of its own. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { storageLimitMessage } from "../../core/storage.ts"; | |
| 11 | 12 | import { canRead, listForks, type Repo } from "../../core/repos.ts"; |
| 12 | 13 | import { FORKS_PER_DAY, forkRepo, repositoryLimitMessage } from "../../actions/repos.ts"; |
| 13 | 14 | import { forkDrift, takeUpdates, type ForkDrift } from "../../actions/forks.ts"; |
| @@ -197,6 +198,8 @@ export async function forkSubmit(ctx: Ctx): Promise<Response> { | ||
| 197 | 198 | ? "You already have a repository by that name." |
| 198 | 199 | : forked.error === "repository_limit" |
| 199 | 200 | ? `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user)}.` |
| 201 | : forked.error === "storage_limit" | |
| 202 | ? `Not made: ${storageLimitMessage(ctx.app, ctx.auth.user.id)}.` | |
| 200 | 203 | : forked.error === "limit" |
| 201 | 204 | ? `That is today's forks made (${FORKS_PER_DAY} a day). Try again tomorrow.` |
| 202 | 205 | : "That fork could not be created."; |
src/web/pages/site.ts +3 −1
| @@ -8,12 +8,13 @@ | ||
| 8 | 8 | * typed still in it. A dead end that loses input is a bug, not a message. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { storageLimitMessage } from "../../core/storage.ts"; | |
| 11 | 12 | import { limitValue } from "../../settings/limits.ts"; |
| 12 | 13 | import { signOutHere } from "../../actions/signins.ts"; |
| 13 | 14 | import { clientAddress } from "../../http/peer.ts"; |
| 14 | 15 | import { verifyEmail } from "../../actions/verify.ts"; |
| 15 | 16 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 16 | import { createRepo, repositoryLimitMessage } from "../../actions/repos.ts"; | |
| 17 | import { createRepo, repositoryLimitMessage, resolveOwner } from "../../actions/repos.ts"; | |
| 17 | 18 | import { HUMAN_CHECK_MESSAGE, PASSKEY_REQUIRED_MESSAGE, signUp } from "../../actions/users.ts"; |
| 18 | 19 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| 19 | 20 | import { listPins } from "../../core/pins.ts"; |
| @@ -690,6 +691,7 @@ export async function newRepoSubmit(ctx: Ctx): Promise<Response> { | ||
| 690 | 691 | return new Response("forbidden", { status: 403 }); |
| 691 | 692 | const messages: Record<string, string> = { |
| 692 | 693 | repository_limit: `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user)}.`, |
| 694 | storage_limit: `Not made: ${storageLimitMessage(ctx.app, resolveOwner(ctx.app, ctx.auth.user, values.owner) ?? ctx.auth.user.id)}.`, | |
outdated
|
||
| 693 | 695 | name_taken: "That owner already has a repository by that name.", |
| 694 | 696 | invalid_name: "Names start with a letter or digit; no spaces, no .git.", |
| 695 | 697 | git_init_failed: "The repository could not be initialised on disk.", |
src/web/studio/make_it_yours.ts +1 −1
| @@ -128,7 +128,7 @@ export async function makeItYoursSubmit(ctx: Ctx): Promise<Response> { | ||
| 128 | 128 | const message = !made.ok |
| 129 | 129 | ? made.error.kind === "invalid" |
| 130 | 130 | ? made.error.message |
| 131 | : made.error.kind === "repository_limit" | |
| 131 | : made.error.kind === "repository_limit" || made.error.kind === "storage_limit" | |
| 132 | 132 | ? `Not made: ${made.error.message}.` |
| 133 | 133 | : made.error.kind === "own" |
| 134 | 134 | ? "This app is already yours." |
test/quotas-storage.test.ts +164 −4
| @@ -4,21 +4,25 @@ | ||
| 4 | 4 | * held to it, and no limit set means no refusal. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; | |
| 8 | 8 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { commitToBranch, createRepo, deleteRepo, forkRepo } from "../src/actions/repos.ts"; |
| 13 | import { makeItYours } from "../src/actions/make_it_yours.ts"; | |
| 14 | import { newApp } from "../src/actions/apps.ts"; | |
| 13 | 15 | import { signUp } from "../src/actions/users.ts"; |
| 14 | 16 | import { createToken } from "../src/core/tokens.ts"; |
| 15 | import { accountBytes, formatBytes, storageRefusal } from "../src/core/storage.ts"; | |
| 17 | import { REWALK_MS, accountBytes, formatBytes, storageRefusal } from "../src/core/storage.ts"; | |
| 16 | 18 | import { getRepo } from "../src/core/repos.ts"; |
| 17 | 19 | import { createAgent } from "../src/actions/crew.ts"; |
| 18 | 20 | import { mirrorAndWait, newFakeState, serveFakeForge } from "./fake-origin.ts"; |
| 19 | 21 | import { serveGitCommand } from "../src/tui/ssh.ts"; |
| 20 | 22 | import type { SshSession } from "../src/ssh/channels.ts"; |
| 21 | 23 | import { getUserById } from "../src/core/users.ts"; |
| 24 | import { createOrg } from "../src/core/orgs.ts"; | |
| 25 | import { createSession } from "../src/core/sessions.ts"; | |
| 22 | 26 | import { setQuota } from "../src/settings/quotas.ts"; |
| 23 | 27 | import { serve, type Servers } from "../src/http/server.ts"; |
| 24 | 28 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| @@ -119,7 +123,7 @@ describe("storage quota", () => { | ||
| 119 | 123 | |
| 120 | 124 | const made = await createRepo(app, getUserById(app.db, bobId)!, { name: "more", visibility: "private" }); |
| 121 | 125 | expect(made.ok).toBe(false); |
| 122 | if (!made.ok) expect(made.error).toBe("repository_limit"); | |
| 126 | if (!made.ok) expect(made.error).toBe("storage_limit"); | |
| 123 | 127 | }); |
| 124 | 128 | |
| 125 | 129 | test("push-to-create over the limit is a git error naming the use, not a not-found", async () => { |
| @@ -169,7 +173,7 @@ describe("storage quota", () => { | ||
| 169 | 173 | else refused = forked.error; |
| 170 | 174 | } |
| 171 | 175 | expect(made).toBeGreaterThan(0); |
| 172 | expect(refused).toBe("repository_limit"); | |
| 176 | expect(refused).toBe("storage_limit"); | |
| 173 | 177 | }); |
| 174 | 178 | |
| 175 | 179 | test("an agent is not held to the limit, the account beside it is", async () => { |
| @@ -229,6 +233,162 @@ describe("storage quota", () => { | ||
| 229 | 233 | expect(refs("ada", "huge")).not.toBe(""); |
| 230 | 234 | }); |
| 231 | 235 | |
| 236 | test("a mirror bigger than the limit is allowed in, and the next create is refused as storage_limit", async () => { | |
| 237 | // The origin: a public repository of 1.5 MB on this same forge. | |
| 238 | expect((await push("ada", adaToken, "bigorigin", 1_500_000)).code).toBe(0); | |
| 239 | app.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'bigorigin'").run(); | |
| 240 | const dave = await signUp(app, { username: "dave", email: "[email protected]", password: "dave-passphrase" }); | |
| 241 | if (!dave.ok) throw new Error("signup"); | |
| 242 | proveAll(app); | |
| 243 | const daveToken = createToken(app.db, dave.value.id, "t", "admin").token; | |
| 244 | const state = newFakeState("forgejo", "someone", "tok"); | |
| 245 | state.repos.push({ | |
| 246 | id: 2, | |
| 247 | fullName: "ada/bigorigin", | |
| 248 | cloneUrl: `${servers.url}/ada/bigorigin.git`, | |
| 249 | defaultBranch: "main", | |
| 250 | }); | |
| 251 | const fake = serveFakeForge(state); | |
| 252 | try { | |
| 253 | const post = (path: string, body: unknown) => | |
| 254 | fetch(`${servers.url}${path}`, { | |
| 255 | method: "POST", | |
| 256 | headers: { authorization: `Bearer ${daveToken}`, "content-type": "application/json" }, | |
| 257 | body: JSON.stringify(body), | |
| 258 | }); | |
| 259 | const connected = await post("/api/v1/user/origins", { host: fake.url, token: "tok" }); | |
| 260 | const originId = ((await connected.json()) as { id: string }).id; | |
| 261 | // Empty at the start, so the mirror is let in; the figure is read once. | |
| 262 | expect(await accountBytes(app, dave.value.id)).toBe(0); | |
| 263 | const job = await mirrorAndWait( | |
| 264 | await post(`/api/v1/user/origins/${originId}/mirror`, { fullName: "ada/bigorigin" }), | |
| 265 | ); | |
| 266 | expect(job?.stage).toBe("done"); | |
| 267 | // The fetch marked the figure stale, so this check walks it and refuses. | |
| 268 | const made = await createRepo(app, getUserById(app.db, dave.value.id)!, { | |
| 269 | name: "more", | |
| 270 | visibility: "private", | |
| 271 | }); | |
| 272 | expect(made).toEqual({ ok: false, error: "storage_limit" }); | |
| 273 | const api = await post("/api/v1/repos", { name: "more" }); | |
| 274 | expect(api.status).toBe(403); | |
| 275 | expect(await api.json()).toMatchObject({ error: "storage_limit" }); | |
| 276 | } finally { | |
| 277 | fake.stop(); | |
| 278 | } | |
| 279 | }); | |
| 280 | ||
| 281 | test("a migrate import bigger than the limit is allowed in, and the next create is refused as storage_limit", async () => { | |
| 282 | const gina = await signUp(app, { username: "gina", email: "[email protected]", password: "gina-passphrase" }); | |
| 283 | if (!gina.ok) throw new Error("signup"); | |
| 284 | proveAll(app); | |
| 285 | const token = createToken(app.db, gina.value.id, "t", "admin").token; | |
| 286 | const state = newFakeState("forgejo", "someone", "tok"); | |
| 287 | state.repos.push({ | |
| 288 | id: 3, | |
| 289 | fullName: "ada/bigorigin", | |
| 290 | cloneUrl: `${servers.url}/ada/bigorigin.git`, | |
| 291 | defaultBranch: "main", | |
| 292 | }); | |
| 293 | const fake = serveFakeForge(state); | |
| 294 | try { | |
| 295 | const post = (path: string, body: unknown) => | |
| 296 | fetch(`${servers.url}${path}`, { | |
| 297 | method: "POST", | |
| 298 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 299 | body: JSON.stringify(body), | |
| 300 | }); | |
| 301 | const connected = await post("/api/v1/user/origins", { host: fake.url, token: "tok" }); | |
| 302 | const originId = ((await connected.json()) as { id: string }).id; | |
| 303 | expect(await accountBytes(app, gina.value.id)).toBe(0); | |
| 304 | const job = await mirrorAndWait( | |
| 305 | await post("/api/v1/migrate", { source: { originId, fullName: "ada/bigorigin" }, name: "moved" }), | |
| 306 | ); | |
| 307 | expect(job?.stage).toBe("done"); | |
| 308 | const made = await createRepo(app, getUserById(app.db, gina.value.id)!, { | |
| 309 | name: "more", | |
| 310 | visibility: "private", | |
| 311 | }); | |
| 312 | expect(made).toEqual({ ok: false, error: "storage_limit" }); | |
| 313 | } finally { | |
| 314 | fake.stop(); | |
| 315 | } | |
| 316 | }); | |
| 317 | ||
| 318 | /** A fresh account holding 1.5 MB, its figure measured, against the 1 MB limit. */ | |
| 319 | async function fullAccount(username: string) { | |
| 320 | const made = await signUp(app, { username, email: `${username}@f.dev`, password: `${username}-passphrase` }); | |
| 321 | if (!made.ok) throw new Error("signup"); | |
| 322 | proveAll(app); | |
| 323 | setQuota(app.db, "storage", 2, adaId); | |
| 324 | const token = createToken(app.db, made.value.id, "t", "admin").token; | |
| 325 | expect((await push(username, token, "data", 1_500_000)).code).toBe(0); | |
| 326 | for (let i = 0; i < 100 && (await accountBytes(app, made.value.id)) < 1_500_000; i++) | |
| 327 | await Bun.sleep(20); | |
| 328 | setQuota(app.db, "storage", 1, adaId); | |
| 329 | return getUserById(app.db, made.value.id)!; | |
| 330 | } | |
| 331 | ||
| 332 | test("an account at the limit is measured again at most every REWALK_MS", async () => { | |
| 333 | expect(REWALK_MS).toBe(5_000); | |
| 334 | const erin = await fullAccount("erin"); | |
| 335 | expect(await storageRefusal(app, erin, erin.id)).toContain("MB of 1 MB"); | |
| 336 | // The disk is emptied behind the cache's back. | |
| 337 | await rm(join(dataDir, "repos", "erin"), { recursive: true, force: true }); | |
| 338 | const now = Date.now(); | |
| 339 | const clock = spyOn(Date, "now"); | |
| 340 | try { | |
| 341 | clock.mockReturnValue(now + REWALK_MS - 1_000); | |
| 342 | expect(await storageRefusal(app, erin, erin.id)).toContain("MB of 1 MB"); | |
| 343 | clock.mockReturnValue(now + REWALK_MS + 1_000); | |
| 344 | expect(await storageRefusal(app, erin, erin.id)).toBeUndefined(); | |
| 345 | } finally { | |
| 346 | clock.mockRestore(); | |
| 347 | } | |
| 348 | }); | |
| 349 | ||
| 350 | test("a copy over the storage limit is a storage_limit, not a raw code", async () => { | |
| 351 | const source = await newApp(app, getUserById(app.db, adaId)!, { name: "garden", visibility: "public" }); | |
| 352 | if (!source.ok) throw new Error(String(source.error)); | |
| 353 | const frank = await fullAccount("frank"); | |
| 354 | const copy = await makeItYours(app, frank, source.value.repo, { dryRun: false }); | |
| 355 | expect(copy.ok).toBe(false); | |
| 356 | if (!copy.ok) { | |
| 357 | expect(copy.error.kind).toBe("storage_limit"); | |
| 358 | if (copy.error.kind === "storage_limit") expect(copy.error.message).toContain("MB of 1 MB"); | |
| 359 | } | |
| 360 | }); | |
| 361 | ||
| 362 | test("a create under a full organisation names the organisation's figure on the page", async () => { | |
| 363 | const gwen = await signUp(app, { username: "gwen", email: "[email protected]", password: "gwen-passphrase" }); | |
| 364 | if (!gwen.ok) throw new Error("signup"); | |
| 365 | proveAll(app); | |
| 366 | const org = createOrg(app.db, "guild", gwen.value.id); | |
| 367 | if (!org.ok) throw new Error("org"); | |
| 368 | setQuota(app.db, "storage", 2, adaId); | |
| 369 | const token = createToken(app.db, gwen.value.id, "t", "admin").token; | |
| 370 | const made = await createRepo(app, gwen.value, { owner: "guild", name: "big", visibility: "private", description: "" }); | |
| 371 | expect(made.ok).toBe(true); | |
| 372 | expect((await push("guild", token, "big", 1_500_000)).code).toBe(0); | |
| 373 | for (let i = 0; i < 100 && (await accountBytes(app, org.value.id)) < 1_500_000; i++) | |
| 374 | await Bun.sleep(20); | |
| 375 | setQuota(app.db, "storage", 1, adaId); | |
| 376 | // Gwen herself holds nothing: only the organisation is over. | |
| 377 | expect(await accountBytes(app, gwen.value.id)).toBe(0); | |
| 378 | const res = await fetch(`${servers.url}/new`, { | |
| 379 | method: "POST", | |
| 380 | headers: { | |
| 381 | cookie: `forge_session=${createSession(app.db, gwen.value.id).token}`, | |
| 382 | origin: servers.url, | |
| 383 | "content-type": "application/x-www-form-urlencoded", | |
| 384 | }, | |
| 385 | body: new URLSearchParams({ owner: "guild", name: "more", visibility: "private" }), | |
| 386 | redirect: "manual", | |
| 387 | }); | |
| 388 | expect(res.status).toBe(422); | |
| 389 | expect(await res.text()).toContain("MB of 1 MB"); | |
| 390 | }); | |
| 391 | ||
| 232 | 392 | test("with no limit set nothing is refused", async () => { |
| 233 | 393 | setQuota(app.db, "storage", null, adaId); |
| 234 | 394 | const pushed = await push("bob", bobToken, "free", 1_500_000); |
No test covers this mark. Deleting the line leaves every test green. Add a migrate-over-the-limit test shaped like the mirror one (empty account, figure read at 0, migrate the 1.5 MB origin, next create is
storage_limit).