| 1 | /** |
| 2 | * A container build through the whole service, on Node: a D1 made of |
| 3 | * node:sqlite with the service's migrations, the other services as fakes, |
| 4 | * and Cloudflare's API (and its registry) as a recorded fetch. Run with |
| 5 | * `--experimental-transform-types` (see package.json). |
| 6 | */ |
| 7 | |
| 8 | import assert from "node:assert/strict"; |
| 9 | import { createHash } from "node:crypto"; |
| 10 | import { readdirSync, readFileSync } from "node:fs"; |
| 11 | import { registerHooks } from "node:module"; |
| 12 | import { DatabaseSync } from "node:sqlite"; |
| 13 | import { beforeEach, test } from "node:test"; |
| 14 | |
| 15 | registerHooks({ |
| 16 | resolve(specifier, context, next) { |
| 17 | try { |
| 18 | return next(specifier, context); |
| 19 | } catch (error) { |
| 20 | if (specifier.startsWith(".")) return next(`${specifier}.ts`, context); |
| 21 | throw error; |
| 22 | } |
| 23 | }, |
| 24 | }); |
| 25 | const { default: worker } = await import("./index.ts"); |
| 26 | |
| 27 | function d1(db: DatabaseSync) { |
| 28 | const value = (v: unknown) => (v === undefined ? null : typeof v === "boolean" ? Number(v) : v); |
| 29 | const statement = (sql: string, params: unknown[] = []) => ({ |
| 30 | sql, |
| 31 | params, |
| 32 | bind: (...args: unknown[]) => statement(sql, args.map(value)), |
| 33 | async first<T>() { |
| 34 | return (db.prepare(sql).get(...(params as never[])) as T) ?? null; |
| 35 | }, |
| 36 | async all<T>() { |
| 37 | return { results: db.prepare(sql).all(...(params as never[])) as T[], success: true }; |
| 38 | }, |
| 39 | async run() { |
| 40 | const r = db.prepare(sql).run(...(params as never[])); |
| 41 | return { success: true, meta: { changes: Number(r.changes) } }; |
| 42 | }, |
| 43 | }); |
| 44 | return { |
| 45 | prepare: (sql: string) => statement(sql), |
| 46 | async batch(statements: ReturnType<typeof statement>[]) { |
| 47 | return statements.map((s) => ({ results: db.prepare(s.sql).all(...(s.params as never[])) })); |
| 48 | }, |
| 49 | }; |
| 50 | } |
| 51 | |
| 52 | const PROJECT = "prj_web"; |
| 53 | const SCRIPT = "web-acme"; |
| 54 | const TOKEN = "build-token"; |
| 55 | const DIGEST = "b".repeat(64); |
| 56 | const IMAGE = `registry.cloudflare.com/acct/g1t-${SCRIPT}@sha256:${DIGEST}`; |
| 57 | |
| 58 | function world(containers: string) { |
| 59 | const sqlite = new DatabaseSync(":memory:"); |
| 60 | const dir = new URL("../migrations/", import.meta.url); |
| 61 | for (const file of readdirSync(dir).sort()) sqlite.exec(readFileSync(new URL(file, dir), "utf8")); |
| 62 | const project = { |
| 63 | id: PROJECT, |
| 64 | workspace: "acme", |
| 65 | slug: "web", |
| 66 | name: "web", |
| 67 | primary: true, |
| 68 | source: { kind: "hosted", repoId: "rep_web", repo: { namespace: "acme", name: "web" }, defaultBranch: "main", rootDir: null }, |
| 69 | }; |
| 70 | const ok = (value: unknown) => ({ ok: true, value }); |
| 71 | const service = (answer: (method: string, args: any) => unknown) => ({ |
| 72 | async fetch(input: string | Request, init?: RequestInit) { |
| 73 | const url = typeof input === "string" ? input : input.url; |
| 74 | const method = new URL(url).pathname.split("/").pop()!; |
| 75 | const args = init?.body ? JSON.parse(String(init.body)) : {}; |
| 76 | return Response.json((await answer(method, args)) ?? ok(null)); |
| 77 | }, |
| 78 | }); |
| 79 | const state = { sqlite, charged: [] as any[], pending: [] as any[] }; |
| 80 | const env = { |
| 81 | DB: d1(sqlite), |
| 82 | CLOUDFLARE_API_TOKEN: "test", |
| 83 | CLOUDFLARE_ACCOUNT_ID: "acct", |
| 84 | DISPATCH_NAMESPACE: "g1t-deployments", |
| 85 | SITE: "https://g1t.sh", |
| 86 | CONTAINERS: containers, |
| 87 | REPOS: service(() => ok({ isPrivate: true })), |
| 88 | PROJECTS: service((method) => (method === "by_repo" ? [project] : ok(project))), |
| 89 | IDENTITY: service(() => ok(null)), |
| 90 | BILLING: service((method, args) => { |
| 91 | if (method === "prices") return { prices: [] }; |
| 92 | if (method === "charge_feature") state.charged.push(args); |
| 93 | if (method === "note_pending") state.pending.push(args); |
| 94 | return ok(true); |
| 95 | }), |
| 96 | WORK: service(() => ok(null)), |
| 97 | ACTIONS: service(() => ({ secrets: { DATABASE_URL: "postgres://db" }, variables: { NODE_ENV: "production" } })), |
| 98 | RUNNER: service(() => ok(true)), |
| 99 | }; |
| 100 | return { state, env }; |
| 101 | } |
| 102 | |
| 103 | let w: ReturnType<typeof world>; |
| 104 | let calls: string[] = []; |
| 105 | let uploads: Record<string, any>[] = []; |
| 106 | let applications: { id: string; name: string }[] = []; |
| 107 | let registryHas = true; |
| 108 | const realFetch = globalThis.fetch; |
| 109 | |
| 110 | function setUp(containers: string) { |
| 111 | w = world(containers); |
| 112 | calls = []; |
| 113 | uploads = []; |
| 114 | applications = []; |
| 115 | registryHas = true; |
| 116 | globalThis.fetch = (async (input: string | Request, init?: RequestInit) => { |
| 117 | const url = new URL(typeof input === "string" ? input : input.url); |
| 118 | const method = init?.method ?? "GET"; |
| 119 | calls.push(`${method} ${url.host === "api.cloudflare.com" ? url.pathname.replace("/client/v4/accounts/acct", "") : url.host + url.pathname}`); |
| 120 | const result = (value: unknown) => Response.json({ success: true, result: value }); |
| 121 | if (url.host === "registry.cloudflare.com") return new Response(null, { status: registryHas ? 200 : 404 }); |
| 122 | if (url.pathname.endsWith("/credentials")) return result({ username: "v1", password: "push-secret", registry_host: "registry.cloudflare.com" }); |
| 123 | if (method === "PUT" && url.pathname.includes("/scripts/")) { |
| 124 | const form = init?.body as FormData; |
| 125 | uploads.push(JSON.parse(String(form.get("metadata")))); |
| 126 | return result({}); |
| 127 | } |
| 128 | if (url.pathname.endsWith("/durable_objects/namespaces")) { |
| 129 | return result([{ id: "ns1", class: "G1tApp", script: SCRIPT, dispatch_namespace: "g1t-deployments" }]); |
| 130 | } |
| 131 | if (url.pathname.endsWith("/containers/applications") && method === "GET") return result(applications); |
| 132 | if (url.pathname.endsWith("/containers/applications") && method === "POST") { |
| 133 | applications.push({ id: "app1", name: JSON.parse(String(init?.body)).name }); |
| 134 | return result({ id: "app1" }); |
| 135 | } |
| 136 | if (url.pathname.endsWith("/graphql")) return Response.json({ data: { viewer: { accounts: [{}] } } }); |
| 137 | return result([]); |
| 138 | }) as typeof fetch; |
| 139 | const db = w.state.sqlite; |
| 140 | db.prepare( |
| 141 | `INSERT INTO settings (project_id, repo_id, workspace, slug, enabled, previews, production, idle_days, updated_at, build_as, instance_type) |
| 142 | VALUES (?, 'rep_web', 'acme', 'web', 1, 1, 1, 7, '2026-10-12T00:00:00Z', 'docker', 'basic')`, |
| 143 | ).run(PROJECT); |
| 144 | open("dpl_1"); |
| 145 | } |
| 146 | |
| 147 | /** A build under way, as `open` leaves it. */ |
| 148 | function open(id: string) { |
| 149 | w.state.sqlite |
| 150 | .prepare( |
| 151 | `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, commit_sha, script, status, token_hash, |
| 152 | trusted, created_by, created_at) |
| 153 | VALUES (?, ?, 'acme', 'web', 'rep_web', 'acme/web', 'production', 'abc1234', ?, 'building', ?, 1, 'g1t', ?)`, |
| 154 | ) |
| 155 | .run(id, PROJECT, SCRIPT, createHash("sha256").update(TOKEN).digest("hex"), new Date().toISOString()); |
| 156 | } |
| 157 | |
| 158 | beforeEach(() => setUp("on")); |
| 159 | |
| 160 | process.on("exit", () => { |
| 161 | globalThis.fetch = realFetch; |
| 162 | }); |
| 163 | |
| 164 | async function post(path: string, body: unknown): Promise<any> { |
| 165 | const response = await worker.fetch( |
| 166 | new Request(`https://deployments${path}`, { method: "POST", body: JSON.stringify(body) }), |
| 167 | w.env as never, |
| 168 | {} as never, |
| 169 | ); |
| 170 | return { status: response.status, ...(await response.json()) }; |
| 171 | } |
| 172 | |
| 173 | const rows = (sql: string, ...params: unknown[]) => |
| 174 | (w.state.sqlite.prepare(sql).all(...(params as never[])) as any[]).map((row) => ({ ...row })); |
| 175 | |
| 176 | const finish = (container: unknown) => |
| 177 | post("/jobs/dpl_1/finish", { token: TOKEN, worker: {}, detected: "container", framework: "node", container, buildSeconds: 20 }); |
| 178 | |
| 179 | test("while containers are off, the registry step says so in its own words", async () => { |
| 180 | setUp("off"); |
| 181 | const answer = await post("/jobs/dpl_1/registry", { token: TOKEN }); |
| 182 | assert.equal(answer.status, 409); |
| 183 | assert.equal(answer.error.code, "not_enabled"); |
| 184 | assert.ok(!calls.some((call) => call.includes("credentials")), "no login is minted"); |
| 185 | }); |
| 186 | |
| 187 | test("a build gets a push-only login for its app's repository, held to its size", async () => { |
| 188 | const answer = await post("/jobs/dpl_1/registry", { token: TOKEN }); |
| 189 | assert.deepEqual(answer.value, { |
| 190 | registry: "registry.cloudflare.com", |
| 191 | repository: `acct/g1t-${SCRIPT}`, |
| 192 | tag: "dpl_1", |
| 193 | username: "v1", |
| 194 | password: "push-secret", |
| 195 | maxImageBytes: 4_000_000_000, |
| 196 | instanceType: "basic", |
| 197 | }); |
| 198 | // Another build's token gets nothing. |
| 199 | assert.equal((await post("/jobs/dpl_1/registry", { token: "nope" })).status, 404); |
| 200 | }); |
| 201 | |
| 202 | test("an image outside the app's repository, or not in the registry, is never run", async () => { |
| 203 | const other = await finish({ image: `registry.cloudflare.com/acct/g1t-other@sha256:${DIGEST}`, port: 3000 }); |
| 204 | assert.deepEqual(other.value, false); |
| 205 | assert.match(rows("SELECT error FROM deployments WHERE id = 'dpl_1'")[0].error, /did not report an image by digest/); |
| 206 | assert.equal(uploads.length, 0); |
| 207 | open("dpl_2"); |
| 208 | registryHas = false; |
| 209 | const missing = await post("/jobs/dpl_2/finish", { token: TOKEN, detected: "container", container: { image: IMAGE } }); |
| 210 | assert.deepEqual(missing.value, false); |
| 211 | assert.match(rows("SELECT error FROM deployments WHERE id = 'dpl_2'")[0].error, /is not in the registry/); |
| 212 | }); |
| 213 | |
| 214 | test("a container app runs behind g1t's Worker, is metered by its own token, and comes down whole", async () => { |
| 215 | const done = await finish({ image: IMAGE, port: 3000, sizeBytes: 123 }); |
| 216 | assert.deepEqual(done.value, true); |
| 217 | // g1t's Worker, its class, and the project's entries bound to it. |
| 218 | const [metadata] = uploads; |
| 219 | assert.deepEqual(metadata.exports, { G1tApp: { type: "durable-object", storage: "sqlite" } }); |
| 220 | const binding = (name: string) => metadata.bindings.find((b: any) => b.name === name); |
| 221 | assert.equal(binding("G1T_PORT").text, "3000"); |
| 222 | assert.equal(binding("G1T_INSTANCE").text, "basic"); |
| 223 | assert.equal(binding("G1T_METER_URL").text, `https://api.g1t.sh/deployments/apps/${SCRIPT}/meter`); |
| 224 | assert.deepEqual(binding("DATABASE_URL"), { type: "secret_text", name: "DATABASE_URL", text: "postgres://db" }); |
| 225 | assert.ok(calls.includes("POST /containers/applications")); |
| 226 | assert.deepEqual(rows("SELECT runtime, container_app_id, instance_type FROM apps"), [{ runtime: "container", container_app_id: "app1", instance_type: "basic" }]); |
| 227 | assert.deepEqual(rows("SELECT detected, framework, image, status FROM deployments WHERE id = 'dpl_1'"), [ |
| 228 | { detected: "container", framework: "node", image: IMAGE, status: "ready" }, |
| 229 | ]); |
| 230 | // The next deploy rolls the application out to the new image. |
| 231 | open("dpl_2"); |
| 232 | await post("/jobs/dpl_2/finish", { token: TOKEN, detected: "container", container: { image: IMAGE, port: 3000 } }); |
| 233 | assert.ok(calls.includes("POST /containers/applications/app1/rollouts")); |
| 234 | |
| 235 | // Its reports: only with the token it was deployed with, each counter only raised. |
| 236 | const token = uploads.at(-1)!.bindings.find((b: any) => b.name === "G1T_METER_TOKEN").text; |
| 237 | const month = new Date().toISOString().slice(0, 7); |
| 238 | const report = { token, epoch: "epoch-0001", month, awakeSeconds: 3600, egressBytes: 1_000_000_000, instanceType: "basic" }; |
| 239 | assert.equal((await post(`/apps/${SCRIPT}/meter`, { ...report, token: "wrong" })).status, 404); |
| 240 | assert.equal((await post(`/apps/${SCRIPT}/meter`, report)).status, 200); |
| 241 | await post(`/apps/${SCRIPT}/meter`, { ...report, awakeSeconds: 60 }); |
| 242 | assert.deepEqual(rows("SELECT awake_seconds, egress_bytes FROM container_meters"), [{ awake_seconds: 3600, egress_bytes: 1_000_000_000 }]); |
| 243 | |
| 244 | // It is noted for the spend limit as it runs: basic for an hour, and 1 GB at $0.05. |
| 245 | w.state.sqlite.exec("INSERT OR IGNORE INTO meters (namespace, month) VALUES ('acme', strftime('%Y-%m', 'now'))"); |
| 246 | await worker.scheduled({} as never, w.env as never); |
| 247 | const pending = w.state.pending.find((p) => p.workspace === "acme"); |
| 248 | const second = 0.25 * 20 + 1 * 2.5 + 4 * 0.07 + 1.5625; |
| 249 | assert.equal(pending.costMicros, Math.ceil(second * 3600 + 50_000)); |
| 250 | assert.match(pending.detail, /1 container hours and 1 GB sent/); |
| 251 | |
| 252 | // Taken down: its container first, then its Worker. |
| 253 | calls = []; |
| 254 | await post("/rpc/take_down", { actor: { id: "usr_1", username: "a", kind: "user", workspaces: [{ slug: "acme", role: "owner" }] }, project: { workspace: "acme", slug: "web" }, branch: null }); |
| 255 | const deleteApp = calls.indexOf("DELETE /containers/applications/app1"); |
| 256 | const deleteScript = calls.findIndex((call) => call.startsWith("DELETE /workers/dispatch/namespaces/g1t-deployments/scripts/web-acme")); |
| 257 | assert.ok(deleteApp >= 0 && deleteScript > deleteApp, calls.join("\n")); |
| 258 | }); |
| 259 | |
| 260 | test("a paused container app keeps its class and stops serving", async () => { |
| 261 | await finish({ image: IMAGE, port: 3000 }); |
| 262 | uploads = []; |
| 263 | // Its workspace reached its limit. |
| 264 | w.env.BILLING = { |
| 265 | async fetch(input: string | Request, init?: RequestInit) { |
| 266 | const method = new URL(typeof input === "string" ? input : input.url).pathname.split("/").pop(); |
| 267 | if (method === "check_limit") return Response.json({ ok: true, value: { state: "stopped" } }); |
| 268 | if (method === "prices") return Response.json({ prices: [] }); |
| 269 | return Response.json({ ok: true, value: true, init: !!init }); |
| 270 | }, |
| 271 | } as never; |
| 272 | await worker.scheduled({} as never, w.env as never); |
| 273 | const paused = uploads.find((u) => u.bindings.some((b: any) => b.name === "G1T_MODE" && b.text === "paused")); |
| 274 | assert.ok(paused, "the Worker was put back paused"); |
| 275 | assert.deepEqual(paused.exports, { G1tApp: { type: "durable-object", storage: "sqlite" } }); |
| 276 | assert.ok(!paused.bindings.some((b: any) => b.name === "DATABASE_URL"), "a paused app holds no secrets"); |
| 277 | assert.ok(rows("SELECT paused_at FROM apps")[0].paused_at); |
| 278 | }); |