Public: anyone can read this; the crew can change it.

A child forge in a cell actually boots on the preview host, and the terminal says where each child runs #6027

Merged opened this

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

Merged

mason/forge:mason/6023-a-child-forge-in-a-cell-actually-boots-o → main 13 commits fork 1 thread · 1 unresolved

  • Merged. mason/6023-a-child-forge-in-a-cell-actually-boots-o was merged into main as bdfa1c0.

Review

Sign in to review this change.
8 files changed +238 −53
docs/SPAWN.md +1
@@ -17,6 +17,7 @@ and user accounts. The child mints its own. A test in #420 and #422 proves it.
1717 | #420 | spawn plan: one validated value, dry-run | #383 |
1818 | #427 | placement seam; hosted inside the parent today | #420 |
1919 | #2926 | `cell` placement: a child as its own `bun src/main.ts` in a cell held to its size (`spawn/cell.ts`); `hosted` where the host cannot hold a cell. The spawn answer and the children list say `placement` | #2925 |
20 | #6023 | a cell child boots inside a forge that is itself in a cell (nothing under the cell's own `/run` is remounted read-only); its cell starts behind the first request, which is answered 503 with `Retry-After` until it is up, and a cell that fails turns the child hosted; the terminal's family lines say where each child runs | #2926 |
2021 | #421 | spawn action: org, instance, apps as repos, briefs | #420, #427 |
2122
2223 `src/actions/spawn.ts` (#421): the child lives in `<data>/children/<bodyId>`; the parent keeps a ledger line in `<data>/spawn/<name>.json` so a retry resumes or answers. Subscription names are in the plan but not yet created in the child (a subscription is a token).
src/apps/contain.ts +43 −20
@@ -390,6 +390,42 @@ function origin(url: string): { host: string; port: number } | undefined {
390390 const isLoopback = (host: string): boolean =>
391391 host === "localhost" || host === "::1" || /^127\./.test(host);
392392
393 /** A path at or under `root`. */
394 const within = (path: string, root: string): boolean => path === root || path.startsWith(`${root}/`);
395
396 /**
397 * What a cell covers and what it shows again, from what the caller names
398 * and what the machine mounts (`hiddenMounts`). Deepest last, so a hidden
399 * directory never covers a writable one inside it. `/tmp` and `/run` are
400 * fresh in every cell, so nothing under them is the machine's: a forge
401 * that itself runs in a cell has its own `/run/forge` mounted there, and
402 * remounting that read-only would leave the inner cell no socket to listen on.
403 */
404 export function cellMounts(
405 spec: Pick<CellSpec, "hide" | "reveal">,
406 machine: readonly string[],
407 kind: (path: string) => "dir" | "file" | undefined = pathKind,
408 ): { hide: string[]; blank: string[]; reveal: string[] } {
409 const hidden = [...new Set([...spec.hide, ...machine])]
410 // A path that is not there has nothing to hide (and cannot be mounted
411 // over on a read-only root).
412 .filter(
413 (path) =>
414 !["", "/"].includes(path) && !within(path, "/tmp") && !within(path, "/run") && kind(path) !== undefined,
415 )
416 .sort((a, b) => a.length - b.length);
417 // A directory is covered by an empty read-only tmpfs; a file (a secret
418 // mounted by itself) by /dev/null.
419 const hide = hidden.filter((path) => kind(path) === "dir");
420 const blank = hidden.filter((path) => kind(path) === "file");
421 // The runtime itself must stay visible when it lives in a hidden home
422 // (`~/.bun/bin/bun` on a developer's machine).
423 const reveal = [dirname(process.execPath), ...(spec.reveal ?? [])].filter((path) =>
424 hide.some((h) => within(path, h)),
425 );
426 return { hide, blank, reveal };
427 }
428
393429 /** Make a cell: its sockets, its proxy, its relays. `close()` undoes it. */
394430 export function openCell(
395431 spec: CellSpec,
@@ -424,26 +460,7 @@ export function openCell(
424460 relay({ tcp: spec.listen }, { unix: join(sockets, "in.sock") }),
425461 );
426462
427 // Deepest last, so a hidden directory never covers a writable one inside it.
428 // What the caller names, plus whatever the machine mounts that may carry
429 // a secret (`hiddenMounts`): a cell never needs either.
430 const hidden = [...new Set([...spec.hide, ...hiddenMounts()])]
431 // A path that is not there has nothing to hide (and cannot be mounted
432 // over on a read-only root). `/tmp` and `/run` are already the cell's own.
433 .filter(
434 (path) =>
435 !["", "/", "/tmp", "/run"].includes(path) && existsSync(path),
436 )
437 .sort((a, b) => a.length - b.length);
438 // A directory is covered by an empty read-only tmpfs; a file (a secret
439 // mounted by itself) by /dev/null.
440 const hide = hidden.filter((path) => isDirectory(path));
441 const blank = hidden.filter((path) => !isDirectory(path));
442 // The runtime itself must stay visible when it lives in a hidden home
443 // (`~/.bun/bin/bun` on a developer's machine).
444 const reveal = [dirname(process.execPath), ...(spec.reveal ?? [])].filter((path) =>
445 hide.some((h) => path === h || path.startsWith(`${h}/`)),
446 );
463 const { hide, blank, reveal } = cellMounts(spec, hiddenMounts());
447464 const writable = [...new Set([spec.dir, spec.home, ...spec.writable])];
448465 for (const path of writable) mkdirSync(path, { recursive: true });
449466 // tmpfs is memory the watch cannot see from outside, so every one is
@@ -541,6 +558,12 @@ export function openCell(
541558 };
542559 }
543560
561 /** What is at `path`: a directory, a file, or nothing. */
562 function pathKind(path: string): "dir" | "file" | undefined {
563 if (!existsSync(path)) return undefined;
564 return isDirectory(path) ? "dir" : "file";
565 }
566
544567 /** Whether `path` is a directory (following links, as bubblewrap will). */
545568 function isDirectory(path: string): boolean {
546569 try {
src/spawn/family.ts +2
@@ -97,6 +97,8 @@ export function familyLines(view: FamilyView): [string, string][] {
9797 c.severedAt !== null ? `follows nothing since ${c.severedAt.slice(0, 10)}` : c.forgeTip === null ? "" : `code at ${c.forgeTip.slice(0, 7)}`,
9898 c.openProposals === null ? "" : `${c.openProposals} open`,
9999 ].filter((b) => b !== "");
100 // Where it runs, as `/forges` says it: "own cell", "hosted", or why it is hosted.
101 if (c.placement !== null) bits.push(c.placement);
100102 out.push([`child ${c.name}`, bits.join(" · ")]);
101103 const follow = followLine(c);
102104 if (follow !== null) out.push(["", follow]);
src/spawn/host.ts +47 −18
@@ -22,7 +22,8 @@ import {
2222 type PlacementKind,
2323 } from "./placement.ts";
2424 import { DEFAULT_SIZE, isSize, type Size } from "./sizes.ts";
25 import { cellChildren, forward } from "./cell.ts";
25 import { cellChildren, forward, type CellChild } from "./cell.ts";
26 import { cellsAvailable } from "../apps/contain.ts";
2627 import { readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2728 import { join } from "node:path";
2829
@@ -118,23 +119,49 @@ export async function resolveHostedChild(
118119 if (name === undefined) return undefined;
119120 const ledger = readLedgerSync(parent, name);
120121 if (ledger === undefined || !ledger.done) return "unknown";
121 // A child in its own cell is not an App here: the request is carried to it.
122 if (ledger.placement === "cell") return settleCell(parent, ledger);
122 // A child in its own cell is not an App here: the request is carried to
123 // it, and while its cell is not up `answerFromCell` says so at once.
124 if (ledger.placement === "cell") {
125 if (cellChildren(parent).get(ledger.bodyId) !== undefined) return "cell";
126 // A host that holds no cell at all is known without waiting.
127 const cells = await cellsAvailable();
128 if (!cells.ok) return openOnce(parent, demoted(parent, ledger, cells.reason));
129 void settleCell(parent, ledger);
130 return "cell";
131 }
123132 return openOnce(parent, ledger);
124133 }
125134
135 /** Starts in flight, by body id: one demotion however many requests asked. */
136 const settling = new WeakMap<App, Map<string, Promise<CellChild | undefined>>>();
137
126138 /**
127139 * Start a child's cell. When the cell cannot come up the child is not left
128 * dead: the ledger turns it hosted, says why (`placementNote`, which
129 * `/forges` lists), and the child is opened in this process.
140 * dead: the ledger turns it hosted and says why (`placementNote`, which
141 * `/forges` lists), and the next request opens it in this process.
130142 */
131 async function settleCell(parent: App, ledger: ChildLedger): Promise<App | "cell"> {
132 const cells = cellChildren(parent);
133 if ((await cells.start(ledger)) !== undefined) return "cell";
134 const why = cells.failure(ledger.bodyId) ?? "its cell did not start";
143 function settleCell(parent: App, ledger: ChildLedger): Promise<CellChild | undefined> {
144 let flights = settling.get(parent);
145 if (!flights) settling.set(parent, (flights = new Map()));
146 let flight = flights.get(ledger.bodyId);
147 if (!flight) {
148 const cells = cellChildren(parent);
149 flight = cells.start(ledger).then((child) => {
150 if (child === undefined) demoted(parent, ledger, cells.failure(ledger.bodyId) ?? "its cell did not start");
151 return child;
152 });
153 const mine = flights;
154 flight.finally(() => mine.delete(ledger.bodyId)).catch(() => {});
155 flights.set(ledger.bodyId, flight);
156 }
157 return flight;
158 }
159
160 /** Record that a cell child is hosted instead, and why; the ledger as it now reads. */
161 function demoted(parent: App, ledger: ChildLedger, why: string): ChildLedger {
135162 demote(parent, ledger.name, why);
136163 parent.log.warn("child forge placed hosted instead", { name: ledger.name, why });
137 return openOnce(parent, { ...ledger, placement: "hosted" });
164 return { ...ledger, placement: "hosted" };
138165 }
139166
140167 function demote(parent: App, name: string, why: string): void {
@@ -174,8 +201,7 @@ export async function bootHostedChild(parent: App, name: string): Promise<void>
174201 let ledger = readLedgerSync(parent, name);
175202 if (ledger === undefined || !ledger.done) return;
176203 if (ledger.placement === "cell") {
177 const settled = await settleCell(parent, ledger);
178 if (settled === "cell") return;
204 if ((await settleCell(parent, ledger)) !== undefined) return;
179205 ledger = { ...ledger, placement: "hosted" };
180206 }
181207 try {
@@ -245,15 +271,18 @@ export async function resolveHostedChildApp(
245271 export const hostedChildApps = childAppsSummary;
246272
247273 /**
248 * Answer a request for a child that runs in its own cell: start it if it is
249 * not up (a parent that restarted, a child that stopped), then forward.
274 * Answer a request for a child that runs in its own cell: forward when it
275 * is up, and when it is not (just spawned, a parent that restarted, a
276 * child that stopped) start it and answer 503 at once.
250277 */
251278 export async function answerFromCell(parent: App, request: Request): Promise<Response> {
252279 const name = parseHostedChildName(request.headers.get("host") ?? "", childHost(parent));
253280 const ledger = name === undefined ? undefined : readLedgerSync(parent, name);
254281 if (ledger === undefined || ledger.placement !== "cell") return new Response("not found", { status: 404 });
255 const child = await cellChildren(parent).start(ledger);
256 // Unreachable once a cell has failed (the ledger turned hosted), but never a hang.
257 if (child === undefined) return new Response("this forge is starting", { status: 503, headers: { "retry-after": "5" } });
258 return forward(child, request, parent.config.clientIpHeader);
282 const child = cellChildren(parent).get(ledger.bodyId);
283 if (child !== undefined) return forward(child, request, parent.config.clientIpHeader);
284 // Never a wait on a cell that may not come up: it starts behind this
285 // answer, and once it has failed the ledger says hosted.
286 void settleCell(parent, ledger);
287 return new Response("this forge is starting", { status: 503, headers: { "retry-after": "5", "cache-control": "no-store" } });
259288 }
test/contain.test.ts +17
@@ -21,6 +21,7 @@ import { openPull, submitReview } from "../src/actions/pulls.ts";
2121 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
2222 import {
2323 cellCounters,
24 cellMounts,
2425 cellsAvailable,
2526 hiddenMounts,
2627 mountMode,
@@ -474,6 +475,22 @@ describe("cells hide every mount that may carry a secret", () => {
474475 ])
475476 expect(hidden).not.toContain(kept);
476477 });
478
479 // A forge that runs in a cell (a preview) has its own sockets at
480 // /run/forge; a child forge's cell inside it remounted them read-only and
481 // never had a socket to listen on (#6023).
482 test("nothing under the cell's own /tmp and /run is hidden again, so a cell inside a cell keeps its sockets", () => {
483 const dirs = new Set(["/data", "/run/forge", "/run/secrets", "/tmp/fc-1", "/etc/plat-ca", "/data/code"]);
484 const kind = (path: string) => (dirs.has(path) ? "dir" : path === "/etc/token" ? "file" : undefined);
485 const mounts = cellMounts(
486 { hide: ["/data"], reveal: ["/data/code"] },
487 ["/run/forge", "/run/secrets", "/tmp", "/tmp/fc-1", "/run", "/etc/plat-ca", "/etc/token", "/var/run/gone"],
488 kind,
489 );
490 expect(mounts.hide).toEqual(["/data", "/etc/plat-ca"]);
491 expect(mounts.blank).toEqual(["/etc/token"]);
492 expect(mounts.reveal).toContain("/data/code");
493 });
477494 });
478495
479496 describe("cells are labelled for the machine sampler (boards/sampler.ts)", () => {
test/family-heartbeat.test.ts +11
@@ -245,6 +245,17 @@ describe("family heartbeats", () => {
245245 expect((await familyView(parent, true, () => ({ total: 0, running: 0, notRunning: [] }))).children[0]?.apps).toBeNull();
246246 });
247247
248 test("the terminal's family lines say where each child runs, as /forges does", async () => {
249 const view = await familyView(parent, true);
250 const lineOf = (v: typeof view) => familyLines(v).find(([k]) => k === "child maria")?.[1] ?? "";
251 expect(view.children[0]?.placement).toBe("hosted");
252 expect(lineOf(view)).toEndWith(" · hosted");
253 const placed = (placement: string) => ({ ...view, children: view.children.map((c) => ({ ...c, placement })) });
254 expect(lineOf(placed("own cell"))).toEndWith(" · own cell");
255 expect(lineOf(placed("hosted: its cell did not come up"))).toEndWith(" · hosted: its cell did not come up");
256 expect(lineOf({ ...view, children: view.children.map((c) => ({ ...c, placement: null })) })).not.toContain("hosted");
257 });
258
248259 test("the terminal's Family card shows the apps line", async () => {
249260 const view = await familyView(parent, true, two);
250261 const { app } = createForgeApp(
test/helpers/real-child-forge.ts added +57
@@ -0,0 +1,57 @@
1 /**
2 * A real child forge in its own cell, as `test/spawn-cell.test.ts` drives
3 * it. Its own process, because a check sets `FORGE_CELLS=off` for the
4 * forges its tests boot (`checks/service.ts`) and the cell probe is asked
5 * once per process; the test starts this one without it.
6 *
7 * The parent holds a finished `cell` ledger for `kid`. The first request
8 * is answered before the cell is up; then the cell is awaited and `/` and
9 * `/healthz` are carried to it. Printed: one JSON line of what was seen.
10 */
11 import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp } from "../../src/app.ts";
15 import { cellCounters, cellsAvailable } from "../../src/apps/contain.ts";
16 import { cellChildren, stopCellChildren } from "../../src/spawn/cell.ts";
17 import { answerFromCell } from "../../src/spawn/host.ts";
18
19 const cells = await cellsAvailable();
20 if (!cells.ok) {
21 console.log(JSON.stringify({ skipped: cells.reason }));
22 process.exit(0);
23 }
24
25 const dataDir = mkdtempSync(join(tmpdir(), "real-child-"));
26 const parent = createApp({ dataDir, url: "https://f.test" });
27 const ledger = { name: "kid", bodyId: "cell-real", size: "small" } as const;
28 mkdirSync(join(dataDir, "spawn"), { recursive: true });
29 writeFileSync(join(dataDir, "spawn", "kid.json"), JSON.stringify({ ...ledger, done: true, placement: "cell" }));
30 const ask = (path: string) => answerFromCell(parent, new Request(`https://kid--f.test${path}`, { headers: { host: "kid--f.test" } }));
31
32 try {
33 const started = performance.now();
34 const first = await ask("/");
35 const firstMs = Math.round(performance.now() - started);
36 const children = cellChildren(parent);
37 const child = await children.start(ledger);
38 const root = child === undefined ? null : (await ask("/")).status;
39 const health = child === undefined ? null : (await ask("/healthz")).status;
40 // The watch reads the cell's tree once a second, by its label.
41 await Bun.sleep(1_500);
42 console.log(
43 JSON.stringify({
44 first: first.status,
45 firstMs,
46 failure: children.failure(ledger.bodyId) ?? null,
47 limits: child?.limits ?? null,
48 root,
49 health,
50 labelled: cellCounters().some((c) => c.label === "forge:kid"),
51 }),
52 );
53 } finally {
54 await stopCellChildren(parent);
55 parent.close();
56 rmSync(dataDir, { recursive: true, force: true });
57 }
test/spawn-cell.test.ts +60 −15
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { askUntilBelieved, cellsAvailable, openCell } from "../src/apps/contain.ts";
6 import { closeHostedChildren, resolveHostedChild } from "../src/spawn/host.ts";
6 import { answerFromCell, closeHostedChildren, resolveHostedChild } from "../src/spawn/host.ts";
77 import { createApp } from "../src/app.ts";
88 import { cellChildren, stopCellChildren, cellEnv, cellLimits, cellPlacement, cellSpecFor, forward, ownsBody } from "../src/spawn/cell.ts";
99 import { fallbackPlacement, hostedPlacement, memoryBodies } from "../src/spawn/placement.ts";
@@ -11,6 +11,9 @@ import type { SpawnPlan } from "../src/spawn/plan.ts";
1111 import { SIZES } from "../src/spawn/sizes.ts";
1212 import { notePeer } from "../src/http/peer.ts";
1313 import type { App } from "../src/app.ts";
14 import { portInsteadOfSocket } from "../src/git/hook-address.ts";
15 import { missing } from "./helpers/tools.ts";
16 import { expectBudget } from "./helpers/budget.ts";
1417 import { openBeatDoor, parentLocal } from "../src/spawn/beat-door.ts";
1518 import { lastHeard, sendBeat } from "../src/spawn/heartbeat.ts";
1619
@@ -251,23 +254,36 @@ describe("a child that will not start", () => {
251254 });
252255 });
253256
257 // Where bwrap and socat are here, whatever FORGE_CELLS says: a check turns
258 // cells off for the forges its tests boot, and this is the one test that
259 // must boot a child in a cell anyway (#6023). In its own process, without it.
260 const cellTools = process.platform === "linux" && !portInsteadOfSocket() && !missing("bwrap", "socat");
261
254262 describe("a real child forge", () => {
255 test.skipIf(!cells.ok)("boots in its cell and answers /healthz through the forward", async () => {
256 const parent = createApp({ dataDir: join(tmp, "real-parent"), url: "https://f.test" });
263 test.skipIf(!cellTools)("boots in its own forge:<name> cell and answers / through the forward; its first request does not wait for it", async () => {
264 const env: Record<string, string> = {};
265 for (const [k, v] of Object.entries(process.env)) if (v !== undefined && k !== "FORGE_CELLS") env[k] = v;
266 const proc = Bun.spawn([process.execPath, join(import.meta.dir, "helpers", "real-child-forge.ts")], { env, stdout: "pipe", stderr: "pipe" });
267 const [out, errText] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
268 const line = out.trim().split("\n").pop() ?? "";
269 let seen: Record<string, unknown>;
257270 try {
258 const children = cellChildren(parent);
259 const child = await children.start({ bodyId: "cell-real", name: "kid", size: "small" });
260 expect(children.failure("cell-real")).toBeUndefined();
261 if (child === undefined) throw new Error(`the child did not start: ${children.failure("cell-real")}`);
262 expect(child.limits).toEqual({ memoryMiB: 512, cpus: 1, diskMiB: 2048 });
263 const res = await forward(child, new Request("http://kid--f.test/healthz", { headers: { host: "kid--f.test" } }));
264 expect(res.status).toBe(200);
265 await children.stop("cell-real");
266 } finally {
267 await stopCellChildren(parent);
268 parent.close();
271 seen = JSON.parse(line) as Record<string, unknown>;
272 } catch {
273 throw new Error(`the child forge run printed no result: ${out.slice(-800)} ${errText.slice(-800)}`);
269274 }
270 }, 60_000);
275 if (typeof seen["skipped"] === "string") {
276 console.log(`SKIPPING: this host refuses a cell inside the test (${seen["skipped"]}), so no child forge was booted in one`);
277 return;
278 }
279 expect(seen["failure"]).toBeNull();
280 expect(seen["first"]).toBe(503);
281 expect(seen["firstMs"] as number).toBeLessThan(1_000);
282 expect(seen["limits"]).toEqual({ memoryMiB: 512, cpus: 1, diskMiB: 2048 });
283 expect(seen["root"] as number).toBeLessThan(400);
284 expect(seen["health"]).toBe(200);
285 expect(seen["labelled"]).toBe(true);
286 }, 90_000);
271287
272288 test.skipIf(!cells.ok)("its heartbeat reaches the parent through the cell, with no public address", async () => {
273289 // The parent's public url is not reachable from here, as in a preview's cell.
@@ -313,4 +329,33 @@ describe("a child whose cell will not come up", () => {
313329 parent.close();
314330 }
315331 });
332
333 test.skipIf(cells.ok)("its first request is answered 503 at once, and once the cell has failed the next is hosted", async () => {
334 const dataDir = join(tmp, "fast-parent");
335 const parent = createApp({ dataDir, url: "https://f.test" });
336 const request = () => new Request("https://kid--f.test/", { headers: { host: "kid--f.test" } });
337 try {
338 mkdirSync(join(dataDir, "spawn"), { recursive: true });
339 const file = join(dataDir, "spawn", "kid.json");
340 writeFileSync(file, JSON.stringify({ name: "kid", bodyId: "cell-f", done: true, placement: "cell", size: "small" }));
341 // The baseline: a child with no ledger, answered from the same read with no start.
342 const unknown = performance.now();
343 expect((await answerFromCell(parent, new Request("https://nobody--f.test/", { headers: { host: "nobody--f.test" } }))).status).toBe(404);
344 const against = performance.now() - unknown;
345 const started = performance.now();
346 const first = await answerFromCell(parent, request());
347 expectBudget(performance.now() - started, { ms: 50, against });
348 expect(first.status).toBe(503);
349 expect(first.headers.get("retry-after")).toBe("5");
350 // The start it set off fails here (no cells), and the ledger turns hosted.
351 for (let i = 0; i < 50 && JSON.parse(readFileSync(file, "utf8")).placement !== "hosted"; i++) await Bun.sleep(10);
352 expect(JSON.parse(readFileSync(file, "utf8")).placement).toBe("hosted");
353 const next = await resolveHostedChild(parent, request());
354 expect(next).not.toBe("cell");
355 expect(next).not.toBe("unknown");
356 } finally {
357 await closeHostedChildren(parent);
358 parent.close();
359 }
360 });
316361 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs