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

Merged opened this

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

Merged

scout/forge:scout/1661-self-deploy-step-1-bun-src-main-ts-prefl → main 7 commits fork 4 threads · 4 unresolved

  • Merged. scout/1661-self-deploy-step-1-bun-src-main-ts-prefl was merged into main as dd95d70.

Review

Sign in to review this change.
5 files changed +297 −21
README.md +1
@@ -35,6 +35,7 @@ feeling on a foundation sized for one person.
3535 ```sh
3636 bun install # dev-only: types + tsc, no runtime deps
3737 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
3839 ```
3940
4041 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[] = [
5555 "test/refresh-meta.test.ts",
5656 "test/restore-card.test.ts",
5757 "test/recovery-doc.test.ts",
58 "test/release-preflight.test.ts",
5859 "test/threats-doc.test.ts",
5960 "test/returns.test.ts",
6061 "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";
outdated

Duplicates the data-dir resolution in src/main.ts. Have main.ts import this so boot and preflight cannot disagree.

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 @@
44 * environment-only — a handful of variables, all with sane defaults.
55 */
66
7 import { parseCidrs } from "./kernel/cidr.ts";
87 import { createApp } from "./app.ts";
98 import { reconcile } from "./core/reconcile.ts";
109 import { syncAllPillars } from "./actions/pillars.ts";
@@ -37,7 +36,7 @@ import { sweepStaleAsks } from "./crew/stale_asks.ts";
3736 import { recordAskLedger } from "./web/studio/asks.ts";
3837 import { startPromptRoutine } from "./crew/prompts.ts";
3938 import { pruneTokens } from "./core/tokens.ts";
40 import { dataHomeRefusal } from "./kernel/data_home.ts";
39 import { configProblem, dataDirOf, preflight } from "./kernel/preflight.ts";
4140 import { running } from "./kernel/version.ts";
4241 import { record as recordDegraded } from "./kernel/degraded_history.ts";
4342 import { vitals } from "./kernel/vitals.ts";
@@ -54,6 +53,17 @@ if (process.argv[2] === "hook") {
5453 await runHookClient(name);
5554 }
5655
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
5767 // A forge can be an app of a forge (docs/APPS.md, "The forge as an app of
5868 // itself"). The host's `FORGE_APP_*` say where this one lives and what it
5969 // 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();
7484 // identity headers sign people in; without it they are ignored.
7585 const edgeToken = (process.env.FORGE_EDGE_TOKEN ?? "").trim();
7686
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);
8696 process.exit(1);
8797 }
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.
9198 const trustedProxies = (process.env.FORGE_TRUSTED_PROXIES ?? "")
9299 .split(",")
93100 .map((s) => s.trim())
94101 .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 }
102102
103103 const app = createApp({
104104 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs