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

The forge ships itself: a landing goes live in seconds with no image, no Forgejo, no Kubernetes roll and no steward #8054

Merged opened this in answer to

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

Merged

torque/forge:torque/8042-the-forge-ships-itself-a-landing-goes-li → main 10 commits fork 4 threads · 4 unresolved

  • Merged. torque/8042-the-forge-ships-itself-a-landing-goes-li was merged into main as c1ddab9.

Review

Sign in to review this change.
23 files changed +1113 −79
README.md +1 −1
@@ -545,7 +545,7 @@ What the crew's tokens bought is on the board (`/board/returns`), in MCP `return
545545
546546 The terminal's palette has `Signed in` for anyone signed in: your browsers, tokens and this terminal's key, with when each was last seen; `d` signs the selected browser out after a confirm, as `/settings` and MCP `sessions` do, and on a token revokes it after a confirm (Keep is the default) through the same `deleteToken` action as Settings and the API, so it stops working at once (`docs/CREW.md`, "Where you are signed in").
547547
548 The release keeper (`src/keeper/`, #7557) cuts, promotes and verifies releases on the forge's own one-minute timer, with no model. It is off until `FORGE_RELEASE_KEEPER=on`, and the pod's service account may `get` and `patch` the forge Deployment.
548 The release keeper (`src/keeper/`, #7557) cuts, promotes and verifies releases on the forge's own one-minute timer, with no model. It is off until `FORGE_RELEASE_KEEPER=on`, and the pod's service account may `get` and `patch` the forge Deployment. `FORGE_RELEASE_KEEPER=swap` needs no cluster: it swaps main's tree in place (`docs/features/ship-itself.md`). Its tag is the forge's own and is not pushed to an origin, so a cut is invisible to Forgejo-side tools. /releases, the terminal's Release keeper and MCP `release_keeper` show each release's short commit and its seconds from landed to live; /changelog's live line says it too.
549549
550550 - **Cut:** it tags main's head as `v<patch + 1>` when main has moved past a live tag, nothing is in flight, the merge queue is still, 30 minutes have passed since its last cut and no hold stands.
551551 - **Verify:** it records the known-good version and image before the tag, sets the Deployment's image, and has the new version spend ten minutes under page load probing `/healthz` each second.
docs/MCP.md +1 −1
@@ -142,7 +142,7 @@ for a tunnel and a forge that sleeps.
142142 | `quotas` | `{ key?, value?, dryRun? }` (forge admin): the per-account quotas `repositories`, `apps`, `storage` (MB), `previews`, `runs`, `checks`, each a number or null for none. `repositories` bounds repos on create, fork and copy; `apps` the apps made; `storage` the MB of repos plus app data, asked on push, create and fork; `previews` live previews; `runs` concurrent agent runs; `checks` concurrent checks. Each is asked before the thing is made or started and a forge admin is never limited; with `key` and `value` (`""` clears), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token | yes |
143143 | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents; MP4/MOV/M4A files also carry about: {kind, seconds, width?, height?, says} | |
144144 | `release_hold` | (admin scope) `{ reason }` or `{ lift }`: hold the release keeper (#7557) with a reason, so it cuts nothing until the hold is lifted; or lift an owner hold by its id. A failed verify's hold is lifted by a verified release after a pull saying `release hold N` lands, not here. A person who may roll back, with an admin-scope token, never an agent | yes |
145 | `release_keeper` | `{}`: what the release keeper (#7557) did: its releases newest first (version, stage — rolling, verifying, verified or rolled_back —, the known-good version it would roll back to, roll probes and non-200s, probes and pages under load, worst ms, seconds dark, reason) and its holds (kind failed or owner, version, reason, probe numbers, the pull that said `release hold N`, lifted); `blocked` says what stops the next cut; for whoever may read /releases | |
145 | `release_keeper` | `{}`: what the release keeper (#7557) did: its releases newest first (version, commit (short sha), stage — rolling, verifying, verified or rolled_back —, the known-good version it would roll back to, roll probes and non-200s, probes and pages under load, worst ms, seconds dark, reason, landedToLiveSeconds — from the merge of its pull to the new version answering, null until it has (#8042)) and its holds (kind failed or owner, version, reason, probe numbers, the pull that said `release hold N`, lifted); `blocked` says what stops the next cut; for whoever may read /releases | |
146146 | `releases` | `{shipped: true}`: what went live, for anyone: `version`, `commit`, `live` (newest first: `repo`, `number`, `title`, `at`) and `onItsWay` (landed, not yet in the running commit), the list /changelog shows. `{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page | |
147147 | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | |
148148 | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`, each label with `open`, its open issues and pulls, for a writer; a read-only token may), create (`create`, `name`, `color` six hex digits), rename or recolour (`update`, `id`, `name` and/or `color`, as the labels page) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `PATCH`/`DELETE …/labels/:id`; create, update and remove need an admin token and a repository admin | yes |
docs/design/areas/table.md +1 −1
@@ -2,4 +2,4 @@
22
33 A §5 `.table` too wide for a narrow screen sits inside a horizontal scroller, so the page itself never scrolls sideways and only the table's figures move.
44
5 - `.table-scroll` — the wrapper `<div>` around a wide `.table`: `overflow-x: auto` with a thin scrollbar, styled in `src/web/style/components.ts`. Pair it with `.table[data-sticky-lead]` to keep each row's name in view while the figures scroll. Written by `src/web/pages/account.ts`, `src/web/pages/audit.ts`, `src/web/pages/boards-held.ts`, `src/web/pages/crew.ts`, `src/web/pages/flow-panel.ts`, `src/web/pages/passkeys.ts`, `src/web/pages/proposal-tokens.ts`, `src/web/pages/settings.ts`, `src/web/pages/spend.ts`, `src/web/pages/subscriptions.ts`, `src/web/pages/system.ts`, `fleetGraphSection`'s figures table in `src/web/fleet-graph.ts`, and `vitalsTable` in `src/web/studio/vitals.ts`.
5 - `.table-scroll` — the wrapper `<div>` around a wide `.table`: `overflow-x: auto` with a thin scrollbar, styled in `src/web/style/components.ts`. Pair it with `.table[data-sticky-lead]` to keep each row's name in view while the figures scroll. Written by `src/web/pages/account.ts`, `src/web/pages/audit.ts`, `src/web/pages/boards-held.ts`, `src/web/pages/crew.ts`, `src/web/pages/flow-panel.ts`, `src/web/pages/keeper-panel.ts`, `src/web/pages/passkeys.ts`, `src/web/pages/proposal-tokens.ts`, `src/web/pages/settings.ts`, `src/web/pages/spend.ts`, `src/web/pages/subscriptions.ts`, `src/web/pages/system.ts`, `fleetGraphSection`'s figures table in `src/web/fleet-graph.ts`, and `vitalsTable` in `src/web/studio/vitals.ts`.
docs/features/ship-itself.md added +34
@@ -0,0 +1,34 @@
1 # The forge ships itself (#8042)
2
3 One path, in the forge: **landing → on cadence the keeper takes main's tree → preflight → swap in place → verify under load → known good, or swap back and hold.** No image, no registry, no Forgejo, no Kubernetes roll, no steward.
4
5 ## Switch
6
7 `FORGE_REPO=<owner>/<name> FORGE_SELF_SWAP=1 FORGE_RELEASE_KEEPER=swap` (the conductor's one line), on a kernel with `net.ipv4.tcp_migrate_req=1` and with the SSH terminal off (`FORGE_SSH_PORT=0`) until it can be handed over. `FORGE_RELEASE_KEEPER=on` is the old Deployment-patch path and stays until production has moved.
8
9 ## What happens
10
11 1. `releaseOnLand` checks each landed tree out to `releases/<sha>/` (`checks/landing.ts`).
12 2. Every minute (`SWAP_INTERVAL_MS`) the keeper (`keeper/keeper.ts`) compares main with the newest tag. `swapper.admits` refuses a cut that could not be swapped: SSH held, the kernel setting missing, a dependency added, a red `--preflight`. Nothing is tagged or recorded then, and the line says why.
13 3. The cut records the known good as `tree/<commit>:<version>`, tags `v<version>` in the forge's own repository only (no origin push, so Forgejo-side tools never see the cut), and `swapper.setImage` pins the running tree under `releases/<commit>/`, starts the new tree beside this process on the same port (`release/swap.ts`, SO_REUSEPORT, `FORGE_VERSION` and `FORGE_COMMIT` set), waits until health names its commit, and drains this process.
14 4. The new process finds a release in flight, spends ten minutes under page load (`keeper/verify.ts`) and either calls `verified` (the tree becomes the last good release) or sets the recorded known-good tree by name, which is the same swap, and holds.
15
16 ## A pod restart
17
18 `release/launch.ts` is the container's first process. `keepGood` records a swapped tree with `base`, the image version it was swapped over, in `release/good.json`. When the same image restarts, the launcher starts that tree first, and a ready older image never replaces it as the last good. A different image version is a deliberate roll (a new base): it runs, and is kept over the swapped tree. A swapped tree that does not become ready in the grace gives way to the image and the rollback card says so. The swapped process is not the launcher's child, so it writes `release/serving.json` (`release/serving.ts`) and the launcher stays up while that pid lives and signals it on SIGTERM; when it dies the container exits and restarts on the newest good tree.
19
20 ## Two writers, apps and runs
21
22 The handover is the existing one (#2040): the new process listens beside the old; the old stops accepting at once, stops its timers (throughput keeper, merge queue, deliveries), lets crew runs in flight finish and land, and closes after its in-flight requests. Runs hold leases that either process renews (`failAbandonedRuns` only fails a lapsed lease), apps are restarted by the new process and the old stops its own after the linger. The pause is measured, not assumed: during the roll the old process probes `/healthz` each second and counts non-200s into `keeper_release` (`roll_probes`, `roll_non200`, `seconds_dark`), shown on /releases; `test/release-keeper-swap.test.ts` drives a client through two swaps and asserts 0 refused requests where the kernel migrates queued connections.
23
24 ## Dependencies
25
26 A release's directory has no `node_modules`, and installing into it before preflight would run a package manager's scripts inside the forge. So a tree whose package.json names any dependency is not swapped: the keeper does not cut it and says a new image is needed (the image is the base). Today `package.json` has none.
27
28 ## Reading it
29
30 /releases, the terminal's Release keeper and MCP `release_keeper`: version, short commit, cut time, **landed to live** (merge of the landed pull to the new version answering), known good, probes, dark seconds. /changelog: the live line adds "Landed to live in …" for the release the forge runs.
31
32 ## What can be deleted once production runs it
33
34 The image-per-release path (release workflow image build and push, the `Dockerfile` `VERSION`/`COMMIT` stamping per release), `keeper/deploy.ts` `kubeDeployer` and `FORGE_KEEPER_DEPLOYMENT`/`CONTAINER`, the promote scripts and `scripts/cut-release.ts`'s image half, the droplet's `forge-release-tick` on pg-grand, and the chart's service-account patch rule.
src/http/mcp/tools/release_keeper.ts +3 −2
@@ -1,11 +1,12 @@
11 import { mayRead } from "../../../release/gate.ts";
22 import { blocking, listHolds, standingHolds } from "../../../keeper/hold.ts";
3 import { withLandedToLive } from "../../../keeper/landed.ts";
34 import { listKeeperReleases } from "../../../keeper/record.ts";
45 import { bad, good, type Tool } from "../shared.ts";
56
67 export const tool: Tool = {
78 name: "release_keeper",
8 doc: "`{}`: what the release keeper (#7557) did: its releases newest first (version, stage — rolling, verifying, verified or rolled_back —, the known-good version it would roll back to, roll probes and non-200s, probes and pages under load, worst ms, seconds dark, reason) and its holds (kind failed or owner, version, reason, probe numbers, the pull that said `release hold N`, lifted); `blocked` says what stops the next cut; for whoever may read /releases",
9 doc: "`{}`: what the release keeper (#7557) did: its releases newest first (version, commit (short sha), stage — rolling, verifying, verified or rolled_back —, the known-good version it would roll back to, roll probes and non-200s, probes and pages under load, worst ms, seconds dark, reason, landedToLiveSeconds — from the merge of its pull to the new version answering, null until it has (#8042)) and its holds (kind failed or owner, version, reason, probe numbers, the pull that said `release hold N`, lifted); `blocked` says what stops the next cut; for whoever may read /releases",
910 description: "The release keeper's releases, holds and what blocks the next cut.",
1011 inputSchema: { type: "object", properties: {} },
1112 writes: false,
@@ -15,7 +16,7 @@ export const tool: Tool = {
1516 const db = ctx.app.db;
1617 return good({
1718 blocked: blocking(standingHolds(db)) ?? null,
18 releases: listKeeperReleases(db),
19 releases: withLandedToLive(db, listKeeperReleases(db)),
1920 holds: listHolds(db),
2021 });
2122 },
src/keeper/deploy.ts +6
@@ -19,6 +19,12 @@ export interface Deployer {
1919 image(): Promise<string>;
2020 /** Name this image on the forge container; the Deployment rolls to it with surge. */
2121 setImage(image: string): Promise<void>;
22 /** The reference that names `version` at `sha` (a swap names a tree, not a tag); the keeper falls back to `imageAt`. */
23 refFor?(version: string, sha: string): string;
24 /** Why `sha` cannot be released by this deployer, checked before anything is cut; undefined when it can. */
25 admits?(sha: string): Promise<string | undefined>;
26 /** `version` read well under load and is the known good: keep it for a restart. */
27 verified?(version: string): void;
2228 }
2329
2430 /** An image reference: registry/path:tag or @digest, no space, no flag. */
src/keeper/keeper.ts +14 −5
@@ -7,7 +7,9 @@
77 * 1. **Cut** on cadence (`cadence.ts`): the known-good version and image
88 * are recorded, then the annotated tag `v<version>` is made on main's
99 * head — a tag, never a commit, so no gate in flight is voided (#6640).
10 * 2. **Promote**: the Deployment is set to the new version's image; it
10 * 2. **Promote**: the Deployment is set to the new version's image (or,
11 * with `swapper.ts` as the deployer, #8042, main's tree is swapped in beside
12 * this process on the same port: no image, no registry, no roll); it
1113 * rolls with surge (`maxSurge 1, maxUnavailable 0`). The old forge
1214 * probes `/healthz` each second while it still serves (`rollProbe`),
1315 * so a roll that went dark says how long.
@@ -119,6 +121,7 @@ async function verifyAndDecide(p: KeeperPorts, r: KeeperRelease): Promise<string
119121 const numbers = { version: r.version, probes: kept.probes, pages: kept.pages, worstMs: kept.worstMs, secondsDark: kept.secondsDark, rollNon200: kept.rollNon200 };
120122 if (verdict.ok) {
121123 finish(p.db, r.id, "verified", "", p.now());
124 p.deployer.verified?.(r.version);
122125 const lifted = liftFixed(p.db, r.version, p.now());
123126 p.bus.append({ kind: "release.verified_live", repoId: p.repo.id, payload: { ...numbers, holdsLifted: lifted } });
124127 fileDark(p, getKeeperRelease(p.db, r.id)!);
@@ -160,7 +163,7 @@ export async function tick(p: KeeperPorts): Promise<string> {
160163 if (flying.stage === "rolling" && p.now() - Date.parse(flying.cutAt) > ROLL_DEADLINE_MS)
161164 return rollBack(p, flying, `${flying.version} was not live ${ROLL_DEADLINE_MS / 3_600_000} h after its cut`);
162165 // A promote that did not take (the API refused, or something set the image back) is sent again.
163 const want = imageAt(flying.knownGood.image, flying.version);
166 const want = refFor(p, flying.knownGood.image, flying.version, flying.sha);
164167 if (flying.stage === "rolling" && (await p.deployer.image()) !== want) await promote(p, flying.version, want);
165168 return `${flying.version} is ${flying.stage}`;
166169 }
@@ -180,17 +183,23 @@ export async function tick(p: KeeperPorts): Promise<string> {
180183 ...(p.minIntervalMs === undefined ? {} : { minIntervalMs: p.minIntervalMs }),
181184 });
182185 if (!decision.cut || main === undefined) return decision.cut ? "main is unreadable" : decision.why;
186 // A tree the deployer cannot release (dependencies changed, preflight red) is not cut: no tag, no row.
187 const refused = await p.deployer.admits?.(main);

A refusal is not remembered: a red preflight or dependency change reruns checkout and a booted preflight every minute. Cache the refusal per sha (and log it once).

188 if (refused !== undefined) return `not cut: ${refused}`;
183189 const image = await p.deployer.image();
184190 const knownGood = { version: p.live(), image };
185191 // The row first, the tag second: a crash between them leaves a `rolling` row
186192 // whose tag the next tick sends (`tagged`), never a tag the keeper has no row for.
187193 const r = recordCut(p.db, { version: decision.version, sha: main, knownGood, at: p.now() });
188 const refused = await tagged(p, r);
189 if (refused !== undefined) return refused;
190 const sent = await promote(p, r.version, imageAt(image, r.version));
194 const tagRefused = await tagged(p, r);
195 if (tagRefused !== undefined) return tagRefused;
196 const sent = await promote(p, r.version, refFor(p, image, r.version, main));
191197 return `cut ${r.version} at ${main.slice(0, 12)}; ${sent}; known good: ${knownGood.version}`;
192198 }
193199
200 const refFor = (p: KeeperPorts, image: string, version: string, sha: string): string =>
201 p.deployer.refFor?.(version, sha) ?? imageAt(image, version);
202
194203 /** Make the release's tag; when it is refused the cut is dropped and the line says why. */
195204 async function tagged(p: KeeperPorts, r: KeeperRelease): Promise<string | undefined> {
196205 const refused = await p.tag(r.version, r.sha);
src/keeper/landed.ts added +55
@@ -0,0 +1,55 @@
1 /**
2 * Seconds from landed to live (#8042): when the commit a release took was
3 * merged, and when the new version first answered. Read from rows the forge
4 * already keeps — the landing's `release` row and its pull's merge time, the
5 * keeper's `live_at` — so there is nothing new to migrate. A commit that
6 * landed without a pull of this forge counts from its checkout.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { listKeeperReleases, type KeeperRelease } from "./record.ts";
11
12 /** When `sha` landed, epoch ms: its pull's merge, else the first time a landing checked it out. */
13 function landedAt(db: Database, sha: string): number | undefined {
14 const row = db
15 .query<{ at: string | null }, [string]>(
16 `SELECT COALESCE(pull.merged_at, release.created_at) AS at FROM release
17 LEFT JOIN issue ON issue.repo_id = release.repo_id AND issue.number = release.pull_number
18 LEFT JOIN pull ON pull.issue_id = issue.id
19 WHERE release.sha = ?1 ORDER BY release.id LIMIT 1`,
20 )
21 .get(sha);
22 const ms = row?.at == null ? NaN : Date.parse(row.at);
23 return Number.isFinite(ms) ? ms : undefined;
24 }
25
26 /** Whole seconds from landing to the new version answering; undefined while it has not, or when the landing is unknown. */
27 export function landedToLive(db: Database, r: Pick<KeeperRelease, "sha" | "liveAt">): number | undefined {
28 if (r.liveAt === null) return undefined;
29 const landed = landedAt(db, r.sha);
30 const live = Date.parse(r.liveAt);
31 if (landed === undefined || !Number.isFinite(live)) return undefined;
32 return Math.max(0, Math.round((live - landed) / 1000));
33 }
34
35 /** "1 min 12 s", "45 s". */
36 export function secondsWords(s: number): string {
37 return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${s % 60} s`;
38 }

Expose these figures to the terminal and MCP too (tui keeper screen, release_keeper tool), each with a test.

39
40 /** The newest release that went live and stayed (or is being verified), with its landed-to-live seconds. */
41 export function newestLive(db: Database): { version: string; sha: string; seconds: number } | undefined {
42 for (const r of listKeeperReleases(db, 10)) {
43 if (r.stage === "rolled_back") continue;
44 const seconds = landedToLive(db, r);
45 if (seconds !== undefined) return { version: r.version, sha: r.sha, seconds };
46 }
47 return undefined;
48 }
49
50 /** A keeper release with its short commit and landed-to-live seconds (null until it answered): what the card, the terminal and MCP show. */
51 export type LandedRelease = KeeperRelease & { readonly commit: string; readonly landedToLiveSeconds: number | null };
52
53 export function withLandedToLive(db: Database, releases: readonly KeeperRelease[]): LandedRelease[] {
54 return releases.map((r) => ({ ...r, commit: r.sha.slice(0, 7), landedToLiveSeconds: landedToLive(db, r) ?? null }));
55 }
src/keeper/swapper.ts added +195
@@ -0,0 +1,195 @@
1 /**
2 * The keeper's hands without a cluster (#8042): the `Deployer` that swaps a
3 * tree in place instead of patching a Deployment's image. A reference is
4 * `tree/<sha>:<version>`; setting it checks the commit out under
5 * `releases/<sha>/`, starts it beside this process on the same port
6 * (`release/swap.ts`, SO_REUSEPORT, handed over once health names the commit),
7 * and drains this process. Nothing here needs a registry, a Forgejo, a
8 * Kubernetes API or a person's machine.
9 *
10 * - **Known good**: before the first swap away from a tree, that tree is
11 * pinned under `releases/<commit>/` (an image's own tree included), so the
12 * rollback is the same swap to a tree that was already live.
13 * - **Dependencies**: a release is swapped in only when its package.json
14 * names no dependency. Its directory has no node_modules, and installing
15 * into it before preflight would run a package manager's scripts inside the
16 * forge; so a tree that adds one is not cut (`admits`), and the line says a
17 * new image is needed. The image only has to be a base.
18 * - **Rollback**: a tree that has served is swapped back to only when it
19 * knows every migration the live database has applied (the guard of
20 * `--rollback`, `release/rollback.ts`); an older tree is refused and the
21 * line says so, since this forge has no down-migration.
22 * - **Refusals**: a commit refused (dependency, red preflight) is remembered
23 * per sha and logged once, so the next minute does not check it out and boot
24 * a preflight again.
25 * - **Restart**: once verified, `verified` keeps the tree as the last good
26 * release over this image's version (`release/launch.ts`), so a pod
27 * restart comes back on it, not on the older image's version.
28 */
29
30 import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
31 import { join } from "node:path";
32 import { err, type Result } from "../kernel/result.ts";
33 import { releaseDir, type Verdict } from "../release/checkout.ts";
34 import { keepGood } from "../release/launch.ts";
35 import { liveSchema, releaseSchema } from "../release/rollback.ts";
36 import { beginSwap, endSwap, startRelease, swapBlocked } from "../release/swap.ts";
37 import type { Deployer } from "./deploy.ts";
38
39 const REF = /^tree\/([0-9a-f]{40}):([0-9A-Za-z][0-9A-Za-z.+_-]{0,63})$/;
40 const TREE_PARTS = ["src", "package.json", "tsconfig.json"];
41 /** A tree that has served: a rollback target, not a candidate, so it is not preflighted again. */
42 const LIVE_MARK = ".was-live";
43 const DEP_FIELDS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
44
45 export interface SwapperInput {
46 readonly dataDir: string;
47 readonly port: number;
48 /** The tree this process runs from: the directory holding `src/main.ts`. */
49 readonly treeRoot: string;
50 readonly version: () => string;
51 readonly commit: () => string | null;
52 readonly env: Record<string, string | undefined>;
53 /** Write `sha`'s tree to `releases/<sha>/`; the directory. */
54 readonly checkout: (sha: string) => Promise<Result<string, string>>;
55 readonly preflight: (dir: string) => Promise<Verdict>;
56 /** Let go of the port and drain, once the new tree is well. */
57 readonly drain: () => void;
58 readonly probe?: Readonly<Record<string, string>>;
59 readonly budgetMs?: number;
60 readonly now?: () => number;
61 }
62
63 /** Why `dir`'s package.json cannot run without an install; undefined when it names no dependency. */
64 export function dependencyChange(dir: string): string | undefined {
65 let pkg: Record<string, unknown>;
66 try {
67 pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as Record<string, unknown>;
68 } catch {
69 return "the tree has no readable package.json";
70 }
71 for (const field of DEP_FIELDS) {
72 const deps = pkg[field];
73 if (typeof deps === "object" && deps !== null && Object.keys(deps).length > 0)
74 return `${field} names ${Object.keys(deps).slice(0, 3).join(", ")}: a dependency change needs a new image, which is the base this swaps over`;
75 }
76 return undefined;
77 }
78
79 export function swapper(i: SwapperInput): Deployer {
80 const now = i.now ?? Date.now;
81 /** Set once this process has handed over: what it then reports as running, so the keeper does not promote again. */
82 let handedTo: string | undefined;
83 const admitted = new Set<string>();
84 /** Commits refused for good (their tree does not change), with the reason. */
85 const refused = new Map<string, string>();
86
87 const here = (): string => {
88 const commit = i.commit();
89 if (commit === null || !/^[0-9a-f]{40}$/.test(commit)) throw new Error("the running commit is not stamped, so there is no known good tree to name");
90 return `tree/${commit}:${i.version()}`;
91 };
92
93 /** Keep the running tree where a rollback finds it. */
94 function pin(): void {
95 const commit = i.commit();
96 if (commit === null || !/^[0-9a-f]{40}$/.test(commit)) return;
97 const target = releaseDir(i.dataDir, commit);
98 if (existsSync(target)) {
99 writeFileSync(join(target, LIVE_MARK), "");
100 return;
101 }
102 const staging = `${target}.pin-${process.pid}.partial`;
103 rmSync(staging, { recursive: true, force: true });
104 mkdirSync(staging, { recursive: true });
105 for (const part of TREE_PARTS)
106 if (existsSync(join(i.treeRoot, part))) cpSync(join(i.treeRoot, part), join(staging, part), { recursive: true });
107 writeFileSync(join(staging, LIVE_MARK), "");
108 renameSync(staging, target);
109 }
110
111 const refuse = (sha: string, why: string): Result<string, string> => {
112 refused.set(sha, why);
113 console.error(`keeper: ${sha.slice(0, 12)} is not swapped in: ${why}`);
114 return err(why);
115 };
116
117 async function prepare(sha: string, forward: boolean): Promise<Result<string, string>> {
118 const before = forward ? refused.get(sha) : undefined;
119 if (before !== undefined) return err(before);
120 const out = await i.checkout(sha);
121 if (!out.ok) return err(`checkout failed: ${out.error}`);
122 if (!forward) return out;
123 if (admitted.has(sha)) return out;
124 const deps = dependencyChange(out.value);
125 if (deps !== undefined) return refuse(sha, deps);
126 const verdict = await i.preflight(out.value);
127 if (!verdict.ok) return refuse(sha, `preflight failed: ${verdict.line}`);
128 admitted.add(sha);
129 return out;
130 }
131
132 /** The guard of `--rollback`: a tree that knows fewer migrations than the live database has applied does not run on it. */
133 async function schemaRefusal(sha: string, dir: string): Promise<string | undefined> {
134 const known = await releaseSchema(dir);
135 const live = liveSchema(i.dataDir);
136 if (known === undefined) return live === 0 ? undefined : `${sha} has no readable schema; not rolling back to it`;
137 return known < live ? `${sha} predates schema ${live}; rolling back would need a down-migration` : undefined;
138 }
139
140 return {
141 async image() {
142 return handedTo ?? here();
143 },
144 refFor: (version, sha) => `tree/${sha}:${version}`,
145 async admits(sha) {
146 // Nothing is cut that this process could not swap in: the same refusals as the swap itself.
147 const blocked = swapBlocked();
148 if (blocked !== undefined) return blocked;
149 const r = await prepare(sha, true);
150 return r.ok ? undefined : r.error;
151 },
152 async setImage(ref) {
153 const m = REF.exec(ref);
154 if (m === null) throw new Error("not a tree reference");
155 const [, sha, version] = m as unknown as [string, string, string];
156 if (handedTo === ref) return;
157 // A tree that has served (pinned when it was swapped away from) is a rollback and is not checked again; any other is admitted, as at the cut.
158 const backward = existsSync(join(releaseDir(i.dataDir, sha), LIVE_MARK));
159 const dir = await prepare(sha, !backward);
160 if (!dir.ok) throw new Error(dir.error);
161 if (backward) {
162 const stopped = await schemaRefusal(sha, dir.value);
163 if (stopped !== undefined) throw new Error(stopped);
164 }
165 const turn = beginSwap();
166 if (!turn.ok) throw new Error(turn.line);
167 let swapped = false;
168 try {
169 pin();
170 const env = { ...i.env, FORGE_DATA: i.dataDir };
171 const started = await startRelease({
172 dir: dir.value,
173 sha,
174 port: i.port,
175 env,
176 version,
177 ...(i.probe === undefined ? {} : { probe: i.probe }),
178 ...(i.budgetMs === undefined ? {} : { budgetMs: i.budgetMs }),
179 });
180 if (!started.ok) throw new Error(started.line);
181 swapped = true;
182 } finally {
183 endSwap(swapped);
184 }
185 handedTo = ref;
186 i.drain();
187 },
188 verified(version) {
189 const commit = i.commit();
190 const base = i.env["FORGE_BASE_VERSION"] || i.env["FORGE_VERSION"] || null;
191 if (commit === null || base === null) return;
192 keepGood(i.dataDir, i.treeRoot, version, commit, now(), base);
193 },
194 };
195 }
src/keeper/wire.ts +44 −9
@@ -3,12 +3,17 @@
33 * repository for main and the tags, the merge queue for "mid-landing", the
44 * kube API for the Deployment, and the public URL for the probes.
55 *
6 * On when `FORGE_RELEASE_KEEPER=on`, `FORGE_REPO` names a repository here,
7 * and the forge runs in a cluster. The Deployment and its container are
8 * `FORGE_KEEPER_DEPLOYMENT` and `FORGE_KEEPER_CONTAINER` (both default
9 * `forge`); the pod's service account needs `get` and `patch` on it.
6 * Two modes. `FORGE_RELEASE_KEEPER=on`, with `FORGE_REPO` naming a
7 * repository here and the forge in a cluster, patches a Deployment: its name
8 * and container are `FORGE_KEEPER_DEPLOYMENT` and `FORGE_KEEPER_CONTAINER`
9 * (both default `forge`), and the pod's service account needs `get` and
10 * `patch` on it.
11 *
12 * `FORGE_RELEASE_KEEPER=swap` (#8042) needs no cluster and wants
13 * `FORGE_SELF_SWAP=1`: the keeper swaps main's tree in place (`swapper.ts`).
1014 */
1115
16 import { join } from "node:path";
1217 import type { App } from "../app.ts";
1318 import { originPush } from "../actions/branches.ts";
1419 import { serviceAccount } from "../cluster/jobs.ts";
@@ -22,7 +27,10 @@ import { repoDir } from "../git/repos.ts";
2227 import { ownProbeHeaders } from "../kernel/probe.ts";
2328 import { running } from "../kernel/version.ts";
2429 import { compareVersions, readTags, recordFor } from "../release/shipped.ts";
30 import { checkoutRelease, preflightRelease } from "../release/checkout.ts";
31 import { handover } from "../release/swap.ts";
2532 import { kubeDeployer } from "./deploy.ts";
33 import { swapper } from "./swapper.ts";
2634 import type { KeeperPorts } from "./keeper.ts";
2735 import { timedGet, verifyUnderLoad } from "./verify.ts";
2836
@@ -31,16 +39,26 @@ const OID = /^[0-9a-f]{40}$/;
3139 const VERSION = /^\d+\.\d+\.\d+$/;
3240 const ZERO = "0".repeat(40);
3341
34 export type KeeperEnv = { on: true; deployment: string; container: string } | { on: false; why: string };
42 export type KeeperEnv =
43 | { on: true; mode: "kube"; deployment: string; container: string }
44 | { on: true; mode: "swap" }
45 | { on: false; why: string };
46
47 /** In swap mode a release is only the swap, so the cadence is a minute, not half an hour. */
48 export const SWAP_INTERVAL_MS = 60_000;
3549
3650 /** Whether the keeper runs, from the environment. */
3751 export function keeperEnv(env: Record<string, string | undefined>): KeeperEnv {
52 if (env.FORGE_RELEASE_KEEPER === "swap") {
53 if (env.FORGE_SELF_SWAP !== "1") return { on: false, why: "FORGE_RELEASE_KEEPER=swap needs FORGE_SELF_SWAP=1" };
54 return { on: true, mode: "swap" };
55 }
3856 if (env.FORGE_RELEASE_KEEPER !== "on") return { on: false, why: "FORGE_RELEASE_KEEPER is not on" };
3957 if (!env.KUBERNETES_SERVICE_HOST) return { on: false, why: "not running in a cluster" };
4058 const deployment = env.FORGE_KEEPER_DEPLOYMENT || "forge";
4159 const container = env.FORGE_KEEPER_CONTAINER || "forge";
4260 if (!LABEL.test(deployment) || !LABEL.test(container)) return { on: false, why: "FORGE_KEEPER_DEPLOYMENT and FORGE_KEEPER_CONTAINER must be DNS labels" };
43 return { on: true, deployment, container };
61 return { on: true, mode: "kube", deployment, container };
4462 }
4563
4664 /** The first active site admin that is a person: the account the page load signs in as. */
@@ -56,17 +74,33 @@ export function loadPages(repo: Pick<Repo, "ownerName" | "name">): string[] {
5674 return ["/boards", `/${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}`, "/explore", "/"];
5775 }
5876
59 export function keeperPorts(app: App, input: { forgeUrl: string; deployment: string; container: string }): KeeperPorts | string {
77 export function keeperPorts(app: App, input: { forgeUrl: string; deployment: string; container: string; swap?: boolean }): KeeperPorts | string {
6078 const named = app.config.forgeRepo;
6179 if (named === undefined) return "FORGE_REPO is not set";
6280 const [owner = "", name = ""] = named.split("/");
6381 const repo = getRepo(app.db, owner, name);
6482 if (repo === undefined) return `FORGE_REPO names ${named}, which is no repository on this forge`;
6583 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
66 const deployer = kubeDeployer({ credentials: serviceAccount(), deployment: input.deployment, container: input.container });
84 const deployer =
85 input.swap === true
86 ? swapper({
87 dataDir: app.config.dataDir,
88 port: app.config.port ?? 0,
89 treeRoot: join(import.meta.dir, "..", ".."),
90 version: () => running().version,
91 commit: () => running().commit,
92 env: process.env,
93 checkout: (sha) => checkoutRelease(app.config.dataDir, dir, sha),
94 preflight: (tree) => preflightRelease(tree, app.config.dataDir),
95 drain: () => handover.drain?.(),
96 probe: ownProbeHeaders(app.config.dataDir),
97 ...(app.config.swapBudgetMs === undefined ? {} : { budgetMs: app.config.swapBudgetMs }),
98 })
99 : kubeDeployer({ credentials: serviceAccount(), deployment: input.deployment, container: input.container });
67100 const probeHeaders = ownProbeHeaders(app.config.dataDir);
68101 const at = (version: string) => runGit(["rev-parse", "--verify", "--quiet", `refs/tags/v${version}^{commit}`], { cwd: dir });
69102 return {
103 ...(input.swap === true ? { minIntervalMs: SWAP_INTERVAL_MS } : {}),
70104 db: app.db,
71105 bus: app.bus,
72106 repo: { id: repo.id, ownerId: repo.ownerId },
@@ -99,7 +133,8 @@ export function keeperPorts(app: App, input: { forgeUrl: string; deployment: str
99133 if (!made.ok) return made.error.stderr || "git refused the tag";
100134 const tagOid = await runGit(["rev-parse", `refs/tags/v${version}`], { cwd: dir });
101135 const owner = getUserById(app.db, repo.ownerId);
102 if (!tagOid.ok || owner === undefined) return undefined;
136 // A swap needs no origin: the tag is the forge's own record of the release.
137 if (!tagOid.ok || owner === undefined || input.swap === true) return undefined;
103138 // A tag the origin did not take is taken back here too: the keeper drops the cut, and a
104139 // tag left behind would be the newest, never live, and stall the cadence.
105140 const untag = async (why: string) => (await runGit(["tag", "--delete", `v${version}`], { cwd: dir }), why);
src/main.ts +6 −1
@@ -658,7 +658,12 @@ if (app.config.forgeRepo) {
658658 // known-good image and hold. Off unless FORGE_RELEASE_KEEPER=on in a cluster (keeper/wire.ts).
659659 const keeperOn = keeperEnv(process.env);
660660 if (keeperOn.on) {
661 const ports = keeperPorts(app, { forgeUrl, deployment: keeperOn.deployment, container: keeperOn.container });
661 const ports = keeperPorts(
662 app,
663 keeperOn.mode === "swap"
664 ? { forgeUrl, deployment: "", container: "", swap: true }
665 : { forgeUrl, deployment: keeperOn.deployment, container: keeperOn.container },
666 );
662667 if (typeof ports === "string") app.log.warn("the release keeper is off", { why: ports });
663668 else {
664669 let said = "";
src/release/launch.ts +89 −29
@@ -17,6 +17,15 @@
1717 * seconds after its crash, and a hang costs the grace (150 s, inside the
1818 * chart's three-minute startup probe).
1919 *
20 * A swapped release (#8042) is a good tree too: the keeper that verified it
21 * keeps it with `base`, the image version it was swapped over. On a restart
22 * of that same image the launcher starts the swapped tree first, so an older
23 * image never silently rolls production back; only a different image (a
24 * deliberate roll) takes precedence, and a swapped tree that is not ready in
25 * the grace falls back to the image, recorded as any rollback is. The
26 * process that took over is not the launcher's child; `serving.ts` names it
27 * and the launcher stays up as long as it does.
28 *
2029 * Node built-ins only, on purpose: it is the one part of a bad release that
2130 * has to work.
2231 */
@@ -25,6 +34,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, r
2534 import { dirname, join } from "node:path";
2635 import type { Subprocess } from "bun";
2736 import { hkdfSync } from "node:crypto";
37 import { clearServing, follow, signalServing } from "./serving.ts";
2838
2939 export const READY_GRACE_MS = 150_000;
3040 const POLL_MS = 500;
@@ -38,6 +48,8 @@ export interface Good {
3848 readonly version: string;
3949 readonly commit: string | null;
4050 readonly at: string;
51 /** The image version this was swapped in over; null for a tree the launcher itself started. */
52 readonly base: string | null;
4153 }
4254
4355 export interface RolledBack {
@@ -78,13 +90,27 @@ export function lastGood(dataDir: string): Good | undefined {
7890 const version = g?.["version"];
7991 if (typeof version !== "string" || !VERSION.test(version) || typeof g?.["at"] !== "string") return undefined;
8092 if (!existsSync(join(goodTree(dataDir, version), "src", "main.ts"))) return undefined;
81 return { version, commit: typeof g["commit"] === "string" ? g["commit"] : null, at: g["at"] };
93 const base = g["base"];
94 return {
95 version,
96 commit: typeof g["commit"] === "string" ? g["commit"] : null,
97 at: g["at"],
98 base: typeof base === "string" && VERSION.test(base) ? base : null,
99 };
82100 }
83101
84102 /** Keep `tree` as the last good release; drop the one it replaces and any standing rollback. */
85 export function keepGood(dataDir: string, tree: string, version: string, commit: string | null, now = Date.now()): void {
86 if (!VERSION.test(version)) return;
87 if (lastGood(dataDir)?.version !== version) {
103 export function keepGood(
104 dataDir: string,
105 tree: string,
106 version: string,
107 commit: string | null,
108 now = Date.now(),
109 base: string | null = null,
110 ): void {
111 if (!VERSION.test(version) || (base !== null && !VERSION.test(base))) return;
112 const kept = lastGood(dataDir);
113 if (kept?.version !== version || kept.commit !== commit || kept.base !== base) {
88114 const target = goodTree(dataDir, version);
89115 const staging = `${target}.partial-${process.pid}`;
90116 rmSync(staging, { recursive: true, force: true });
@@ -93,7 +119,7 @@ export function keepGood(dataDir: string, tree: string, version: string, commit:
93119 if (existsSync(join(tree, part))) cpSync(join(tree, part), join(staging, part), { recursive: true });
94120 rmSync(target, { recursive: true, force: true });
95121 renameSync(staging, target);
96 writeJson(goodFile(dataDir), { version, commit, at: new Date(now).toISOString() });
122 writeJson(goodFile(dataDir), { version, commit, at: new Date(now).toISOString(), base });
97123 }
98124 for (const name of readdirSync(join(releaseDir(dataDir), "good")))
99125 if (name !== version) rmSync(join(releaseDir(dataDir), "good", name), { recursive: true, force: true });
@@ -190,6 +216,8 @@ function start(input: LaunchInput, tree: string, version: string, commit: string
190216 for (const [k, v] of Object.entries(input.env)) if (v !== undefined) env[k] = v;
191217 env["FORGE_VERSION"] = version;
192218 env["FORGE_COMMIT"] = commit ?? "";
219 // What a swap in this process's descendants is over: the image, not the tree running now.
220 env["FORGE_BASE_VERSION"] = input.version;
193221 const child = Bun.spawn([process.execPath, join(tree, "src", "main.ts")], {
194222 cwd: tree,
195223 env,
@@ -201,42 +229,65 @@ function start(input: LaunchInput, tree: string, version: string, commit: string
201229 return child;
202230 }
203231
232 /** Wait for `child` to answer health as `version`: undefined once it does, else why it did not. */
233 async function becomesReady(input: LaunchInput, child: Subprocess, version: string): Promise<string | undefined> {
234 const now = input.now ?? Date.now;
235 const grace = input.graceMs ?? READY_GRACE_MS;
236 const began = now();
237 for (;;) {
238 if (input.stopping?.()) return "asked to stop before it was ready";
239 if (gone(child)) return `it exited with ${child.exitCode ?? child.signalCode} before it was ready`;
240 if ((await versionAt(input.port, probeHeaders(input.dataDir))) === version) return undefined;
241 if (now() - began >= grace) return `not ready within ${Math.round(grace / 1000)}s`;
242 await Bun.sleep(POLL_MS);
243 }
244 }
245
204246 /**
205247 * Start this image's forge and answer once it is ready (`release`), was
206248 * replaced by the last good one (`rollback`), or neither runs (`none`).
207 * The child returned is the one to wait on.
249 * The child returned is the one to wait on. A swapped tree kept over this
250 * very image goes first (see the header).
208251 */
209252 export async function launch(input: LaunchInput): Promise<Launched> {
210253 const now = input.now ?? Date.now;
211 const grace = input.graceMs ?? READY_GRACE_MS;
212 const release = start(input, input.tree, input.version, input.commit);
213 const began = now();
214 let why: string;
215 for (;;) {
254 const swapped = lastGood(input.dataDir);
255 let passed: { version: string; why: string } | undefined;
256 if (swapped !== undefined && swapped.base === input.version && swapped.version !== input.version) {
257 const first = start(input, goodTree(input.dataDir, swapped.version), swapped.version, swapped.commit);
258 const failed = await becomesReady(input, first, swapped.version);
259 if (failed === undefined)
260 return { ran: "release", child: first, line: `${swapped.version} is ready: the swapped release kept over image ${input.version}` };
261 await stop(first);
216262 if (input.stopping?.()) return { ran: "none", line: "asked to stop before it was ready" };
217 if (gone(release)) {
218 why = `it exited with ${release.exitCode ?? release.signalCode} before it was ready`;
219 break;
263 passed = { version: swapped.version, why: failed };
264 }
265 const release = start(input, input.tree, input.version, input.commit);
266 const why = await becomesReady(input, release, input.version);
267 if (why === undefined) {
268 try {
269 keepGood(input.dataDir, input.tree, input.version, input.commit, now());
270 } catch (error) {
271 // Serving matters more than keeping: the next version falls back to the one before.
272 return { ran: "release", child: release, line: `${input.version} is ready; not kept: ${String(error).slice(0, 200)}` };
220273 }
221 if ((await versionAt(input.port, probeHeaders(input.dataDir))) === input.version) {
222 try {
223 keepGood(input.dataDir, input.tree, input.version, input.commit, now());
224 } catch (error) {
225 // Serving matters more than keeping: the next version falls back to the one before.
226 return { ran: "release", child: release, line: `${input.version} is ready; not kept: ${String(error).slice(0, 200)}` };
227 }
228 return { ran: "release", child: release, line: `${input.version} is ready and kept as the last good release` };
274 // The swapped tree that would not start: the image runs in its place, and the forge says so.
275 if (passed !== undefined) {
276 writeJson(rolledFile(input.dataDir), {
277 from: passed.version,
278 to: input.version,
279 at: new Date(now()).toISOString(),
280 why: passed.why,
281 paged: false,
282 } satisfies RolledBack);
283 return { ran: "rollback", child: release, line: `${passed.version}: ${passed.why}; the image ${input.version} runs instead` };
229284 }
230 if (now() - began >= grace) {
231 why = `not ready within ${Math.round(grace / 1000)}s`;
232 break;
233 }
234 await Bun.sleep(POLL_MS);
285 return { ran: "release", child: release, line: `${input.version} is ready and kept as the last good release` };
235286 }
236287 await stop(release);
237288 if (input.stopping?.()) return { ran: "none", line: "asked to stop before it was ready" };
238289 const good = lastGood(input.dataDir);
239 if (good === undefined || good.version === input.version)
290 if (good === undefined || good.version === input.version || passed?.version === good.version)
240291 return { ran: "none", line: `${input.version}: ${why}; no earlier release is kept to roll back to` };
241292 writeJson(rolledFile(input.dataDir), {
242293 from: input.version,
@@ -256,10 +307,14 @@ if (import.meta.main) {
256307 const graceS = Number(env["FORGE_READY_GRACE_S"] ?? "");
257308 let current: Subprocess | undefined;
258309 let stopping = false;
310 const dataHere = env["FORGE_DATA"] || "data";
311 // Whatever an earlier container left names processes that are not ours.
312 clearServing(dataHere);
259313 for (const signal of ["SIGTERM", "SIGINT"] as const)
260314 process.on(signal, () => {
261315 stopping = true;
262316 current?.kill(signal);
317 signalServing(dataHere, signal);
263318 });
264319 const launched = await launch({
265320 tree: join(import.meta.dir, "..", ".."),
@@ -275,5 +330,10 @@ if (import.meta.main) {
275330 stopping: () => stopping,
276331 });
277332 console.error(`launch: ${launched.line}`);

Redundant alias of dataHere; use dataHere.

278 process.exit(launched.ran === "none" ? (stopping ? 0 : 1) : await launched.child.exited);
333 if (launched.ran === "none") process.exit(stopping ? 0 : 1);
334 const code = await launched.child.exited;
335 // A swapped release took over and the process we started has drained: this container is that
336 // process's now, and goes when it does (the pod then restarts on the newest good tree).
337 await follow(dataHere, launched.child.pid);
338 process.exit(code);
279339 }
src/release/serving.ts added +72
@@ -0,0 +1,72 @@
1 /**
2 * Who serves the port now (#8042). A swapped release is started by the
3 * process it replaces, so it is not the launcher's child; the launcher
4 * (`launch.ts`) would see its own child drain and exit, and a container whose
5 * first process exits takes the release with it. So the process that took
6 * over writes `release/serving.json` ({pid, sha}), and the launcher stays up
7 * while that pid lives and passes it SIGTERM when it is told to stop. The
8 * launcher clears the file on start, so a pid is only ever one this
9 * container started. Node built-ins only, as `launch.ts` is.
10 */
11
12 import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
13 import { dirname, join } from "node:path";
14
15 export interface Serving {
16 readonly pid: number;
17 readonly sha: string;
18 }
19
20 const file = (dataDir: string): string => join(dataDir, "release", "serving.json");
21
22 export function writeServing(dataDir: string, s: Serving): void {
23 const path = file(dataDir);
24 mkdirSync(dirname(path), { recursive: true });
25 const tmp = `${path}.tmp-${process.pid}`;
26 writeFileSync(tmp, JSON.stringify(s));
27 renameSync(tmp, path);
28 }
29
30 export function readServing(dataDir: string): Serving | undefined {
31 try {
32 const v = JSON.parse(readFileSync(file(dataDir), "utf8")) as { pid?: unknown; sha?: unknown };
33 if (!Number.isInteger(v.pid) || (v.pid as number) <= 1 || typeof v.sha !== "string") return undefined;
34 return { pid: v.pid as number, sha: v.sha };
35 } catch {
36 return undefined;
37 }
38 }
39
40 export function clearServing(dataDir: string): void {
41 if (existsSync(file(dataDir))) rmSync(file(dataDir), { force: true });
42 }
43
44 export function alive(pid: number): boolean {
45 try {
46 process.kill(pid, 0);
47 return true;
48 } catch (error) {
49 // EPERM: it exists and is not ours to signal.
50 return (error as NodeJS.ErrnoException).code === "EPERM";
51 }
52 }
53
54 /** Pass `signal` to the process that took over from `ownPid`, if one lives. */
55 export function signalServing(dataDir: string, signal: NodeJS.Signals, ownPid?: number): void {
56 const s = readServing(dataDir);
57 if (s === undefined || s.pid === ownPid || !alive(s.pid)) return;
58 try {
59 process.kill(s.pid, signal);
60 } catch {
61 // Gone between the look and the signal.
62 }
63 }
64
65 /** Return once the process serving is `ownPid` again or none lives: the swapped release is gone. */
66 export async function follow(dataDir: string, ownPid: number, pollMs = 1000): Promise<void> {
67 for (;;) {
68 const s = readServing(dataDir);
69 if (s === undefined || s.pid === ownPid || !alive(s.pid)) return;
70 await new Promise((r) => setTimeout(r, pollMs));
71 }
72 }
src/release/swap.ts +17 −12
@@ -18,6 +18,7 @@
1818
1919 import { existsSync, readFileSync } from "node:fs";
2020 import { join } from "node:path";
21 import { writeServing } from "./serving.ts";
2122
2223 export const SWAP_BUDGET_MS = 60_000;
2324 const POLL_MS = 250;
@@ -32,6 +33,8 @@ export interface SwapInput {
3233 /** The environment the new forge starts with: the old one's, as it is. */
3334 env: Record<string, string | undefined>;
3435 budgetMs?: number;
36 /** The version the new process runs as (`FORGE_VERSION`); the environment's own when absent. */
37 version?: string;
3538 /** Sent with each health read (`kernel/probe.ts`), so the answer names its commit. */
3639 probe?: Readonly<Record<string, string>>;
3740 }
@@ -62,6 +65,7 @@ export async function startRelease(input: SwapInput): Promise<Swapped> {
6265 const env: Record<string, string> = {};
6366 for (const [k, v] of Object.entries(input.env)) if (v !== undefined) env[k] = v;
6467 env["FORGE_COMMIT"] = input.sha;
68 if (input.version !== undefined) env["FORGE_VERSION"] = input.version;
6569 env["PORT"] = String(input.port);
6670 env["FORGE_SELF_SWAP"] = "1";
6771 const proc = Bun.spawn([process.execPath, entry], {
@@ -81,6 +85,8 @@ export async function startRelease(input: SwapInput): Promise<Swapped> {
8185 return { ok: false, line: `the new process exited with ${exited} before it was healthy` };
8286 if (healthyAs(await readHealth(input.port, input.probe ?? {}), input.sha)) {
8387 proc.unref();
88 // The launcher of a container waits on this pid now (`serving.ts`).
89 if (env["FORGE_DATA"]) writeServing(env["FORGE_DATA"], { pid: proc.pid, sha: input.sha });
8490 return { ok: true, pid: proc.pid };
8591 }
8692 await Bun.sleep(POLL_MS);
@@ -164,22 +170,21 @@ let underWay = false;
164170 export function beginSwap(): { ok: true } | { ok: false; line: string; tell: boolean } {
165171 if (underWay)
166172 return { ok: false, line: "a swap is already under way or done in this process", tell: false };
167 if (handover.ssh === true)
168 return {
169 ok: false,
170 line: "this process holds the SSH terminal, which a new process cannot take over yet; restart the forge to run this tree",
171 tell: true,
172 };
173 if (!migratesQueuedConnections())
174 return {
175 ok: false,
176 line: "the kernel would reset connections queued on the old listener when it closes; set net.ipv4.tcp_migrate_req=1 (Linux 5.14+) to swap, or restart the forge to run this tree",
177 tell: true,
178 };
173 const blocked = swapBlocked();
174 if (blocked !== undefined) return { ok: false, line: blocked, tell: true };
179175 underWay = true;
180176 return { ok: true };
181177 }
182178
179 /** Why this process cannot swap at all (SSH held, queued connections not migrated); undefined when it can. */
180 export function swapBlocked(): string | undefined {
181 if (handover.ssh === true)
182 return "this process holds the SSH terminal, which a new process cannot take over yet; restart the forge to run this tree";
183 if (!migratesQueuedConnections())
184 return "the kernel would reset connections queued on the old listener when it closes; set net.ipv4.tcp_migrate_req=1 (Linux 5.14+) to swap, or restart the forge to run this tree";
185 return undefined;
186 }
187
183188 /** A swap that failed gives the turn back; one that worked keeps it, for this process is leaving. */
184189 export function endSwap(swapped: boolean): void {
185190 if (!swapped) underWay = false;
src/tui/screens/keeper.ts +9 −5
@@ -1,14 +1,16 @@
11 /**
22 * The release keeper in the terminal (#7557), the /releases card's read:
33 * what holds the next cut, then each release the keeper cut with its stage,
4 * the known-good version it rolls back to and what the probes counted.
4 * its short commit, landed to live (#8042), the known-good version it rolls
5 * back to and what the probes counted.
56 * Gated by `release/gate.ts` as the page is. `h` holds releases with a
67 * reason and `h` with `lift N` lifts an owner's hold, through the same
78 * functions the card and MCP `release_hold` call.
89 */
910
1011 import { blocking, holdAs, liftAs, standingHolds } from "../../keeper/hold.ts";
11 import { listKeeperReleases, type KeeperRelease, type Stage } from "../../keeper/record.ts";
12 import { secondsWords, withLandedToLive, type LandedRelease } from "../../keeper/landed.ts";
13 import { listKeeperReleases, type Stage } from "../../keeper/record.ts";
1214 import { mayRead, mayRollBack } from "../../release/gate.ts";
1315 import type { Ctx, Screen } from "../app/app.ts";
1416 import { binding } from "../app/keys.ts";
@@ -29,22 +31,24 @@ const STAGE: Record<Stage, { label: string; tone: Tone }> = {
2931 export interface KeeperRead {
3032 /** What holds the next cut, or the line that says nothing does. */
3133 readonly blocked: string;
32 readonly releases: readonly KeeperRelease[];
34 readonly releases: readonly LandedRelease[];
3335 }
3436
3537 /** The card's content for this person; undefined for anyone the page would 404. */
3638 export function readKeeper(f: Session): KeeperRead | undefined {
3739 if (!mayRead(f.app, f.user)) return undefined;
38 return { blocked: blocking(standingHolds(f.db)) ?? "Nothing holds the next cut.", releases: listKeeperReleases(f.db) };
40 return { blocked: blocking(standingHolds(f.db)) ?? "Nothing holds the next cut.", releases: withLandedToLive(f.db, listKeeperReleases(f.db)) };
3941 }
4042
41 export function keeperRows(releases: readonly KeeperRelease[]): ListRow[] {
43 export function keeperRows(releases: readonly LandedRelease[]): ListRow[] {
4244 return releases.map((r) => ({
4345 title: r.version,
4446 tone: STAGE[r.stage].tone,
4547 meta: metaOf(
4648 STAGE[r.stage].label,
49 r.commit,
4750 `cut ${ago(r.cutAt)}`,
51 r.landedToLiveSeconds === null ? "not live yet" : `landed to live ${secondsWords(r.landedToLiveSeconds)}`,
4852 `known good ${r.knownGood.version}`,
4953 `roll ${r.rollNon200}/${r.rollProbes} non-200`,
5054 `${r.probes} probes, ${r.pages} pages`,
src/web/pages/changelog.ts +10 −3
@@ -8,6 +8,7 @@
88
99 import { notePhase, takeSessionTime, timePhaseAsync } from "../../core/route_timing.ts";
1010 import type { Ctx } from "../../http/context.ts";
11 import { newestLive, secondsWords } from "../../keeper/landed.ts";
1112 import { dayHeading, dayOf, daysJs, shippedToday } from "../../release/days.ts";
1213 import { readLandings, type Landing, type Landings } from "../../release/landings.ts";
1314 import { liveRegionsJs } from "../client.ts";
@@ -109,7 +110,13 @@ export const localDaysJs = /* js */ `
109110 `;
110111
111112 /** The region: the top line, what is on its way, and what went live by day. */
112 export function shippedRegion(read: Landings, choice: string, now: Date = new Date(), zone = "UTC"): Html {
113 export function shippedRegion(
114 read: Landings,
115 choice: string,
116 now: Date = new Date(),
117 zone = "UTC",
118 toLive?: { version: string; seconds: number },
119 ): Html {
113120 const keep = (l: Landing) => choice === "all" || l.repo === choice;
114121 const live = read.live.filter(keep);
115122 const onItsWay = read.onItsWay.filter(keep);
@@ -120,7 +127,7 @@ export function shippedRegion(read: Landings, choice: string, now: Date = new Da
120127 const version =
121128 read.version !== "dev" ? (commit === null ? read.version : html`${read.version} · ${commit}`) : (commit ?? "a development build");
122129 const top = html`<p class="small muted" id="shipped-top">
123 Live: ${version}. <span id="shipped-today">${shippedToday(today, zone === "UTC")}</span>
130 Live: ${version}.${toLive !== undefined && toLive.version === read.version ? html` Landed to live in ${secondsWords(toLive.seconds)}.` : null} <span id="shipped-today">${shippedToday(today, zone === "UTC")}</span>
124131 </p>`;
125132 const way =
126133 onItsWay.length === 0
@@ -147,7 +154,7 @@ export async function changelogPage(ctx: Ctx): Promise<Response> {
147154 ...others.map((r) => ({ label: r, href: `/changelog?repo=${encodeURIComponent(r)}`, current: choice === r })),
148155 ]);
149156 const body = html`${pageHead("Changelog", { sub: "What went live, as it happens." })}${filters}
150 <div id="shipped" data-live="${LIVE}" aria-live="polite">${shippedRegion(read, choice)}</div>`;
157 <div id="shipped" data-live="${LIVE}" aria-live="polite">${shippedRegion(read, choice, new Date(), "UTC", newestLive(ctx.app.db))}</div>`;
151158 const response = page({ ctx, title: "Changelog", body, scriptJs: `${liveRegionsJs}\n${localDaysJs}` });
152159 notePhase(ctx.request, "render", performance.now() - rendering);
153160 return response;
src/web/pages/keeper-panel.ts +8 −6
@@ -6,6 +6,7 @@
66
77 import type { Database } from "bun:sqlite";
88 import { blocking, MAX_REASON, standingHolds } from "../../keeper/hold.ts";
9 import { secondsWords, withLandedToLive } from "../../keeper/landed.ts";
910 import { listKeeperReleases, type Stage } from "../../keeper/record.ts";
1011 import { html, when, type Html } from "../html.ts";
1112 import { badge, btn, card, time, type Tone } from "../ui.ts";
@@ -19,28 +20,29 @@ const STAGE: Record<Stage, { label: string; tone: Tone }> = {
1920
2021 /** `mayHold` is `mayRollBack`: the card then offers the owner's hold and its lift. */
2122 export function keeperPanel(db: Database, mayHold = false): Html | undefined {
22 const releases = listKeeperReleases(db);
23 const releases = withLandedToLive(db, listKeeperReleases(db));
2324 const holds = standingHolds(db);
2425 const blocked = blocking(holds);
2526 if (releases.length === 0 && blocked === undefined && !mayHold) return undefined;
2627 const rows = releases.map((r) => {
2728 const s = STAGE[r.stage];
2829 return html`<tr>
29 <td>${r.version} ${badge(s.label, { tone: s.tone })}</td>
30 <td>${r.version} <span class="small muted">${r.commit}</span> ${badge(s.label, { tone: s.tone })}</td>
3031 <td>${time(r.cutAt)}</td>
32 <td>${r.landedToLiveSeconds === null ? "–" : secondsWords(r.landedToLiveSeconds)}</td>
3133 <td>${r.knownGood.version}</td>
3234 <td>${r.rollNon200} of ${r.rollProbes}</td>
3335 <td>${r.probes} / ${r.pages}</td>
3436 <td>${r.worstMs} ms</td>
3537 <td>${r.secondsDark} s</td>
36 </tr>${when(r.reason !== "", () => html`<tr><td colspan="7" class="small muted">${r.reason}</td></tr>`)}`;
38 </tr>${when(r.reason !== "", () => html`<tr><td colspan="8" class="small muted">${r.reason}</td></tr>`)}`;
3739 });
3840 return card(html`<h2>Release keeper</h2>
3941 <p class="small ${blocked === undefined ? "muted" : ""}" id="keeper-blocked">${blocked ?? "Nothing holds the next cut."}</p>
40 ${when(rows.length > 0, () => html`<table class="table">
41 <thead><tr><th>Version</th><th>Cut</th><th>Known good</th><th>Roll non-200</th><th>Probes / pages</th><th>Worst</th><th>Dark</th></tr></thead>
42 ${when(rows.length > 0, () => html`<div class="table-scroll" role="region" tabindex="0" aria-label="Keeper releases"><table class="table" data-sticky-lead>
43 <thead><tr><th>Version</th><th>Cut</th><th>Landed to live</th><th>Known good</th><th>Roll non-200</th><th>Probes / pages</th><th>Worst</th><th>Dark</th></tr></thead>
4244 <tbody>${rows}</tbody>
outdated

Eight-column table: wrap in the named scroll wrapper / responsive table pattern so it works at 360px.

43 </table>`)}
45 </table></div>`)}
4446 ${when(mayHold, () => html`${holds.filter((h) => h.kind === "owner").map(
4547 (h) => html`<form method="post" action="/releases/hold/${h.id}/lift" class="small">Owner hold ${h.id}: ${h.reason} ${btn("Lift", { type: "submit" })}</form>`,
4648 )}
test/home-cache.test.ts +5 −1
@@ -134,7 +134,11 @@ test("warm home holds p95 under the stated budget", async () => {
134134 panelCache(f.app.db).clear();
135135 const missed = await counted();
136136 expect(missed.hits).toEqual({ kpis: false, panels: false });
137 const missOnly = [...missed.ran.keys()].filter((sql) => !warm.ran.has(sql));
137 // Reads only: a route-timing flush that lands inside this one request (its INSERTs, PRAGMAs,
138 // total_changes) is a write any request may carry, not the cache missing.
139 const missOnly = [...missed.ran.keys()].filter(
140 (sql) => !warm.ran.has(sql) && /^\s*SELECT\b/i.test(sql) && !sql.includes("total_changes()"),
141 );
138142 // The KPI reads (eight on this fixture) and the pinned panel's own query.
139143 expect(missOnly.length).toBeGreaterThan(5);
140144 expect(missOnly).toContain('SELECT "kind" AS "group", COUNT(*) AS "value" FROM "run" GROUP BY 1 ORDER BY 1 LIMIT 1000');
test/keeper-landed-doors.test.ts added +68
@@ -0,0 +1,68 @@
1 /**
2 * Landed to live and the short commit at every door (#8042): the /releases
3 * card, the terminal's Release keeper and MCP `release_keeper` read the same
4 * figure from `keeper/landed.ts`. Pull 7 merged at noon; the keeper cut at
5 * once, and 0.7.284 answered 42 s later, so each door says 42 s.
6 */
7 import { describe, expect, test } from "bun:test";
8 import type { Ctx as HttpCtx } from "../src/http/context.ts";
9 import { tool as keeperTool } from "../src/http/mcp/tools/release_keeper.ts";
10 import { tick } from "../src/keeper/keeper.ts";
11 import type { LandedRelease } from "../src/keeper/landed.ts";
12 import type { User } from "../src/core/users.ts";
13 import { createForgeApp } from "../src/tui/forge.ts";
14 import { keeperScreen } from "../src/tui/screens/keeper.ts";
15 import { headlessTerminal } from "../src/tui/term/terminal.ts";
16 import { keeperPanel } from "../src/web/pages/keeper-panel.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { B, keeperForge } from "./helpers/keeper.ts";
19 import { settleApp } from "./helpers/tui.ts";
20
21 quietLogs();
22
23 const identity = (user?: User) => ({ sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user });
24
25 /** B landed through pull 7 at the clock's noon, was cut, and answered as 0.7.284 42 s after it merged. */
26 async function liveForge() {
27 const f = await keeperForge();
28 const merged = new Date(f.world.now).toISOString();
29 f.app.db
30 .query(`INSERT INTO release (sha, repo_id, pull_number, ok, line, created_at) VALUES (?1, ?2, 7, 1, 'preflight ok', ?3)`)
31 .run(B, f.repo.id, merged);
32 expect(await tick(f.ports)).toContain("cut 0.7.284");
33 f.world.now = Date.parse(merged) + 42_000;
34 f.world.live = "0.7.284";
35 await tick(f.ports);
36 return f;
37 }
38
39 describe("landed to live at every door", () => {
40 test("MCP release_keeper gives each release its short commit and landed-to-live seconds", async () => {
41 const f = await liveForge();
42 const read = await keeperTool.run({ app: f.app } as unknown as HttpCtx, f.user, {});
43 if (!read.ok) throw new Error("release_keeper refused an admin");
44 const [first] = (read.value as { releases: LandedRelease[] }).releases;
45 expect(first).toMatchObject({ version: "0.7.284", commit: "bbbbbbb", landedToLiveSeconds: 42 });
46 });
47
48 test("the terminal's Release keeper shows the short commit and landed to live", async () => {
49 const f = await liveForge();
50 const terminal = headlessTerminal(200, 20);
51 const { app: tui, session } = createForgeApp(f.app, identity(f.user), { terminal, exitHooks: false });
52 tui.start();
53 tui.ctx.push(keeperScreen(session, tui.ctx));
54 await settleApp(tui);
55 const text = tui.screen.toText();
56 tui.stop?.();
57 expect(text).toContain("bbbbbbb");
58 expect(text).toContain("landed to live 42 s");
59 });
60
61 test("/releases puts the eight-column table in the named scroller, with the figure in it", async () => {
62 const f = await liveForge();
63 const card = String(keeperPanel(f.app.db));
64 expect(card).toMatch(/<div class="table-scroll" role="region" tabindex="0" aria-label="Keeper releases"><table class="table" data-sticky-lead>/);
65 expect(card).toContain('<span class="small muted">bbbbbbb</span>');
66 expect(card).toContain("<td>42 s</td>");
67 });
68 });
test/release-keeper-swap.test.ts added +343
@@ -0,0 +1,343 @@
1 /**
2 * The keeper ships by swapping, not by image (#8042). On a hosted child
3 * forge's shape: a running tree, a landed tree, a keeper that cuts on cadence,
4 * swaps it in beside the running process on the same port, verifies it under
5 * load and keeps it as known good; then a bad release is swapped in, fails
6 * its verify, and the known-good tree is swapped back and held. The stand-in
7 * forges are a few lines that answer health as the commit and version they
8 * are told; the swap, the pin, the keep and the keeper are the real code.
9 */
10
11 import { afterAll, describe, expect, test } from "bun:test";
12 import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13 import { tmpdir } from "node:os";
14 import { join } from "node:path";
15 import { err, ok } from "../src/kernel/result.ts";
16 import { standingHolds } from "../src/keeper/hold.ts";
17 import { landedToLive, newestLive, secondsWords } from "../src/keeper/landed.ts";
18 import { tick } from "../src/keeper/keeper.ts";
19 import { listKeeperReleases } from "../src/keeper/record.ts";
20 import { keeperEnv, SWAP_INTERVAL_MS } from "../src/keeper/wire.ts";
21 import { swapper, dependencyChange } from "../src/keeper/swapper.ts";
22 import { keepGood, lastGood } from "../src/release/launch.ts";
23 import { releaseDir } from "../src/release/checkout.ts";
24 import { liveSchema } from "../src/release/rollback.ts";
25 import { readServing } from "../src/release/serving.ts";
26 import { endSwap, handover } from "../src/release/swap.ts";
27 import { keeperForge, A, B, C } from "./helpers/keeper.ts";
28
29 const STAND_IN = `
30 Bun.serve({
31 port: Number(process.env.PORT),
32 reusePort: true,
33 fetch: () => Response.json({ ok: true, draining: false, commit: process.env.FORGE_COMMIT, version: process.env.FORGE_VERSION }),
34 });
35 `;
36 const realMigrate = (() => {
37 try {
38 return readFileSync("/proc/sys/net/ipv4/tcp_migrate_req", "utf8").trim() === "1";
39 } catch {
40 return false;
41 }
42 })();
43 handover.migrate = true;
44
45 const dirs: string[] = [];
46 const pids = new Set<number>();
47 afterAll(() => {
48 // The swap turn and the kernel switch are this process's: leave them as other files find them.
49 endSwap(false);
50 delete handover.migrate;
51 for (const pid of pids) {
52 try {
53 process.kill(pid, "SIGKILL");
54 } catch {}
55 }
56 for (const d of dirs) rmSync(d, { recursive: true, force: true });
57 });
58
59 /** `migrations` is how many the tree knows: more than any live database, unless a test says fewer. */
60 function tree(root: string, main = STAND_IN, pkg = "{}", migrations = 9999): void {
61 mkdirSync(join(root, "src", "core"), { recursive: true });
62 writeFileSync(join(root, "src", "core", "schema.ts"), `export const migrations = new Array(${migrations}).fill(0);\n`);
63 writeFileSync(join(root, "src", "main.ts"), main);
64 writeFileSync(join(root, "package.json"), pkg);
65 }
66
67 async function freePort(): Promise<number> {
68 const s = Bun.serve({ port: 0, fetch: () => new Response("") });
69 const port = s.port ?? 0;
70 s.stop(true);
71 return port;
72 }
73
74 async function health(port: number): Promise<{ commit?: string; version?: string } | undefined> {
75 try {
76 const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, { headers: { connection: "close" }, signal: AbortSignal.timeout(3_000) });
77 return (await res.json()) as { commit?: string; version?: string };
78 } catch {
79 return undefined;
80 }
81 }
82
83 /** A forge running tree A on a port, with a swapper that checks landed trees out of `landed`. */
84 async function hosted(landed: Record<string, { main?: string; pkg?: string; preflight?: boolean }>) {
85 const f = await keeperForge();
86 const base = mkdtempSync(join(tmpdir(), "forge-keeper-swap-"));
87 dirs.push(base);
88 const dataDir = f.app.config.dataDir;
89 const running = join(base, "running");
90 tree(running);
91 const port = await freePort();
92 const old = Bun.serve({ port, reusePort: true, fetch: () => Response.json({ ok: true, draining: false, commit: A, version: "0.7.283" }) });
93 const now = { commit: A as string, version: "0.7.283" };
94 let before: number | undefined = undefined;
95 const drained: string[] = [];
96 const build = () => swapper({
97 dataDir,
98 port,
99 treeRoot: running,
100 version: () => now.version,
101 commit: () => now.commit,
102 env: { PATH: process.env.PATH, FORGE_VERSION: "0.7.283", FORGE_BASE_VERSION: "0.7.283" },
103 checkout: async (sha) => {
104 const spec = landed[sha];
105 if (spec === undefined) return err("no such commit");
106 const dir = releaseDir(dataDir, sha);
107 if (!existsSync(dir)) tree(dir, spec.main, spec.pkg);
108 return ok(dir);
109 },
110 preflight: async (dir) => {
111 const sha = dir.split("/").pop()!;
112 return landed[sha]?.preflight === false ? { ok: false, line: "boot failed: (fixture)" } : { ok: true, line: "preflight ok" };
113 },
114 drain: () => {
115 drained.push(now.commit);
116 // The process that handed over lets go of the port; the one before it, started by a swap, is stopped.
117 if (before === undefined) old.stop(true);
118 else
119 try {
120 process.kill(before, "SIGTERM");
121 } catch {}
122 before = readServing(dataDir)?.pid;
123 if (before !== undefined) pids.add(before);
124 },
125 });
126 const h = {
127 f,
128 ports: { ...f.ports, deployer: build(), minIntervalMs: SWAP_INTERVAL_MS } as typeof f.ports,
129 dataDir,
130 port,
131 now,
132 drained,
133 running,
134 /** The swapped-in process is a new process: its own swapper, its own turn at the swap. */
135 become(commit: string, version: string) {
136 now.commit = commit;
137 now.version = version;
138 endSwap(false);
139 h.ports = { ...h.ports, deployer: build() };
140 },
141 };
142 return h;
143 }
144
145 describe("the keeper swaps main's tree in; a pod restart comes back on it", () => {
146 test("land, swap, verify, bad release, swap back: no refused request, known good kept, held", async () => {
147 const h = await hosted({ [B]: {}, [C]: {} });
148 const { f } = h;
149 // A client that hits the port for the whole story.
150 let stop = false;
151 const seen = { ok: 0, bad: 0 };
152 const hammer = (async () => {
153 while (!stop) {
154 const got = await health(h.port);
155 if (got?.commit === undefined) seen.bad++;
156 else seen.ok++;
157 await Bun.sleep(5);
158 }
159 })();
160
161 // Land: main moves to B (a landing the forge already checked out and recorded).
162 f.land(7, "A change");
163 f.app.db.query(`INSERT INTO release (sha, repo_id, pull_number, ok, line, created_at) VALUES (?1, ?2, 7, 1, 'preflight ok', ?3)`).run(B, f.repo.id, new Date(f.world.now).toISOString());
164 f.world.now += 61_000; // the cadence minute
165 const cut = await tick(h.ports);
166 expect(cut).toContain("cut 0.7.284");
167 expect(cut).toContain(`promoted to tree/${B}:0.7.284`);
168 // The new process answers on the same port as its own commit and version, beside the draining one.
169 expect(await (async () => { for (let i = 0; i < 40; i++) { const g = await health(h.port); if (g?.commit === B) return g; await Bun.sleep(50); } })()).toMatchObject({ commit: B, version: "0.7.284" });
170 expect(h.drained).toEqual([A]);
171 expect(readServing(h.dataDir)?.sha).toBe(B);
172 // The tree it left is pinned where a rollback finds it, marked as having served.
173 expect(existsSync(join(releaseDir(h.dataDir, A), "src", "main.ts"))).toBe(true);
174 expect(existsSync(join(releaseDir(h.dataDir, A), ".was-live"))).toBe(true);
175
176 // Verify: the new process is the keeper now.
177 h.become(B, "0.7.284");
178 f.world.live = "0.7.284";
179 f.world.now += 5_000;
180 expect(await tick(h.ports)).toContain("0.7.284 verified live");
181 expect(lastGood(h.dataDir)).toMatchObject({ version: "0.7.284", commit: B, base: "0.7.283" });
182 expect(existsSync(join(h.dataDir, "release", "good", "0.7.284", "src", "main.ts"))).toBe(true);
183 const [first] = listKeeperReleases(f.app.db);
184 expect(first).toMatchObject({ stage: "verified", knownGood: { version: "0.7.283", image: `tree/${A}:0.7.283` } });
185 // Landed to live: from the merge to the new version answering, the page's number.
186 // Merged, 61 s to the cadence's minute, 5 s for the new process to answer.
187 expect(landedToLive(f.app.db, first!)).toBe(66);
188 expect(newestLive(f.app.db)).toMatchObject({ version: "0.7.284", sha: B });
189
190 // A bad release: C boots and answers, then fails under load.
191 f.world.main = C;
192 f.world.now += 61_000;
193 expect(await tick(h.ports)).toContain(`promoted to tree/${C}:0.7.285`);
194 h.become(C, "0.7.285");
195 f.world.live = "0.7.285";
196 f.world.healthz = (n) => (n >= 6 ? { status: 503, ms: 4 } : { status: 200, ms: 4 });
197 const verdict = await tick(h.ports);
198 expect(verdict).toContain("0.7.285 failed its verify");
199 expect(verdict).toContain(`rolled back to 0.7.284 (tree/${B}:0.7.284)`);
200 // Swapped back: the pinned B tree serves again as 0.7.284.
201 const back = await (async () => { for (let i = 0; i < 60; i++) { const g = await health(h.port); if (g?.commit === B && g.version === "0.7.284") return g; await Bun.sleep(50); } })();
202 expect(back).toMatchObject({ commit: B, version: "0.7.284" });
203 expect(standingHolds(f.app.db)[0]).toMatchObject({ kind: "failed", version: "0.7.285" });
204 // What a restart would run is still the verified B, not the bad C and not the image.
205 expect(lastGood(h.dataDir)).toMatchObject({ version: "0.7.284", commit: B, base: "0.7.283" });
206
207 stop = true;
208 await hammer;
209 expect(seen.ok).toBeGreaterThan(10);
210 // The promise of 0 refused requests holds where the kernel migrates queued connections (the check runs there).
211 if (realMigrate) expect(seen.bad).toBe(0);
212 }, 60_000);
213
214 test("a tree that adds a dependency is not cut: no tag, no row, and the line asks for a new image", async () => {
215 const h = await hosted({ [B]: { pkg: JSON.stringify({ dependencies: { left: "1.0.0" } }) } });
216 h.f.world.now += 61_000;
217 const line = await tick(h.ports);
218 expect(line).toContain("not cut: dependencies names left");
219 expect(line).toContain("needs a new image");
220 expect(h.f.world.tags).toHaveLength(0);
221 expect(listKeeperReleases(h.f.app.db)).toHaveLength(0);
222 expect(h.drained).toEqual([]);
223 expect(dependencyChange(join(h.running))).toBeUndefined();
224 });
225
226 test("a process that cannot swap (it holds the SSH terminal) cuts nothing", async () => {
227 const h = await hosted({ [B]: {} });
228 h.f.world.now += 61_000;
229 handover.ssh = true;
230 try {
231 expect(await tick(h.ports)).toContain("not cut: this process holds the SSH terminal");
232 } finally {
233 delete handover.ssh;
234 }
235 expect(h.f.world.tags).toHaveLength(0);
236 });
237
238 test("a tree whose preflight is red is not cut", async () => {
239 const h = await hosted({ [B]: { preflight: false } });
240 h.f.world.now += 61_000;
241 expect(await tick(h.ports)).toContain("not cut: preflight failed: boot failed");
242 expect(h.f.world.tags).toHaveLength(0);
243 });
244
245 test("a release that will not come up leaves the running process serving and the cut sent again", async () => {
246 const h = await hosted({ [B]: { main: `await Bun.sleep(60000)` } });
247 h.f.world.now += 61_000;
248 const line = await tick({ ...h.ports, deployer: swapper({ ...swapOf(h), budgetMs: 800 }) });
249 expect(line).toContain("refused");
250 expect((await health(h.port))?.commit).toBe(A);
251 expect(h.drained).toEqual([]);
252 }, 20_000);
253
254 test("a refusal is remembered per commit: two ticks check out and preflight once", async () => {
255 const h = await hosted({ [B]: {} });
256 h.f.world.now += 61_000;
257 let checkouts = 0;
258 let preflights = 0;
259 const base = swapOf(h);
260 const deployer = swapper({
261 ...base,
262 checkout: async (sha: string) => {
263 checkouts++;
264 tree(releaseDir(h.dataDir, sha));
265 return ok(releaseDir(h.dataDir, sha));
266 },
267 preflight: async () => {
268 preflights++;
269 return { ok: false, line: "boot failed: (fixture)" };
270 },
271 });
272 const ports = { ...h.ports, deployer };
273 expect(await tick(ports)).toContain("not cut: preflight failed");
274 h.f.world.now += 61_000;
275 expect(await tick(ports)).toContain("not cut: preflight failed");
276 expect(preflights).toBe(1);
277 expect(checkouts).toBe(1);
278 });
279
280 test("a swap back to a tree that knows fewer migrations than the database is refused, and says why", async () => {
281 const h = await hosted({});
282 const live = liveSchema(h.dataDir);
283 expect(live).toBeGreaterThan(1);
284 // B has served (pinned and marked) but predates the live schema; C is as new.
285 tree(releaseDir(h.dataDir, B), STAND_IN, "{}", live - 1);
286 writeFileSync(join(releaseDir(h.dataDir, B), ".was-live"), "");
287 // C never answers: one that did would win the swap on a fast machine and be left on the port.
288 tree(releaseDir(h.dataDir, C), `await Bun.sleep(60000)`, "{}", live);
289 writeFileSync(join(releaseDir(h.dataDir, C), ".was-live"), "");
290 endSwap(false);
291 const deployer = swapper({ ...swapOf(h), checkout: async (sha: string) => ok(releaseDir(h.dataDir, sha)), budgetMs: 800 });
292 await expect(deployer.setImage(`tree/${B}:0.7.284`)).rejects.toThrow(`${B} predates schema ${live}; rolling back would need a down-migration`);
293 expect(h.drained).toEqual([]);
294 expect((await health(h.port))?.commit).toBe(A);
295 // A tree with the live schema is not refused by the guard (this one then fails to answer in its short budget, a different line).
296 await expect(deployer.setImage(`tree/${C}:0.7.285`)).rejects.not.toThrow("predates schema");
297 }, 20_000);
298
299 test("the running commit must be stamped to name a known good", async () => {
300 const h = await hosted({ [B]: {} });
301 h.now.commit = "";
302 await expect(h.ports.deployer.image()).rejects.toThrow("not stamped");
303 });
304 });
305
306 /** The pieces `hosted` built the swapper from, for a variant of it. */
307 function swapOf(h: Awaited<ReturnType<typeof hosted>>) {
308 return {
309 dataDir: h.dataDir,
310 port: h.port,
311 treeRoot: h.running,
312 version: () => h.now.version,
313 commit: () => h.now.commit,
314 env: { PATH: process.env.PATH },
315 checkout: async (sha: string) => {
316 tree(releaseDir(h.dataDir, sha), `await Bun.sleep(60000)`);
317 return ok(releaseDir(h.dataDir, sha));
318 },
319 preflight: async () => ({ ok: true, line: "ok" }),
320 drain: () => h.drained.push("x"),
321 };
322 }
323
324 test("swap mode needs no cluster, only the swap switch", () => {
325 expect(keeperEnv({ FORGE_RELEASE_KEEPER: "swap", FORGE_SELF_SWAP: "1" })).toEqual({ on: true, mode: "swap" });
326 expect(keeperEnv({ FORGE_RELEASE_KEEPER: "swap" })).toMatchObject({ on: false });
327 expect(keeperEnv({ FORGE_RELEASE_KEEPER: "on", KUBERNETES_SERVICE_HOST: "10.0.0.1" })).toMatchObject({ on: true, mode: "kube" });
328 expect(SWAP_INTERVAL_MS).toBe(60_000);
329 });
330
331 test("seconds are said in words", () => {
332 expect(secondsWords(45)).toBe("45 s");
333 expect(secondsWords(72)).toBe("1 min 12 s");
334 });
335
336 test("keeping a swapped tree names the image it is over", () => {
337 const dir = mkdtempSync(join(tmpdir(), "forge-keep-"));
338 dirs.push(dir);
339 const t = join(dir, "t");
340 tree(t);
341 keepGood(dir, t, "0.7.290", B, 1, "0.7.283");
342 expect(lastGood(dir)?.base).toBe("0.7.283");
343 });
test/release-launch.test.ts +6 −3
@@ -154,7 +154,10 @@ test("a record that is not one reads as none", () => {
154154 test("the image starts the launcher, and the launcher imports nothing a release could break", async () => {
155155 const repo = join(import.meta.dir, "..");
156156 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);
157 // serving.ts (#8042) is the launcher's own sibling, node built-ins only, so the rule holds for both.
158 for (const [file, sibling] of [["launch.ts", "./serving.ts"], ["serving.ts", undefined]] as const) {
159 const source = await Bun.file(join(repo, "src/release", file)).text();
160 const imports = new Bun.Transpiler({ loader: "ts" }).scanImports(source).map((i) => i.path);
161 expect(imports.every((p) => p.startsWith("node:") || p === "bun" || p === sibling)).toBe(true);
162 }
160163 });
test/release-swapped-launch.test.ts added +124
@@ -0,0 +1,124 @@
1 /**
2 * A pod restart comes back on the newest good swapped tree (#8042). The image
3 * is a base: while the same image restarts, the swapped release kept over it
4 * runs first, and an older image's version never overwrites it. A different
5 * image (a deliberate roll) is the newer base; a swapped tree that will not
6 * start gives way to the image, and the forge says so.
7 */
8
9 import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
10 import { mkdirSync, mkdtempSync, 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 { keepGood, lastGood, launch, rolledBack, type Launched } from "../src/release/launch.ts";
15 import { clearServing, follow, readServing, writeServing } from "../src/release/serving.ts";
16
17 let root: string;
18 let n = 0;
19 const running: Subprocess[] = [];
20 beforeAll(() => {
21 root = mkdtempSync(join(tmpdir(), "forge-swapped-launch-"));
22 });
23 afterEach(async () => {
24 for (const child of running.splice(0)) {
25 child.kill("SIGKILL");
26 await child.exited;
27 }
28 });
29 afterAll(() => rmSync(root, { recursive: true, force: true }));
30
31 function tree(main: string): string {
32 const dir = join(root, `tree-${++n}`);
33 mkdirSync(join(dir, "src"), { recursive: true });
34 writeFileSync(join(dir, "src", "main.ts"), main);
35 writeFileSync(join(dir, "package.json"), "{}");
36 return dir;
37 }
38 const SERVES = `
39 Bun.serve({ hostname: "127.0.0.1", port: Number(process.env.PORT), fetch(req) {
40 if (new URL(req.url).pathname !== "/api/v1/health") return new Response("", { status: 404 });
41 return Response.json({ ok: true, version: process.env.FORGE_VERSION, commit: process.env.FORGE_COMMIT || null, base: process.env.FORGE_BASE_VERSION });
42 } });
43 `;
44 const THROWS = `throw new Error("(test fixture) the swapped tree does not start");\n`;
45
46 async function run(dataDir: string, dir: string, version: string, graceMs = 30_000): Promise<Launched & { port: number }> {
47 const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("") });
48 const port = probe.port ?? 0;
49 await probe.stop(true);
50 const launched = await launch({
51 tree: dir,
52 dataDir,
53 port,
54 version,
55 commit: null,
56 env: { PATH: process.env.PATH, PORT: String(port) },
57 graceMs,
58 started: (child) => running.push(child),
59 });
60 return { ...launched, port };
61 }
62
63 const healthAt = async (port: number) =>
64 (await (await fetch(`http://127.0.0.1:${port}/api/v1/health`)).json()) as { version: string; commit: string; base: string };
65
66 const SWAPPED = "5".repeat(40);
67
68 test("the same image restarting runs the swapped release kept over it, and keeps it", async () => {
69 const data = join(root, "data-same");
70 keepGood(data, tree(SERVES), "0.7.290", SWAPPED, Date.now(), "0.7.283");
71 expect(lastGood(data)).toMatchObject({ version: "0.7.290", commit: SWAPPED, base: "0.7.283" });
72 // The image is the older 0.7.283; its tree throws, so only the swapped tree can answer.
73 const launched = await run(data, tree(THROWS), "0.7.283");
74 expect(launched.ran).toBe("release");
75 expect(await healthAt(launched.port)).toMatchObject({ version: "0.7.290", commit: SWAPPED, base: "0.7.283" });
76 expect(lastGood(data)).toMatchObject({ version: "0.7.290", base: "0.7.283" });
77 expect(rolledBack(data)).toBeUndefined();
78 });
79
80 test("a stale image that is ready does not replace the swapped release as the last good one", async () => {
81 const data = join(root, "data-stale");
82 keepGood(data, tree(SERVES), "0.7.290", SWAPPED, Date.now(), "0.7.283");
83 await run(data, tree(SERVES), "0.7.283");
84 expect(lastGood(data)?.version).toBe("0.7.290");
85 });
86
87 test("a different image is a deliberate roll: it runs, and is kept over the swapped tree", async () => {
88 const data = join(root, "data-newer");
89 keepGood(data, tree(SERVES), "0.7.290", SWAPPED, Date.now(), "0.7.283");
90 const launched = await run(data, tree(SERVES), "0.7.300");
91 expect(launched.ran).toBe("release");
92 expect(await healthAt(launched.port)).toMatchObject({ version: "0.7.300" });
93 expect(lastGood(data)).toMatchObject({ version: "0.7.300", base: null });
94 });
95
96 test("a swapped tree that does not start gives way to the image, recorded as a rollback", async () => {
97 const data = join(root, "data-broken");
98 keepGood(data, tree(THROWS), "0.7.290", SWAPPED, Date.now(), "0.7.283");
99 const launched = await run(data, tree(SERVES), "0.7.283");
100 expect(launched.ran).toBe("rollback");
101 expect(launched.line).toContain("0.7.290: it exited with 1 before it was ready; the image 0.7.283 runs instead");
102 expect(await healthAt(launched.port)).toMatchObject({ version: "0.7.283" });
103 expect(rolledBack(data)).toMatchObject({ from: "0.7.290", to: "0.7.283", paged: false });
104 });
105
106 test("the launcher follows the process that took over, and lets go when it is gone", async () => {
107 const data = join(root, "data-follow");
108 clearServing(data);
109 expect(readServing(data)).toBeUndefined();
110 const heir = Bun.spawn([process.execPath, "-e", "setTimeout(() => {}, 700)"], { stdout: "ignore", stderr: "ignore" });
111 writeServing(data, { pid: heir.pid, sha: SWAPPED });
112 expect(readServing(data)).toEqual({ pid: heir.pid, sha: SWAPPED });
113 const began = Date.now();
114 await follow(data, 1, 50);
115 expect(Date.now() - began).toBeGreaterThanOrEqual(400);
116 await heir.exited;
117 // Its own child serving again (or nobody) ends the wait at once.
118 writeServing(data, { pid: 4242, sha: SWAPPED });
119 const own = Date.now();
120 await follow(data, 4242, 50);
121 expect(Date.now() - own).toBeLessThan(200);
122 clearServing(data);
123 expect(readServing(data)).toBeUndefined();
124 });
test/sentinel.test.ts +2
@@ -61,6 +61,8 @@ function plantRun(
6161 }
6262
6363 beforeAll(async () => {
64 // The error ledger is the process's: a file run before this one in the same shard leaves its lines there.
65 clearErrors();
6466 dataDir = await mkdtemp(join(tmpdir(), "forge-sentinel-"));
6567 app = createApp({ dataDir, port: 0 });
6668 const a = await createUser(app.db, {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs