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

The release proof removes the forges and accounts it makes, pass or fail #12141

Merged opened this in answer to

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

Merged

torque/forge:torque/12139-the-release-proof-removes-the-forges-and → main 5 commits fork

  • Merged. torque/12139-the-release-proof-removes-the-forges-and was merged into main as ba8d50c.

Review

Sign in to review this change.
2 files changed +205 −11
src/release/journey.ts +101 −8
@@ -55,8 +55,25 @@ export interface JourneyResult {
5555 readonly forge: string | null;
5656 /** Whether the throwaway forge was removed afterwards (true when none was made). */
5757 readonly removed: boolean;
58 /** `probe-` forges an interrupted run left (older than `STALE_PROBE_MS`) this run removed. */
59 readonly swept?: readonly string[];
60 /** Why the sweep stopped early, when it did; the walk's verdict never depends on it. */
61 readonly sweepError?: string;
5862 }
5963
64 /** The sweep as a whole may take this long, however many it finds: it never slows the proof. */
65 export const SWEEP_BUDGET_MS = 5_000;
66 /** At most this many stale forges are removed per run; a host with more clears over several. */
67 export const SWEEP_MAX = 5;
68 /** Pauses between removal attempts: a transient failure is not hit again within milliseconds. */
69 export const REMOVE_BACKOFF_MS: readonly number[] = [250, 1000];
70
71 /** A probe forge older than this was left by a run that never finished: an hour. */
72 export const STALE_PROBE_MS = 60 * 60 * 1000;
73
74 /** What a probe forge is called; its account goes with it (the forge's data is its only home). */
75 const PROBE_NAME = /^probe-[0-9a-f]{8}$/;
76
6077 /** The longest any one request may take. */
6178 export const REQUEST_TIMEOUT_MS = 10_000;
6279
@@ -240,17 +257,76 @@ export async function walkJourney(options: JourneyOptions): Promise<JourneyResul
240257 }
241258 } catch (e) {
242259 failure = e instanceof Step ? e.message : "the walk stopped unexpectedly";
260 } finally {
261 // Afterwards, passed or failed: stop the crew's run, then remove the forge and its account.
262 if (forge !== null) {
263 if (child !== undefined && cookie !== "") await cancelRuns(doFetch, child, cookie);
264 removed = await removeForge(doFetch, hostUrl, forge, options.adminToken, sleep);
265 }
243266 }
244
245 // Afterwards, passed or failed: stop the crew's run, then remove the forge.
246 if (forge !== null) {
247 if (child !== undefined && cookie !== "") await cancelRuns(doFetch, child, cookie);
248 removed = await removeForge(doFetch, hostUrl, forge, options.adminToken);
249 }
250 const result = { ok: failure === undefined, screens, readyMs, firstActivityMs, forge, removed };
267 // What an interrupted run left: swept after this walk's own cleanup, never its own forge.
268 // Bounded (`SWEEP_BUDGET_MS`, `SWEEP_MAX`) and never part of the verdict.
269 const sweep = await sweepProbes(doFetch, hostUrl, options.adminToken, Date.now(), forge, sleep);
270 const result = {
271 ok: failure === undefined,
272 screens,
273 readyMs,
274 firstActivityMs,
275 forge,
276 removed,
277 swept: sweep.gone,
278 ...(sweep.error === undefined ? {} : { sweepError: sweep.error }),
279 };
251280 return failure === undefined ? result : { ...result, failure };
252281 }
253282
283 /**
284 * Removes the `probe-` forges the host lists that were begun more than an hour ago
285 * (`STALE_PROBE_MS`): what a run killed or timed out part-way left behind. The scope is
286 * this proof's own namespace: `probe-` plus eight hex digits is the name `probeName` gives
287 * a throwaway forge, and an hour is far past the proof's own budget, so a run still in
288 * progress is never touched. A forge with no begun-at, a name of any other shape, and
289 * `except` are left alone. Reads `GET /api/v1/forges` (an admin's token). Never throws: what
290 * stopped it comes back in `error`. `at` is the wall clock in ms (the forge's `createdAt` is
291 * wall-clock time, not the walk's monotonic one). Bounded by `SWEEP_BUDGET_MS` and `SWEEP_MAX`.
292 */
293 export async function sweepProbes(
294 doFetch: typeof fetch,
295 host: URL,
296 token: string | undefined,
297 at: number,
298 except: string | null = null,
299 sleep: (ms: number) => Promise<void> = (ms) => Bun.sleep(ms),
300 ): Promise<{ gone: string[]; error?: string }> {
301 const gone: string[] = [];
302 if (token === undefined || token === "") return { gone };
303 const deadline = AbortSignal.timeout(SWEEP_BUDGET_MS);
304 try {
305 const res = await doFetch(new URL("/api/v1/forges", host).href, {
306 redirect: "manual",
307 signal: deadline,
308 headers: { authorization: `Bearer ${token}`, accept: "application/json" },
309 });
310 if (res.status !== 200) {
311 await res.body?.cancel();
312 return { gone, error: `the forge list answered ${res.status}` };
313 }
314 const items = ((await res.json()) as { items?: unknown }).items;
315 if (!Array.isArray(items)) return { gone, error: "the forge list had no items" };
316 for (const item of items as { name?: unknown; createdAt?: unknown }[]) {
317 const { name, createdAt } = item ?? {};
318 if (typeof name !== "string" || !PROBE_NAME.test(name) || name === except) continue;
319 const begun = typeof createdAt === "string" ? Date.parse(createdAt) : Number.NaN;
320 if (!Number.isFinite(begun) || at - begun < STALE_PROBE_MS) continue;
321 if (gone.length >= SWEEP_MAX || deadline.aborted) break;
322 if (await removeForge(doFetch, host, name, token, sleep, [])) gone.push(name);
323 }
324 return { gone };
325 } catch (e) {
326 return { gone, error: e instanceof Error ? e.message : "the sweep stopped unexpectedly" };
327 }
328 }
329
254330 /** Cancels each run the forge's home links to; a miss is never an error (the forge's removal ends them anyway). */
255331 async function cancelRuns(doFetch: typeof fetch, child: URL, cookie: string): Promise<void> {
256332 try {
@@ -266,8 +342,25 @@ async function cancelRuns(doFetch: typeof fetch, child: URL, cookie: string): Pr
266342 }
267343
268344 /** Removes the throwaway forge from the host; true when the host said it is gone. */
269 async function removeForge(doFetch: typeof fetch, host: URL, name: string, token: string | undefined): Promise<boolean> {
345 async function removeForge(
346 doFetch: typeof fetch,
347 host: URL,
348 name: string,
349 token: string | undefined,
350 sleep: (ms: number) => Promise<void>,
351 backoff: readonly number[] = REMOVE_BACKOFF_MS,
352 ): Promise<boolean> {
270353 if (token === undefined || token === "") return false;
354 if (await removeOnce(doFetch, host, name, token)) return true;
355 // Each retry waits first (`backoff`); the sweep passes none, so a stale forge is tried once.
356 for (const ms of backoff) {
357 await sleep(ms);
358 if (await removeOnce(doFetch, host, name, token)) return true;
359 }
360 return false;
361 }
362
363 async function removeOnce(doFetch: typeof fetch, host: URL, name: string, token: string): Promise<boolean> {
271364 try {
272365 const url = new URL(`/forges/${name}/remove`, host);
273366 const res = await doFetch(url.href, {
test/release-journey.test.ts +104 −3
@@ -13,11 +13,12 @@ import { childDirOf, childNamed } from "../src/actions/spawn.ts";
1313 import { createApp, type App } from "../src/app.ts";
1414 import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts";
1515 import { createToken } from "../src/core/tokens.ts";
16 import { useChildFetch } from "../src/spawn/clients.ts";
1617 import { addSubscription } from "../src/core/subscriptions.ts";
1718 import { createUser } from "../src/core/users.ts";
1819 import { FIRST_FIVE_MINUTES } from "../src/design/first_five_minutes.ts";
1920 import { serve, type Servers } from "../src/http/server.ts";
20 import { walkJourney } from "../src/release/journey.ts";
21 import { REMOVE_BACKOFF_MS, sweepProbes, walkJourney } from "../src/release/journey.ts";
2122 import { useProbe } from "../src/spawn/door.ts";
2223 import { firstBoot } from "../src/spawn/first-boot.ts";
2324 import { quietLogs } from "./helpers/globals.ts";
@@ -28,7 +29,7 @@ let dir = "";
2829 let parent: App;
2930 let parentServers: Servers;
3031 let adminToken = "";
31 const kids = new Map<string, { app: App; servers: Servers; crew: CrewHandle }>();
32 const kids = new Map<string, { app: App; servers: Servers; crew: CrewHandle; dataDir: string }>();
3233 /** A step that answers 503, as `METHOD path` on the child. */
3334 let broken: string | undefined;
3435
@@ -36,6 +37,8 @@ beforeAll(async () => {
3637 dir = await mkdtemp(join(tmpdir(), "forge-journey-"));
3738 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
3839 useProbe(parent, () => async () => true);
40 // The forge list asks each child about itself; none of those asks leaves this machine.
41 useChildFetch(parent, async () => new Response("{}", { status: 503 }));
3942 const alice = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
4043 if (!alice.ok) throw new Error(alice.error);
4144 adminToken = createToken(parent.db, alice.value.id, "journey", "admin").token;
@@ -76,7 +79,7 @@ sleep 20
7679 );
7780 chmodSync(fake, 0o755);
7881 const crew = await startCrew(app, { forgeUrl: servers.url, localUrl: servers.url, binary: fake, concurrency: 1, budgetUsd: 1, runTimeoutMs: 30_000, networkRetryScale: 0, tickMs: 100 });
79 const k = { app, servers, crew };
82 const k = { app, servers, crew, dataDir };
8083 kids.set(name, k);
8184 return k;
8285 }
@@ -159,3 +162,101 @@ test("an address that is not http(s) is refused before anything is sent", async
159162 const result = await walkJourney({ base: "ftp://forge.test", fetch: (() => { throw new Error("no request"); }) as unknown as typeof fetch });
160163 expect(result).toMatchObject({ ok: false, forge: null, removed: true, failure: "the forge's address is not http or https" });
161164 });
165
166 test("a walk that blows up half-way (outside any step) still removes its forge", async () => {
167 let calls = 0;
168 const log: string[] = [];
169 const inner = routed(log);
170 // `now` throws once the account exists: nothing in the walk catches it as a step.
171 const now = () => {
172 if (log.includes("POST /claim")) throw new Error("clock exploded");
173 return (calls += 1);
174 };
175 const result = await walkJourney({ base: parentServers.url, fetch: inner, adminToken, now, sleep: () => Bun.sleep(100), pollMs: 100 }); // real-timer: the stand-in crew child runs in real time
176 // The walk does not reject: it says what stopped it, and cleans up all the same.
177 expect(result.ok).toBe(false);
178 expect(result.failure).toBe("the walk stopped unexpectedly");
179 expect(result.removed).toBe(true);
180 expect(log).toContain("POST /claim");
181 const name = [...kids.keys()].at(-1)!;
182 expect(await forgeGone(name)).toBe(true);
183 // The account lived in the forge's own data: that directory is gone, and the account with it.
184 expect(existsSync(kids.get(name)!.dataDir)).toBe(false);
185 }, 60_000);
186
187 test("a run sweeps the probe forges an interrupted run left over an hour ago, and only those", async () => {
188 const start = async (name: string) => {
189 const res = await fetch(`${parentServers.url}/forges/start`, { method: "POST", redirect: "manual", headers: { origin: "https://forge.test" }, body: new URLSearchParams({ name, js: "1" }) });
190 await res.body?.cancel();
191 expect(res.status).toBe(303);
192 };
193 const age = async (name: string, ms: number) => {
194 const file = join(dir, "spawn", `${name}.json`);
195 const ledger = JSON.parse(await Bun.file(file).text());
196 ledger.createdAt = new Date(Date.now() - ms).toISOString();
197 await Bun.write(file, JSON.stringify(ledger));
198 };
199 await start("probe-aaaaaaaa");
200 await start("probe-bbbbbbbb");
201 await start("keepme-forge");
202 await age("probe-aaaaaaaa", 2 * 60 * 60 * 1000);
203 await age("keepme-forge", 5 * 60 * 60 * 1000);
204 const result = await walkJourney({ base: parentServers.url, fetch: routed([]), adminToken, pollMs: 0, now: (() => { let t = 0; return () => (t += 5_000); })(), sleep: async () => {} });
205 expect(result.swept).toEqual(["probe-aaaaaaaa"]);
206 expect(await forgeGone("probe-aaaaaaaa")).toBe(true);
207 expect(await childNamed(parent, "probe-bbbbbbbb")).toBeDefined(); // under an hour: may be a run in progress
208 expect(await childNamed(parent, "keepme-forge")).toBeDefined(); // not a probe's name
209 for (const n of ["probe-bbbbbbbb", "keepme-forge"])
210 await fetch(`${parentServers.url}/forges/${n}/remove`, { method: "POST", headers: { origin: parentServers.url, authorization: `Bearer ${adminToken}` } });
211 }, 60_000);
212
213 test("a removal that fails once is tried again after a pause, and the result says it is gone", async () => {
214 const log: string[] = [];
215 const inner = routed(log);
216 const pauses: number[] = [];
217 let failed = 0;
218 const flaky = (async (input: string | URL | Request, init?: RequestInit) => {
219 if (new URL(String(input)).pathname === "/api/v1/forges") throw new Error("the list is down");
220 if (/\/forges\/probe-[0-9a-f]{8}\/remove$/.test(new URL(String(input)).pathname) && failed < 1) {
221 failed += 1;
222 return new Response("busy", { status: 503 });
223 }
224 return inner(input, init);
225 }) as typeof fetch;
226 let t = 0;
227 const sleep = async (ms: number) => {
228 pauses.push(ms);
229 await Bun.sleep(100); // real-timer: the stand-in crew child runs in real time
230 };
231 const result = await walkJourney({ base: parentServers.url, fetch: flaky, adminToken, pollMs: 100, now: () => (t += 10), sleep });
232 // The sweep's list was down: that is reported, and the passing walk is still passing.
233 expect(result.failure).toBeUndefined();
234 expect(result.ok).toBe(true);
235 expect(result.sweepError).toBe("the list is down");
236 expect(failed).toBe(1);
237 expect(result.removed).toBe(true);
238 expect(pauses).toContain(REMOVE_BACKOFF_MS[0]!);
239 expect(await forgeGone(result.forge!)).toBe(true);
240 }, 60_000);
241
242 test("a removal that never succeeds waits between its attempts and says it is not gone", async () => {
243 const inner = routed([]);
244 const pauses: number[] = [];
245 const stuck = (async (input: string | URL | Request, init?: RequestInit) =>
246 /\/remove$/.test(new URL(String(input)).pathname) && init?.method === "POST" ? new Response("no", { status: 503 }) : inner(input, init)) as typeof fetch;
247 let t = 0;
248 const result = await walkJourney({ base: parentServers.url, fetch: stuck, adminToken, pollMs: 0, now: () => (t += 5_000), sleep: async (ms) => void pauses.push(ms) });
249 expect(result.removed).toBe(false);
250 expect(pauses).toEqual([...REMOVE_BACKOFF_MS]);
251 await fetch(`${parentServers.url}/forges/${result.forge}/remove`, { method: "POST", headers: { origin: parentServers.url, authorization: `Bearer ${adminToken}` } });
252 }, 60_000);
253
254 test("a sweep with no admin token does nothing and says nothing is wrong", async () => {
255 const never = (() => { throw new Error("no request"); }) as unknown as typeof fetch;
256 expect(await sweepProbes(never, new URL(parentServers.url), undefined, Date.now())).toEqual({ gone: [] });
257 });
258
259 test("a sweep that throws comes back as its reason and never throws", async () => {
260 const broke = (async () => { throw new Error("the list is down"); }) as unknown as typeof fetch;
261 expect(await sweepProbes(broke, new URL(parentServers.url), adminToken, Date.now())).toEqual({ gone: [], error: "the list is down" });
262 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs