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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/2652-a-release-that-does-not-boot-never-reach → main 6 commits fork
19 files
changed +1261
−164
Dockerfile +11 −3
| @@ -14,8 +14,8 @@ RUN apt-get update \ | ||
| 14 | 14 | WORKDIR /app |
| 15 | 15 | COPY package.json tsconfig.json ./ |
| 16 | 16 | 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). | |
| 19 | 19 | |
| 20 | 20 | # What the running forge says it is (src/kernel/version.ts): the release |
| 21 | 21 | # 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 | ||
| 29 | 29 | ENV FORGE_DATA=/data PORT=8080 FORGE_SSH_PORT=2222 |
| 30 | 30 | EXPOSE 8080 2222 |
| 31 | 31 | 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 | |
| 32 | 37 | # tini reaps what an agent run leaves behind: a claude run spawns git, bun, |
| 33 | 38 | # the app under test, and their children. |
| 34 | 39 | 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. | ||
| 243 | 243 | |
| 244 | 244 | A new version proves itself once per version: after a boot that changes |
| 245 | 245 | `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 | |
| 247 | 247 | keeps the verdict in `$FORGE_APP_DATA/release/proof.json`. A failure is a |
| 248 | 248 | `Release X is unproven: …` card on an admin's board and on the terminal's first |
| 249 | 249 | 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. | ||
| 141 | 141 | |
| 142 | 142 | Release as the release workflow does. Nothing in this recipe changes the |
| 143 | 143 | 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; | ||
| 24 | 24 | */ |
| 25 | 25 | export const WORKER_MEMORY_BYTES = 512 * 1024 * 1024; |
| 26 | 26 | |
| 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"; | |
| 46 | 31 | |
| 47 | 32 | /** |
| 48 | 33 | * How many shards: one per CPU the process may use, no more than its |
scripts/smoke.ts +2 −138
| @@ -11,144 +11,8 @@ | ||
| 11 | 11 | * never answers with `Response.error()`. |
| 12 | 12 | */ |
| 13 | 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("&", "&"); | |
| 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"; | |
| 152 | 16 | |
| 153 | 17 | if (import.meta.main) { |
| 154 | 18 | const args = process.argv.slice(2); |
src/checks/service.ts +1 −1
| @@ -61,7 +61,7 @@ import { appendRunLog } from "../runs/log.ts"; | ||
| 61 | 61 | import { runningLimitSentence, runningRuns } from "../settings/quotas.ts"; |
| 62 | 62 | import { exportTree, readFileAt } from "../apps/checkout.ts"; |
| 63 | 63 | import { parseCheck } from "../apps/manifest.ts"; |
| 64 | import { workerCpus } from "../../scripts/shards.ts"; | |
| 64 | import { workerCpus } from "../core/capacity.ts"; | |
| 65 | 65 | import { accountActive } from "../core/accounts.ts"; |
| 66 | 66 | import { childEnv, runStep } from "../apps/process.ts"; |
| 67 | 67 | 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 { | ||
| 176 | 176 | return machine; |
| 177 | 177 | } |
| 178 | 178 | |
| 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 | ||
| 179 | 199 | const GIB = 1024 ** 3; |
| 180 | 200 | const MIB = 1024 ** 2; |
| 181 | 201 |
src/crew/notify.ts +25
| @@ -31,6 +31,7 @@ import { getUserById, type User } from "../core/users.ts"; | ||
| 31 | 31 | import { landedLines } from "../core/watches.ts"; |
| 32 | 32 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 33 | 33 | import { lastMirror, mirrorNeed } from "../snapshot/mirror.ts"; |
| 34 | import { markRollbackPaged, rollbackNeed, rolledBack } from "../release/launch.ts"; | |
| 34 | 35 | import { drillNeed, firstSeen, lastVerdict } from "../snapshot/verdict.ts"; |
| 35 | 36 | import { health } from "../http/health.ts"; |
| 36 | 37 | import { forgeVitals } from "../actions/health.ts"; |
| @@ -309,6 +310,28 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier { | ||
| 309 | 310 | } |
| 310 | 311 | } |
| 311 | 312 | |
| 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 | ||
| 312 | 335 | async function pageDrain(userId: string, url: string, baseline: boolean): Promise<void> { |
| 313 | 336 | const current = drainIncident(drainStart(), now()); |
| 314 | 337 | if (baseline || !paged.has(userId)) { |
| @@ -409,6 +432,7 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier { | ||
| 409 | 432 | await tell(id, true).catch(() => undefined); |
| 410 | 433 | await page(id, true).catch(() => undefined); |
| 411 | 434 | } |
| 435 | await pageRollback().catch(() => undefined); | |
| 412 | 436 | }); |
| 413 | 437 | |
| 414 | 438 | // 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 { | ||
| 428 | 452 | queue = queue.then(async () => { |
| 429 | 453 | try { |
| 430 | 454 | for (const id of people()) await page(id, false); |
| 455 | await pageRollback(); | |
| 431 | 456 | } catch (error) { |
| 432 | 457 | app.log.error("notify failed", { error: String(error) }); |
| 433 | 458 | } |
src/kernel/vitals.ts +5
| @@ -26,6 +26,7 @@ import { lastMirror, mirrorNeed } from "../snapshot/mirror.ts"; | ||
| 26 | 26 | import { lastSnapshotOutcome, snapshotNeed } from "../snapshot/outcome.ts"; |
| 27 | 27 | import { drillNeed, firstSeen as drillFirstSeen, lastVerdict } from "../snapshot/verdict.ts"; |
| 28 | 28 | import { proofNeed } from "../release/prove.ts"; |
| 29 | import { rollbackNeed } from "../release/launch.ts"; | |
| 29 | 30 | import { behindStanding } from "../release/behind.ts"; |
| 30 | 31 | import { newestDrills } from "../core/domain_drills.ts"; |
| 31 | 32 | import { DOMAINS } from "./domains.ts"; |
| @@ -399,6 +400,10 @@ export function vitals(input: VitalsInput): Vitals { | ||
| 399 | 400 | const unproven = proofNeed(input.dataDir); |
| 400 | 401 | if (unproven !== undefined) need("release", unproven.sentence, unproven.since, "/board"); |
| 401 | 402 | |
| 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 | ||
| 402 | 407 | // Production serving a version older than a release main has had over an hour is a card until they match. |
| 403 | 408 | const lagging = behindStanding(now); |
| 404 | 409 | 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Is this release so: once per version, after a boot that changed it, the |
| 3 | 3 | * 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 | |
| 5 | 5 | * one small file under the data directory (`release/proof.json`), so a |
| 6 | 6 | * restart of the same version never runs it again and vitals reads it |
| 7 | 7 | * without the network. A failure is a `release` card, paged once by the |
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; |
| 12 | 12 | import { join } from "node:path"; |
| 13 | import { smoke, type Failure } from "../../scripts/smoke.ts"; | |
| 13 | import { smoke, type Failure } from "./smoke.ts"; | |
| 14 | 14 | import { UNSTAMPED } from "../kernel/version.ts"; |
| 15 | 15 | |
| 16 | 16 | /** 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("&", "&"); | |
| 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 }> = { | ||
| 28 | 28 | "src/git/smart-ssh.ts": { sites: ["stdout+stderr"], reason: "pump() drains stdout and stderr into the channel" }, |
| 29 | 29 | "src/kernel/data_home.ts": { sites: ["stdout"], reason: "spawnSync buffers stdout; stderr ignored" }, |
| 30 | 30 | "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" }, | |
| 31 | 32 | "src/release/checkout.ts": { sites: ["stdout+stderr"], reason: "stdout and stderr both read via Response.text()" }, |
| 32 | 33 | "src/cli/config.ts": { sites: ["stdout"], reason: "stdout awaited with the exit; stderr ignored" }, |
| 33 | 34 | }; |
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 | }); |
new above
offline