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

/capacity and MCP `capacity` say whether this forge can swap itself, and why not: tcp_migrate_req, SSH held, or FORGE_SELF_SWAP off #8403

Merged opened this

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

Merged

scout/forge:scout/8383-capacity-and-mcp-capacity-say-whether-th → main 3 commits fork

  • Merged. scout/8383-capacity-and-mcp-capacity-say-whether-th was merged into main as 892851f.

Review

Sign in to review this change.
8 files changed +253 −10
docs/features/ship-itself.md +2
@@ -42,3 +42,5 @@ A child needs `FORGE_REPO`, `FORGE_SELF_SWAP=1`, `FORGE_RELEASE_KEEPER=swap`. A
4242 The drill, reading `/releases` after each step (version, landed to live, probes, seconds dark): (1) land a pull and wait for the cut; (2) it swaps and verifies; (3) land a pull that boots but fails the verify (`/healthz` answers 500) and watch it swap back by name and hold; (4) restart the pod and see it return on the last good tree (`release/good.json`). The four dark-second figures go into the pull that proves it.
4343
4444 **`tcp_migrate_req`.** Both processes bind the port with SO_REUSEPORT; when the old closes its listener the kernel resets what was queued on it unless `tcp_migrate_req=1` moves those connections to the other listener (Linux 5.14+). Without it `swapBlocked` refuses the cut and the release goes out by restart. The setting is per network namespace; a pod gets it through `securityContext.sysctls` (the kubelet needs `--allowed-unsafe-sysctls=net.ipv4.tcp_migrate_req`) or a node-level setting. This is not verified here.
45
46 **To check a child, open its /capacity.** The admin page's Self-swap row says `ready` or why not (the SSH terminal held, `tcp_migrate_req` off, or `FORGE_SELF_SWAP` off), whether `FORGE_SELF_SWAP` is set, and the kernel's `tcp_migrate_req` as read: `1`, `0` or `unreadable`. The terminal's Capacity screen shows the same row, and the MCP `capacity` tool carries it as `selfSwap: { enabled, migrateReq, blocked }` (#8383).
src/actions/capacity.ts +6 −1
@@ -24,6 +24,7 @@ import {
2424 } from "../core/capacity.ts";
2525 import { egressTotals, npmBytesLastHour, npmTopRepoLastHour, type EgressTotal } from "../apps/egress.ts";
2626 import { getRepoById } from "../core/repos.ts";
27 import { selfSwapState, type SelfSwap } from "../release/swap.ts";
2728 import { passkeyIds } from "../core/passkeys.ts";
2829 import type { User } from "../core/users.ts";
2930 import { err, ok, type Result } from "../kernel/result.ts";
@@ -100,7 +101,10 @@ export function changedOf(db: Database, key: CapacityKey): { changed?: { at: str
100101 export function listCapacity(
101102 app: App,
102103 actor: User | undefined,
103 ): Result<{ settings: CapacityRow[]; changes: CapacityChange[]; egress: EgressHour }, "not_found"> {
104 ): Result<
105 { settings: CapacityRow[]; changes: CapacityChange[]; egress: EgressHour; selfSwap: SelfSwap },
106 "not_found"
107 > {
104108 const who = admin(actor);
105109 if (!who.ok) return who;
106110 return ok({
@@ -116,6 +120,7 @@ export function listCapacity(
116120 })),
117121 changes: capacityChanges(app.db),
118122 egress: egressHour(app.db),
123 selfSwap: selfSwapState(app.config.selfSwap === true),
119124 });
120125 }
121126
src/http/mcp/tools/capacity.ts +1 −1
@@ -5,7 +5,7 @@ import { good, bad, type Tool } from "../shared.ts";
55
66 export const tool: Tool = {
77 name: "capacity",
8 doc: "`{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges, `egress.npmBytesLastHour` and `npmTopRepoLastHour`; only the first six are settable by `key`); with `key` and `value` (`\"\"` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token",
8 doc: "`{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges, `egress.npmBytesLastHour` and `npmTopRepoLastHour`, and `selfSwap: { enabled, migrateReq, blocked }`: whether `FORGE_SELF_SWAP=1`, the kernel's `tcp_migrate_req` (`1`, `0`, `unreadable`), and why this forge cannot swap itself, null when it can; only the first six are settable by `key`); with `key` and `value` (`\"\"` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token",
99 description:
1010 "Forge capacity and guards, live. With `key` and `value` (\"\" restores default), plans.",
1111 inputSchema: {
src/release/swap.ts +28 −3
@@ -145,14 +145,39 @@ const MIGRATE_SYSCTL = "/proc/sys/net/ipv4/tcp_migrate_req";
145145 * the swap would fail requests. `handover.migrate` stands in for the file in tests.
146146 */
147147 export function migratesQueuedConnections(): boolean {
148 if (handover.migrate !== undefined) return handover.migrate;
148 return migrateReq() === "1";
149 }
150
151 /** The kernel's `tcp_migrate_req` as read: `1`, `0` (or any other value), or `unreadable`. */
152 export function migrateReq(): "1" | "0" | "unreadable" {
153 if (handover.migrate !== undefined) return handover.migrate ? "1" : "0";
149154 try {
150 return readFileSync(MIGRATE_SYSCTL, "utf8").trim() === "1";
155 return readFileSync(MIGRATE_SYSCTL, "utf8").trim() === "1" ? "1" : "0";
151156 } catch {
152 return false;
157 return "unreadable";
153158 }
154159 }
155160
161 export interface SelfSwap {
162 /** Whether `FORGE_SELF_SWAP=1` was set at boot. */
163 readonly enabled: boolean;
164 readonly migrateReq: "1" | "0" | "unreadable";
165 /** Why this forge cannot swap itself; null when it can. */
166 readonly blocked: string | null;
167 }
168
169 /**
170 * Whether this forge can swap itself, as plain data for /capacity and the
171 * MCP `capacity` tool (#8383): what `beginSwap` would refuse, and why, before
172 * a landing is refused for it. `enabled` is the app's `selfSwap`.
173 */
174 export function selfSwapState(enabled: boolean): SelfSwap {
175 const blocked =
176 swapBlocked() ??
177 (enabled ? null : "FORGE_SELF_SWAP is off, so a landed release waits for a restart; set FORGE_SELF_SWAP=1 to swap");
178 return { enabled, migrateReq: migrateReq(), blocked };
179 }
180
156181 let underWay = false;
157182
158183 /**
src/tui/screens/capacity.ts +18 −2
@@ -5,6 +5,7 @@
55 * through the same actions as MCP `capacity`. The actions decide who may.
66 */
77
8 import type { SelfSwap } from "../../release/swap.ts";
89 import { changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts";
910 import { SETTING_WORDS } from "../../actions/setting_words.ts";
1011 import { CAPACITY, validCapacity, type CapacityKey } from "../../core/capacity.ts";
@@ -34,6 +35,16 @@ const refused = (error: "not_found" | "no_such_setting" | "invalid_value" | "no_
3435 ? SETTING_WORDS.outOfRange
3536 : "no such capacity setting";
3637
38 /** Whether this forge can swap itself (#8383), as /settings/capacity says it. */
39 export function selfSwapRow(s: SelfSwap): ListRow {
40 return {
41 title: "Self-swap",
42 tone: s.blocked === null ? "success" : "attn",
43 meta: `${s.blocked === null ? "ready" : "blocked"} · FORGE_SELF_SWAP ${s.enabled ? "set" : "off"} · tcp_migrate_req ${s.migrateReq}`,
44 ...(s.blocked === null ? {} : { below: [s.blocked] }),
45 };
46 }
47
3748 const shown = (v: string): string => (v === "" ? "auto" : v);
3849
3950 export function capacityScreen(f: Session, ctx: Ctx): Screen {
@@ -42,11 +53,16 @@ export function capacityScreen(f: Session, ctx: Ctx): Screen {
4253 const listed = listCapacity(f.app, f.user);
4354 return listed.ok ? listed.value.settings : [];
4455 };
45 const lines = (): ListRow[] =>
46 rows().map((r) => ({
56 const lines = (): ListRow[] => {
57 const listed = listCapacity(f.app, f.user);
58 if (!listed.ok) return [];
59 const guards = listed.value.settings.map((r) => ({
4760 title: `${r.label} (${r.key})`,
4861 meta: `${shown(r.value)} · ${r.source} · takes ${boundsOf(r.key)}${r.source === "setting" ? ` · default ${shown(r.default)}` : ""}`,
4962 }));
63 // After the guards, so the cursor (bounded by the settings) never lands on it.
64 return [...guards, selfSwapRow(listed.value.selfSwap)];
65 };
5066
5167 const plan = (row: CapacityRow, typed: string): void => {
5268 const to = typed === "" ? row.default : typed;
src/web/pages/capacity.ts +24 −3
@@ -16,7 +16,7 @@ import { notFoundPage, signInFirst } from "../chrome.ts";
1616 import { field, readForm } from "../form.ts";
1717 import { html, when } from "../html.ts";
1818 import { flashTo } from "../layout.ts";
19 import { btn, card, icon, row, time } from "../ui.ts";
19 import { badge, btn, card, icon, row, time } from "../ui.ts";
2020 import { usageShell } from "./usage-tabs.ts";
2121
2222 function requireAdmin(ctx: Ctx): Response | undefined {
@@ -76,7 +76,7 @@ export function capacityPage(ctx: Ctx): Response {
7676 listCapacity(ctx.app, ctx.auth.user),
7777 );
7878 if (!listed.ok) return notFoundPage(ctx);
79 const { settings, changes, egress } = listed.value;
79 const { settings, changes, egress, selfSwap } = listed.value;
8080 const errs = timePhase(ctx.request, "errors", () =>
8181 ctx.app.signals.errors.recent(10),
8282 );
@@ -193,7 +193,28 @@ export function capacityPage(ctx: Ctx): Response {
193193 pad: false,
194194 },
195195 );
196 const body = html`${happened}${connections}${when(
196 // Whether a landing here can swap the forge in place (#8383): the reason, before one is refused.
197 const swap = card(
198 html`<ul class="list">
199 ${row({
200 id: "self-swap",
201 title: "Self-swap",
202 meta: html`${selfSwap.blocked ?? "A landed release of the forge takes over the port in place."}
203 · <span class="mono">FORGE_SELF_SWAP</span> ${selfSwap.enabled ? "is set" : "is off"}
204 · <span class="mono">net.ipv4.tcp_migrate_req</span> reads
205 <span class="mono">${selfSwap.migrateReq}</span>`,
206 trail: badge(selfSwap.blocked === null ? "ready" : "blocked", {
207 tone: selfSwap.blocked === null ? "success" : "attn",
208 }),
209 })}
210 </ul>`,
211 {
212 head: html`${icon("activity")}
213 <h2>Self-swap</h2>`,
214 pad: false,
215 },
216 );
217 const body = html`${happened}${connections}${swap}${when(
197218 reported,
198219 () => html`<p class="muted small mb" id="egress-reported">
199220 ${icon("activity")} Egress is reported, not enforced: every new app,
test/capacity-selfswap.test.ts added +138
@@ -0,0 +1,138 @@
1 /**
2 * /capacity and MCP `capacity` say whether this forge can swap itself, and
3 * why not (#8383): ready, the kernel not migrating queued connections, the
4 * SSH terminal held, or FORGE_SELF_SWAP off. `handover` stands in for the
5 * kernel file and the SSH listener.
6 */
7 import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { createToken } from "../src/core/tokens.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { handover, selfSwapState } from "../src/release/swap.ts";
16 import { seedAdmin } from "./helpers/admin.ts";
17
18 let dataDir = "";
19 let app: App, servers: Servers, bearer = "";
20
21 beforeAll(async () => {
22 dataDir = await mkdtemp(join(tmpdir(), "forge-selfswap-"));
23 app = createApp({ dataDir, port: 0, selfSwap: true });
24 servers = await serve(app);
25 const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
26 if (!admin.ok) throw new Error(admin.error);
27 const minted = await fetch(`${servers.url}/api/v1/tokens`, {
28 method: "POST",
29 headers: { "content-type": "application/json" },
30 body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }),
31 });
32 bearer = ((await minted.json()) as { token: string }).token;
33 });
34
35 afterEach(() => {
36 delete handover.migrate;
37 delete handover.ssh;
38 });
39
40 afterAll(async () => {
41 servers.stop();
42 app.close();
43 await rm(dataDir, { recursive: true, force: true });
44 });
45
46 const page = async (url = servers.url, token = bearer): Promise<string> => {
47 const res = await fetch(`${url}/settings/capacity`, { headers: { authorization: `Bearer ${token}` } });
48 expect(res.status).toBe(200);
49 const body = await res.text();
50 const at = body.indexOf('id="self-swap"');
51 expect(at).toBeGreaterThan(-1);
52 return body.slice(at, body.indexOf("</li>", at));
53 };
54
55 const call = async (url: string, token: string) => {
56 const res = await fetch(`${url}/mcp`, {
57 method: "POST",
58 headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
59 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "capacity", arguments: {} } }),
60 });
61 return ((await res.json()) as { result: { isError?: boolean; content: { text: string }[] } }).result;
62 };
63
64 const tool = async (url = servers.url, token = bearer): Promise<{ enabled: boolean; migrateReq: string; blocked: string | null }> => {
65 const result = await call(url, token);
66 expect(result.isError).not.toBe(true);
67 return (JSON.parse(result.content[0]!.text) as { selfSwap: never }).selfSwap;
68 };
69
70 describe("self-swap on /capacity and MCP capacity", () => {
71 test("ready: enabled, the kernel migrates, no SSH held", async () => {
72 handover.migrate = true;
73 handover.ssh = false;
74 expect(await tool()).toEqual({ enabled: true, migrateReq: "1", blocked: null });
75 const row = await page();
76 expect(row).toContain("ready");
77 expect(row).toContain("is set");
78 expect(row).toMatch(/tcp_migrate_req<\/span> reads\s*<span class="mono">1</);
79 });
80
81 test("kernel off: tcp_migrate_req 0 and the sentence that names it", async () => {
82 handover.migrate = false;
83 const s = await tool();
84 expect(s.migrateReq).toBe("0");
85 expect(s.blocked).toContain("net.ipv4.tcp_migrate_req=1");
86 const row = await page();
87 expect(row).toContain("blocked");
88 expect(row).toContain("net.ipv4.tcp_migrate_req=1 (Linux 5.14+)");
89 expect(row).toMatch(/reads\s*<span class="mono">0</);
90 });
91
92 test("SSH held: the terminal is named, whatever the kernel says", async () => {
93 handover.migrate = true;
94 handover.ssh = true;
95 const s = await tool();
96 expect(s.migrateReq).toBe("1");
97 expect(s.blocked).toContain("holds the SSH terminal");
98 expect(await page()).toContain("holds the SSH terminal");
99 });
100
101 test("FORGE_SELF_SWAP off is a reason of its own, on the page and the tool", async () => {
102 handover.migrate = true;
103 handover.ssh = false;
104 expect(selfSwapState(false).blocked).toContain("FORGE_SELF_SWAP is off");
105 const dir = await mkdtemp(join(tmpdir(), "forge-selfswap-off-"));
106 const off = createApp({ dataDir: dir, port: 0 });
107 const offServers = await serve(off);
108 try {
109 const made = await seedAdmin(off, { username: "dana", email: "[email protected]", password: "correct-horse-battery" });
110 if (!made.ok) throw new Error(made.error);
111 const token = createToken(off.db, made.value.id, "t", "admin").token;
112 const s = await tool(offServers.url, token);
113 expect(s.enabled).toBe(false);
114 expect(s.migrateReq).toBe("1");
115 expect(s.blocked).toContain("FORGE_SELF_SWAP is off");
116 const row = await page(offServers.url, token);
117 expect(row).toContain("FORGE_SELF_SWAP is off");
118 expect(row).toContain("is off");
119 expect(row).toContain("blocked");
120 } finally {
121 offServers.stop();
122 off.close();
123 await rm(dir, { recursive: true, force: true });
124 }
125 });
126
127 test("a member gets no selfSwap from the tool and a 404 from the page", async () => {
128 const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
129 if (!bob.ok) throw new Error(bob.error);
130 const token = createToken(app.db, bob.value.id, "t", "admin").token;
131 const result = await call(servers.url, token);
132 expect(result.isError).toBe(true);
133 expect(result.content[0]!.text).not.toContain("selfSwap");
134 const res = await fetch(`${servers.url}/settings/capacity`, { headers: { authorization: `Bearer ${token}` } });
135 expect(res.status).toBe(404);
136 expect(await res.text()).not.toContain("self-swap");
137 });
138 });
test/tui-capacity.test.ts +36
@@ -15,6 +15,7 @@ import { CAPACITY_KEYS, capacityValue } from "../src/core/capacity.ts";
1515 import { createUser, type User } from "../src/core/users.ts";
1616 import { createForgeApp, forgeCommands } from "../src/tui/forge.ts";
1717 import { capacityScreen } from "../src/tui/screens/capacity.ts";
18 import { handover } from "../src/release/swap.ts";
1819 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1920 import { quietLogs } from "./helpers/globals.ts";
2021 import { settleApp } from "./helpers/tui.ts";
@@ -182,4 +183,39 @@ describe("tui capacity", () => {
182183 h.stop();
183184 }
184185 });
186
187 test("shows whether the forge can swap itself, and why not (#8383)", async () => {
188 try {
189 handover.migrate = true;
190 handover.ssh = false;
191 let h = boot(carol);
192 await h.press();
193 // This app was built without selfSwap: FORGE_SELF_SWAP is off.
194 expect(h.text()).toContain("Self-swap");
195 expect(h.text()).toContain("blocked · FORGE_SELF_SWAP off · tcp_migrate_req 1");
196 expect(h.text()).toContain("FORGE_SELF_SWAP is off");
197 h.stop();
198 handover.migrate = false;
199 h = boot(carol);
200 await h.press();
201 expect(h.text()).toContain("tcp_migrate_req 0");
202 expect(h.text()).toContain("net.ipv4.tcp_migrate_req=1");
203 h.stop();
204 handover.migrate = true;
205 handover.ssh = true;
206 h = boot(carol);
207 await h.press();
208 expect(h.text()).toContain("holds the SSH terminal");
209 h.stop();
210 for (const who of [bob, bot]) {
211 const m = boot(who);
212 await m.press();
213 expect(m.text()).not.toContain("Self-swap");
214 m.stop();
215 }
216 } finally {
217 delete handover.migrate;
218 delete handover.ssh;
219 }
220 });
185221 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs