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

Merged opened this

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

Merged

scout/forge:scout/2981-a-spawn-that-would-pass-the-host-s-child → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/2981-a-spawn-that-would-pass-the-host-s-child was merged into main as 3937843.

Review

Sign in to review this change.
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
22022202 members. On `/explore` and `/search` it names the page's own read, `?q=` carried
22032203 over and encoded: `GET /api/v1/explore?q=…` (MCP tool `explore`) or
22042204 `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";
5353 import { loadOrCreateHostKey } from "../ssh/keys.ts";
5454 import { claimLink, hasAdmin, mintClaim, spawnedOrg } from "../spawn/claim.ts";
5555 import { childPublicUrl, parentOrigin, type Placement } from "../spawn/placement.ts";
56 import { quotaRefusal } from "../spawn/quota.ts";
5657 import { planSpawn, type SpawnInput, type SpawnPlan, type SpawnSource } from "../spawn/plan.ts";
5758 import { DEFAULT_SIZE, isSize, type Size } from "../spawn/sizes.ts";
5859 import { chainForChild, DEFAULT_MODE, forgeLinkOf, type FollowMode, type Hop } from "../spawn/chain.ts";
@@ -242,6 +243,8 @@ async function make(
242243 // A retry of an unfinished child is a new attempt: its failure has a start.
243244 app.bus.append({ kind: "forge.spawn.started", payload: { ...(deps.drill === true ? { drill: 1 } : {}) } });
244245 } else {
246 const over = quotaRefusal(app.config.dataDir, plan.size ?? DEFAULT_SIZE);
247 if (over !== undefined) return err({ kind: "refused", reason: "quota", message: over });
245248 const placed = await deps.placement.place(plan);
246249 if (!placed.ok) return err({ kind: "refused", ...placed.error });
247250 if (!SAFE_ID.test(placed.value.bodyId)) {
src/spawn/placement.ts +1 −1
@@ -17,7 +17,7 @@ export interface Placed {
1717 readonly bodyId: string;
1818 }
1919
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 };
2121
2222 export interface Placement {
2323 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 @@
99
1010 import { DEFAULT_SIZE, SIZES, type Size } from "../../spawn/sizes.ts";
1111 import { childUrlOf, spawnDoor } from "../../spawn/door.ts";
12 import { quotaFull } from "../../spawn/quota.ts";
1213 import { MAX_BUDGET_USD, MIN_BUDGET_USD } from "../../spawn/plan.ts";
1314 import type { Ctx, Screen } from "../app/app.ts";
1415 import { binding } from "../app/keys.ts";
@@ -56,6 +57,8 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen {
5657 let error: string | undefined;
5758 let plan: string[] | undefined;
5859 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);
5962
6063 const val = (k: string): string => (values[k] ?? "").trim();
6164
@@ -154,8 +157,13 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen {
154157 binding(["enter"], "spawn (on the plan)"),
155158 binding(["esc"], "back"),
156159 ],
157 render(area, s) {
160 render(full, s) {
158161 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 }
159167 if (plan !== undefined) {
160168 s.text(area.x, area.y, truncate("The plan", area.w), style(c.accent, c.bg), area.x + area.w);
161169 plan.forEach((line, i) => {
src/web/layout.ts +5 −2
@@ -38,6 +38,7 @@ import { prefersJson } from "./form.ts";
3838 import { notePhase } from "../core/route_timing.ts";
3939 import { html, raw, when, type Html } from "./html.ts";
4040 import { noRoom } from "../spawn/door.ts";
41 import { quotaFull } from "../spawn/quota.ts";
4142 import { icon, iconSprite, inlineIcons, themeGlyph } from "./icons.ts";
4243 import { btn, copyField, kbd, menu, type MenuItem } from "./ui.ts";
4344
@@ -172,11 +173,13 @@ const NEW_FORGE: MenuItem = {
172173
173174 /** The door shows to every person; with no room left it is disabled and says why. */
174175 const isPerson = (ctx: Ctx): boolean => ctx.auth.user?.kind === "user";
176 const roomless = (ctx: Ctx): string | undefined =>

This reads the ledger from disk on every page for every person once a limit is set. Memoise it for about a second.

177 noRoom(ctx.app) ?? quotaFull(ctx.app.config.dataDir);
175178 const sendsForges = (ctx: Ctx): boolean =>
176 isPerson(ctx) && noRoom(ctx.app) === undefined;
179 isPerson(ctx) && roomless(ctx) === undefined;
177180 const forgeDoor = (ctx: Ctx): MenuItem[] => {
178181 if (!isPerson(ctx)) return [];
179 const why = noRoom(ctx.app);
182 const why = roomless(ctx);
180183 return [why === undefined ? NEW_FORGE : { ...NEW_FORGE, disabled: why }];
181184 };
182185
src/web/pages/new-forge.ts +8 −3
@@ -14,6 +14,7 @@ import { listReposVisibleTo } from "../../core/repos.ts";
1414 import type { User } from "../../core/users.ts";
1515 import type { Ctx } from "../../http/context.ts";
1616 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
17 import { quotaFull } from "../../spawn/quota.ts";
1718 import { childHost, childUrlOf, noRoom, spawnDoor, type Door } from "../../spawn/door.ts";
1819 import {
1920 LEFT_BEHIND,
@@ -101,13 +102,16 @@ function checks(
101102 /** What the form reads before it draws: room, then (when there is room) the repositories and the crew. */
102103 interface Reads {
103104 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;
104107 readonly repos: ReturnType<typeof listReposVisibleTo>;
105108 readonly crew: ReturnType<typeof listAgents>;
106109 }
107110
108111 function readsFor(ctx: Ctx, user: User | undefined): Reads {
109112 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);
111115 // A stranger sees only public repositories, and no crew: nothing private
112116 // travels to a child they have not claimed.
113117 const repos = timePhase(ctx.request, "repos", () =>
@@ -117,7 +121,7 @@ function readsFor(ctx: Ctx, user: User | undefined): Reads {
117121 user === undefined
118122 ? []
119123 : timePhase(ctx.request, "crew", () => listAgents(ctx.app.db));
120 return { full, repos, crew };
124 return { full, quota, repos, crew };
121125 }
122126
123127 /** The three sizes, the one asked for chosen (small when the value is not one of them). */
@@ -141,7 +145,7 @@ function formView(
141145 plan: Html | null,
142146 reads: Reads,
143147 ): Html {
144 const { full, repos, crew } = reads;
148 const { full, quota, repos, crew } = reads;
145149 if (full)
146150 return html`<div class="narrow-wide">
147151 ${pageHead("New forge")}
@@ -160,6 +164,7 @@ function formView(
160164 : "Your own forge, or one for someone else: the apps you choose, a crew, and one link to claim.",
161165 actions: btn("Forges you sent", { href: "/forges", size: "sm" }),
162166 })}
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.

163168 ${errors["form"]
164169 ? html`<p class="error mb" role="alert">${errors["form"]}</p>`
165170 : 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&#39;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 () => {
105105 expect(back.screen).toContain("The plan");
106106 expect(back.size).toBeUndefined();
107107 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs