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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8042-the-forge-ships-itself-a-landing-goes-li → main 10 commits fork 4 threads · 4 unresolved
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 | ||
| 545 | 545 | |
| 546 | 546 | 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"). |
| 547 | 547 | |
| 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. | |
| 549 | 549 | |
| 550 | 550 | - **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. |
| 551 | 551 | - **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. | ||
| 142 | 142 | | `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 | |
| 143 | 143 | | `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} | | |
| 144 | 144 | | `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 | | | |
| 146 | 146 | | `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 | | |
| 147 | 147 | | `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` | | |
| 148 | 148 | | `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 @@ | ||
| 2 | 2 | |
| 3 | 3 | 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. |
| 4 | 4 | |
| 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 @@ | ||
| 1 | 1 | import { mayRead } from "../../../release/gate.ts"; |
| 2 | 2 | import { blocking, listHolds, standingHolds } from "../../../keeper/hold.ts"; |
| 3 | import { withLandedToLive } from "../../../keeper/landed.ts"; | |
| 3 | 4 | import { listKeeperReleases } from "../../../keeper/record.ts"; |
| 4 | 5 | import { bad, good, type Tool } from "../shared.ts"; |
| 5 | 6 | |
| 6 | 7 | export const tool: Tool = { |
| 7 | 8 | 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", | |
| 9 | 10 | description: "The release keeper's releases, holds and what blocks the next cut.", |
| 10 | 11 | inputSchema: { type: "object", properties: {} }, |
| 11 | 12 | writes: false, |
| @@ -15,7 +16,7 @@ export const tool: Tool = { | ||
| 15 | 16 | const db = ctx.app.db; |
| 16 | 17 | return good({ |
| 17 | 18 | blocked: blocking(standingHolds(db)) ?? null, |
| 18 | releases: listKeeperReleases(db), | |
| 19 | releases: withLandedToLive(db, listKeeperReleases(db)), | |
| 19 | 20 | holds: listHolds(db), |
| 20 | 21 | }); |
| 21 | 22 | }, |
src/keeper/deploy.ts +6
| @@ -19,6 +19,12 @@ export interface Deployer { | ||
| 19 | 19 | image(): Promise<string>; |
| 20 | 20 | /** Name this image on the forge container; the Deployment rolls to it with surge. */ |
| 21 | 21 | 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; | |
| 22 | 28 | } |
| 23 | 29 | |
| 24 | 30 | /** An image reference: registry/path:tag or @digest, no space, no flag. */ |
src/keeper/keeper.ts +14 −5
| @@ -7,7 +7,9 @@ | ||
| 7 | 7 | * 1. **Cut** on cadence (`cadence.ts`): the known-good version and image |
| 8 | 8 | * are recorded, then the annotated tag `v<version>` is made on main's |
| 9 | 9 | * 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 | |
| 11 | 13 | * rolls with surge (`maxSurge 1, maxUnavailable 0`). The old forge |
| 12 | 14 | * probes `/healthz` each second while it still serves (`rollProbe`), |
| 13 | 15 | * so a roll that went dark says how long. |
| @@ -119,6 +121,7 @@ async function verifyAndDecide(p: KeeperPorts, r: KeeperRelease): Promise<string | ||
| 119 | 121 | const numbers = { version: r.version, probes: kept.probes, pages: kept.pages, worstMs: kept.worstMs, secondsDark: kept.secondsDark, rollNon200: kept.rollNon200 }; |
| 120 | 122 | if (verdict.ok) { |
| 121 | 123 | finish(p.db, r.id, "verified", "", p.now()); |
| 124 | p.deployer.verified?.(r.version); | |
| 122 | 125 | const lifted = liftFixed(p.db, r.version, p.now()); |
| 123 | 126 | p.bus.append({ kind: "release.verified_live", repoId: p.repo.id, payload: { ...numbers, holdsLifted: lifted } }); |
| 124 | 127 | fileDark(p, getKeeperRelease(p.db, r.id)!); |
| @@ -160,7 +163,7 @@ export async function tick(p: KeeperPorts): Promise<string> { | ||
| 160 | 163 | if (flying.stage === "rolling" && p.now() - Date.parse(flying.cutAt) > ROLL_DEADLINE_MS) |
| 161 | 164 | return rollBack(p, flying, `${flying.version} was not live ${ROLL_DEADLINE_MS / 3_600_000} h after its cut`); |
| 162 | 165 | // 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); | |
| 164 | 167 | if (flying.stage === "rolling" && (await p.deployer.image()) !== want) await promote(p, flying.version, want); |
| 165 | 168 | return `${flying.version} is ${flying.stage}`; |
| 166 | 169 | } |
| @@ -180,17 +183,23 @@ export async function tick(p: KeeperPorts): Promise<string> { | ||
| 180 | 183 | ...(p.minIntervalMs === undefined ? {} : { minIntervalMs: p.minIntervalMs }), |
| 181 | 184 | }); |
| 182 | 185 | 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); | |
| 188 | if (refused !== undefined) return `not cut: ${refused}`; | |
| 183 | 189 | const image = await p.deployer.image(); |
| 184 | 190 | const knownGood = { version: p.live(), image }; |
| 185 | 191 | // The row first, the tag second: a crash between them leaves a `rolling` row |
| 186 | 192 | // whose tag the next tick sends (`tagged`), never a tag the keeper has no row for. |
| 187 | 193 | 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)); | |
| 191 | 197 | return `cut ${r.version} at ${main.slice(0, 12)}; ${sent}; known good: ${knownGood.version}`; |
| 192 | 198 | } |
| 193 | 199 | |
| 200 | const refFor = (p: KeeperPorts, image: string, version: string, sha: string): string => | |
| 201 | p.deployer.refFor?.(version, sha) ?? imageAt(image, version); | |
| 202 | ||
| 194 | 203 | /** Make the release's tag; when it is refused the cut is dropped and the line says why. */ |
| 195 | 204 | async function tagged(p: KeeperPorts, r: KeeperRelease): Promise<string | undefined> { |
| 196 | 205 | 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 @@ | ||
| 3 | 3 | * repository for main and the tags, the merge queue for "mid-landing", the |
| 4 | 4 | * kube API for the Deployment, and the public URL for the probes. |
| 5 | 5 | * |
| 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`). | |
| 10 | 14 | */ |
| 11 | 15 | |
| 16 | import { join } from "node:path"; | |
| 12 | 17 | import type { App } from "../app.ts"; |
| 13 | 18 | import { originPush } from "../actions/branches.ts"; |
| 14 | 19 | import { serviceAccount } from "../cluster/jobs.ts"; |
| @@ -22,7 +27,10 @@ import { repoDir } from "../git/repos.ts"; | ||
| 22 | 27 | import { ownProbeHeaders } from "../kernel/probe.ts"; |
| 23 | 28 | import { running } from "../kernel/version.ts"; |
| 24 | 29 | import { compareVersions, readTags, recordFor } from "../release/shipped.ts"; |
| 30 | import { checkoutRelease, preflightRelease } from "../release/checkout.ts"; | |
| 31 | import { handover } from "../release/swap.ts"; | |
| 25 | 32 | import { kubeDeployer } from "./deploy.ts"; |
| 33 | import { swapper } from "./swapper.ts"; | |
| 26 | 34 | import type { KeeperPorts } from "./keeper.ts"; |
| 27 | 35 | import { timedGet, verifyUnderLoad } from "./verify.ts"; |
| 28 | 36 | |
| @@ -31,16 +39,26 @@ const OID = /^[0-9a-f]{40}$/; | ||
| 31 | 39 | const VERSION = /^\d+\.\d+\.\d+$/; |
| 32 | 40 | const ZERO = "0".repeat(40); |
| 33 | 41 | |
| 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; | |
| 35 | 49 | |
| 36 | 50 | /** Whether the keeper runs, from the environment. */ |
| 37 | 51 | 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 | } | |
| 38 | 56 | if (env.FORGE_RELEASE_KEEPER !== "on") return { on: false, why: "FORGE_RELEASE_KEEPER is not on" }; |
| 39 | 57 | if (!env.KUBERNETES_SERVICE_HOST) return { on: false, why: "not running in a cluster" }; |
| 40 | 58 | const deployment = env.FORGE_KEEPER_DEPLOYMENT || "forge"; |
| 41 | 59 | const container = env.FORGE_KEEPER_CONTAINER || "forge"; |
| 42 | 60 | 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 }; | |
| 44 | 62 | } |
| 45 | 63 | |
| 46 | 64 | /** 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[] { | ||
| 56 | 74 | return ["/boards", `/${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}`, "/explore", "/"]; |
| 57 | 75 | } |
| 58 | 76 | |
| 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 { | |
| 60 | 78 | const named = app.config.forgeRepo; |
| 61 | 79 | if (named === undefined) return "FORGE_REPO is not set"; |
| 62 | 80 | const [owner = "", name = ""] = named.split("/"); |
| 63 | 81 | const repo = getRepo(app.db, owner, name); |
| 64 | 82 | if (repo === undefined) return `FORGE_REPO names ${named}, which is no repository on this forge`; |
| 65 | 83 | 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 }); | |
| 67 | 100 | const probeHeaders = ownProbeHeaders(app.config.dataDir); |
| 68 | 101 | const at = (version: string) => runGit(["rev-parse", "--verify", "--quiet", `refs/tags/v${version}^{commit}`], { cwd: dir }); |
| 69 | 102 | return { |
| 103 | ...(input.swap === true ? { minIntervalMs: SWAP_INTERVAL_MS } : {}), | |
| 70 | 104 | db: app.db, |
| 71 | 105 | bus: app.bus, |
| 72 | 106 | repo: { id: repo.id, ownerId: repo.ownerId }, |
| @@ -99,7 +133,8 @@ export function keeperPorts(app: App, input: { forgeUrl: string; deployment: str | ||
| 99 | 133 | if (!made.ok) return made.error.stderr || "git refused the tag"; |
| 100 | 134 | const tagOid = await runGit(["rev-parse", `refs/tags/v${version}`], { cwd: dir }); |
| 101 | 135 | 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; | |
| 103 | 138 | // A tag the origin did not take is taken back here too: the keeper drops the cut, and a |
| 104 | 139 | // tag left behind would be the newest, never live, and stall the cadence. |
| 105 | 140 | 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) { | ||
| 658 | 658 | // known-good image and hold. Off unless FORGE_RELEASE_KEEPER=on in a cluster (keeper/wire.ts). |
| 659 | 659 | const keeperOn = keeperEnv(process.env); |
| 660 | 660 | 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 | ); | |
| 662 | 667 | if (typeof ports === "string") app.log.warn("the release keeper is off", { why: ports }); |
| 663 | 668 | else { |
| 664 | 669 | let said = ""; |
src/release/launch.ts +89 −29
| @@ -17,6 +17,15 @@ | ||
| 17 | 17 | * seconds after its crash, and a hang costs the grace (150 s, inside the |
| 18 | 18 | * chart's three-minute startup probe). |
| 19 | 19 | * |
| 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 | * | |
| 20 | 29 | * Node built-ins only, on purpose: it is the one part of a bad release that |
| 21 | 30 | * has to work. |
| 22 | 31 | */ |
| @@ -25,6 +34,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, r | ||
| 25 | 34 | import { dirname, join } from "node:path"; |
| 26 | 35 | import type { Subprocess } from "bun"; |
| 27 | 36 | import { hkdfSync } from "node:crypto"; |
| 37 | import { clearServing, follow, signalServing } from "./serving.ts"; | |
| 28 | 38 | |
| 29 | 39 | export const READY_GRACE_MS = 150_000; |
| 30 | 40 | const POLL_MS = 500; |
| @@ -38,6 +48,8 @@ export interface Good { | ||
| 38 | 48 | readonly version: string; |
| 39 | 49 | readonly commit: string | null; |
| 40 | 50 | 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; | |
| 41 | 53 | } |
| 42 | 54 | |
| 43 | 55 | export interface RolledBack { |
| @@ -78,13 +90,27 @@ export function lastGood(dataDir: string): Good | undefined { | ||
| 78 | 90 | const version = g?.["version"]; |
| 79 | 91 | if (typeof version !== "string" || !VERSION.test(version) || typeof g?.["at"] !== "string") return undefined; |
| 80 | 92 | 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 | }; | |
| 82 | 100 | } |
| 83 | 101 | |
| 84 | 102 | /** 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) { | |
| 88 | 114 | const target = goodTree(dataDir, version); |
| 89 | 115 | const staging = `${target}.partial-${process.pid}`; |
| 90 | 116 | rmSync(staging, { recursive: true, force: true }); |
| @@ -93,7 +119,7 @@ export function keepGood(dataDir: string, tree: string, version: string, commit: | ||
| 93 | 119 | if (existsSync(join(tree, part))) cpSync(join(tree, part), join(staging, part), { recursive: true }); |
| 94 | 120 | rmSync(target, { recursive: true, force: true }); |
| 95 | 121 | 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 }); | |
| 97 | 123 | } |
| 98 | 124 | for (const name of readdirSync(join(releaseDir(dataDir), "good"))) |
| 99 | 125 | 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 | ||
| 190 | 216 | for (const [k, v] of Object.entries(input.env)) if (v !== undefined) env[k] = v; |
| 191 | 217 | env["FORGE_VERSION"] = version; |
| 192 | 218 | 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; | |
| 193 | 221 | const child = Bun.spawn([process.execPath, join(tree, "src", "main.ts")], { |
| 194 | 222 | cwd: tree, |
| 195 | 223 | env, |
| @@ -201,42 +229,65 @@ function start(input: LaunchInput, tree: string, version: string, commit: string | ||
| 201 | 229 | return child; |
| 202 | 230 | } |
| 203 | 231 | |
| 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 | ||
| 204 | 246 | /** |
| 205 | 247 | * Start this image's forge and answer once it is ready (`release`), was |
| 206 | 248 | * 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). | |
| 208 | 251 | */ |
| 209 | 252 | export async function launch(input: LaunchInput): Promise<Launched> { |
| 210 | 253 | 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); | |
| 216 | 262 | 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)}` }; | |
| 220 | 273 | } |
| 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` }; | |
| 229 | 284 | } |
| 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` }; | |
| 235 | 286 | } |
| 236 | 287 | await stop(release); |
| 237 | 288 | if (input.stopping?.()) return { ran: "none", line: "asked to stop before it was ready" }; |
| 238 | 289 | 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) | |
| 240 | 291 | return { ran: "none", line: `${input.version}: ${why}; no earlier release is kept to roll back to` }; |
| 241 | 292 | writeJson(rolledFile(input.dataDir), { |
| 242 | 293 | from: input.version, |
| @@ -256,10 +307,14 @@ if (import.meta.main) { | ||
| 256 | 307 | const graceS = Number(env["FORGE_READY_GRACE_S"] ?? ""); |
| 257 | 308 | let current: Subprocess | undefined; |
| 258 | 309 | 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); | |
| 259 | 313 | for (const signal of ["SIGTERM", "SIGINT"] as const) |
| 260 | 314 | process.on(signal, () => { |
| 261 | 315 | stopping = true; |
| 262 | 316 | current?.kill(signal); |
| 317 | signalServing(dataHere, signal); | |
| 263 | 318 | }); |
| 264 | 319 | const launched = await launch({ |
| 265 | 320 | tree: join(import.meta.dir, "..", ".."), |
| @@ -275,5 +330,10 @@ if (import.meta.main) { | ||
| 275 | 330 | stopping: () => stopping, |
| 276 | 331 | }); |
| 277 | 332 | 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); | |
| 279 | 339 | } |
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 @@ | ||
| 18 | 18 | |
| 19 | 19 | import { existsSync, readFileSync } from "node:fs"; |
| 20 | 20 | import { join } from "node:path"; |
| 21 | import { writeServing } from "./serving.ts"; | |
| 21 | 22 | |
| 22 | 23 | export const SWAP_BUDGET_MS = 60_000; |
| 23 | 24 | const POLL_MS = 250; |
| @@ -32,6 +33,8 @@ export interface SwapInput { | ||
| 32 | 33 | /** The environment the new forge starts with: the old one's, as it is. */ |
| 33 | 34 | env: Record<string, string | undefined>; |
| 34 | 35 | budgetMs?: number; |
| 36 | /** The version the new process runs as (`FORGE_VERSION`); the environment's own when absent. */ | |
| 37 | version?: string; | |
| 35 | 38 | /** Sent with each health read (`kernel/probe.ts`), so the answer names its commit. */ |
| 36 | 39 | probe?: Readonly<Record<string, string>>; |
| 37 | 40 | } |
| @@ -62,6 +65,7 @@ export async function startRelease(input: SwapInput): Promise<Swapped> { | ||
| 62 | 65 | const env: Record<string, string> = {}; |
| 63 | 66 | for (const [k, v] of Object.entries(input.env)) if (v !== undefined) env[k] = v; |
| 64 | 67 | env["FORGE_COMMIT"] = input.sha; |
| 68 | if (input.version !== undefined) env["FORGE_VERSION"] = input.version; | |
| 65 | 69 | env["PORT"] = String(input.port); |
| 66 | 70 | env["FORGE_SELF_SWAP"] = "1"; |
| 67 | 71 | const proc = Bun.spawn([process.execPath, entry], { |
| @@ -81,6 +85,8 @@ export async function startRelease(input: SwapInput): Promise<Swapped> { | ||
| 81 | 85 | return { ok: false, line: `the new process exited with ${exited} before it was healthy` }; |
| 82 | 86 | if (healthyAs(await readHealth(input.port, input.probe ?? {}), input.sha)) { |
| 83 | 87 | 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 }); | |
| 84 | 90 | return { ok: true, pid: proc.pid }; |
| 85 | 91 | } |
| 86 | 92 | await Bun.sleep(POLL_MS); |
| @@ -164,22 +170,21 @@ let underWay = false; | ||
| 164 | 170 | export function beginSwap(): { ok: true } | { ok: false; line: string; tell: boolean } { |
| 165 | 171 | if (underWay) |
| 166 | 172 | 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 }; | |
| 179 | 175 | underWay = true; |
| 180 | 176 | return { ok: true }; |
| 181 | 177 | } |
| 182 | 178 | |
| 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 | ||
| 183 | 188 | /** A swap that failed gives the turn back; one that worked keeps it, for this process is leaving. */ |
| 184 | 189 | export function endSwap(swapped: boolean): void { |
| 185 | 190 | if (!swapped) underWay = false; |
src/tui/screens/keeper.ts +9 −5
| @@ -1,14 +1,16 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The release keeper in the terminal (#7557), the /releases card's read: |
| 3 | 3 | * 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. | |
| 5 | 6 | * Gated by `release/gate.ts` as the page is. `h` holds releases with a |
| 6 | 7 | * reason and `h` with `lift N` lifts an owner's hold, through the same |
| 7 | 8 | * functions the card and MCP `release_hold` call. |
| 8 | 9 | */ |
| 9 | 10 | |
| 10 | 11 | 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"; | |
| 12 | 14 | import { mayRead, mayRollBack } from "../../release/gate.ts"; |
| 13 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| 14 | 16 | import { binding } from "../app/keys.ts"; |
| @@ -29,22 +31,24 @@ const STAGE: Record<Stage, { label: string; tone: Tone }> = { | ||
| 29 | 31 | export interface KeeperRead { |
| 30 | 32 | /** What holds the next cut, or the line that says nothing does. */ |
| 31 | 33 | readonly blocked: string; |
| 32 | readonly releases: readonly KeeperRelease[]; | |
| 34 | readonly releases: readonly LandedRelease[]; | |
| 33 | 35 | } |
| 34 | 36 | |
| 35 | 37 | /** The card's content for this person; undefined for anyone the page would 404. */ |
| 36 | 38 | export function readKeeper(f: Session): KeeperRead | undefined { |
| 37 | 39 | 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)) }; | |
| 39 | 41 | } |
| 40 | 42 | |
| 41 | export function keeperRows(releases: readonly KeeperRelease[]): ListRow[] { | |
| 43 | export function keeperRows(releases: readonly LandedRelease[]): ListRow[] { | |
| 42 | 44 | return releases.map((r) => ({ |
| 43 | 45 | title: r.version, |
| 44 | 46 | tone: STAGE[r.stage].tone, |
| 45 | 47 | meta: metaOf( |
| 46 | 48 | STAGE[r.stage].label, |
| 49 | r.commit, | |
| 47 | 50 | `cut ${ago(r.cutAt)}`, |
| 51 | r.landedToLiveSeconds === null ? "not live yet" : `landed to live ${secondsWords(r.landedToLiveSeconds)}`, | |
| 48 | 52 | `known good ${r.knownGood.version}`, |
| 49 | 53 | `roll ${r.rollNon200}/${r.rollProbes} non-200`, |
| 50 | 54 | `${r.probes} probes, ${r.pages} pages`, |
src/web/pages/changelog.ts +10 −3
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | |
| 9 | 9 | import { notePhase, takeSessionTime, timePhaseAsync } from "../../core/route_timing.ts"; |
| 10 | 10 | import type { Ctx } from "../../http/context.ts"; |
| 11 | import { newestLive, secondsWords } from "../../keeper/landed.ts"; | |
| 11 | 12 | import { dayHeading, dayOf, daysJs, shippedToday } from "../../release/days.ts"; |
| 12 | 13 | import { readLandings, type Landing, type Landings } from "../../release/landings.ts"; |
| 13 | 14 | import { liveRegionsJs } from "../client.ts"; |
| @@ -109,7 +110,13 @@ export const localDaysJs = /* js */ ` | ||
| 109 | 110 | `; |
| 110 | 111 | |
| 111 | 112 | /** 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 { | |
| 113 | 120 | const keep = (l: Landing) => choice === "all" || l.repo === choice; |
| 114 | 121 | const live = read.live.filter(keep); |
| 115 | 122 | const onItsWay = read.onItsWay.filter(keep); |
| @@ -120,7 +127,7 @@ export function shippedRegion(read: Landings, choice: string, now: Date = new Da | ||
| 120 | 127 | const version = |
| 121 | 128 | read.version !== "dev" ? (commit === null ? read.version : html`${read.version} · ${commit}`) : (commit ?? "a development build"); |
| 122 | 129 | 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> | |
| 124 | 131 | </p>`; |
| 125 | 132 | const way = |
| 126 | 133 | onItsWay.length === 0 |
| @@ -147,7 +154,7 @@ export async function changelogPage(ctx: Ctx): Promise<Response> { | ||
| 147 | 154 | ...others.map((r) => ({ label: r, href: `/changelog?repo=${encodeURIComponent(r)}`, current: choice === r })), |
| 148 | 155 | ]); |
| 149 | 156 | 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>`; | |
| 151 | 158 | const response = page({ ctx, title: "Changelog", body, scriptJs: `${liveRegionsJs}\n${localDaysJs}` }); |
| 152 | 159 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 153 | 160 | return response; |
src/web/pages/keeper-panel.ts +8 −6
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import { blocking, MAX_REASON, standingHolds } from "../../keeper/hold.ts"; |
| 9 | import { secondsWords, withLandedToLive } from "../../keeper/landed.ts"; | |
| 9 | 10 | import { listKeeperReleases, type Stage } from "../../keeper/record.ts"; |
| 10 | 11 | import { html, when, type Html } from "../html.ts"; |
| 11 | 12 | import { badge, btn, card, time, type Tone } from "../ui.ts"; |
| @@ -19,28 +20,29 @@ const STAGE: Record<Stage, { label: string; tone: Tone }> = { | ||
| 19 | 20 | |
| 20 | 21 | /** `mayHold` is `mayRollBack`: the card then offers the owner's hold and its lift. */ |
| 21 | 22 | export function keeperPanel(db: Database, mayHold = false): Html | undefined { |
| 22 | const releases = listKeeperReleases(db); | |
| 23 | const releases = withLandedToLive(db, listKeeperReleases(db)); | |
| 23 | 24 | const holds = standingHolds(db); |
| 24 | 25 | const blocked = blocking(holds); |
| 25 | 26 | if (releases.length === 0 && blocked === undefined && !mayHold) return undefined; |
| 26 | 27 | const rows = releases.map((r) => { |
| 27 | 28 | const s = STAGE[r.stage]; |
| 28 | 29 | 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> | |
| 30 | 31 | <td>${time(r.cutAt)}</td> |
| 32 | <td>${r.landedToLiveSeconds === null ? "–" : secondsWords(r.landedToLiveSeconds)}</td> | |
| 31 | 33 | <td>${r.knownGood.version}</td> |
| 32 | 34 | <td>${r.rollNon200} of ${r.rollProbes}</td> |
| 33 | 35 | <td>${r.probes} / ${r.pages}</td> |
| 34 | 36 | <td>${r.worstMs} ms</td> |
| 35 | 37 | <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>`)}`; | |
| 37 | 39 | }); |
| 38 | 40 | return card(html`<h2>Release keeper</h2> |
| 39 | 41 | <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> | |
| 42 | 44 | <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>`)} | |
| 44 | 46 | ${when(mayHold, () => html`${holds.filter((h) => h.kind === "owner").map( |
| 45 | 47 | (h) => html`<form method="post" action="/releases/hold/${h.id}/lift" class="small">Owner hold ${h.id}: ${h.reason} ${btn("Lift", { type: "submit" })}</form>`, |
| 46 | 48 | )} |
test/home-cache.test.ts +5 −1
| @@ -134,7 +134,11 @@ test("warm home holds p95 under the stated budget", async () => { | ||
| 134 | 134 | panelCache(f.app.db).clear(); |
| 135 | 135 | const missed = await counted(); |
| 136 | 136 | 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 | ); | |
| 138 | 142 | // The KPI reads (eight on this fixture) and the pinned panel's own query. |
| 139 | 143 | expect(missOnly.length).toBeGreaterThan(5); |
| 140 | 144 | 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", () => { | ||
| 154 | 154 | test("the image starts the launcher, and the launcher imports nothing a release could break", async () => { |
| 155 | 155 | const repo = join(import.meta.dir, ".."); |
| 156 | 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); | |
| 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 | } | |
| 160 | 163 | }); |
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( | ||
| 61 | 61 | } |
| 62 | 62 | |
| 63 | 63 | 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(); | |
| 64 | 66 | dataDir = await mkdtemp(join(tmpdir(), "forge-sentinel-")); |
| 65 | 67 | app = createApp({ dataDir, port: 0 }); |
| 66 | 68 | const a = await createUser(app.db, { |
new above
offline
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).