Public: anyone can read this; the crew can change it.
Self-deploy, step 1: `bun src/main.ts --preflight` proves a tree can boot: config parses, migrations dry-run on a copy of the database, then exits 0 or 1 with one line #1674
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1661-self-deploy-step-1-bun-src-main-ts-prefl → main 7 commits fork 4 threads · 4 unresolved
5 files
changed +297
−21
README.md +1
| @@ -35,6 +35,7 @@ feeling on a foundation sized for one person. | ||
| 35 | 35 | sh |
| 36 | 36 | bun install # dev-only: types + tsc, no runtime deps |
| 37 | 37 | bun run src/main.ts # web on :3000, ssh on :2222 (FORGE_SSH_PORT=0 turns it off; a taken port turns it off with one warning) |
| 38 | bun src/main.ts --preflight # can this tree boot? config parses, migrations run on a copy of the database; one line, exit 0 or 1, no socket opened. Run it before the swap, not under load: the copy is not atomic against a live writer, and a `preflight-*` directory a killed run left behind is swept by the next run once an hour old, and a missing data directory is created, as boot would | |
| 38 | 39 | |
| 39 | 40 | |
| 40 | 41 | If your shell already has `FORGE_URL` set — a crew workspace sets it to the |
scripts/gate.ts +1
| @@ -55,6 +55,7 @@ export const GATE: readonly string[] = [ | ||
| 55 | 55 | "test/refresh-meta.test.ts", |
| 56 | 56 | "test/restore-card.test.ts", |
| 57 | 57 | "test/recovery-doc.test.ts", |
| 58 | "test/release-preflight.test.ts", | |
| 58 | 59 | "test/threats-doc.test.ts", |
| 59 | 60 | "test/returns.test.ts", |
| 60 | 61 | "test/review-route.test.ts", |
src/kernel/preflight.ts added +117
| @@ -0,0 +1,117 @@ | ||
| 1 | /** | |
| 2 | * Can this tree boot? `bun src/main.ts --preflight` asks without starting | |
| 3 | * anything: the configuration parses, and every migration runs on a copy of | |
| 4 | * the database. One line out, exit 0 or 1. No socket is opened and the live | |
| 5 | * database is never opened — only read as bytes. | |
| 6 | * | |
| 7 | * The copy lives in a directory of its own under the data directory (the | |
| 8 | * same disk, so a database that fits fits twice) and is removed in a | |
| 9 | * `finally`, whatever happened. | |
| 10 | */ | |
| 11 | ||
| 12 | import { Database } from "bun:sqlite"; | |
| 13 | import { | |
| 14 | copyFileSync, | |
| 15 | existsSync, | |
| 16 | mkdirSync, | |
| 17 | mkdtempSync, | |
| 18 | readdirSync, | |
| 19 | rmSync, | |
| 20 | statSync, | |
| 21 | } from "node:fs"; | |
| 22 | import { join } from "node:path"; | |
| 23 | import { parseCidrs } from "./cidr.ts"; | |
| 24 | import { dataHomeRefusal } from "./data_home.ts"; | |
| 25 | import { applyPragmas } from "./db.ts"; | |
| 26 | import { migrate } from "./migrate.ts"; | |
| 27 | ||
| 28 | /** A scratch directory older than this was left by a killed run. */ | |
outdated
configProblem repeats the boot's refusal, proxy and URL checks; boot should call this function instead of its own copy. |
||
| 29 | const STALE_MS = 60 * 60 * 1000; | |
| 30 | ||
| 31 | function sweepStale(dataDir: string): void { | |
| 32 | for (const name of readdirSync(dataDir)) { | |
| 33 | if (!name.startsWith("preflight-")) continue; | |
| 34 | const path = join(dataDir, name); | |
| 35 | try { | |
| 36 | if (Date.now() - statSync(path).mtimeMs > STALE_MS) | |
| 37 | rmSync(path, { recursive: true, force: true }); | |
| 38 | } catch { | |
| 39 | // Gone already, or not ours to read: leave it. | |
| 40 | } | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | export type Preflight = { ok: boolean; line: string }; | |
| 45 | ||
| 46 | /** Where this forge keeps its state: the same answer boot gives. */ | |
| 47 | export function dataDirOf(env: Record<string, string | undefined>): string { | |
| 48 | const hosted = (env.FORGE_APP_URL ?? "").trim() !== ""; | |
| 49 | return env.FORGE_DATA ?? (hosted ? env.FORGE_APP_DATA : undefined) ?? "data"; | |
outdated
No test reaches this URL refusal. It is a new boot behaviour, so add a test that fails without it: a bad FORGE_URL gives ok:false naming FORGE_URL, and a bad FORGE_APP_URL names FORGE_APP_URL. |
||
| 50 | } | |
| 51 | ||
| 52 | /** Why the configuration will not boot, in one line; `null` when it will. */ | |
| 53 | export function configProblem( | |
| 54 | env: Record<string, string | undefined>, | |
| 55 | treeDir: string, | |
| 56 | ): string | null { | |
| 57 | const refusal = dataHomeRefusal(dataDirOf(env), treeDir); | |
| 58 | if (refusal !== null) return refusal; | |
| 59 | const proxies = (env.FORGE_TRUSTED_PROXIES ?? "") | |
| 60 | .split(",") | |
| 61 | .map((s) => s.trim()) | |
| 62 | .filter((s) => s !== ""); | |
| 63 | const parsed = parseCidrs(proxies.join(",")); | |
| 64 | if (!parsed.ok) | |
| 65 | return `FORGE_TRUSTED_PROXIES: "${parsed.error}" is not an address or range`; | |
| 66 | const hosted = (env.FORGE_APP_URL ?? "").trim(); | |
| 67 | const url = hosted !== "" ? hosted : (env.FORGE_URL ?? "").trim(); | |
| 68 | if (url !== "") { | |
| 69 | try { | |
| 70 | new URL(url); | |
| 71 | } catch { | |
| 72 | return `${hosted !== "" ? "FORGE_APP_URL" : "FORGE_URL"}: "${url}" is not a URL`; | |
| 73 | } | |
| 74 | } | |
| 75 | return null; | |
| 76 | } | |
| 77 | ||
| 78 | export function preflight( | |
| 79 | env: Record<string, string | undefined>, | |
| 80 | treeDir: string, | |
| 81 | migrations: readonly string[], | |
| 82 | ): Preflight { | |
| 83 | const bad = configProblem(env, treeDir); | |
| 84 | if (bad !== null) return { ok: false, line: `preflight failed: ${bad}` }; | |
| 85 | ||
| 86 | const dataDir = dataDirOf(env); | |
| 87 | let scratch: string | undefined; | |
| 88 | try { | |
| 89 | mkdirSync(dataDir, { recursive: true }); | |
| 90 | sweepStale(dataDir); | |
| 91 | scratch = mkdtempSync(join(dataDir, "preflight-")); | |
| 92 | const live = join(dataDir, "forge.db"); | |
| 93 | const copy = join(scratch, "forge.db"); | |
| 94 | // The write-ahead log holds commits the main file does not yet; the copy | |
| 95 | // needs both to be the database the forge would open. | |
| 96 | for (const suffix of ["", "-wal"]) | |
| 97 | if (existsSync(live + suffix)) copyFileSync(live + suffix, copy + suffix); | |
| 98 | const db = new Database(copy, { create: true, strict: true }); | |
| 99 | try { | |
| 100 | applyPragmas(db); | |
| 101 | migrate(db, migrations); | |
| 102 | } finally { | |
| 103 | db.close(); | |
| 104 | } | |
| 105 | return { | |
| 106 | ok: true, | |
| 107 | line: `preflight ok: ${migrations.length} migrations apply to a copy of the database`, | |
| 108 | }; | |
| 109 | } catch (error) { | |
| 110 | const message = (error instanceof Error ? error.message : String(error)) | |
| 111 | .replace(/\s+/g, " ") | |
| 112 | .trim(); | |
| 113 | return { ok: false, line: `preflight failed: ${message}` }; | |
| 114 | } finally { | |
| 115 | if (scratch !== undefined) rmSync(scratch, { recursive: true, force: true }); | |
| 116 | } | |
| 117 | } |
src/main.ts +21 −21
| @@ -4,7 +4,6 @@ | ||
| 4 | 4 | * environment-only — a handful of variables, all with sane defaults. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { parseCidrs } from "./kernel/cidr.ts"; | |
| 8 | 7 | import { createApp } from "./app.ts"; |
| 9 | 8 | import { reconcile } from "./core/reconcile.ts"; |
| 10 | 9 | import { syncAllPillars } from "./actions/pillars.ts"; |
| @@ -37,7 +36,7 @@ import { sweepStaleAsks } from "./crew/stale_asks.ts"; | ||
| 37 | 36 | import { recordAskLedger } from "./web/studio/asks.ts"; |
| 38 | 37 | import { startPromptRoutine } from "./crew/prompts.ts"; |
| 39 | 38 | import { pruneTokens } from "./core/tokens.ts"; |
| 40 | import { dataHomeRefusal } from "./kernel/data_home.ts"; | |
| 39 | import { configProblem, dataDirOf, preflight } from "./kernel/preflight.ts"; | |
| 41 | 40 | import { running } from "./kernel/version.ts"; |
| 42 | 41 | import { record as recordDegraded } from "./kernel/degraded_history.ts"; |
| 43 | 42 | import { vitals } from "./kernel/vitals.ts"; |
| @@ -54,6 +53,17 @@ if (process.argv[2] === "hook") { | ||
| 54 | 53 | await runHookClient(name); |
| 55 | 54 | } |
| 56 | 55 | |
| 56 | // `--preflight` asks whether this tree can boot and answers in one line: | |
| 57 | // config parsed, migrations run on a copy of the database. No socket, no | |
| 58 | // timers, and the live database is never opened. | |
| 59 | if (process.argv.includes("--preflight")) { | |
| 60 | const { migrations } = await import("./core/schema.ts"); | |
| 61 | const result = preflight(process.env, import.meta.dir, migrations); | |
| 62 | // CLI answer on the stream a person or a script reads. | |
| 63 | (result.ok ? console.log : console.error)(result.line); | |
| 64 | process.exit(result.ok ? 0 : 1); | |
| 65 | } | |
| 66 | ||
| 57 | 67 | // A forge can be an app of a forge (docs/APPS.md, "The forge as an app of |
| 58 | 68 | // itself"). The host's `FORGE_APP_*` say where this one lives and what it |
| 59 | 69 | // answers to; `FORGE_URL` there names the host, not this forge. Hosted, the |
| @@ -74,31 +84,21 @@ const canonicalUrl = hosted ? hostedAt : (process.env.FORGE_URL ?? "").trim(); | ||
| 74 | 84 | // identity headers sign people in; without it they are ignored. |
| 75 | 85 | const edgeToken = (process.env.FORGE_EDGE_TOKEN ?? "").trim(); |
| 76 | 86 | |
| 77 | const dataDir = | |
| 78 | process.env.FORGE_DATA ?? | |
| 79 | (hosted ? process.env.FORGE_APP_DATA : undefined) ?? | |
| 80 | "data"; | |
| 81 | // State never lands in the tree the forge runs from (#622): a database or a | |
| 82 | // host key there is one `git add -A` from every clone. | |
| 83 | const refusal = dataHomeRefusal(dataDir, import.meta.dir); | |
| 84 | if (refusal !== null) { | |
| 85 | console.error(refusal); | |
| 87 | const dataDir = dataDirOf(process.env); | |
| 88 | // One answer for boot and `--preflight` (kernel/preflight.ts): the data | |
| 89 | // directory is not the tree itself (#622: a database or a host key there is | |
| 90 | // one `git add -A` from every clone), FORGE_TRUSTED_PROXIES parses (a typo | |
| 91 | // fails the boot, not a limit that quietly counts every stranger as one), | |
| 92 | // and the forge's URL is one. | |
| 93 | const problem = configProblem(process.env, import.meta.dir); | |
| 94 | if (problem !== null) { | |
| 95 | console.error(problem); | |
| 86 | 96 | process.exit(1); |
| 87 | 97 | } |
| 88 | ||
| 89 | // FORGE_TRUSTED_PROXIES is checked here so a typo fails the boot, not a | |
| 90 | // limit that quietly counts every stranger as one. | |
| 91 | 98 | const trustedProxies = (process.env.FORGE_TRUSTED_PROXIES ?? "") |
| 92 | 99 | .split(",") |
| 93 | 100 | .map((s) => s.trim()) |
| 94 | 101 | .filter((s) => s !== ""); |
| 95 | { | |
| 96 | const parsed = parseCidrs(trustedProxies.join(",")); | |
| 97 | if (!parsed.ok) { | |
| 98 | console.error(`FORGE_TRUSTED_PROXIES: "${parsed.error}" is not an address or range`); | |
| 99 | process.exit(1); | |
| 100 | } | |
| 101 | } | |
| 102 | 102 | |
| 103 | 103 | const app = createApp({ |
| 104 | 104 | dataDir, |
test/release-preflight.test.ts added +157
| @@ -0,0 +1,157 @@ | ||
| 1 | /** | |
| 2 | * `bun src/main.ts --preflight` (#1674): a tree that can boot says so in one | |
| 3 | * line and exits 0; one whose migration breaks names the migration and exits | |
| 4 | * 1; either way the live database is not touched and no copy is left behind. | |
| 5 | */ | |
| 6 | ||
| 7 | import { Database } from "bun:sqlite"; | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, utimesSync } from "node:fs"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { migrations } from "../src/core/schema.ts"; | |
| 13 | import { openDb } from "../src/kernel/db.ts"; | |
| 14 | import { preflight } from "../src/kernel/preflight.ts"; | |
| 15 | ||
| 16 | const tree = join(import.meta.dir, "..", "src"); | |
| 17 | let dir: string; | |
| 18 | ||
| 19 | beforeAll(() => { | |
| 20 | dir = mkdtempSync(join(tmpdir(), "preflight-")); | |
| 21 | }); | |
| 22 | afterAll(() => rmSync(dir, { recursive: true, force: true })); | |
| 23 | ||
| 24 | function liveDb(name: string, upTo: readonly string[]): string { | |
| 25 | const data = join(dir, name); | |
| 26 | mkdirSync(data); | |
| 27 | openDb(join(data, "forge.db"), upTo).close(); | |
| 28 | return data; | |
| 29 | } | |
| 30 | ||
| 31 | function run(data: string) { | |
| 32 | const p = Bun.spawnSync(["bun", join(tree, "main.ts"), "--preflight"], { | |
| 33 | env: { ...process.env, FORGE_DATA: data, FORGE_APP_URL: "" }, | |
| 34 | stdout: "pipe", | |
| 35 | stderr: "pipe", | |
| 36 | }); | |
| 37 | return { | |
| 38 | code: p.exitCode, | |
| 39 | out: p.stdout.toString() + p.stderr.toString(), | |
| 40 | }; | |
| 41 | } | |
| 42 | ||
| 43 | describe("--preflight", () => { | |
| 44 | test("a good tree passes with one line, over a database one migration behind", () => { | |
| 45 | const data = liveDb("behind", migrations.slice(0, -1)); | |
| 46 | const { code, out } = run(data); | |
| 47 | expect(code).toBe(0); | |
| 48 | expect(out.trim().split("\n")).toHaveLength(1); | |
| 49 | expect(out).toContain("preflight ok"); | |
| 50 | }); | |
| 51 | ||
| 52 | test("a good tree passes with no database at all", () => { | |
| 53 | const { code } = run(join(dir, "fresh")); | |
| 54 | expect(code).toBe(0); | |
| 55 | }); | |
| 56 | ||
| 57 | test("a broken migration fails and is named", () => { | |
| 58 | const data = liveDb("broken", migrations); | |
| 59 | const bad = [...migrations, "CREATE TABLE ok_one (id INTEGER);", "ALTER TABLE nowhere ADD COLUMN x TEXT;"]; | |
| 60 | const result = preflight({ FORGE_DATA: data }, tree, bad); | |
| 61 | expect(result.ok).toBe(false); | |
| 62 | expect(result.line).toContain(`migration ${bad.length} failed`); | |
| 63 | expect(result.line.split("\n")).toHaveLength(1); | |
| 64 | }); | |
| 65 | ||
| 66 | test("the live database is byte-identical afterwards and no copy remains", () => { | |
| 67 | const data = liveDb("bytes", migrations.slice(0, -1)); | |
| 68 | const before = readFileSync(join(data, "forge.db")); | |
| 69 | const files = readdirSync(data).sort(); | |
| 70 | expect(run(data).code).toBe(0); | |
| 71 | const bad = [...migrations, "SELECT * FROM nowhere;"]; | |
| 72 | expect(preflight({ FORGE_DATA: data }, tree, bad).ok).toBe(false); | |
| 73 | expect(Buffer.compare(readFileSync(join(data, "forge.db")), before)).toBe(0); | |
| 74 | expect(readdirSync(data).sort()).toEqual(files); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a configuration that will not parse fails before any database work", () => { | |
| 78 | const result = preflight( | |
| 79 | { FORGE_DATA: join(dir, "cfg"), FORGE_TRUSTED_PROXIES: "not-an-address" }, | |
outdated
Only the proxies config failure is covered. Add the not-a-URL case and the data-dir-equals-tree case alongside it. |
||
| 80 | tree, | |
| 81 | migrations, | |
| 82 | ); | |
| 83 | expect(result.ok).toBe(false); | |
| 84 | expect(result.line).toContain("FORGE_TRUSTED_PROXIES"); | |
| 85 | expect(readdirSync(dir)).not.toContain("cfg"); | |
| 86 | }); | |
| 87 | ||
| 88 | test("a FORGE_URL or FORGE_APP_URL that is not a URL fails naming the variable", () => { | |
| 89 | const data = join(dir, "url"); | |
| 90 | const url = preflight({ FORGE_DATA: data, FORGE_URL: "not a url" }, tree, migrations); | |
| 91 | expect(url.ok).toBe(false); | |
| 92 | expect(url.line).toContain("FORGE_URL"); | |
| 93 | const hosted = preflight( | |
| 94 | { FORGE_DATA: data, FORGE_APP_URL: "not a url" }, | |
| 95 | tree, | |
| 96 | migrations, | |
| 97 | ); | |
| 98 | expect(hosted.ok).toBe(false); | |
| 99 | expect(hosted.line).toContain("FORGE_APP_URL"); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a data directory that is the tree itself is refused", () => { | |
| 103 | // The gate may run from a tree with no .git, so make the checkout here. | |
| 104 | const repo = join(dir, "repo"); | |
| 105 | mkdirSync(join(repo, "src"), { recursive: true }); | |
| 106 | const init = Bun.spawnSync(["git", "init", "-q", repo]); | |
| 107 | expect(init.exitCode).toBe(0); | |
| 108 | const result = preflight({ FORGE_DATA: repo }, join(repo, "src"), migrations); | |
| 109 | expect(result.ok).toBe(false); | |
| 110 | expect(result.line).toStartWith("preflight failed:"); | |
| 111 | expect(readdirSync(repo).sort()).toEqual([".git", "src"]); | |
| 112 | }); | |
| 113 | ||
| 114 | test("the write-ahead log is copied: an unmigrated commit in it is seen", () => { | |
| 115 | const data = join(dir, "wal"); | |
| 116 | mkdirSync(data); | |
| 117 | const live = new Database(join(data, "forge.db"), { create: true, strict: true }); | |
| 118 | live.run("PRAGMA journal_mode = WAL"); | |
| 119 | live.run("PRAGMA wal_autocheckpoint = 0"); | |
| 120 | live.run("CREATE TABLE only_in_wal (x)"); | |
| 121 | expect(readdirSync(data)).toContain("forge.db-wal"); | |
| 122 | const needsIt = ["INSERT INTO only_in_wal VALUES (1)"]; | |
| 123 | const result = preflight({ FORGE_DATA: data }, tree, needsIt); | |
| 124 | live.close(); | |
| 125 | // Without the -wal copy the table is absent and this migration fails. | |
| 126 | expect(result.line).toStartWith("preflight ok"); | |
| 127 | }); | |
| 128 | ||
| 129 | test("a scratch directory left by a killed run is swept; a fresh one is not", () => { | |
| 130 | const data = liveDb("stale", migrations); | |
| 131 | const old = join(data, "preflight-old"); | |
| 132 | const fresh = join(data, "preflight-fresh"); | |
| 133 | mkdirSync(old); | |
| 134 | mkdirSync(fresh); | |
| 135 | const longAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); | |
| 136 | utimesSync(old, longAgo, longAgo); | |
| 137 | expect(preflight({ FORGE_DATA: data }, tree, migrations).ok).toBe(true); | |
| 138 | expect(readdirSync(data).sort()).toEqual(["forge.db", "preflight-fresh"]); | |
| 139 | }); | |
| 140 | ||
| 141 | test("boot with a bad FORGE_URL exits 1 with that one line", () => { | |
| 142 | const p = Bun.spawnSync(["bun", join(tree, "main.ts")], { | |
| 143 | env: { | |
| 144 | ...process.env, | |
| 145 | FORGE_DATA: join(dir, "boot"), | |
| 146 | FORGE_APP_URL: "", | |
| 147 | FORGE_URL: "not a url", | |
| 148 | }, | |
| 149 | stdout: "pipe", | |
| 150 | stderr: "pipe", | |
| 151 | }); | |
| 152 | expect(p.exitCode).toBe(1); | |
| 153 | const lines = p.stderr.toString().trim().split("\n"); | |
| 154 | expect(lines).toHaveLength(1); | |
| 155 | expect(lines[0]).toContain("FORGE_URL"); | |
| 156 | }); | |
| 157 | }); |
new above
offline
Duplicates the data-dir resolution in src/main.ts. Have main.ts import this so boot and preflight cannot disagree.