g1t/services/deployments/src/moves.flow.test.ts
| 1 | /** |
| 2 | * The whole service, on Node, following a repository transfer: a D1 made |
| 3 | * of node:sqlite with the service's migrations, the other services as |
| 4 | * fakes that answer by method, and Cloudflare's API as a recorded fetch. |
| 5 | * |
| 6 | * Run with `--experimental-transform-types` (see package.json): the |
| 7 | * service's own modules import without extensions, so a resolve hook adds |
| 8 | * them. |
| 9 | */ |
| 10 | |
| 11 | import assert from "node:assert/strict"; |
| 12 | import { readdirSync, readFileSync } from "node:fs"; |
| 13 | import { registerHooks } from "node:module"; |
| 14 | import { DatabaseSync } from "node:sqlite"; |
| 15 | import { beforeEach, test } from "node:test"; |
| 16 | |
| 17 | registerHooks({ |
| 18 | resolve(specifier, context, next) { |
| 19 | try { |
| 20 | return next(specifier, context); |
| 21 | } catch (error) { |
| 22 | if (specifier.startsWith(".")) return next(`${specifier}.ts`, context); |
| 23 | throw error; |
| 24 | } |
| 25 | }, |
| 26 | }); |
| 27 | const { default: worker } = await import("./index.ts"); |
| 28 | |
| 29 | // ---- D1, on node:sqlite ------------------------------------------------ |
| 30 | |
| 31 | function d1(db: DatabaseSync) { |
| 32 | const value = (v: unknown) => (v === undefined ? null : typeof v === "boolean" ? Number(v) : v); |
| 33 | const statement = (sql: string, params: unknown[] = []) => ({ |
| 34 | sql, |
| 35 | params, |
| 36 | bind: (...args: unknown[]) => statement(sql, args.map(value)), |
| 37 | async first<T>() { |
| 38 | return (db.prepare(sql).get(...(params as never[])) as T) ?? null; |
| 39 | }, |
| 40 | async all<T>() { |
| 41 | return { results: db.prepare(sql).all(...(params as never[])) as T[], success: true }; |
| 42 | }, |
| 43 | async run() { |
| 44 | const r = db.prepare(sql).run(...(params as never[])); |
| 45 | return { success: true, meta: { changes: Number(r.changes) } }; |
| 46 | }, |
| 47 | }); |
| 48 | return { |
| 49 | prepare: (sql: string) => statement(sql), |
| 50 | async batch(statements: ReturnType<typeof statement>[]) { |
| 51 | db.exec("BEGIN"); |
| 52 | try { |
| 53 | const out = statements.map((s) => ({ results: db.prepare(s.sql).all(...(s.params as never[])) })); |
| 54 | db.exec("COMMIT"); |
| 55 | return out; |
| 56 | } catch (error) { |
| 57 | db.exec("ROLLBACK"); |
| 58 | throw error; |
| 59 | } |
| 60 | }, |
| 61 | }; |
| 62 | } |
| 63 | |
| 64 | // ---- The world ------------------------------------------------------------ |
| 65 | |
| 66 | const API = "rep_api"; |
| 67 | const PROJECT = "prj_api"; |
| 68 | |
| 69 | type World = ReturnType<typeof world>; |
| 70 | |
| 71 | function world() { |
| 72 | const sqlite = new DatabaseSync(":memory:"); |
| 73 | const dir = new URL("../migrations/", import.meta.url); |
| 74 | for (const file of readdirSync(dir).sort()) sqlite.exec(readFileSync(new URL(file, dir), "utf8")); |
| 75 | const state = { |
| 76 | sqlite, |
| 77 | /** Where the repository is now. */ |
| 78 | path: { namespace: "flagon-io", name: "lab-api" }, |
| 79 | /** Where the projects service has the project now. */ |
| 80 | projectWorkspace: "flagon-io", |
| 81 | /** Each workspace's usage limit state. */ |
| 82 | limits: new Map<string, string>([["syntaqx", "stopped"]]), |
| 83 | runnerUp: true, |
| 84 | /** Builds the runner was asked for: deploy id and token. */ |
| 85 | builds: [] as { deployId: string; token: string; workspace: string }[], |
| 86 | /** Cloudflare API calls, as `METHOD path`. */ |
| 87 | cloudflare: [] as string[], |
| 88 | kv: new Map<string, { value: string; expiration?: number }>(), |
| 89 | }; |
| 90 | const project = () => ({ |
| 91 | id: PROJECT, |
| 92 | workspace: state.projectWorkspace, |
| 93 | slug: "lab-api", |
| 94 | name: "lab-api", |
| 95 | primary: true, |
| 96 | source: { kind: "hosted", repoId: API, repo: { ...state.path }, defaultBranch: "main", rootDir: null }, |
| 97 | }); |
| 98 | const ok = (value: unknown) => ({ ok: true, value }); |
| 99 | const service = (answer: (method: string, args: any) => unknown) => ({ |
| 100 | async fetch(input: string | Request, init?: RequestInit) { |
| 101 | const url = typeof input === "string" ? input : input.url; |
| 102 | const method = new URL(url).pathname.split("/").pop()!; |
| 103 | const args = init?.body ? JSON.parse(String(init.body)) : {}; |
| 104 | const answered = await answer(method, args); |
| 105 | if (answered instanceof Response) return answered; |
| 106 | return Response.json(answered ?? ok(null)); |
| 107 | }, |
| 108 | }); |
| 109 | const env = { |
| 110 | DB: d1(sqlite), |
| 111 | CLOUDFLARE_API_TOKEN: "test", |
| 112 | CLOUDFLARE_ACCOUNT_ID: "acct", |
| 113 | DISPATCH_NAMESPACE: "g1t-deployments", |
| 114 | SITE: "https://g1t.sh", |
| 115 | DOMAINS: { |
| 116 | async get(key: string) { |
| 117 | const found = state.kv.get(key); |
| 118 | return found ? JSON.parse(found.value) : null; |
| 119 | }, |
| 120 | async put(key: string, value: string, options?: { expiration?: number }) { |
| 121 | state.kv.set(key, { value, expiration: options?.expiration }); |
| 122 | }, |
| 123 | async delete(key: string) { |
| 124 | state.kv.delete(key); |
| 125 | }, |
| 126 | }, |
| 127 | REPOS: service((method) => { |
| 128 | if (method === "path_by_id") return state.path; |
| 129 | if (method === "get") return ok({ isPrivate: true }); |
| 130 | return ok([]); |
| 131 | }), |
| 132 | PROJECTS: service((method) => { |
| 133 | if (method === "by_repo") return [project()]; |
| 134 | if (method === "graph") return { dependsOn: [], usedBy: [] }; |
| 135 | return ok(project()); |
| 136 | }), |
| 137 | IDENTITY: service((method, args) => { |
| 138 | if (method === "get_workspace") return { id: `wsp_${args.slug}`, slug: args.slug }; |
| 139 | if (method === "collaborator_permission") return ok({ role: "write" }); |
| 140 | return ok(null); |
| 141 | }), |
| 142 | BILLING: service((method, args) => { |
| 143 | if (method === "has_feature") return ok(true); |
| 144 | if (method === "check_limit") return ok({ state: state.limits.get(args.workspace) ?? "ok" }); |
| 145 | if (method === "entitlements") return ok({ plan: "paid" }); |
| 146 | if (method === "reserve") return ok({ id: "res_1", paidBy: "credit" }); |
| 147 | if (method === "prices") return { prices: [] }; |
| 148 | return ok(null); |
| 149 | }), |
| 150 | WORK: service((method) => { |
| 151 | if (method === "get_pull") { |
| 152 | return ok({ pull: { status: "open", headCommit: "pr1head", branch: "v2", author: { username: "syntaqx", kind: "user" } } }); |
| 153 | } |
| 154 | return ok(null); |
| 155 | }), |
| 156 | ACTIONS: service(() => ({ secrets: {}, variables: {} })), |
| 157 | RUNNER: service((method, args) => { |
| 158 | if (!state.runnerUp) return new Response("unavailable", { status: 503 }); |
| 159 | if (method === "start_deploy") state.builds.push({ deployId: args.deployId, token: args.token, workspace: args.workspace }); |
| 160 | return ok(true); |
| 161 | }), |
| 162 | }; |
| 163 | return { state, env }; |
| 164 | } |
| 165 | |
| 166 | let w: World; |
| 167 | const realFetch = globalThis.fetch; |
| 168 | |
| 169 | beforeEach(() => { |
| 170 | w = world(); |
| 171 | // Cloudflare's API: every call succeeds, and none lists anything. |
| 172 | globalThis.fetch = (async (input: string | Request, init?: RequestInit) => { |
| 173 | const url = new URL(typeof input === "string" ? input : input.url); |
| 174 | w.state.cloudflare.push(`${init?.method ?? "GET"} ${url.pathname}`); |
| 175 | if (url.pathname.endsWith("/graphql")) return Response.json({ data: { viewer: { accounts: [{}] } } }); |
| 176 | return Response.json({ success: true, result: [], result_info: { total_pages: 1 } }); |
| 177 | }) as typeof fetch; |
| 178 | // As production was on 2026-10-06: the project's rows still in syntaqx, |
| 179 | // its apps paused there by syntaqx's limit, minutes before the transfer. |
| 180 | const db = w.state.sqlite; |
| 181 | db.prepare( |
| 182 | `INSERT INTO settings (project_id, repo_id, workspace, slug, enabled, previews, production, idle_days, updated_at) |
| 183 | VALUES (?, ?, 'syntaqx', 'lab-api', 1, 1, 1, 7, '2026-10-05T00:00:00Z')`, |
| 184 | ).run(PROJECT, API); |
| 185 | const app = db.prepare( |
| 186 | `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at, paused_at) |
| 187 | VALUES (?, ?, 'syntaqx', 'lab-api', ?, ?, ?, ?, '2026-10-05T00:26:29Z', '2026-10-05T00:26:29Z', '2026-10-06T01:20:54Z')`, |
| 188 | ); |
| 189 | app.run("lab-api-syntaqx", PROJECT, "production", null, null, "8e500b1"); |
| 190 | app.run("lab-api-git-v2-syntaqx", PROJECT, "preview", "v2", 1, "d2ca224"); |
| 191 | const deployment = db.prepare( |
| 192 | `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha, script, status, |
| 193 | trusted, created_by, created_at, finished_at) |
| 194 | VALUES (?, ?, 'syntaqx', 'lab-api', ?, 'syntaqx/lab-api', ?, ?, ?, ?, ?, 'ready', 1, 'g1t', '2026-10-05T00:26:00Z', '2026-10-05T00:26:29Z')`, |
| 195 | ); |
| 196 | deployment.run("dpl_prod", PROJECT, API, "production", null, null, "8e500b1", "lab-api-syntaqx"); |
| 197 | deployment.run("dpl_prev", PROJECT, API, "preview", "v2", 1, "d2ca224", "lab-api-git-v2-syntaqx"); |
| 198 | }); |
| 199 | |
| 200 | process.on("exit", () => { |
| 201 | globalThis.fetch = realFetch; |
| 202 | }); |
| 203 | |
| 204 | const transferred = { |
| 205 | id: "evt_1", |
| 206 | type: "repo.transferred", |
| 207 | source: "repos", |
| 208 | time: "2026-10-06T01:23:38Z", |
| 209 | repoId: API, |
| 210 | actor: "usr_1", |
| 211 | data: { from: "syntaqx", name: "lab-api", repoId: API, to: "flagon-io" }, |
| 212 | }; |
| 213 | |
| 214 | /** Delivers the event as the queue would; whether it was acked. */ |
| 215 | async function deliver(event: unknown, attempts = 1): Promise<boolean> { |
| 216 | let acked = false; |
| 217 | let retried = false; |
| 218 | const message = { body: event, attempts, ack: () => (acked = true), retry: () => (retried = true) }; |
| 219 | await worker.queue({ messages: [message] } as never, w.env as never); |
| 220 | assert.notEqual(acked, retried); |
| 221 | return acked; |
| 222 | } |
| 223 | |
| 224 | const rows = (sql: string, ...params: unknown[]) => |
| 225 | (w.state.sqlite.prepare(sql).all(...(params as never[])) as any[]).map((row) => ({ ...row })); |
| 226 | |
| 227 | test("a transfer rebuilds every app under the new workspace, paused ones too", async () => { |
| 228 | assert.equal(await deliver(transferred), true); |
| 229 | assert.deepEqual(rows("SELECT workspace FROM settings"), [{ workspace: "flagon-io" }]); |
| 230 | const queued = rows("SELECT script, kind, commit_sha FROM deployments WHERE status = 'queued' ORDER BY script"); |
| 231 | assert.deepEqual(queued, [ |
| 232 | { script: "lab-api-flagon-io", kind: "production", commit_sha: "8e500b1" }, |
| 233 | { script: "lab-api-git-v2-flagon-io", kind: "preview", commit_sha: "pr1head" }, |
| 234 | ]); |
| 235 | assert.equal(w.state.builds.length, 2); |
| 236 | assert.ok(w.state.builds.every((b) => b.workspace === "flagon-io")); |
| 237 | }); |
| 238 | |
| 239 | test("a second delivery builds nothing more", async () => { |
| 240 | await deliver(transferred); |
| 241 | assert.equal(await deliver(transferred, 2), true); |
| 242 | assert.equal(w.state.builds.length, 2); |
| 243 | assert.equal(rows("SELECT id FROM deployments WHERE status = 'queued'").length, 2); |
| 244 | }); |
| 245 | |
| 246 | test("a rebuild that could not be queued is not acked, and the next delivery queues it", async () => { |
| 247 | w.state.runnerUp = false; |
| 248 | assert.equal(await deliver(transferred), false); |
| 249 | // The rows moved, but nothing is building. |
| 250 | assert.deepEqual(rows("SELECT workspace FROM settings"), [{ workspace: "flagon-io" }]); |
| 251 | assert.equal(rows("SELECT id FROM deployments WHERE status IN ('queued', 'building')").length, 0); |
| 252 | w.state.runnerUp = true; |
| 253 | assert.equal(await deliver(transferred, 2), true); |
| 254 | assert.deepEqual( |
| 255 | rows("SELECT script FROM deployments WHERE status = 'queued' ORDER BY script").map((r) => r.script), |
| 256 | ["lab-api-flagon-io", "lab-api-git-v2-flagon-io"], |
| 257 | ); |
| 258 | }); |
| 259 | |
| 260 | test("the sweep picks up a move whose deliveries ran out, keyed on the new workspace", async () => { |
| 261 | w.state.runnerUp = false; |
| 262 | await deliver(transferred); |
| 263 | w.state.runnerUp = true; |
| 264 | // A failed attempt was just made: the sweep waits before trying again. |
| 265 | await worker.scheduled({} as never, w.env as never); |
| 266 | assert.equal(rows("SELECT id FROM deployments WHERE status = 'queued'").length, 0); |
| 267 | w.state.sqlite.exec("UPDATE deployments SET created_at = '2026-10-06T00:00:00Z' WHERE status = 'failed'"); |
| 268 | await worker.scheduled({} as never, w.env as never); |
| 269 | assert.equal(rows("SELECT id FROM deployments WHERE status = 'queued'").length, 2); |
| 270 | // syntaqx is over its limit, flagon-io is not: nothing of the project's is |
| 271 | // paused for syntaqx's sake, and nothing new was paused at all. |
| 272 | assert.ok(!w.state.cloudflare.some((call) => call.startsWith("PUT") && /scripts\/lab-api-flagon-io/.test(call))); |
| 273 | }); |
| 274 | |
| 275 | test("a move waits, without refused deployments, while the new workspace is over its limit", async () => { |
| 276 | w.state.limits.set("flagon-io", "stopped"); |
| 277 | assert.equal(await deliver(transferred), true); |
| 278 | assert.equal(rows("SELECT id FROM deployments WHERE created_at > '2026-10-06'").length, 0); |
| 279 | w.state.limits.delete("flagon-io"); |
| 280 | await worker.scheduled({} as never, w.env as never); |
| 281 | assert.equal(rows("SELECT id FROM deployments WHERE status = 'queued'").length, 2); |
| 282 | }); |
| 283 | |
| 284 | test("once the new app is live, the old address redirects to it, paused or not", async () => { |
| 285 | await deliver(transferred); |
| 286 | const build = w.state.builds.find((b) => rows("SELECT script FROM deployments WHERE id = ?", b.deployId)[0].script === "lab-api-flagon-io")!; |
| 287 | const finish = await worker.fetch( |
| 288 | new Request(`https://deployments/jobs/${build.deployId}/finish`, { |
| 289 | method: "POST", |
| 290 | body: JSON.stringify({ token: build.token, worker: { mainModule: "index.js", modules: [{ name: "index.js", contentBase64: btoa("export default {}"), contentType: "application/javascript+module" }] }, buildSeconds: 30 }), |
| 291 | }), |
| 292 | w.env as never, |
| 293 | {} as never, |
| 294 | ); |
| 295 | assert.deepEqual(await finish.json(), { ok: true, value: true }); |
| 296 | assert.deepEqual( |
| 297 | rows("SELECT script, workspace FROM apps WHERE kind = 'production'"), |
| 298 | [{ script: "lab-api-flagon-io", workspace: "flagon-io" }], |
| 299 | ); |
| 300 | const [redirect] = rows("SELECT script, target, workspace FROM redirects"); |
| 301 | assert.deepEqual({ ...redirect }, { script: "lab-api-syntaqx", target: "lab-api-flagon-io.g1t.page", workspace: "flagon-io" }); |
| 302 | // The dispatcher's entry, followed before the old (paused) script runs. |
| 303 | const entry = w.state.kv.get("lab-api-syntaqx.g1t.page")!; |
| 304 | assert.deepEqual(JSON.parse(entry.value), { script: "lab-api-syntaqx", redirect: "lab-api-flagon-io.g1t.page" }); |
| 305 | assert.ok(entry.expiration! > Date.now() / 1000 + 89 * 24 * 3600); |
| 306 | // And the old script itself is the redirect too. |
| 307 | assert.ok(w.state.cloudflare.some((call) => call === "PUT /client/v4/accounts/acct/workers/dispatch/namespaces/g1t-deployments/scripts/lab-api-syntaqx")); |
| 308 | // The preview is still to come; the old one stays until it is live. |
| 309 | assert.equal(rows("SELECT script FROM apps WHERE kind = 'preview'")[0].script, "lab-api-git-v2-syntaqx"); |
| 310 | }); |
| 311 | |
| 312 | test("the sweep never pauses a moved app for its old workspace's limit", async () => { |
| 313 | // Rows moved (as the event does) but no rebuild yet, and the apps not paused. |
| 314 | w.state.sqlite.exec("UPDATE settings SET workspace = 'flagon-io'; UPDATE apps SET paused_at = NULL"); |
| 315 | w.state.runnerUp = false; |
| 316 | await worker.scheduled({} as never, w.env as never); |
| 317 | assert.deepEqual(rows("SELECT paused_at FROM apps").map((r) => r.paused_at), [null, null]); |
| 318 | // When the project's own workspace is over its limit, they are paused. |
| 319 | w.state.limits.set("flagon-io", "stopped"); |
| 320 | await worker.scheduled({} as never, w.env as never); |
| 321 | assert.ok(rows("SELECT paused_at FROM apps").every((r) => r.paused_at)); |
| 322 | }); |