Public: anyone can read this; the crew can change it.
A spawn that would pass the host's child quota is refused with one sentence, and the New forge form says so before it is filled in #5843
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2981-a-spawn-that-would-pass-the-host-s-child → main 2 commits fork 2 threads · 2 unresolved
9 files
changed +241
−7
README.md +9
| @@ -2202,3 +2202,12 @@ The here-manifest (`GET /api/v1/here?path=…`) reads the same for guests and | ||
| 2202 | 2202 | members. On `/explore` and `/search` it names the page's own read, `?q=` carried |
| 2203 | 2203 | over and encoded: `GET /api/v1/explore?q=…` (MCP tool `explore`) or |
| 2204 | 2204 | `GET /api/v1/search?q=…` (MCP tool `search`), listed once. |
| 2205 | ||
| 2206 | ### Child quota | |
| 2207 | ||
| 2208 | `FORGE_CHILD_CPUS`, `FORGE_CHILD_MEMORY_MIB` and `FORGE_CHILD_DISK_MIB` declare | |
| 2209 | what all children together may hold; each is the sum of the sizes in the spawn | |
| 2210 | ledger (`spawn/sizes.ts`). **Unset means no quota.** A spawn that would pass a | |
| 2211 | declared limit is a 409 `refused` with one sentence naming it, and the New | |
| 2212 | forge page shows that sentence above the form when even a small forge would | |
| 2213 | not fit (`spawn/quota.ts`). |
src/actions/spawn.ts +3
| @@ -53,6 +53,7 @@ import { loadKey, open } from "../kernel/secret.ts"; | ||
| 53 | 53 | import { loadOrCreateHostKey } from "../ssh/keys.ts"; |
| 54 | 54 | import { claimLink, hasAdmin, mintClaim, spawnedOrg } from "../spawn/claim.ts"; |
| 55 | 55 | import { childPublicUrl, parentOrigin, type Placement } from "../spawn/placement.ts"; |
| 56 | import { quotaRefusal } from "../spawn/quota.ts"; | |
| 56 | 57 | import { planSpawn, type SpawnInput, type SpawnPlan, type SpawnSource } from "../spawn/plan.ts"; |
| 57 | 58 | import { DEFAULT_SIZE, isSize, type Size } from "../spawn/sizes.ts"; |
| 58 | 59 | import { chainForChild, DEFAULT_MODE, forgeLinkOf, type FollowMode, type Hop } from "../spawn/chain.ts"; |
| @@ -242,6 +243,8 @@ async function make( | ||
| 242 | 243 | // A retry of an unfinished child is a new attempt: its failure has a start. |
| 243 | 244 | app.bus.append({ kind: "forge.spawn.started", payload: { ...(deps.drill === true ? { drill: 1 } : {}) } }); |
| 244 | 245 | } else { |
| 246 | const over = quotaRefusal(app.config.dataDir, plan.size ?? DEFAULT_SIZE); | |
| 247 | if (over !== undefined) return err({ kind: "refused", reason: "quota", message: over }); | |
| 245 | 248 | const placed = await deps.placement.place(plan); |
| 246 | 249 | if (!placed.ok) return err({ kind: "refused", ...placed.error }); |
| 247 | 250 | if (!SAFE_ID.test(placed.value.bodyId)) { |
src/spawn/placement.ts +1 −1
| @@ -17,7 +17,7 @@ export interface Placed { | ||
| 17 | 17 | readonly bodyId: string; |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | export type PlacementRefusal = { readonly reason: "children" | "depth" | "exists" | "unknown"; readonly message: string }; | |
| 20 | export type PlacementRefusal = { readonly reason: "children" | "depth" | "exists" | "quota" | "unknown"; readonly message: string }; | |
| 21 | 21 | |
| 22 | 22 | export interface Placement { |
| 23 | 23 | place(plan: SpawnPlan): Promise<Result<Placed, PlacementRefusal>>; |
src/spawn/quota.ts added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | /** | |
| 2 | * The child quota: what all children together may hold on this host. Declared | |
| 3 | * by FORGE_CHILD_CPUS, FORGE_CHILD_MEMORY_MIB and FORGE_CHILD_DISK_MIB; a | |
| 4 | * variable that is unset (or not a whole number above zero) declares no limit, | |
| 5 | * so test hosts and forges that never set them are never refused. Used is the | |
| 6 | * sum of the sizes in the spawn ledger (a ledger without a size reads small). | |
| 7 | */ | |
| 8 | ||
| 9 | import { readdirSync, readFileSync } from "node:fs"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { DEFAULT_SIZE, isSize, SIZES, type Size, type SizeLimits } from "./sizes.ts"; | |
| 12 | ||
| 13 | interface Axis { | |
| 14 | readonly env: string; | |
| 15 | readonly key: keyof SizeLimits; | |
| 16 | readonly words: string; | |
| 17 | readonly unit: string; | |
| 18 | } | |
| 19 | ||
| 20 | const AXES: readonly Axis[] = [ | |
| 21 | { env: "FORGE_CHILD_CPUS", key: "cpu", words: "CPU", unit: "" }, | |
| 22 | { env: "FORGE_CHILD_MEMORY_MIB", key: "memoryMiB", words: "memory", unit: " MiB" }, | |
| 23 | { env: "FORGE_CHILD_DISK_MIB", key: "diskMiB", words: "disk", unit: " MiB" }, | |
| 24 | ]; | |
| 25 | ||
| 26 | function limitOf(env: Record<string, string | undefined>, name: string): number | undefined { | |
| 27 | const v = env[name]; | |
| 28 | if (v === undefined || !/^[0-9]{1,9}$/.test(v)) return undefined; | |
| 29 | const n = Number(v); | |
| 30 | return n > 0 ? n : undefined; | |
| 31 | } | |
| 32 | ||
| 33 | /** What the children already made hold, summed from the ledger lines under `dataDir/spawn`. */ | |
| 34 | export function heldByChildren(dataDir: string): SizeLimits { | |
| 35 | const held = { cpu: 0, memoryMiB: 0, diskMiB: 0 }; | |
| 36 | let files: string[] = []; | |
| 37 | try { | |
| 38 | files = readdirSync(join(dataDir, "spawn")); | |
| 39 | } catch { | |
| 40 | return held; | |
| 41 | } | |
| 42 | for (const f of files) { | |
| 43 | if (!f.endsWith(".json")) continue; | |
| 44 | let size: Size = DEFAULT_SIZE; | |
| 45 | try { | |
| 46 | const s = (JSON.parse(readFileSync(join(dataDir, "spawn", f), "utf8")) as { size?: unknown }).size; | |
| 47 | if (isSize(s)) size = s; | |
| 48 | } catch { | |
| 49 | continue; | |
| 50 | } | |
| 51 | held.cpu += SIZES[size].cpu; | |
| 52 | held.memoryMiB += SIZES[size].memoryMiB; | |
| 53 | held.diskMiB += SIZES[size].diskMiB; | |
| 54 | } | |
| 55 | return held; | |
| 56 | } | |
| 57 | ||
| 58 | /** One sentence naming the first declared limit a child of this size would pass, or undefined. */ | |
| 59 | export function quotaRefusal( | |
| 60 | dataDir: string, | |
| 61 | size: Size, | |
| 62 | env: Record<string, string | undefined> = process.env, | |
| 63 | ): string | undefined { | |
| 64 | const limits = AXES.map((a) => ({ a, max: limitOf(env, a.env) })).filter((l) => l.max !== undefined); | |
| 65 | if (limits.length === 0) return undefined; | |
| 66 | const held = heldByChildren(dataDir); | |
| 67 | for (const { a, max } of limits) { | |
| 68 | if (held[a.key] + SIZES[size][a.key] > max!) | |
| 69 | return `a ${size} forge would pass this host's child ${a.words} limit (${a.env}=${max}${a.unit}; ${held[a.key]}${a.unit} already held)`; | |
| 70 | } | |
| 71 | return undefined; | |
| 72 | } | |
| 73 | ||
| 74 | /** Why even the smallest forge would not fit, or undefined: what the New forge page says before the form. */ | |
| 75 | export const quotaFull = (dataDir: string, env?: Record<string, string | undefined>): string | undefined => | |
| 76 | quotaRefusal(dataDir, DEFAULT_SIZE, env); |
src/tui/screens/newforge.ts +9 −1
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | |
| 10 | 10 | import { DEFAULT_SIZE, SIZES, type Size } from "../../spawn/sizes.ts"; |
| 11 | 11 | import { childUrlOf, spawnDoor } from "../../spawn/door.ts"; |
| 12 | import { quotaFull } from "../../spawn/quota.ts"; | |
| 12 | 13 | import { MAX_BUDGET_USD, MIN_BUDGET_USD } from "../../spawn/plan.ts"; |
| 13 | 14 | import type { Ctx, Screen } from "../app/app.ts"; |
| 14 | 15 | import { binding } from "../app/keys.ts"; |
| @@ -56,6 +57,8 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen { | ||
| 56 | 57 | let error: string | undefined; |
| 57 | 58 | let plan: string[] | undefined; |
| 58 | 59 | let busy = false; |
| 60 | // Said when the screen opens, before anything is typed: the host's child quota has no room even for small. | |
| 61 | const quota = quotaFull(f.app.config.dataDir); | |
| 59 | 62 | |
| 60 | 63 | const val = (k: string): string => (values[k] ?? "").trim(); |
| 61 | 64 | |
| @@ -154,8 +157,13 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen { | ||
| 154 | 157 | binding(["enter"], "spawn (on the plan)"), |
| 155 | 158 | binding(["esc"], "back"), |
| 156 | 159 | ], |
| 157 | render(area, s) { | |
| 160 | render(full, s) { | |
| 158 | 161 | const c = ctx.theme.c; |
| 162 | let area = full; | |
| 163 | if (quota !== undefined) { | |
| 164 | s.text(full.x, full.y, truncate(`This forge cannot hold another child: ${quota}.`, full.w), style(c.attn, c.bg), full.x + full.w); | |
| 165 | area = { ...full, y: full.y + 2, h: Math.max(1, full.h - 2) }; | |
| 166 | } | |
| 159 | 167 | if (plan !== undefined) { |
| 160 | 168 | s.text(area.x, area.y, truncate("The plan", area.w), style(c.accent, c.bg), area.x + area.w); |
| 161 | 169 | plan.forEach((line, i) => { |
src/web/layout.ts +5 −2
| @@ -38,6 +38,7 @@ import { prefersJson } from "./form.ts"; | ||
| 38 | 38 | import { notePhase } from "../core/route_timing.ts"; |
| 39 | 39 | import { html, raw, when, type Html } from "./html.ts"; |
| 40 | 40 | import { noRoom } from "../spawn/door.ts"; |
| 41 | import { quotaFull } from "../spawn/quota.ts"; | |
| 41 | 42 | import { icon, iconSprite, inlineIcons, themeGlyph } from "./icons.ts"; |
| 42 | 43 | import { btn, copyField, kbd, menu, type MenuItem } from "./ui.ts"; |
| 43 | 44 | |
| @@ -172,11 +173,13 @@ const NEW_FORGE: MenuItem = { | ||
| 172 | 173 | |
| 173 | 174 | /** The door shows to every person; with no room left it is disabled and says why. */ |
| 174 | 175 | const isPerson = (ctx: Ctx): boolean => ctx.auth.user?.kind === "user"; |
| 176 | const roomless = (ctx: Ctx): string | undefined => | |
| 177 | noRoom(ctx.app) ?? quotaFull(ctx.app.config.dataDir); | |
| 175 | 178 | const sendsForges = (ctx: Ctx): boolean => |
| 176 | isPerson(ctx) && noRoom(ctx.app) === undefined; | |
| 179 | isPerson(ctx) && roomless(ctx) === undefined; | |
| 177 | 180 | const forgeDoor = (ctx: Ctx): MenuItem[] => { |
| 178 | 181 | if (!isPerson(ctx)) return []; |
| 179 | const why = noRoom(ctx.app); | |
| 182 | const why = roomless(ctx); | |
| 180 | 183 | return [why === undefined ? NEW_FORGE : { ...NEW_FORGE, disabled: why }]; |
| 181 | 184 | }; |
| 182 | 185 |
src/web/pages/new-forge.ts +8 −3
| @@ -14,6 +14,7 @@ import { listReposVisibleTo } from "../../core/repos.ts"; | ||
| 14 | 14 | import type { User } from "../../core/users.ts"; |
| 15 | 15 | import type { Ctx } from "../../http/context.ts"; |
| 16 | 16 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 17 | import { quotaFull } from "../../spawn/quota.ts"; | |
| 17 | 18 | import { childHost, childUrlOf, noRoom, spawnDoor, type Door } from "../../spawn/door.ts"; |
| 18 | 19 | import { |
| 19 | 20 | LEFT_BEHIND, |
| @@ -101,13 +102,16 @@ function checks( | ||
| 101 | 102 | /** What the form reads before it draws: room, then (when there is room) the repositories and the crew. */ |
| 102 | 103 | interface Reads { |
| 103 | 104 | readonly full: string | undefined; |
| 105 | /** Why even a small forge would pass the host's child quota; the form still draws. */ | |
| 106 | readonly quota: string | undefined; | |
| 104 | 107 | readonly repos: ReturnType<typeof listReposVisibleTo>; |
| 105 | 108 | readonly crew: ReturnType<typeof listAgents>; |
| 106 | 109 | } |
| 107 | 110 | |
| 108 | 111 | function readsFor(ctx: Ctx, user: User | undefined): Reads { |
| 109 | 112 | const full = timePhase(ctx.request, "room", () => noRoom(ctx.app)); |
| 110 | if (full) return { full, repos: [], crew: [] }; | |
| 113 | if (full) return { full, quota: undefined, repos: [], crew: [] }; | |
| 114 | const quota = quotaFull(ctx.app.config.dataDir); | |
| 111 | 115 | // A stranger sees only public repositories, and no crew: nothing private |
| 112 | 116 | // travels to a child they have not claimed. |
| 113 | 117 | const repos = timePhase(ctx.request, "repos", () => |
| @@ -117,7 +121,7 @@ function readsFor(ctx: Ctx, user: User | undefined): Reads { | ||
| 117 | 121 | user === undefined |
| 118 | 122 | ? [] |
| 119 | 123 | : timePhase(ctx.request, "crew", () => listAgents(ctx.app.db)); |
| 120 | return { full, repos, crew }; | |
| 124 | return { full, quota, repos, crew }; | |
| 121 | 125 | } |
| 122 | 126 | |
| 123 | 127 | /** The three sizes, the one asked for chosen (small when the value is not one of them). */ |
| @@ -141,7 +145,7 @@ function formView( | ||
| 141 | 145 | plan: Html | null, |
| 142 | 146 | reads: Reads, |
| 143 | 147 | ): Html { |
| 144 | const { full, repos, crew } = reads; | |
| 148 | const { full, quota, repos, crew } = reads; | |
| 145 | 149 | if (full) |
| 146 | 150 | return html`<div class="narrow-wide"> |
| 147 | 151 | ${pageHead("New forge")} |
| @@ -160,6 +164,7 @@ function formView( | ||
| 160 | 164 | : "Your own forge, or one for someone else: the apps you choose, a crew, and one link to claim.", |
| 161 | 165 | actions: btn("Forges you sent", { href: "/forges", size: "sm" }), |
| 162 | 166 | })} |
| 167 | ${quota ? html`<p role="status" id="quota-full">This forge cannot hold another child: ${quota}.</p>` : null} | |
outdated
Hand-rolled <p class=error mb>. Use the notice component DESIGN.md names, if there is one. Mirror this sentence on the tty screen as well. |
||
| 163 | 168 | ${errors["form"] |
| 164 | 169 | ? html`<p class="error mb" role="alert">${errors["form"]}</p>` |
| 165 | 170 | : null} |
test/child-quota.test.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * The child quota (#2981): FORGE_CHILD_* declare what children may hold in | |
| 3 | * total; unset declares nothing, and a spawn past a limit is one sentence. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { quotaRefusal } from "../src/spawn/quota.ts"; | |
| 14 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 15 | import { hostedPlacement, memoryBodies } from "../src/spawn/placement.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | const PASSWORD = "a-long-new-forge-passphrase"; | |
| 21 | const VARS = ["FORGE_CHILD_CPUS", "FORGE_CHILD_MEMORY_MIB", "FORGE_CHILD_DISK_MIB"]; | |
| 22 | ||
| 23 | describe("child quota", () => { | |
| 24 | let dir = ""; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | let cookie = ""; | |
| 28 | const url = (p: string) => `${servers.url}${p}`; | |
| 29 | const clear = () => VARS.forEach((v) => delete process.env[v]); | |
| 30 | const spawn = (name: string, size = "small") => | |
| 31 | fetch(url("/forges/new"), { | |
| 32 | method: "POST", | |
| 33 | headers: { cookie, origin: "https://forge.test" }, | |
| 34 | body: new URLSearchParams([["name", name], ["fresh", "a tiny app"], ["size", size], ["do", "spawn"]]), | |
| 35 | redirect: "manual", | |
| 36 | }); | |
| 37 | ||
| 38 | beforeAll(async () => { | |
| 39 | dir = await mkdtemp(join(tmpdir(), "forge-quota-")); | |
| 40 | app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 41 | const a = await createUser(app.db, { | |
| 42 | origin: "seed", | |
| 43 | trusted: true, | |
| 44 | username: "alice", | |
| 45 | email: "[email protected]", | |
| 46 | password: PASSWORD, | |
| 47 | isAdmin: true, | |
| 48 | }); | |
| 49 | if (!a.ok) throw new Error("user"); | |
| 50 | usePlacement(app, hostedPlacement({ host: "https://forge.test", store: memoryBodies(), maxChildren: 10 })); | |
| 51 | useProbe(app, () => ({ live: async () => true }) as never); | |
| 52 | servers = await serve(app); | |
| 53 | const res = await fetch(url("/signin"), { | |
| 54 | method: "POST", | |
| 55 | body: new URLSearchParams({ username: "alice", password: PASSWORD }), | |
| 56 | redirect: "manual", | |
| 57 | }); | |
| 58 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 59 | }, 30_000); | |
| 60 | ||
| 61 | afterAll(async () => { | |
| 62 | clear(); | |
| 63 | servers.stop(); | |
| 64 | app.close(); | |
| 65 | await rm(dir, { recursive: true, force: true }); | |
| 66 | }); | |
| 67 | ||
| 68 | test("unset means no quota: nothing is refused and the page has no sentence", async () => { | |
| 69 | clear(); | |
| 70 | expect(quotaRefusal(dir, "large")).toBeUndefined(); | |
| 71 | expect(await (await fetch(url("/forges/new"), { headers: { cookie } })).text()).not.toContain("quota-full"); | |
| 72 | }); | |
| 73 | ||
| 74 | test("a value that is not a whole number above zero declares nothing", () => { | |
| 75 | expect(quotaRefusal(dir, "large", { FORGE_CHILD_CPUS: "0", FORGE_CHILD_DISK_MIB: "lots" })).toBeUndefined(); | |
| 76 | }); | |
| 77 | ||
| 78 | test("set and full: 409 refused with one sentence, and the page says it above the form", async () => { | |
| 79 | clear(); | |
| 80 | process.env["FORGE_CHILD_CPUS"] = "1"; | |
| 81 | const first = await spawn("first"); | |
| 82 | expect(first.status).toBe(201); | |
| 83 | const second = await spawn("second"); | |
| 84 | expect(second.status).toBe(409); | |
| 85 | expect(await second.text()).toContain("would pass this host's child CPU limit (FORGE_CHILD_CPUS=1"); | |
| 86 | const page = await (await fetch(url("/forges/new"), { headers: { cookie } })).text(); | |
| 87 | expect(page).toMatch(/id="quota-full"[^>]*>[^<]*FORGE_CHILD_CPUS=1/); | |
| 88 | expect(page).toContain('id="new-forge"'); | |
| 89 | // The New menu offers the door disabled, with the same sentence. | |
| 90 | const home = await (await fetch(url("/"), { headers: { cookie } })).text(); | |
| 91 | expect(home).toContain("FORGE_CHILD_CPUS=1"); | |
| 92 | // Field errors still render beside their fields. | |
| 93 | const bad = await fetch(url("/forges/new"), { | |
| 94 | method: "POST", | |
| 95 | headers: { cookie, origin: "https://forge.test" }, | |
| 96 | body: new URLSearchParams([["name", "Not A Name"], ["fresh", "x"], ["do", "plan"]]), | |
| 97 | }); | |
| 98 | expect(bad.status).toBe(422); | |
| 99 | expect(await bad.text()).toMatch(/id="f-name-err"[^>]*>[^<]*name must be/); | |
| 100 | }); | |
| 101 | ||
| 102 | test("room for small but not large is refused only for large", async () => { | |
| 103 | clear(); | |
| 104 | process.env["FORGE_CHILD_MEMORY_MIB"] = "2000"; | |
| 105 | expect(quotaRefusal(dir, "small")).toBeUndefined(); | |
| 106 | expect(quotaRefusal(dir, "large")).toContain("memory limit"); | |
| 107 | }); | |
| 108 | }); |
test/tui-newforge-size.test.ts +22
| @@ -105,3 +105,25 @@ test("small, chosen or left alone, records no size", async () => { | ||
| 105 | 105 | expect(back.screen).toContain("The plan"); |
| 106 | 106 | expect(back.size).toBeUndefined(); |
| 107 | 107 | }); |
| 108 | ||
| 109 | test("a full child quota is said when the screen opens, before anything is typed", async () => { | |
| 110 | const terminal = headlessTerminal(120, 40); | |
| 111 | const { app: tui, session } = createForgeApp( | |
| 112 | app, | |
| 113 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: admin }, | |
| 114 | { terminal, exitHooks: false }, | |
| 115 | ); | |
| 116 | process.env["FORGE_CHILD_CPUS"] = "1"; | |
| 117 | await Bun.write(join(dir, "spawn", "held.json"), JSON.stringify({ name: "held", bodyId: "b", size: "small" })); | |
| 118 | try { | |
| 119 | tui.start(); | |
| 120 | tui.ctx.push(newForgeScreen(session, tui.ctx)); | |
| 121 | await settleApp(tui); | |
| 122 | expect(tui.screen.toText()).toContain("cannot hold another child"); | |
| 123 | expect(tui.screen.toText()).toContain("FORGE_CHILD_CPUS=1"); | |
| 124 | expect(tui.screen.toText()).toContain("Name"); | |
| 125 | } finally { | |
| 126 | delete process.env["FORGE_CHILD_CPUS"]; | |
| 127 | tui.stop?.(); | |
| 128 | } | |
| 129 | }); |
new above
offline
This reads the ledger from disk on every page for every person once a limit is set. Memoise it for about a second.