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

The spawned-forge front door runs unchanged on celld: wrangler.json, a pinned binary, and a test through it #493

Merged opened this

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

Merged

scout/forge:scout/490-the-spawned-forge-front-door-runs-unchan → main 1 commit fork

  • Merged. scout/490-the-spawned-forge-front-door-runs-unchan was merged into main as c349a39.

Review

Sign in to review this change.
4 files changed +201 −0
infra/forge-cloudflare/README.md +33
@@ -21,3 +21,36 @@ bucket (`use_lockfile`).
2121 **Not here:** the Container body. The Cloudflare provider has no Containers
2222 resource, so that body is deployed with `wrangler` and its URL given as
2323 `body_url`. See `docs/SPIKE-CLOUDFLARE.md`.
24
25 ## Run it on celld
26
27 The same `worker.js`, unchanged, runs on [celld](https://github.com/denoland/celld)
28 from `wrangler.json` — no Cloudflare account, no Docker, no bucket. A child forge
29 can have its front door on our own machines.
30
31 ```
32 bun -e 'import {celld} from "./scripts/celld"; console.log(await celld())' # path of the pinned binary
33 echo 'BODY_URL=http://127.0.0.1:8080' > infra/forge-cloudflare/.dev.vars # the body; overrides wrangler.json
34 $(that path) dev infra/forge-cloudflare --port 9876 # esbuild must be on PATH
35 ```
36
37 `wrangler.json` holds only keys celld accepts, no secret and no real hostname;
38 its `BODY_URL` is a loopback placeholder, and `.dev.vars` overrides it. The
39 Durable Object migration is `new_sqlite_classes` (celld refuses `new_classes`).
40 `scripts/celld.ts` fetches release **v0.6.1** only, checks a SHA-256 written in
41 the script, and caches the binary under the temp dir (linux x64/arm64, macOS
42 arm64; any other platform is an error). celld is a beta whose releases break
43 compatibility, so an upgrade is a deliberate pull: change the version and
44 digests together and run `test/forge-cloudflare-celld.test.ts`. That test skips,
45 saying why, when the download fails or `esbuild` is missing, and is not in the
46 `forge.json` gate.
47
48 Measured on a 4-vCPU x86_64 Linux VM (DO-Regular), celld 0.6.1, stub body on
49 loopback, esbuild 0.28.2:
50
51 | | |
52 |---|---|
53 | Cold start: `celld dev --clean` spawned → first request answered | ≈ 2.0 s |
54 | Next request | ≈ 150 ms |
55 | p50 over 100 requests, through the Worker and Durable Object to the body | ≈ 29 ms |
56 | p99 | ≈ 126 ms |
infra/forge-cloudflare/wrangler.json added +10
@@ -0,0 +1,10 @@
1 {
2 "name": "forge-front-door",
3 "main": "worker.js",
4 "compatibility_date": "2025-01-01",
5 "durable_objects": {
6 "bindings": [{ "name": "FORGE", "class_name": "Forge" }]
7 },
8 "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Forge"] }],
9 "vars": { "BODY_URL": "http://127.0.0.1:8080" }
10 }
scripts/celld.ts added +57
@@ -0,0 +1,57 @@
1 /**
2 * celld, pinned: fetch the one release this repository has been tried against,
3 * check its SHA-256, unpack it into a cache under the temp dir, return its path.
4 * A new celld is a deliberate pull: change VERSION and every digest together.
5 */
6 import { createHash } from "node:crypto";
7 import { chmodSync, existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10
11 export const VERSION = "v0.6.1";
12 const BASE = `https://github.com/denoland/celld/releases/download/${VERSION}/`;
13
14 /** Asset name and SHA-256 of the compressed download, by `${platform}-${arch}`. */
15 const ASSETS: Record<string, { file: string; sha256: string }> = {
16 "linux-x64": {
17 file: "celld-x86_64-unknown-linux-gnu.gz",
18 sha256: "79a8253cff5d4e8a4a9f7a2611e393390f7fe9025f00e88467875b007c44866b",
19 },
20 "linux-arm64": {
21 file: "celld-aarch64-unknown-linux-gnu.gz",
22 sha256: "ab99053bcced225bb5b54f428792260c905b782b8a61947362a12ce3a9c22def",
23 },
24 "darwin-arm64": {
25 file: "celld-aarch64-apple-darwin.gz",
26 sha256: "3033cc4f428433f4239ac616a04092cd4b6c7db19f2f5ba9926c86ec56c34c95",
27 },
28 };
29
30 /** The path of the pinned celld binary, fetched on first use. Throws when it cannot be had. */
31 export async function celld(
32 platform: string = process.platform,
33 arch: string = process.arch,
34 ): Promise<string> {
35 const asset = ASSETS[`${platform}-${arch}`];
36 if (!asset)
37 throw new Error(
38 `celld ${VERSION} has no build for ${platform}-${arch}; it has ${Object.keys(ASSETS).join(", ")}`,
39 );
40 const dir = join(tmpdir(), `forge-celld-${VERSION}-${asset.sha256.slice(0, 12)}`);
41 const bin = join(dir, "celld");
42 if (existsSync(bin)) return bin;
43
44 const res = await fetch(BASE + asset.file, { signal: AbortSignal.timeout(120_000) });
45 if (!res.ok) throw new Error(`celld download: ${res.status} for ${asset.file}`);
46 const gz = new Uint8Array(await res.arrayBuffer());
47 const sum = createHash("sha256").update(gz).digest("hex");
48 if (sum !== asset.sha256)
49 throw new Error(`celld download: SHA-256 ${sum} is not the pinned ${asset.sha256}`);
50
51 mkdirSync(dir, { recursive: true });
52 const tmp = `${bin}.${process.pid}.part`;
53 writeFileSync(tmp, Bun.gunzipSync(gz));
54 chmodSync(tmp, 0o755);
55 renameSync(tmp, bin);
56 return bin;
57 }
test/forge-cloudflare-celld.test.ts added +101
@@ -0,0 +1,101 @@
1 /**
2 * The front door, unchanged, on celld: the same worker.js the Cloudflare test
3 * drives, served by `celld dev`. Skips, saying why, when celld cannot be
4 * fetched or esbuild is not on PATH. Not in the forge.json gate: it needs a
5 * GitHub download.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { cpSync, mkdtempSync, rmSync } from "node:fs";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { celld } from "../scripts/celld";
12
13 function freePort(): number {
14 const s = Bun.serve({ port: 0, fetch: () => new Response() });
15 const port = s.port as number;
16 s.stop(true);
17 return port;
18 }
19
20 let skip = "";
21 let bin = "";
22 if (!Bun.which("esbuild")) skip = "esbuild is not on PATH (celld bundles with it)";
23 else
24 try {
25 bin = await celld();
26 } catch (e) {
27 skip = `celld cannot be fetched: ${(e as Error).message}`;
28 }
29 if (skip) console.warn(`forge-cloudflare-celld skipped: ${skip}`);
30
31 const seen: { method: string; path: string; proto: string | null; body: string }[] = [];
32 let stub: ReturnType<typeof Bun.serve> | undefined;
33 let proc: ReturnType<typeof Bun.spawn> | undefined;
34 let dir = "";
35 let base = "";
36
37 async function until(path: string): Promise<Response> {
38 const end = Date.now() + 60_000;
39 for (;;) {
40 try {
41 return await fetch(base + path);
42 } catch (e) {
43 if (Date.now() > end) throw e;
44 await Bun.sleep(100);
45 }
46 }
47 }
48
49 beforeAll(async () => {
50 if (skip) return;
51 stub = Bun.serve({
52 port: 0,
53 hostname: "127.0.0.1",
54 async fetch(req) {
55 const u = new URL(req.url);
56 seen.push({
57 method: req.method,
58 path: u.pathname + u.search,
59 proto: req.headers.get("x-forwarded-proto"),
60 body: await req.text(),
61 });
62 return new Response("body ok");
63 },
64 });
65 dir = mkdtempSync(join(tmpdir(), "forge-celld-test-"));
66 cpSync(join(import.meta.dir, "../infra/forge-cloudflare"), dir, { recursive: true });
67 // .dev.vars overrides the config's vars, as for wrangler dev
68 await Bun.write(join(dir, ".dev.vars"), `BODY_URL=http://127.0.0.1:${stub.port}\n`);
69 const port = freePort();
70 base = `http://127.0.0.1:${port}`;
71 proc = Bun.spawn([bin, "dev", dir, "--port", String(port), "--clean", "--no-watch"], {
72 stdout: "ignore",
73 stderr: "ignore",
74 });
75 }, 30_000);
76
77 afterAll(async () => {
78 proc?.kill("SIGKILL");
79 await proc?.exited;
80 stub?.stop(true);
81 if (dir) rmSync(dir, { recursive: true, force: true });
82 }, 15_000);
83
84 describe.skipIf(!!skip)("forge-cloudflare worker on celld", () => {
85 test("a GET and a POST reach the body, told https", async () => {
86 const get = await until("/a/b?x=1");
87 expect(get.status).toBe(200);
88 expect(await get.text()).toBe("body ok");
89 const post = await fetch(`${base}/post`, { method: "POST", body: "hello" });
90 expect(post.status).toBe(200);
91 expect(seen[0]).toMatchObject({ method: "GET", path: "/a/b?x=1", proto: "https" });
92 expect(seen.at(-1)).toMatchObject({ method: "POST", path: "/post", proto: "https", body: "hello" });
93 }, 70_000);
94
95 test("/healthz is 503 {ok:false} once the body is stopped", async () => {
96 stub!.stop(true);
97 const r = await fetch(`${base}/healthz`);
98 expect(r.status).toBe(503);
99 expect(((await r.json()) as { ok: boolean }).ok).toBe(false);
100 });
101 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs