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

A release that does not boot never reaches production, and a crash-looping roll undoes itself in minutes #2655

Merged opened this in answer to

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

Merged

mason/forge:mason/2652-a-release-that-does-not-boot-never-reach → main 6 commits fork

  • Merged. mason/2652-a-release-that-does-not-boot-never-reach was merged into main as b9c94a8.

Review

Sign in to review this change.
19 files changed +1261 −164
Dockerfile +11 −3
@@ -14,8 +14,8 @@ RUN apt-get update \
1414 WORKDIR /app
1515 COPY package.json tsconfig.json ./
1616 COPY src ./src
17 # src/ imports from scripts/ (checks/service.ts, release/prove.ts): without it the image cannot boot (0.7.166).
18 COPY scripts ./scripts
17 # src/ alone: nothing under it imports scripts/ or test/ (test/src-imports.test.ts),
18 # and the boot proof below fails the build if that ever stops being so (#2652).
1919
2020 # What the running forge says it is (src/kernel/version.ts): the release
2121 # passes the tag it is building as VERSION and the commit as COMMIT, and the
@@ -29,7 +29,15 @@ ENV FORGE_VERSION=$VERSION FORGE_COMMIT=$COMMIT
2929 ENV FORGE_DATA=/data PORT=8080 FORGE_SSH_PORT=2222
3030 EXPOSE 8080 2222
3131 USER bun
32 # The build boots what it built (#2652): `src/main.ts` on an empty data
33 # directory must answer /api/v1/health as $VERSION, or the build fails and
34 # nothing is pushed. 0.7.166 passed its check from the repository and crashed
35 # at import from this image.
36 RUN bun src/release/boot.ts
3237 # tini reaps what an agent run leaves behind: a claude run spawns git, bun,
3338 # the app under test, and their children.
3439 ENTRYPOINT ["tini", "--"]
35 CMD ["bun", "src/main.ts"]
40 # The launcher runs src/main.ts, and when this version does not answer health
41 # within FORGE_READY_GRACE_S (150), runs the last version that did, kept on
42 # the volume (src/release/launch.ts, #2652).
43 CMD ["bun", "src/release/launch.ts"]
README.md +1 −1
@@ -243,7 +243,7 @@ forge shows nothing.
243243
244244 A new version proves itself once per version: after a boot that changes
245245 `FORGE_VERSION`, the forge waits (up to 10 minutes) for its public URL to
246 answer `/healthz` as that version, then runs `scripts/smoke.ts` against it and
246 answer `/healthz` as that version, then runs the smoke checks (`src/release/smoke.ts`) against it and
247247 keeps the verdict in `$FORGE_APP_DATA/release/proof.json`. A failure is a
248248 `Release X is unproven: …` card on an admin's board and on the terminal's first
249249 screen (the same cards), and is paged once; a later passing version clears it.
charts/app/templates/boot-proof.yaml added +65
@@ -0,0 +1,65 @@
1 {{/*
2 The promote preflight (#2652): before an upgrade touches the Deployment, the
3 new image boots beside the running forge on a copy of its data
4 (`src/release/boot.ts --from /data`) and must answer health as its version.
5 A refusal fails the upgrade, so the old pod is never taken down for a
6 release that cannot start. The volume is local-path, bound to its node, so
7 this pod schedules where the forge is; it reads the volume, writes only its
8 own scratch directory on it, and removes that.
9 */}}
10 apiVersion: batch/v1
11 kind: Job
12 metadata:
13 name: {{ .Release.Name }}-boot-proof
14 labels:
15 app: {{ .Release.Name }}-boot-proof
16 annotations:
17 helm.sh/hook: pre-upgrade
18 helm.sh/hook-weight: "0"
19 # A failed proof is kept for its log until the next upgrade replaces it.
20 helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
21 spec:
22 backoffLimit: 0
23 activeDeadlineSeconds: 300
24 template:
25 metadata:
26 labels:
27 app: {{ .Release.Name }}-boot-proof
28 spec:
29 restartPolicy: Never
30 {{- with .Values.imagePullSecrets }}
31 imagePullSecrets:
32 {{- toYaml . | nindent 8 }}
33 {{- end }}
34 securityContext:
35 runAsNonRoot: true
36 runAsUser: 1000
37 runAsGroup: 1000
38 fsGroup: 1000
39 fsGroupChangePolicy: OnRootMismatch
40 seccompProfile:
41 type: RuntimeDefault
42 containers:
43 - name: boot-proof
44 image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
45 command: ["bun", "src/release/boot.ts", "--from", "/data", "--budget", "180"]
46 securityContext:
47 allowPrivilegeEscalation: false
48 capabilities:
49 drop: [ALL]
50 volumeMounts:
51 - name: data
52 mountPath: /data
53 # A forge with no crew, no apps and no SSH: a fraction of the
54 # running one, so it fits beside it where a surge pod may not.
55 resources:
56 requests:
57 cpu: 100m
58 memory: 256Mi
59 limits:
60 cpu: "1"
61 memory: 1Gi
62 volumes:
63 - name: data
64 persistentVolumeClaim:
65 claimName: {{ .Release.Name }}-data
docs/ops/ROLL.md +22
@@ -141,3 +141,25 @@ request routed to it in that beat is refused.
141141
142142 Release as the release workflow does. Nothing in this recipe changes the
143143 workflow or the chart.
144
145 ## A release that does not boot (#2652)
146
147 Three guards stand between a release that cannot start and production, all
148 in `src/release/`:
149
150 - **At build.** The Dockerfile runs `bun src/release/boot.ts` on the image it
151 just built: `src/main.ts` on an empty data directory must answer
152 `/api/v1/health` as `$VERSION` within two minutes, or the build fails and
153 nothing is pushed. The image ships `src/` alone; `test/src-imports.test.ts`
154 refuses any import from `scripts/` or `test/`.
155 - **Before the roll.** The chart's pre-upgrade hook
156 (`charts/app/templates/boot-proof.yaml`) runs the new image's
157 `boot.ts --from /data` beside the running forge, on a copy of its database
158 and top-level files. A refusal fails the upgrade while the old pod serves;
159 the failed Job is kept for its log (`kubectl -n "$NS" logs job/$REL-boot-proof`).
160 - **After the roll.** The image starts `src/release/launch.ts`, which runs
161 `src/main.ts` and, when that version exits or is not ready within
162 `FORGE_READY_GRACE_S` (150 s, inside the startup probe), starts the last
163 version that was ready, kept on the volume under `release/good/`. The forge
164 that comes up shows a `release:rolled-back` card and pages the admins once.
165 The next version that reads ready clears it.
scripts/shards.ts +4 −19
@@ -24,25 +24,10 @@ export const SHARDS_MAX = 32;
2424 */
2525 export const WORKER_MEMORY_BYTES = 512 * 1024 * 1024;
2626
27 /**
28 * The share of a check cell's declared CPUs the workers are sized to. The
29 * cell's watchdog (`src/apps/contain.ts`) stops a check whose tree averages
30 * more than `check.limits.cpus` cores over 30 s, and a worker is more than
31 * one core at its peaks (Bun's own threads, git children, a booted forge):
32 * one worker per declared CPU averaged 16.1 of 16 and stopped main (#2161).
33 */
34 export const CELL_CPU_SHARE = 3 / 4;
35
36 /**
37 * The CPUs the gate sizes its workers to: the machine's, held under
38 * {@link CELL_CPU_SHARE} of the cell's CPU limit (`FORGE_CELL_CPUS`) when
39 * there is one, at least one. `availableParallelism()` sees the host, not
40 * the cell: the cell is a watchdog, not a CPU quota the process can read.
41 */
42 export function workerCpus(machine: number, declared: number | undefined): number {
43 if (declared === undefined || !Number.isFinite(declared) || declared <= 0) return machine;
44 return Math.max(1, Math.min(machine, Math.floor(declared * CELL_CPU_SHARE)));
45 }
27 // Lives in src/ because the running forge sizes its checks with it, and the
28 // image ships src/ alone (#2652).
29 export { CELL_CPU_SHARE, workerCpus } from "../src/core/capacity.ts";
30 import { workerCpus } from "../src/core/capacity.ts";
4631
4732 /**
4833 * How many shards: one per CPU the process may use, no more than its
scripts/smoke.ts +2 −138
@@ -11,144 +11,8 @@
1111 * never answers with `Response.error()`.
1212 */
1313
14 export const AGENTS: Record<string, string> = {
15 phone:
16 "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
17 desktop:
18 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
19 };
20
21 export interface Failure {
22 url: string;
23 agent: string;
24 status: number | string;
25 contentType: string;
26 why: string;
27 }
28
29 /** The first stylesheet href in a page, or null. */
30 export function stylesheetHref(html: string): string | null {
31 for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
32 if (!/\brel\s*=\s*["']?stylesheet/i.test(tag)) continue;
33 const href = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(tag);
34 const value = href?.[1] ?? href?.[2];
35 if (value) return value.replaceAll("&amp;", "&");
36 }
37 return null;
38 }
39
40 /** The part of a service worker that answers navigations, comments removed. */
41 export function navigationBranch(source: string): string | null {
42 const at = source.search(/mode\s*===?\s*["']navigate["']/);
43 if (at < 0) return null;
44 const rest = source.slice(at);
45 const end = rest.search(/\breturn\s*;/);
46 return (end < 0 ? rest : rest.slice(0, end)).replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
47 }
48
49 export interface Options {
50 /** Wait for `/healthz` to report this `X-Forge-Version`. */
51 version?: string | undefined;
52 /** Longest wait for it, in milliseconds. */
53 waitMs?: number;
54 /** Pause between polls, in milliseconds. */
55 pollMs?: number;
56 /** The fetch to use; the global one unless a caller (the forge proving itself, a test) gives its own. */
57 fetch?: typeof fetch;
58 /** Accept a redirect from `/board` as the sign-in it is, for a caller holding no token. */
59 anonymousBoard?: boolean;
60 }
61
62 /** Polls `/healthz` until it reports `version`; null when it does, else a failure. */
63 async function waitForVersion(root: URL, version: string, waitMs: number, pollMs: number, fetch: typeof globalThis.fetch): Promise<Failure | null> {
64 const url = new URL("/healthz", root).href;
65 const deadline = Date.now() + waitMs;
66 let seen = "none";
67 let status: number | string = "error";
68 for (;;) {
69 try {
70 const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(5_000) });
71 await res.body?.cancel();
72 status = res.status;
73 seen = res.headers.get("x-forge-version") ?? "none";
74 if (seen === version) return null;
75 } catch (error) {
76 status = "error";
77 seen = `none (${error})`;
78 }
79 if (Date.now() + pollMs > deadline) break;
80 await Bun.sleep(pollMs);
81 }
82 return { url, agent: "-", status, contentType: "", why: `wanted X-Forge-Version ${version}, last saw ${seen}` };
83 }
84
85 /** Runs every check against `base`; returns the failures (empty is green). */
86 export async function smoke(base: string, token = "", options: Options = {}): Promise<Failure[]> {
87 const root = new URL(base);
88 if (root.protocol !== "http:" && root.protocol !== "https:") throw new Error("url must be http(s)");
89 const failures: Failure[] = [];
90 const fetch = options.fetch ?? globalThis.fetch;
91 if (options.version) {
92 const late = await waitForVersion(root, options.version, options.waitMs ?? 120_000, options.pollMs ?? 1_000, fetch);
93 if (late) return [late];
94 }
95
96 const get = async (path: string, agent: string, headers: Record<string, string> = {}) => {
97 const url = new URL(path, root).href;
98 try {
99 const res = await fetch(url, {
100 redirect: "manual",
101 signal: AbortSignal.timeout(10_000),
102 headers: { "user-agent": AGENTS[agent]!, ...headers },
103 });
104 return { url, res, body: await res.text() };
105 } catch (error) {
106 failures.push({ url, agent, status: "error", contentType: "", why: String(error) });
107 return null;
108 }
109 };
110 const fail = (url: string, agent: string, res: Response, why: string) =>
111 failures.push({ url, agent, status: res.status, contentType: res.headers.get("content-type") ?? "", why });
112
113 for (const agent of Object.keys(AGENTS)) {
114 const auth: Record<string, string> = token ? { authorization: `Bearer ${token}` } : {};
115 const home = await get("/", agent);
116 if (home) {
117 if (home.res.status !== 200) fail(home.url, agent, home.res, "expected 200");
118 else {
119 const href = stylesheetHref(home.body);
120 if (!href) failures.push({ url: home.url, agent, status: 200, contentType: "", why: "no stylesheet linked" });
121 else {
122 const css = await get(href, agent);
123 if (css) {
124 const type = css.res.headers.get("content-type") ?? "";
125 if (css.res.status !== 200) fail(css.url, agent, css.res, "expected 200");
126 else if (!/^text\/css\b/i.test(type)) fail(css.url, agent, css.res, "expected text/css");
127 else if (!css.body.trim()) fail(css.url, agent, css.res, "empty stylesheet");
128 }
129 }
130 }
131 }
132 const board = await get("/board", agent, auth);
133 const signIn = options.anonymousBoard && !token && board?.res.status === 302;
134 if (board && board.res.status !== 200 && !signIn) fail(board.url, agent, board.res, "expected 200 (a 302 is sign-in)");
135 const health = await get("/healthz", agent);
136 if (health && health.res.status !== 200) fail(health.url, agent, health.res, "expected 200");
137 }
138
139 const sw = await get("/sw.js", "desktop");
140 if (sw) {
141 const type = sw.res.headers.get("content-type") ?? "";
142 if (sw.res.status !== 200) fail(sw.url, "desktop", sw.res, "expected 200");
143 else if (!/^(text|application)\/(x-)?javascript\b/i.test(type)) fail(sw.url, "desktop", sw.res, "expected javascript");
144 else {
145 const nav = navigationBranch(sw.body);
146 if (nav === null) fail(sw.url, "desktop", sw.res, "no navigation branch");
147 else if (/Response\s*\.\s*error\s*\(/.test(nav)) fail(sw.url, "desktop", sw.res, "a navigation can be answered with Response.error()");
148 }
149 }
150 return failures;
151 }
14 import { smoke } from "../src/release/smoke.ts";
15 export * from "../src/release/smoke.ts";
15216
15317 if (import.meta.main) {
15418 const args = process.argv.slice(2);
src/checks/service.ts +1 −1
@@ -61,7 +61,7 @@ import { appendRunLog } from "../runs/log.ts";
6161 import { runningLimitSentence, runningRuns } from "../settings/quotas.ts";
6262 import { exportTree, readFileAt } from "../apps/checkout.ts";
6363 import { parseCheck } from "../apps/manifest.ts";
64 import { workerCpus } from "../../scripts/shards.ts";
64 import { workerCpus } from "../core/capacity.ts";
6565 import { accountActive } from "../core/accounts.ts";
6666 import { childEnv, runStep } from "../apps/process.ts";
6767 import { noteReached } from "../apps/egress_report.ts";
src/core/capacity.ts +20
@@ -176,6 +176,26 @@ export function cpuLimit(file = "/sys/fs/cgroup/cpu.max"): number {
176176 return machine;
177177 }
178178
179 /**
180 * The share of a check cell's declared CPUs the workers are sized to. The
181 * cell's watchdog (`src/apps/contain.ts`) stops a check whose tree averages
182 * more than `check.limits.cpus` cores over 30 s, and a worker is more than
183 * one core at its peaks (Bun's own threads, git children, a booted forge):
184 * one worker per declared CPU averaged 16.1 of 16 and stopped main (#2161).
185 */
186 export const CELL_CPU_SHARE = 3 / 4;
187
188 /**
189 * The CPUs the gate sizes its workers to: the machine's, held under
190 * {@link CELL_CPU_SHARE} of the cell's CPU limit (`FORGE_CELL_CPUS`) when
191 * there is one, at least one. `availableParallelism()` sees the host, not
192 * the cell: the cell is a watchdog, not a CPU quota the process can read.
193 */
194 export function workerCpus(machine: number, declared: number | undefined): number {
195 if (declared === undefined || !Number.isFinite(declared) || declared <= 0) return machine;
196 return Math.max(1, Math.min(machine, Math.floor(declared * CELL_CPU_SHARE)));
197 }
198
179199 const GIB = 1024 ** 3;
180200 const MIB = 1024 ** 2;
181201
src/crew/notify.ts +25
@@ -31,6 +31,7 @@ import { getUserById, type User } from "../core/users.ts";
3131 import { landedLines } from "../core/watches.ts";
3232 import { err, ok, type Result } from "../kernel/result.ts";
3333 import { lastMirror, mirrorNeed } from "../snapshot/mirror.ts";
34 import { markRollbackPaged, rollbackNeed, rolledBack } from "../release/launch.ts";
3435 import { drillNeed, firstSeen, lastVerdict } from "../snapshot/verdict.ts";
3536 import { health } from "../http/health.ts";
3637 import { forgeVitals } from "../actions/health.ts";
@@ -309,6 +310,28 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier {
309310 }
310311 }
311312
313 /**
314 * A roll the image's launcher undid (`release/launch.ts`) is news once,
315 * ever: it happened before this process began, so no baseline would page
316 * it. Marked on disk once it reached an admin, or when no admin can be
317 * reached; a page the backoff held is tried again on the pager's tick.
318 */
319 async function pageRollback(): Promise<void> {
320 const dataDir = app.config.dataDir;
321 const need = rollbackNeed(dataDir);
322 if (need === undefined || rolledBack(dataDir)?.paged !== false) return;
323 let reachable = false;
324 let delivered = false;
325 for (const id of people()) {
326 const user = getUserById(app.db, id);
327 const url = notifyUrlOf(app.db, id);
328 if (user === undefined || url === undefined || !user.isAdmin || user.kind !== "user") continue;
329 reachable = true;
330 if (await push(id, url, need.sentence)) delivered = true;
331 }
332 if (delivered || !reachable) markRollbackPaged(dataDir);
333 }
334
312335 async function pageDrain(userId: string, url: string, baseline: boolean): Promise<void> {
313336 const current = drainIncident(drainStart(), now());
314337 if (baseline || !paged.has(userId)) {
@@ -409,6 +432,7 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier {
409432 await tell(id, true).catch(() => undefined);
410433 await page(id, true).catch(() => undefined);
411434 }
435 await pageRollback().catch(() => undefined);
412436 });
413437
414438 // Eight days of silence is no event: the clock wakes the notifier too.
@@ -428,6 +452,7 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier {
428452 queue = queue.then(async () => {
429453 try {
430454 for (const id of people()) await page(id, false);
455 await pageRollback();
431456 } catch (error) {
432457 app.log.error("notify failed", { error: String(error) });
433458 }
src/kernel/vitals.ts +5
@@ -26,6 +26,7 @@ import { lastMirror, mirrorNeed } from "../snapshot/mirror.ts";
2626 import { lastSnapshotOutcome, snapshotNeed } from "../snapshot/outcome.ts";
2727 import { drillNeed, firstSeen as drillFirstSeen, lastVerdict } from "../snapshot/verdict.ts";
2828 import { proofNeed } from "../release/prove.ts";
29 import { rollbackNeed } from "../release/launch.ts";
2930 import { behindStanding } from "../release/behind.ts";
3031 import { newestDrills } from "../core/domain_drills.ts";
3132 import { DOMAINS } from "./domains.ts";
@@ -399,6 +400,10 @@ export function vitals(input: VitalsInput): Vitals {
399400 const unproven = proofNeed(input.dataDir);
400401 if (unproven !== undefined) need("release", unproven.sentence, unproven.since, "/board");
401402
403 // A roll the image's launcher undid is a card until a later version reads ready.
404 const undone = rollbackNeed(input.dataDir);
405 if (undone !== undefined) need("release:rolled-back", undone.sentence, undone.since, "/board");
406
402407 // Production serving a version older than a release main has had over an hour is a card until they match.
403408 const lagging = behindStanding(now);
404409 if (lagging !== undefined) need("release:behind", `${lagging.sentence}.`, lagging.since, "/board");
src/release/boot.ts added +231
@@ -0,0 +1,231 @@
1 /**
2 * Does this tree boot? `bun src/release/boot.ts` starts `src/main.ts` on a
3 * scratch data directory and a free loopback port, asks `/api/v1/health`
4 * until it answers as the version the image is stamped with, and stops it
5 * whatever the answer. One line out, exit 0 or 1 (2 for a bad argument).
6 *
7 * bun src/release/boot.ts an empty data directory: the
8 * release build (Dockerfile)
9 * bun src/release/boot.ts --from /data a copy of a live one: the
10 * promote preflight (chart hook)
11 *
12 * 0.7.166 passed the full check from the repository and crashed at boot from
13 * its image (`Cannot find module '../../scripts/smoke.ts'`); nothing had
14 * started the image it was (#2652). This is that start, at build and before
15 * the roll. With `--from`, the copy is the database (`VACUUM INTO`, so a
16 * forge writing it beside this is read whole) and the data directory's
17 * top-level files (the secret key); repositories and app data stay where
18 * they are. It lives in a directory of its own under the data directory and
19 * is removed in a `finally`.
20 *
21 * It imports nothing a release could break but the stamp it reads: the child
22 * is the thing on trial, not this.
23 */
24
25 import { Database } from "bun:sqlite";
26 import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
27 import { tmpdir } from "node:os";
28 import { join } from "node:path";
29 import { running } from "../kernel/version.ts";
30
31 export const BOOT_BUDGET_MS = 120_000;
32 const POLL_MS = 250;
33 /** A scratch directory older than this was left by a killed run. */
34 const STALE_MS = 60 * 60 * 1000;
35 const SCRATCH = "boot-proof-";
36
37 export type BootProof = { ok: true; ms: number } | { ok: false; line: string };
38
39 export interface BootInput {
40 /** The directory holding `src/main.ts`. */
41 readonly tree: string;
42 /** What `/api/v1/health` must say it is. */
43 readonly version: string;
44 /** The data directory the forge boots on; a scratch one, never a live one. */
45 readonly dataDir: string;
46 readonly budgetMs?: number;
47 /** Where PATH, HOME and TMPDIR come from; the process's own unless a test says. */
48 readonly env?: Readonly<Record<string, string | undefined>>;
49 }
50
51 async function freePort(): Promise<number> {
52 const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("") });
53 const port = probe.port ?? 0;
54 await probe.stop(true);
55 return port;
56 }
57
58 /** The line of what the child printed that says why: the first error, else the last line. */
59 export function why(stderr: string): string {
60 const lines = stderr
61 .split("\n")
62 .map((l) => l.trim())
63 .filter((l) => l !== "");
64 const line = lines.find((l) => /\berror\b/i.test(l)) ?? lines.at(-1) ?? "";
65 // eslint-disable-next-line no-control-regex
66 return line.replace(/[\u0000-\u001f\u007f]+/g, " ").slice(0, 300);
67 }
68
69 async function versionAt(port: number): Promise<string | undefined> {
70 try {
71 const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, {
72 headers: { connection: "close" },
73 signal: AbortSignal.timeout(2_000),
74 });
75 if (!res.ok) return undefined;
76 const body = (await res.json()) as { version?: unknown };
77 return typeof body.version === "string" ? body.version : undefined;
78 } catch {
79 return undefined;
80 }
81 }
82
83 /** Start the tree on `dataDir` and wait for it to answer health as `version`. */
84 export async function bootProof(input: BootInput): Promise<BootProof> {
85 const entry = join(input.tree, "src", "main.ts");
86 if (!existsSync(entry)) return { ok: false, line: `${input.tree} has no src/main.ts` };
87 const host = input.env ?? process.env;
88 const port = await freePort();
89 const env: Record<string, string> = {
90 PATH: host["PATH"] ?? "/usr/local/bin:/usr/bin:/bin",
91 FORGE_DATA: input.dataDir,
92 PORT: String(port),
93 FORGE_SSH_PORT: "0",
94 // The crew stays off a copy of production's queue.
95 FORGE_APP_ENV: "preview",
96 FORGE_VERSION: input.version,
97 };
98 for (const key of ["HOME", "TMPDIR", "FORGE_TEST_SOCKET"]) {
99 const value = host[key];
100 if (value !== undefined && value !== "") env[key] = value;
101 }
102 const started = performance.now();
103 const budget = input.budgetMs ?? BOOT_BUDGET_MS;
104 const child = Bun.spawn([process.execPath, entry], {
105 cwd: input.tree,
106 env,
107 stdin: "ignore",
108 stdout: "ignore",
109 stderr: "pipe",
110 });
111 let stderr = "";
112 const reading = (async () => {
113 const decoder = new TextDecoder();
114 for await (const chunk of child.stderr) stderr = (stderr + decoder.decode(chunk, { stream: true })).slice(-8_192);
115 })().catch(() => undefined);
116 let last = "no answer";
117 try {
118 while (performance.now() - started < budget) {
119 if (child.exitCode !== null) {
120 await reading;
121 const said = why(stderr);
122 return { ok: false, line: `exited with ${child.exitCode} before it answered health${said ? `: ${said}` : ""}` };
123 }
124 const seen = await versionAt(port);
125 if (seen === input.version) return { ok: true, ms: Math.round(performance.now() - started) };
126 if (seen !== undefined) last = `health answered as ${seen}`;
127 await Bun.sleep(POLL_MS);
128 }
129 const said = why(stderr);
130 return {
131 ok: false,
132 line: `did not answer health as ${input.version} within ${Math.round(budget / 1000)}s (${last})${said ? `: ${said}` : ""}`,
133 };
134 } finally {
135 child.kill("SIGKILL");
136 await child.exited;
137 }
138 }
139
140 /**
141 * Copy what a forge boots on from `from` into `to`: the database, whole, as
142 * of one moment, and the data directory's other top-level files.
143 */
144 export function copyData(from: string, to: string): void {
145 mkdirSync(to, { recursive: true });
146 for (const entry of readdirSync(from, { withFileTypes: true })) {
147 if (!entry.isFile() || entry.name.startsWith("forge.db")) continue;
148 copyFileSync(join(from, entry.name), join(to, entry.name));
149 }
150 const live = join(from, "forge.db");
151 if (!existsSync(live)) return;
152 const db = new Database(live, { readonly: true });
153 try {
154 db.run("VACUUM INTO ?", [join(to, "forge.db")]);
155 } finally {
156 db.close();
157 }
158 }
159
160 function sweepStale(dir: string): void {
161 for (const name of readdirSync(dir)) {
162 if (!name.startsWith(SCRATCH)) continue;
163 const path = join(dir, name);
164 try {
165 if (Date.now() - statSync(path).mtimeMs > STALE_MS) rmSync(path, { recursive: true, force: true });
166 } catch {
167 // Gone already, or not ours: leave it.
168 }
169 }
170 }
171
172 export interface ProveBootInput {
173 readonly tree: string;
174 readonly version: string;
175 /** A live data directory to boot a copy of; an empty one when absent. */
176 readonly from?: string | undefined;
177 readonly budgetMs?: number;
178 readonly env?: Readonly<Record<string, string | undefined>>;
179 }
180
181 /** The whole proof: a scratch directory, the copy when asked, the boot, the clean-up. */
182 export async function proveBoot(input: ProveBootInput): Promise<BootProof> {
183 let root = input.env?.["TMPDIR"] || tmpdir();
184 if (input.from !== undefined) {
185 if (!existsSync(join(input.from, "forge.db")))
186 return { ok: false, line: `${input.from} holds no forge.db to copy` };
187 root = input.from;
188 sweepStale(root);
189 }
190 const scratch = mkdtempSync(join(root, SCRATCH));
191 try {
192 const dataDir = join(scratch, "data");
193 if (input.from !== undefined) copyData(input.from, dataDir);
194 else mkdirSync(dataDir);
195 return await bootProof({ ...input, dataDir });
196 } catch (error) {
197 return { ok: false, line: `could not prepare the scratch data directory: ${String(error).slice(0, 200)}` };
198 } finally {
199 rmSync(scratch, { recursive: true, force: true });
200 }
201 }
202
203 /** `--from <dir>` and `--budget <seconds>`, or why not. */
204 export function parseArgs(argv: readonly string[]): { from?: string; budgetMs: number } | string {
205 let from: string | undefined;
206 let budgetMs = BOOT_BUDGET_MS;
207 for (let i = 0; i < argv.length; i++) {
208 const flag = argv[i];
209 const value = argv[i + 1];
210 if (flag === "--from" && value !== undefined && value !== "") from = value;
211 else if (flag === "--budget" && value !== undefined && /^[1-9][0-9]{0,3}$/.test(value)) budgetMs = Number(value) * 1000;
212 else return "usage: bun src/release/boot.ts [--from <data dir>] [--budget <seconds, 1 to 9999>]";
213 i++;
214 }
215 return from === undefined ? { budgetMs } : { from, budgetMs };
216 }
217
218 if (import.meta.main) {
219 const args = parseArgs(process.argv.slice(2));
220 if (typeof args === "string") {
221 console.error(args);
222 process.exit(2);
223 }
224 const { version } = running();
225 const tree = join(import.meta.dir, "..", "..");
226 const result = await proveBoot({ tree, version, ...args });
227 const what = args.from === undefined ? "an empty data directory" : `a copy of ${args.from}`;
228 if (result.ok) console.log(`boot ok: ${version} answered health on ${what} in ${result.ms}ms`);
229 else console.error(`boot failed: ${version} on ${what} ${result.line}`);
230 process.exit(result.ok ? 0 : 1);
231 }
src/release/launch.ts added +262
@@ -0,0 +1,262 @@
1 /**
2 * The image's first process (#2652): start this image's forge, and if it
3 * does not answer health as its own version within the grace, start the
4 * last version that did instead.
5 *
6 * A version that reads ready is copied onto the volume
7 * (`release/good/<version>/`: its `src/` and the two files beside it) and
8 * named in `release/good.json`; the one before it is removed. A version
9 * that exits first, or is still not ready when the grace runs out, is
10 * stopped, `release/rolled-back.json` says what happened, and the kept tree
11 * runs under its own stamp. The forge that comes up reads that record: a
12 * card while it stands (`kernel/vitals.ts`) and a page to the admins once
13 * (`crew/notify.ts`). The next version that reads ready clears it.
14 *
15 * 0.7.166 crash-looped for two hours because the old pod was gone and
16 * nothing undid the roll; with this, the same image serves the old version
17 * seconds after its crash, and a hang costs the grace (150 s, inside the
18 * chart's three-minute startup probe).
19 *
20 * Node built-ins only, on purpose: it is the one part of a bad release that
21 * has to work.
22 */
23
24 import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
25 import { dirname, join } from "node:path";
26 import type { Subprocess } from "bun";
27
28 export const READY_GRACE_MS = 150_000;
29 const POLL_MS = 500;
30 /** How long a stopped release has to exit before it is killed. */
31 const STOP_MS = 10_000;
32 /** What a kept tree is: what the Dockerfile copies into the image. */
33 const TREE_PARTS = ["src", "package.json", "tsconfig.json"];
34 const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/;
35
36 export interface Good {
37 readonly version: string;
38 readonly commit: string | null;
39 readonly at: string;
40 }
41
42 export interface RolledBack {
43 /** The version that did not become ready. */
44 readonly from: string;
45 /** The version running in its place. */
46 readonly to: string;
47 readonly at: string;
48 readonly why: string;
49 /** Whether the admins were told; set once by the forge that told them. */
50 readonly paged: boolean;
51 }
52
53 const releaseDir = (dataDir: string): string => join(dataDir, "release");
54 const goodFile = (dataDir: string): string => join(releaseDir(dataDir), "good.json");
55 const rolledFile = (dataDir: string): string => join(releaseDir(dataDir), "rolled-back.json");
56 export const goodTree = (dataDir: string, version: string): string => join(releaseDir(dataDir), "good", version);
57
58 function readJson(path: string): Record<string, unknown> | undefined {
59 try {
60 const value = JSON.parse(readFileSync(path, "utf8")) as unknown;
61 return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
62 } catch {
63 return undefined;
64 }
65 }
66
67 function writeJson(path: string, value: object): void {
68 mkdirSync(dirname(path), { recursive: true });
69 const tmp = `${path}.tmp`;
70 writeFileSync(tmp, JSON.stringify(value));
71 renameSync(tmp, path);
72 }
73
74 /** The last version that read ready, while its tree is kept. */
75 export function lastGood(dataDir: string): Good | undefined {
76 const g = readJson(goodFile(dataDir));
77 const version = g?.["version"];
78 if (typeof version !== "string" || !VERSION.test(version) || typeof g?.["at"] !== "string") return undefined;
79 if (!existsSync(join(goodTree(dataDir, version), "src", "main.ts"))) return undefined;
80 return { version, commit: typeof g["commit"] === "string" ? g["commit"] : null, at: g["at"] };
81 }
82
83 /** Keep `tree` as the last good release; drop the one it replaces and any standing rollback. */
84 export function keepGood(dataDir: string, tree: string, version: string, commit: string | null, now = Date.now()): void {
85 if (!VERSION.test(version)) return;
86 if (lastGood(dataDir)?.version !== version) {
87 const target = goodTree(dataDir, version);
88 const staging = `${target}.partial-${process.pid}`;
89 rmSync(staging, { recursive: true, force: true });
90 mkdirSync(staging, { recursive: true });
91 for (const part of TREE_PARTS)
92 if (existsSync(join(tree, part))) cpSync(join(tree, part), join(staging, part), { recursive: true });
93 rmSync(target, { recursive: true, force: true });
94 renameSync(staging, target);
95 writeJson(goodFile(dataDir), { version, commit, at: new Date(now).toISOString() });
96 }
97 for (const name of readdirSync(join(releaseDir(dataDir), "good")))
98 if (name !== version) rmSync(join(releaseDir(dataDir), "good", name), { recursive: true, force: true });
99 rmSync(rolledFile(dataDir), { force: true });
100 }
101
102 /** The rollback the launcher made, while it stands. */
103 export function rolledBack(dataDir: string): RolledBack | undefined {
104 const r = readJson(rolledFile(dataDir));
105 if (r === undefined) return undefined;
106 const { from, to, at, why, paged } = r;
107 if (typeof from !== "string" || typeof to !== "string" || typeof at !== "string" || typeof why !== "string")
108 return undefined;
109 return { from, to, at, why, paged: paged === true };
110 }
111
112 /** Record that the admins were told, so no later boot tells them again. */
113 export function markRollbackPaged(dataDir: string): void {
114 const r = rolledBack(dataDir);
115 if (r !== undefined && !r.paged) writeJson(rolledFile(dataDir), { ...r, paged: true });
116 }
117
118 /** The card a standing rollback is. */
119 export function rollbackNeed(dataDir: string): { sentence: string; since: string } | undefined {
120 const r = rolledBack(dataDir);
121 if (r === undefined) return undefined;
122 return { sentence: `Release ${r.from} did not become ready (${r.why}); rolled back to ${r.to}.`, since: r.at };
123 }
124
125 async function versionAt(port: number): Promise<string | undefined> {
126 try {
127 const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, {
128 headers: { connection: "close" },
129 signal: AbortSignal.timeout(2_000),
130 });
131 if (!res.ok) return undefined;
132 const body = (await res.json()) as { version?: unknown };
133 return typeof body.version === "string" ? body.version : undefined;
134 } catch {
135 return undefined;
136 }
137 }
138
139 const gone = (child: Subprocess): boolean => child.exitCode !== null || child.signalCode !== null;
140
141 async function stop(child: Subprocess): Promise<void> {
142 if (gone(child)) return;
143 child.kill("SIGTERM");
144 const timer = setTimeout(() => child.kill("SIGKILL"), STOP_MS);
145 await child.exited;
146 clearTimeout(timer);
147 }
148
149 export interface LaunchInput {
150 /** This image's tree: the directory holding `src/main.ts`. */
151 readonly tree: string;
152 readonly dataDir: string;
153 readonly port: number;
154 /** What this image is stamped as, and what its health must answer. */
155 readonly version: string;
156 readonly commit: string | null;
157 /** The environment either forge starts with. */
158 readonly env: Readonly<Record<string, string | undefined>>;
159 readonly graceMs?: number;
160 readonly now?: () => number;
161 /** Told of each process started, so a signal reaches the one running. */
162 readonly started?: (child: Subprocess) => void;
163 /** True once the launcher was asked to stop: a release exiting then is no failure. */
164 readonly stopping?: () => boolean;
165 }
166
167 export type Launched =
168 | { readonly ran: "release" | "rollback"; readonly child: Subprocess; readonly line: string }
169 | { readonly ran: "none"; readonly line: string };
170
171 function start(input: LaunchInput, tree: string, version: string, commit: string | null): Subprocess {
172 const env: Record<string, string> = {};
173 for (const [k, v] of Object.entries(input.env)) if (v !== undefined) env[k] = v;
174 env["FORGE_VERSION"] = version;
175 env["FORGE_COMMIT"] = commit ?? "";
176 const child = Bun.spawn([process.execPath, join(tree, "src", "main.ts")], {
177 cwd: tree,
178 env,
179 stdin: "ignore",
180 stdout: "inherit",
181 stderr: "inherit",
182 });
183 input.started?.(child);
184 return child;
185 }
186
187 /**
188 * Start this image's forge and answer once it is ready (`release`), was
189 * replaced by the last good one (`rollback`), or neither runs (`none`).
190 * The child returned is the one to wait on.
191 */
192 export async function launch(input: LaunchInput): Promise<Launched> {
193 const now = input.now ?? Date.now;
194 const grace = input.graceMs ?? READY_GRACE_MS;
195 const release = start(input, input.tree, input.version, input.commit);
196 const began = now();
197 let why: string;
198 for (;;) {
199 if (input.stopping?.()) return { ran: "none", line: "asked to stop before it was ready" };
200 if (gone(release)) {
201 why = `it exited with ${release.exitCode ?? release.signalCode} before it was ready`;
202 break;
203 }
204 if ((await versionAt(input.port)) === input.version) {
205 try {
206 keepGood(input.dataDir, input.tree, input.version, input.commit, now());
207 } catch (error) {
208 // Serving matters more than keeping: the next version falls back to the one before.
209 return { ran: "release", child: release, line: `${input.version} is ready; not kept: ${String(error).slice(0, 200)}` };
210 }
211 return { ran: "release", child: release, line: `${input.version} is ready and kept as the last good release` };
212 }
213 if (now() - began >= grace) {
214 why = `not ready within ${Math.round(grace / 1000)}s`;
215 break;
216 }
217 await Bun.sleep(POLL_MS);
218 }
219 await stop(release);
220 if (input.stopping?.()) return { ran: "none", line: "asked to stop before it was ready" };
221 const good = lastGood(input.dataDir);
222 if (good === undefined || good.version === input.version)
223 return { ran: "none", line: `${input.version}: ${why}; no earlier release is kept to roll back to` };
224 writeJson(rolledFile(input.dataDir), {
225 from: input.version,
226 to: good.version,
227 at: new Date(now()).toISOString(),
228 why,
229 paged: false,
230 } satisfies RolledBack);
231 const child = start(input, goodTree(input.dataDir, good.version), good.version, good.commit);
232 return { ran: "rollback", child, line: `${input.version}: ${why}; rolled back to ${good.version}` };
233 }
234
235 if (import.meta.main) {
236 const env = process.env;
237 const version = (env["FORGE_VERSION"] ?? "").trim().replace(/^v(?=\d)/, "");
238 const commit = (env["FORGE_COMMIT"] ?? "").trim();
239 const graceS = Number(env["FORGE_READY_GRACE_S"] ?? "");
240 let current: Subprocess | undefined;
241 let stopping = false;
242 for (const signal of ["SIGTERM", "SIGINT"] as const)
243 process.on(signal, () => {
244 stopping = true;
245 current?.kill(signal);
246 });
247 const launched = await launch({
248 tree: join(import.meta.dir, "..", ".."),
249 dataDir: env["FORGE_DATA"] || "data",
250 port: Number(env["PORT"] ?? 3000),
251 version: VERSION.test(version) ? version : "dev",
252 commit: commit === "" ? null : commit,
253 env,
254 ...(Number.isInteger(graceS) && graceS > 0 ? { graceMs: graceS * 1000 } : {}),
255 started: (child) => {
256 current = child;
257 },
258 stopping: () => stopping,
259 });
260 console.error(`launch: ${launched.line}`);
261 process.exit(launched.ran === "none" ? (stopping ? 0 : 1) : await launched.child.exited);
262 }
src/release/prove.ts +2 −2
@@ -1,7 +1,7 @@
11 /**
22 * Is this release so: once per version, after a boot that changed it, the
33 * forge waits for its own public URL to answer `/healthz` as that version,
4 * then runs the smoke checks (`scripts/smoke.ts`) against it. The verdict is
4 * then runs the smoke checks (`release/smoke.ts`) against it. The verdict is
55 * one small file under the data directory (`release/proof.json`), so a
66 * restart of the same version never runs it again and vitals reads it
77 * without the network. A failure is a `release` card, paged once by the
@@ -10,7 +10,7 @@
1010
1111 import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1212 import { join } from "node:path";
13 import { smoke, type Failure } from "../../scripts/smoke.ts";
13 import { smoke, type Failure } from "./smoke.ts";
1414 import { UNSTAMPED } from "../kernel/version.ts";
1515
1616 /** How long the public URL has to answer as the new version. */
src/release/smoke.ts added +151
@@ -0,0 +1,151 @@
1 /**
2 * Is the forge answering, as a phone and as a desktop? Each of `/`, `/board`
3 * (signed in with a token) and `/healthz` is fetched once per user agent and
4 * must answer 200; the stylesheet linked from `/` must be a non-empty
5 * `text/css` 200; `/sw.js` must be a 200 JavaScript response whose navigation
6 * branch never answers with `Response.error()`. With `version`, `/healthz` is
7 * first polled until `X-Forge-Version` is that version.
8 *
9 * The running forge proves each new version with it (`release/prove.ts`), so
10 * it lives here: the image ships src/ and nothing else (#2652).
11 * `bun scripts/smoke.ts <url>` is the same checks from a shell.
12 */
13
14 export const AGENTS: Record<string, string> = {
15 phone:
16 "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
17 desktop:
18 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
19 };
20
21 export interface Failure {
22 url: string;
23 agent: string;
24 status: number | string;
25 contentType: string;
26 why: string;
27 }
28
29 /** The first stylesheet href in a page, or null. */
30 export function stylesheetHref(html: string): string | null {
31 for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
32 if (!/\brel\s*=\s*["']?stylesheet/i.test(tag)) continue;
33 const href = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(tag);
34 const value = href?.[1] ?? href?.[2];
35 if (value) return value.replaceAll("&amp;", "&");
36 }
37 return null;
38 }
39
40 /** The part of a service worker that answers navigations, comments removed. */
41 export function navigationBranch(source: string): string | null {
42 const at = source.search(/mode\s*===?\s*["']navigate["']/);
43 if (at < 0) return null;
44 const rest = source.slice(at);
45 const end = rest.search(/\breturn\s*;/);
46 return (end < 0 ? rest : rest.slice(0, end)).replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
47 }
48
49 export interface Options {
50 /** Wait for `/healthz` to report this `X-Forge-Version`. */
51 version?: string | undefined;
52 /** Longest wait for it, in milliseconds. */
53 waitMs?: number;
54 /** Pause between polls, in milliseconds. */
55 pollMs?: number;
56 /** The fetch to use; the global one unless a caller (the forge proving itself, a test) gives its own. */
57 fetch?: typeof fetch;
58 /** Accept a redirect from `/board` as the sign-in it is, for a caller holding no token. */
59 anonymousBoard?: boolean;
60 }
61
62 /** Polls `/healthz` until it reports `version`; null when it does, else a failure. */
63 async function waitForVersion(root: URL, version: string, waitMs: number, pollMs: number, fetch: typeof globalThis.fetch): Promise<Failure | null> {
64 const url = new URL("/healthz", root).href;
65 const deadline = Date.now() + waitMs;
66 let seen = "none";
67 let status: number | string = "error";
68 for (;;) {
69 try {
70 const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(5_000) });
71 await res.body?.cancel();
72 status = res.status;
73 seen = res.headers.get("x-forge-version") ?? "none";
74 if (seen === version) return null;
75 } catch (error) {
76 status = "error";
77 seen = `none (${error})`;
78 }
79 if (Date.now() + pollMs > deadline) break;
80 await Bun.sleep(pollMs);
81 }
82 return { url, agent: "-", status, contentType: "", why: `wanted X-Forge-Version ${version}, last saw ${seen}` };
83 }
84
85 /** Runs every check against `base`; returns the failures (empty is green). */
86 export async function smoke(base: string, token = "", options: Options = {}): Promise<Failure[]> {
87 const root = new URL(base);
88 if (root.protocol !== "http:" && root.protocol !== "https:") throw new Error("url must be http(s)");
89 const failures: Failure[] = [];
90 const fetch = options.fetch ?? globalThis.fetch;
91 if (options.version) {
92 const late = await waitForVersion(root, options.version, options.waitMs ?? 120_000, options.pollMs ?? 1_000, fetch);
93 if (late) return [late];
94 }
95
96 const get = async (path: string, agent: string, headers: Record<string, string> = {}) => {
97 const url = new URL(path, root).href;
98 try {
99 const res = await fetch(url, {
100 redirect: "manual",
101 signal: AbortSignal.timeout(10_000),
102 headers: { "user-agent": AGENTS[agent]!, ...headers },
103 });
104 return { url, res, body: await res.text() };
105 } catch (error) {
106 failures.push({ url, agent, status: "error", contentType: "", why: String(error) });
107 return null;
108 }
109 };
110 const fail = (url: string, agent: string, res: Response, why: string) =>
111 failures.push({ url, agent, status: res.status, contentType: res.headers.get("content-type") ?? "", why });
112
113 for (const agent of Object.keys(AGENTS)) {
114 const auth: Record<string, string> = token ? { authorization: `Bearer ${token}` } : {};
115 const home = await get("/", agent);
116 if (home) {
117 if (home.res.status !== 200) fail(home.url, agent, home.res, "expected 200");
118 else {
119 const href = stylesheetHref(home.body);
120 if (!href) failures.push({ url: home.url, agent, status: 200, contentType: "", why: "no stylesheet linked" });
121 else {
122 const css = await get(href, agent);
123 if (css) {
124 const type = css.res.headers.get("content-type") ?? "";
125 if (css.res.status !== 200) fail(css.url, agent, css.res, "expected 200");
126 else if (!/^text\/css\b/i.test(type)) fail(css.url, agent, css.res, "expected text/css");
127 else if (!css.body.trim()) fail(css.url, agent, css.res, "empty stylesheet");
128 }
129 }
130 }
131 }
132 const board = await get("/board", agent, auth);
133 const signIn = options.anonymousBoard && !token && board?.res.status === 302;
134 if (board && board.res.status !== 200 && !signIn) fail(board.url, agent, board.res, "expected 200 (a 302 is sign-in)");
135 const health = await get("/healthz", agent);
136 if (health && health.res.status !== 200) fail(health.url, agent, health.res, "expected 200");
137 }
138
139 const sw = await get("/sw.js", "desktop");
140 if (sw) {
141 const type = sw.res.headers.get("content-type") ?? "";
142 if (sw.res.status !== 200) fail(sw.url, "desktop", sw.res, "expected 200");
143 else if (!/^(text|application)\/(x-)?javascript\b/i.test(type)) fail(sw.url, "desktop", sw.res, "expected javascript");
144 else {
145 const nav = navigationBranch(sw.body);
146 if (nav === null) fail(sw.url, "desktop", sw.res, "no navigation branch");
147 else if (/Response\s*\.\s*error\s*\(/.test(nav)) fail(sw.url, "desktop", sw.res, "a navigation can be answered with Response.error()");
148 }
149 }
150 return failures;
151 }
test/notify-rollback.test.ts added +98
@@ -0,0 +1,98 @@
1 /**
2 * A roll the image's launcher undid pages the admins once (#2652). It
3 * happened before the forge that serves began, so the boot baseline would
4 * swallow it; the record on disk says whether it was told, and a second
5 * boot does not tell it again.
6 */
7
8 import { afterEach, beforeEach, expect, test } from "bun:test";
9 import { mkdirSync, writeFileSync } from "node:fs";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { setNotifyUrl, startNotifier } from "../src/crew/notify.ts";
16 import { vitals } from "../src/kernel/vitals.ts";
17 import { rolledBack } from "../src/release/launch.ts";
18
19 let dataDir: string;
20 let app: App;
21 const posts: string[] = [];
22 let deliver = true;
23
24 beforeEach(async () => {
25 dataDir = await mkdtemp(join(tmpdir(), "forge-notify-rollback-"));
26 app = createApp({ dataDir, port: 0 });
27 const made = await createUser(app.db, {
28 origin: "seed",
29 trusted: true,
30 username: "alice",
31 email: "[email protected]",
32 password: "a-good-passphrase",
33 adminIfFirst: true,
34 });
35 if (!made.ok) throw new Error(made.error);
36 setNotifyUrl(app.db, made.value.id, "https://ntfy.example.test/root-topic");
37 posts.length = 0;
38 deliver = true;
39 });
40 afterEach(async () => {
41 app.db.close();
42 await rm(dataDir, { recursive: true, force: true });
43 });
44
45 const record = () => {
46 mkdirSync(join(dataDir, "release"), { recursive: true });
47 writeFileSync(
48 join(dataDir, "release", "rolled-back.json"),
49 JSON.stringify({ from: "0.7.166", to: "0.7.162", at: "2026-10-19T05:17:00.000Z", why: "it exited with 1 before it was ready", paged: false }),
50 );
51 };
52
53 async function boot(): Promise<void> {
54 const notifier = startNotifier(app, {
55 forgeUrl: "https://forge.example.test",
56 drainingSince: () => null,
57 causes: () => [],
58 send: async (_u, body) => {
59 if (deliver) posts.push(body);
60 return deliver;
61 },
62 });
63 await notifier.settled();
64 await notifier.tick();
65 await notifier.close();
66 }
67
68 const SENTENCE = "Release 0.7.166 did not become ready (it exited with 1 before it was ready); rolled back to 0.7.162.";
69
70 test("a rollback is a card linking the board, and pages once across boots", async () => {
71 record();
72 expect(vitals({ db: app.db, signals: app.signals, dataDir }).degraded.find((d) => d.cause === "release:rolled-back")).toMatchObject({
73 sentence: SENTENCE,
74 href: "/board",
75 since: "2026-10-19T05:17:00.000Z",
76 });
77 await boot();
78 expect(posts).toEqual([`${SENTENCE}\nhttps://forge.example.test/`]);
79 expect(rolledBack(dataDir)?.paged).toBe(true);
80 await boot();
81 expect(posts).toHaveLength(1);
82 });
83
84 test("a page that was not delivered is not marked, and goes out on a later try", async () => {
85 record();
86 deliver = false;
87 await boot();
88 expect(rolledBack(dataDir)?.paged).toBe(false);
89 deliver = true;
90 await boot();
91 expect(posts).toHaveLength(1);
92 expect(rolledBack(dataDir)?.paged).toBe(true);
93 });
94
95 test("no record, no page", async () => {
96 await boot();
97 expect(posts).toEqual([]);
98 });
test/release-boot.test.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * A release that does not boot is refused before it ships (#2652): the
3 * build and the promote preflight both run `src/release/boot.ts`, which
4 * starts the tree on a scratch data directory and wants `/api/v1/health` to
5 * answer as the stamped version. 0.7.166's image crashed at import; a tree
6 * that does the same is refused here, naming the error.
7 */
8
9 import { afterAll, beforeAll, expect, test } from "bun:test";
10 import { Database } from "bun:sqlite";
11 import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { bootProof, parseArgs, proveBoot, why } from "../src/release/boot.ts";
15
16 let root: string;
17 beforeAll(() => {
18 root = mkdtempSync(join(tmpdir(), "forge-release-boot-"));
19 });
20 afterAll(() => rmSync(root, { recursive: true, force: true }));
21
22 /** A tree whose `src/main.ts` is `main`. */
23 function tree(name: string, main: string): string {
24 const dir = join(root, name);
25 mkdirSync(join(dir, "src"), { recursive: true });
26 writeFileSync(join(dir, "src", "main.ts"), main);
27 return dir;
28 }
29
30 /** A forge in miniature: health answers with whatever `version()` says. */
31 const serving = (version: string) => `
32 Bun.serve({ hostname: "127.0.0.1", port: Number(process.env.PORT), fetch(req) {
33 if (new URL(req.url).pathname !== "/api/v1/health") return new Response("", { status: 404 });
34 return Response.json({ ok: true, version: ${version} });
35 } });
36 `;
37
38 test("a tree whose main import throws is refused, naming the module it could not find", async () => {
39 const dir = tree("broken", `import { smoke } from "../../scripts/smoke.ts";\nconsole.log(smoke);\n`);
40 const result = await proveBoot({ tree: dir, version: "0.7.166", budgetMs: 30_000 });
41 expect(result.ok).toBe(false);
42 if (!result.ok) {
43 expect(result.line).toStartWith("exited with 1 before it answered health: ");
44 expect(result.line).toContain("Cannot find module '../../scripts/smoke.ts'");
45 }
46 });
47
48 test("a tree that answers as its version passes; the scratch directory is gone after", async () => {
49 const dir = tree("well", serving("process.env.FORGE_VERSION"));
50 const before = readdirSync(tmpdir()).filter((n) => n.startsWith("boot-proof-")).length;
51 const result = await proveBoot({ tree: dir, version: "1.2.3", budgetMs: 30_000 });
52 expect(result).toMatchObject({ ok: true });
53 expect(readdirSync(tmpdir()).filter((n) => n.startsWith("boot-proof-")).length).toBe(before);
54 });
55
56 test("an answer as another version is not a pass: the budget runs out and says what it saw", async () => {
57 const dir = tree("stale", serving(`"0.0.1"`));
58 const result = await bootProof({ tree: dir, version: "1.2.3", dataDir: join(root, "stale-data"), budgetMs: 2_000 });
59 expect(result).toEqual({ ok: false, line: "did not answer health as 1.2.3 within 2s (health answered as 0.0.1)" });
60 });
61
62 test("--from boots a copy of the live data: the database as it is, the top-level files, never the live directory", async () => {
63 const live = join(root, "live");
64 mkdirSync(join(live, "repos", "a"), { recursive: true });
65 writeFileSync(join(live, "secret.key"), "k", { mode: 0o600 });
66 const db = new Database(join(live, "forge.db"), { create: true });
67 db.run("PRAGMA journal_mode=WAL");
68 db.run("CREATE TABLE stamp (v TEXT)");
69 db.run("INSERT INTO stamp VALUES ('4.5.6')");
70 // Held open, as the running forge holds it: the row is in the log, not yet the file.
71 const dir = tree(
72 "copied",
73 `import { Database } from "bun:sqlite";
74 import { existsSync, readFileSync } from "node:fs";
75 const data = process.env.FORGE_DATA!;
76 if (readFileSync(data + "/secret.key", "utf8") !== "k" || existsSync(data + "/repos")) process.exit(3);
77 const v = new Database(data + "/forge.db").query("SELECT v FROM stamp").get().v;
78 ${serving("v")}`,
79 );
80 try {
81 expect(await proveBoot({ tree: dir, version: "4.5.6", from: live, budgetMs: 30_000 })).toMatchObject({ ok: true });
82 } finally {
83 db.close();
84 }
85 expect(readdirSync(live).sort()).toEqual(["forge.db", "repos", "secret.key"]);
86 expect(await proveBoot({ tree: dir, version: "4.5.6", from: join(root, "nowhere") })).toEqual({
87 ok: false,
88 line: `${join(root, "nowhere")} holds no forge.db to copy`,
89 });
90 });
91
92 test("this tree, shipped as the image ships it (src/ alone), boots", async () => {
93 const image = join(root, "image");
94 const repo = join(import.meta.dir, "..");
95 cpSync(join(repo, "src"), join(image, "src"), { recursive: true });
96 for (const file of ["package.json", "tsconfig.json"]) cpSync(join(repo, file), join(image, file));
97 expect(existsSync(join(image, "scripts"))).toBe(false);
98 const result = await proveBoot({ tree: image, version: "9.9.9", budgetMs: 60_000 });
99 expect(result).toMatchObject({ ok: true });
100 }, 90_000);
101
102 test("the reason is the error line, one line, bounded", () => {
103 expect(why("\n1 | import x\n ^\nerror: Cannot find module 'x'\n at y\n\nBun v1.3\n")).toBe("error: Cannot find module 'x'");
104 expect(why("just died\n")).toBe("just died");
105 expect(why("")).toBe("");
106 expect(why("x".repeat(500)).length).toBe(300);
107 });
108
109 test("arguments: --from and --budget, nothing else", () => {
110 expect(parseArgs([])).toEqual({ budgetMs: 120_000 });
111 expect(parseArgs(["--from", "/data", "--budget", "60"])).toEqual({ from: "/data", budgetMs: 60_000 });
112 for (const bad of [["--from"], ["--budget", "0"], ["--budget", "1e3"], ["--nope"], ["/data"]])
113 expect(typeof parseArgs(bad)).toBe("string");
114 });
115
116 test("the build runs the proof on the image it built, and the chart runs it on a copy before an upgrade", async () => {
117 const repo = join(import.meta.dir, "..");
118 const docker = await Bun.file(join(repo, "Dockerfile")).text();
119 const lines = docker.split("\n");
120 const copied = lines.findIndex((l) => l.startsWith("COPY src "));
121 const proved = lines.indexOf("RUN bun src/release/boot.ts");
122 expect(copied).toBeGreaterThan(-1);
123 expect(proved).toBeGreaterThan(copied);
124 expect(proved).toBeGreaterThan(lines.findIndex((l) => l.startsWith("ENV FORGE_VERSION=")));
125 const hook = await Bun.file(join(repo, "charts/app/templates/boot-proof.yaml")).text();
126 expect(hook).toContain("helm.sh/hook: pre-upgrade");
127 expect(hook).toContain('command: ["bun", "src/release/boot.ts", "--from", "/data", "--budget", "180"]');
128 expect(hook).toContain("claimName: {{ .Release.Name }}-data");
129 expect(hook).toContain("backoffLimit: 0");
130 });
test/release-launch.test.ts added +160
@@ -0,0 +1,160 @@
1 /**
2 * A roll to a version that never becomes ready undoes itself (#2652): the
3 * image's launcher (`src/release/launch.ts`) starts its forge, and when that
4 * forge exits or is not ready within the grace, it starts the last version
5 * that was, from the tree kept on the volume, and records the rollback for
6 * the forge to show and page once.
7 */
8
9 import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
10 import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import type { Subprocess } from "bun";
14 import {
15 keepGood,
16 lastGood,
17 launch,
18 markRollbackPaged,
19 READY_GRACE_MS,
20 rollbackNeed,
21 rolledBack,
22 type Launched,
23 } from "../src/release/launch.ts";
24
25 let root: string;
26 let n = 0;
27 const running: Subprocess[] = [];
28 beforeAll(() => {
29 root = mkdtempSync(join(tmpdir(), "forge-release-launch-"));
30 });
31 const reap = async () => {
32 for (const child of running.splice(0)) {
33 child.kill("SIGKILL");
34 await child.exited;
35 }
36 };
37 afterEach(reap);
38 afterAll(() => rmSync(root, { recursive: true, force: true }));
39
40 function tree(main: string): string {
41 const dir = join(root, `tree-${++n}`);
42 mkdirSync(join(dir, "src"), { recursive: true });
43 writeFileSync(join(dir, "src", "main.ts"), main);
44 writeFileSync(join(dir, "package.json"), "{}");
45 return dir;
46 }
47
48 const SERVES = `
49 Bun.serve({ hostname: "127.0.0.1", port: Number(process.env.PORT), fetch(req) {
50 if (new URL(req.url).pathname !== "/api/v1/health") return new Response("", { status: 404 });
51 return Response.json({ ok: true, version: process.env.FORGE_VERSION, commit: process.env.FORGE_COMMIT || null });
52 } });
53 `;
54 const THROWS = `throw new Error("(test fixture) a release whose main throws at import, as 0.7.166's did");\n`;
55 const HANGS = `setInterval(() => {}, 1_000);\n`;
56
57 async function freePort(): Promise<number> {
58 const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("") });
59 const port = probe.port ?? 0;
60 await probe.stop(true);
61 return port;
62 }
63
64 async function run(dataDir: string, dir: string, version: string, graceMs = 30_000): Promise<Launched & { port: number }> {
65 const port = await freePort();
66 const launched = await launch({
67 tree: dir,
68 dataDir,
69 port,
70 version,
71 commit: null,
72 env: { PATH: process.env.PATH, PORT: String(port) },
73 graceMs,
74 started: (child) => running.push(child),
75 });
76 return { ...launched, port };
77 }
78
79 async function healthAt(port: number): Promise<{ version: string; commit: string | null } | undefined> {
80 for (let i = 0; i < 100; i++) {
81 try {
82 return (await (await fetch(`http://127.0.0.1:${port}/api/v1/health`)).json()) as { version: string; commit: string | null };
83 } catch {
84 await Bun.sleep(100);
85 }
86 }
87 return undefined;
88 }
89
90 test("a version that reads ready is kept as the last good release", async () => {
91 const data = join(root, "data-ready");
92 expect((await run(data, tree(SERVES), "0.7.162")).ran).toBe("release");
93 expect(lastGood(data)).toMatchObject({ version: "0.7.162" });
94 expect(existsSync(join(data, "release", "good", "0.7.162", "src", "main.ts"))).toBe(true);
95 expect(existsSync(join(data, "release", "good", "0.7.162", "package.json"))).toBe(true);
96 });
97
98 test("a version whose main throws at import is undone at once: the last good one serves under its own stamp", async () => {
99 const data = join(root, "data-throws");
100 keepGood(data, tree(SERVES), "0.7.162", "a".repeat(40));
101 const launched = await run(data, tree(THROWS), "0.7.166");
102 expect(launched.ran).toBe("rollback");
103 expect(launched.line).toBe("0.7.166: it exited with 1 before it was ready; rolled back to 0.7.162");
104 expect(await healthAt(launched.port)).toMatchObject({ version: "0.7.162", commit: "a".repeat(40) });
105 expect(rolledBack(data)).toMatchObject({ from: "0.7.166", to: "0.7.162", paged: false });
106 expect(rollbackNeed(data)?.sentence).toBe(
107 "Release 0.7.166 did not become ready (it exited with 1 before it was ready); rolled back to 0.7.162.",
108 );
109 expect(lastGood(data)?.version).toBe("0.7.162");
110 });
111
112 test("a version that never becomes ready is undone when the grace runs out, well inside five minutes", async () => {
113 expect(READY_GRACE_MS).toBeLessThan(5 * 60_000);
114 const data = join(root, "data-hangs");
115 keepGood(data, tree(SERVES), "0.7.162", null);
116 const launched = await run(data, tree(HANGS), "0.7.167", 2_000);
117 expect(launched.ran).toBe("rollback");
118 expect(launched.line).toBe("0.7.167: not ready within 2s; rolled back to 0.7.162");
119 expect(await healthAt(launched.port)).toMatchObject({ version: "0.7.162" });
120 });
121
122 test("with nothing kept, or only itself, there is nothing to roll back to", async () => {
123 expect(await run(join(root, "data-empty"), tree(THROWS), "0.7.166")).toMatchObject({
124 ran: "none",
125 line: "0.7.166: it exited with 1 before it was ready; no earlier release is kept to roll back to",
126 });
127 const self = join(root, "data-self");
128 keepGood(self, tree(SERVES), "0.7.166", null);
129 expect((await run(self, tree(THROWS), "0.7.166")).ran).toBe("none");
130 expect(rolledBack(self)).toBeUndefined();
131 });
132
133 test("the next version that reads ready replaces the kept tree and clears the rollback", async () => {
134 const data = join(root, "data-next");
135 keepGood(data, tree(SERVES), "0.7.162", null);
136 await run(data, tree(THROWS), "0.7.166");
137 await reap();
138 markRollbackPaged(data);
139 expect(rolledBack(data)?.paged).toBe(true);
140 expect((await run(data, tree(SERVES), "0.7.167")).ran).toBe("release");
141 expect(readdirSync(join(data, "release", "good"))).toEqual(["0.7.167"]);
142 expect(rolledBack(data)).toBeUndefined();
143 });
144
145 test("a record that is not one reads as none", () => {
146 const data = join(root, "data-garbage");
147 mkdirSync(join(data, "release"), { recursive: true });
148 writeFileSync(join(data, "release", "rolled-back.json"), "{nope");
149 writeFileSync(join(data, "release", "good.json"), JSON.stringify({ version: "../x", at: "t" }));
150 expect(rolledBack(data)).toBeUndefined();
151 expect(lastGood(data)).toBeUndefined();
152 });
153
154 test("the image starts the launcher, and the launcher imports nothing a release could break", async () => {
155 const repo = join(import.meta.dir, "..");
156 expect(await Bun.file(join(repo, "Dockerfile")).text()).toContain('CMD ["bun", "src/release/launch.ts"]');
157 const source = await Bun.file(join(repo, "src/release/launch.ts")).text();
158 const imports = new Bun.Transpiler({ loader: "ts" }).scanImports(source).map((i) => i.path);
159 expect(imports.every((p) => p.startsWith("node:") || p === "bun")).toBe(true);
160 });
test/spawn-pipes.test.ts +1
@@ -28,6 +28,7 @@ const PIPING: Record<string, { sites: string[]; reason: string }> = {
2828 "src/git/smart-ssh.ts": { sites: ["stdout+stderr"], reason: "pump() drains stdout and stderr into the channel" },
2929 "src/kernel/data_home.ts": { sites: ["stdout"], reason: "spawnSync buffers stdout; stderr ignored" },
3030 "src/looks/capture.ts": { sites: ["stderr"], reason: "stderr read in a background loop; stdout ignored" },
31 "src/release/boot.ts": { sites: ["stderr"], reason: "stderr read to its end in a background loop, awaited before the reason is read; stdout ignored" },
3132 "src/release/checkout.ts": { sites: ["stdout+stderr"], reason: "stdout and stderr both read via Response.text()" },
3233 "src/cli/config.ts": { sites: ["stdout"], reason: "stdout awaited with the exit; stderr ignored" },
3334 };
test/src-imports.test.ts added +70
@@ -0,0 +1,70 @@
1 /**
2 * The image ships `src/` and nothing else (Dockerfile): a module under src/
3 * that imports from scripts/ or test/ boots from the repository and crashes
4 * in production. 0.7.166 did, on `src/release/prove.ts` importing
5 * `scripts/smoke.ts`, and was down two hours (#2652). Every relative import
6 * a src/ file makes, static or dynamic, must resolve inside src/.
7 */
8
9 import { expect, test } from "bun:test";
10 import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11 import { dirname, join, relative, resolve, sep } from "node:path";
12
13 const ROOT = resolve(import.meta.dir, "..");
14 const SRC = join(ROOT, "src");
15
16 function sources(dir: string): string[] {
17 const out: string[] = [];
18 for (const entry of readdirSync(dir, { withFileTypes: true })) {
19 const path = join(dir, entry.name);
20 if (entry.isDirectory()) out.push(...sources(path));
21 else if (/\.(ts|tsx|js|mjs)$/.test(entry.name) && !entry.name.endsWith(".d.ts")) out.push(path);
22 }
23 return out;
24 }
25
26 /** Each relative import in `files` that resolves outside `src`, as `file -> specifier`. */
27 export function escapes(files: readonly string[], src: string): string[] {
28 const scanners = { ts: new Bun.Transpiler({ loader: "ts" }), tsx: new Bun.Transpiler({ loader: "tsx" }) };
29 const bad: string[] = [];
30 for (const file of files) {
31 const scanner = file.endsWith(".tsx") ? scanners.tsx : scanners.ts;
32 for (const { path } of scanner.scanImports(readFileSync(file, "utf8").replace(/^#!.*/, ""))) {
33 if (!path.startsWith(".")) continue;
34 const target = resolve(dirname(file), path);
35 if (target !== src && !target.startsWith(src + sep)) bad.push(`${relative(ROOT, file)} -> ${path}`);
36 }
37 }
38 return bad;
39 }
40
41 test("nothing under src/ imports from outside src/ (scripts/, test/, …)", () => {
42 expect(escapes(sources(SRC), SRC)).toEqual([]);
43 });
44
45 test("the walk sees a static import, a re-export and a dynamic import that leave src/", () => {
46 const dir = join(process.env.TMPDIR ?? "/tmp", `src-imports-${process.pid}`);
47 const src = join(dir, "src");
48 const file = join(src, "release", "x.ts");
49 mkdirSync(dirname(file), { recursive: true });
50 writeFileSync(
51 file,
52 [
53 'import { smoke } from "../../scripts/smoke.ts";',
54 'export { h } from "../../test/helpers/h.ts";',
55 'const m = await import("../../scripts/shards.ts");',
56 'import { ok } from "../kernel/result.ts";',
57 'const text = `import { lint } from "../../scripts/lint-design.ts";`;',
58 "console.log(smoke, m, ok, text);",
59 ].join("\n"),
60 );
61 try {
62 expect(escapes([file], src).map((l) => l.split(" -> ")[1])).toEqual([
63 "../../scripts/smoke.ts",
64 "../../test/helpers/h.ts",
65 "../../scripts/shards.ts",
66 ]);
67 } finally {
68 rmSync(dir, { recursive: true, force: true });
69 }
70 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs