| 1 | // The deploy manifest (deploy/stack.jsonc) and what it implies: each |
| 2 | // unit's Wrangler config, the shared crates and packages it is built from, |
| 3 | // and which units a set of changed files touches. Pure apart from reading |
| 4 | // files and `cargo metadata`, so the tests can drive it. |
| 5 | |
| 6 | import { execFileSync } from "node:child_process"; |
| 7 | import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; |
| 8 | import { dirname, join, relative } from "node:path"; |
| 9 | import { fileURLToPath } from "node:url"; |
| 10 | |
| 11 | export const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 12 | export const STACK_FILE = "deploy/stack.jsonc"; |
| 13 | export const KINDS = ["rust-worker", "ts-worker", "react-router", "astro"]; |
| 14 | export const SELF_HOST = ["run", "off", "separate", "none"]; |
| 15 | |
| 16 | /** Strips comments and trailing commas from JSONC. Strings are respected. */ |
| 17 | export function parseJsonc(text) { |
| 18 | let result = ""; |
| 19 | let inString = false; |
| 20 | for (let i = 0; i < text.length; i++) { |
| 21 | const char = text[i]; |
| 22 | if (inString) { |
| 23 | result += char; |
| 24 | if (char === "\\") result += text[++i]; |
| 25 | else if (char === '"') inString = false; |
| 26 | } else if (char === '"') { |
| 27 | inString = true; |
| 28 | result += char; |
| 29 | } else if (char === "/" && text[i + 1] === "/") { |
| 30 | while (i < text.length && text[i] !== "\n") i++; |
| 31 | result += "\n"; |
| 32 | } else if (char === "/" && text[i + 1] === "*") { |
| 33 | i = text.indexOf("*/", i + 2) + 1; |
| 34 | } else { |
| 35 | result += char; |
| 36 | } |
| 37 | } |
| 38 | return JSON.parse(result.replace(/,(\s*[}\]])/g, "$1")); |
| 39 | } |
| 40 | |
| 41 | const readJsonc = (path) => parseJsonc(readFileSync(path, "utf8")); |
| 42 | const posix = (path) => path.replaceAll("\\", "/"); |
| 43 | |
| 44 | /** |
| 45 | * The manifest, each unit with its Wrangler config beside it. |
| 46 | * Units keep the manifest's order. |
| 47 | */ |
| 48 | export function loadStack(root = ROOT) { |
| 49 | const raw = readJsonc(join(root, STACK_FILE)); |
| 50 | const units = Object.entries(raw.units).map(([id, unit]) => { |
| 51 | const configPath = join(root, unit.path, "wrangler.jsonc"); |
| 52 | const config = existsSync(configPath) ? readJsonc(configPath) : null; |
| 53 | return { |
| 54 | id, |
| 55 | secrets: [], |
| 56 | setup: [], |
| 57 | inputs: [], |
| 58 | ...unit, |
| 59 | config, |
| 60 | bindsTo: (config?.services ?? []).map((binding) => binding.service), |
| 61 | }; |
| 62 | }); |
| 63 | return { stages: raw.stages, resources: raw.resources ?? {}, units }; |
| 64 | } |
| 65 | |
| 66 | /** The deployable stages (every stage but migrations), in order. */ |
| 67 | export const codeStages = (stack) => stack.stages.filter((stage) => stage !== "migrations"); |
| 68 | |
| 69 | /** |
| 70 | * Workspace crates: name -> { dir, deps: [names of workspace crates it |
| 71 | * depends on as a normal or build dependency] }. From `cargo metadata |
| 72 | * --no-deps`, which reads only the workspace's manifests. |
| 73 | */ |
| 74 | export function cargoWorkspace(root = ROOT, metadata = null) { |
| 75 | const meta = |
| 76 | metadata ?? |
| 77 | JSON.parse( |
| 78 | execFileSync("cargo", ["metadata", "--no-deps", "--format-version", "1", "--offline"], { |
| 79 | cwd: root, |
| 80 | encoding: "utf8", |
| 81 | maxBuffer: 64 * 1024 * 1024, |
| 82 | }), |
| 83 | ); |
| 84 | const crates = new Map(); |
| 85 | for (const pkg of meta.packages) { |
| 86 | crates.set(pkg.name, { |
| 87 | dir: posix(relative(meta.workspace_root ?? root, dirname(pkg.manifest_path))), |
| 88 | deps: [], |
| 89 | raw: pkg, |
| 90 | }); |
| 91 | } |
| 92 | for (const crate of crates.values()) { |
| 93 | crate.deps = crate.raw.dependencies |
| 94 | // Dev-dependencies are not in what deploys. |
| 95 | .filter((dep) => dep.kind !== "dev" && dep.path) |
| 96 | .map((dep) => dep.name) |
| 97 | .filter((name) => crates.has(name)); |
| 98 | delete crate.raw; |
| 99 | } |
| 100 | return crates; |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * npm workspace packages: name -> { dir, deps: [workspace package names] }. |
| 105 | * dependencies and devDependencies both count: a build reads either. |
| 106 | */ |
| 107 | export function npmWorkspace(root = ROOT) { |
| 108 | const rootPkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); |
| 109 | const dirs = []; |
| 110 | for (const pattern of rootPkg.workspaces ?? []) { |
| 111 | if (pattern.endsWith("/*")) { |
| 112 | const parent = pattern.slice(0, -2); |
| 113 | if (!existsSync(join(root, parent))) continue; |
| 114 | for (const name of readdirSync(join(root, parent)).sort()) { |
| 115 | if (existsSync(join(root, parent, name, "package.json"))) dirs.push(`${parent}/${name}`); |
| 116 | } |
| 117 | } else if (existsSync(join(root, pattern, "package.json"))) { |
| 118 | dirs.push(pattern); |
| 119 | } |
| 120 | } |
| 121 | const packages = new Map(); |
| 122 | const declared = new Map(); |
| 123 | for (const dir of dirs) { |
| 124 | const pkg = JSON.parse(readFileSync(join(root, dir, "package.json"), "utf8")); |
| 125 | packages.set(pkg.name, { dir, deps: [] }); |
| 126 | declared.set(pkg.name, Object.keys({ ...pkg.dependencies, ...pkg.devDependencies })); |
| 127 | } |
| 128 | for (const [name, pkg] of packages) pkg.deps = declared.get(name).filter((dep) => packages.has(dep)); |
| 129 | return packages; |
| 130 | } |
| 131 | |
| 132 | /** Every name reachable from `start` through `graph` (start excluded). */ |
| 133 | function closure(graph, start) { |
| 134 | const seen = new Set(); |
| 135 | const stack = [...(graph.get(start)?.deps ?? [])]; |
| 136 | while (stack.length) { |
| 137 | const name = stack.pop(); |
| 138 | if (seen.has(name)) continue; |
| 139 | seen.add(name); |
| 140 | stack.push(...(graph.get(name)?.deps ?? [])); |
| 141 | } |
| 142 | return [...seen]; |
| 143 | } |
| 144 | |
| 145 | /** Files at the root every unit of a kind is built with. */ |
| 146 | export function globalInputs(kind) { |
| 147 | const inputs = ["package.json"]; |
| 148 | if (kind === "rust-worker") inputs.push("Cargo.toml", "Cargo.lock", "scripts/build-rust-worker.mjs"); |
| 149 | // A Rust worker's JavaScript is a small shim worker-build bundles itself, |
| 150 | // so the npm lockfile only changes what npm-built units ship. |
| 151 | else inputs.push("package-lock.json", "tsconfig.base.json"); |
| 152 | return inputs; |
| 153 | } |
| 154 | |
| 155 | /** |
| 156 | * Adds to each unit what it is built from: |
| 157 | * crate / pkg: its own crate or package name, when it has one; |
| 158 | * dependsOn: folders of the shared crates and packages it uses; |
| 159 | * inputs: the manifest's own inputs plus the root files its kind uses; |
| 160 | * image: for a Containers image, the folders and files it is built from |
| 161 | * (`dirs`, `files`), and its base's folder (`baseDirs`). |
| 162 | */ |
| 163 | export function resolveStack(stack, { cargo, npm }) { |
| 164 | const crateByDir = new Map([...cargo].map(([name, crate]) => [crate.dir, name])); |
| 165 | const pkgByDir = new Map([...npm].map(([name, pkg]) => [pkg.dir, name])); |
| 166 | for (const unit of stack.units) { |
| 167 | const crate = crateByDir.get(unit.path) ?? null; |
| 168 | const pkg = pkgByDir.get(unit.path) ?? null; |
| 169 | const dirs = new Set(); |
| 170 | if (crate) for (const name of closure(cargo, crate)) dirs.add(cargo.get(name).dir); |
| 171 | if (pkg) for (const name of closure(npm, pkg)) dirs.add(npm.get(name).dir); |
| 172 | dirs.delete(unit.path); |
| 173 | unit.crate = crate; |
| 174 | unit.pkg = pkg; |
| 175 | unit.dependsOn = [...dirs].sort(); |
| 176 | unit.inputs = [...new Set([...(unit.inputs ?? []), ...globalInputs(unit.kind)])].sort(); |
| 177 | if (unit.image) { |
| 178 | const name = unit.image.crate; |
| 179 | const crates = name && cargo.has(name) ? [name, ...closure(cargo, name)] : []; |
| 180 | // The image is the base (recorded in its lock) plus the binary: a |
| 181 | // change to the base's folder reaches the image only through a new |
| 182 | // lock, which `build-base` writes. |
| 183 | const lock = unit.image.base?.lock; |
| 184 | unit.image = { |
| 185 | ...unit.image, |
| 186 | dirs: crates.map((c) => cargo.get(c).dir).sort(), |
| 187 | files: [unit.image.dockerfile, ...(lock ? [lock] : []), "Cargo.toml", "Cargo.lock", "scripts/build-runner.mjs"], |
| 188 | baseDirs: unit.image.base ? [unit.image.base.context] : [], |
| 189 | }; |
| 190 | for (const dir of unit.image.dirs) if (dir !== unit.path) unit.dependsOn.push(dir); |
| 191 | unit.dependsOn = [...new Set(unit.dependsOn)].sort(); |
| 192 | unit.inputs = [...new Set([...unit.inputs, "Cargo.toml", "Cargo.lock", "scripts/build-runner.mjs"])].sort(); |
| 193 | } |
| 194 | } |
| 195 | return stack; |
| 196 | } |
| 197 | |
| 198 | /** The manifest, resolved against this checkout. */ |
| 199 | export function resolvedStack(root = ROOT) { |
| 200 | return resolveStack(loadStack(root), { cargo: cargoWorkspace(root), npm: npmWorkspace(root) }); |
| 201 | } |
| 202 | |
| 203 | const under = (file, dir) => file === dir || file.startsWith(`${dir}/`); |
| 204 | |
| 205 | /** |
| 206 | * Why a set of changed files (repository-relative, `/`-separated) touches |
| 207 | * a unit: the first file that does, and through what. Null if none does. |
| 208 | */ |
| 209 | export function touches(unit, files) { |
| 210 | for (const file of files) { |
| 211 | if (under(file, unit.path)) return { file, via: "its own folder" }; |
| 212 | } |
| 213 | for (const file of files) { |
| 214 | const dir = unit.dependsOn.find((d) => under(file, d)); |
| 215 | if (dir) return { file, via: dir }; |
| 216 | if (unit.inputs.includes(file)) return { file, via: file }; |
| 217 | } |
| 218 | return null; |
| 219 | } |
| 220 | |
| 221 | /** Whether changed files touch a unit's Containers image. */ |
| 222 | export function touchesImage(unit, files) { |
| 223 | if (!unit.image) return false; |
| 224 | return files.some((file) => unit.image.files.includes(file) || unit.image.dirs.some((dir) => under(file, dir))); |
| 225 | } |
| 226 | |
| 227 | /** Whether changed files touch the folder a unit's base image is built from. */ |
| 228 | export function touchesBase(unit, files) { |
| 229 | return Boolean(unit.image?.baseDirs?.length) && files.some((file) => unit.image.baseDirs.some((dir) => under(file, dir))); |
| 230 | } |
| 231 | |
| 232 | /** Units named on the command line: short names, folders or Worker names. */ |
| 233 | export function pick(stack, names) { |
| 234 | const wanted = names.flatMap((name) => name.split(",")).map((name) => name.trim().replace(/\/$/, "")).filter(Boolean); |
| 235 | const found = []; |
| 236 | for (const name of wanted) { |
| 237 | const unit = stack.units.find((u) => u.id === name || u.path === name || u.worker === name); |
| 238 | if (!unit) throw new Error(`No unit called ${name}. Units: ${stack.units.map((u) => u.id).join(", ")}`); |
| 239 | if (!found.includes(unit)) found.push(unit); |
| 240 | } |
| 241 | return found; |
| 242 | } |
| 243 | |
| 244 | /** Units grouped by stage, in stage order, keeping the manifest's order inside each. */ |
| 245 | export function byStage(stack, units) { |
| 246 | return codeStages(stack) |
| 247 | .map((stage) => ({ stage, units: units.filter((u) => u.stage === stage) })) |
| 248 | .filter((group) => group.units.length); |
| 249 | } |
| 250 | |
| 251 | /** The most Rust workers one CI job builds; more are split across jobs. */ |
| 252 | export const RUST_PER_JOB = 4; |
| 253 | |
| 254 | /** |
| 255 | * How a stage's units are split into CI jobs, so units that share a build |
| 256 | * share a sandbox: Rust workers (one Cargo target, at most RUST_PER_JOB to |
| 257 | * a job, each on a 4-vCPU machine), the TypeScript Workers (cheap), |
| 258 | * each site that runs a framework build, and each unit whose Containers |
| 259 | * image must be rebuilt (`images`: ids), which needs Docker. |
| 260 | */ |
| 261 | export function buildGroups(units, images = []) { |
| 262 | const groups = new Map(); |
| 263 | const add = (key, id) => { |
| 264 | if (!groups.has(key)) groups.set(key, []); |
| 265 | groups.get(key).push(id); |
| 266 | }; |
| 267 | const rust = units.filter((u) => u.kind === "rust-worker"); |
| 268 | const shards = Math.ceil(rust.length / RUST_PER_JOB); |
| 269 | rust.forEach((unit, i) => add(shards > 1 ? `rust-${(i % shards) + 1}` : "rust", unit.id)); |
| 270 | for (const unit of units.filter((u) => u.kind !== "rust-worker")) { |
| 271 | add(images.includes(unit.id) ? `${unit.id}-image` : unit.kind === "ts-worker" ? "ts" : unit.id, unit.id); |
| 272 | } |
| 273 | return [...groups].map(([group, ids]) => ({ group, units: ids.join(","), rust: group.startsWith("rust") })); |
| 274 | } |
| 275 | |
| 276 | /** |
| 277 | * What is wrong with the manifest, as sentences. Empty when it is right. |
| 278 | * `wranglerConfigs`: every wrangler.jsonc in the repository (relative paths). |
| 279 | */ |
| 280 | export function problems(stack, wranglerConfigs = findWranglerConfigs()) { |
| 281 | const out = []; |
| 282 | const stages = codeStages(stack); |
| 283 | if (stack.stages[0] !== "migrations") out.push('The first stage is "migrations".'); |
| 284 | const byWorker = new Map(); |
| 285 | for (const unit of stack.units) { |
| 286 | const where = `Unit ${unit.id}`; |
| 287 | if (!KINDS.includes(unit.kind)) out.push(`${where}: kind is one of ${KINDS.join(", ")}.`); |
| 288 | if (!stages.includes(unit.stage)) out.push(`${where}: stage is one of ${stages.join(", ")}.`); |
| 289 | if (!SELF_HOST.includes(unit.self_host)) out.push(`${where}: self_host is one of ${SELF_HOST.join(", ")}.`); |
| 290 | if (!unit.config) { |
| 291 | out.push(`${where}: ${unit.path}/wrangler.jsonc does not exist.`); |
| 292 | continue; |
| 293 | } |
| 294 | if (unit.config.name !== unit.worker) out.push(`${where}: worker is ${unit.config.name} in its wrangler.jsonc, not ${unit.worker}.`); |
| 295 | byWorker.set(unit.worker, unit); |
| 296 | const dbs = (unit.config.d1_databases ?? []).filter((db) => db.migrations_dir); |
| 297 | const d1 = dbs[0] ? { database: dbs[0].database_name, migrations: dbs[0].migrations_dir } : null; |
| 298 | if (dbs.length > 1) out.push(`${where}: more than one D1 database with migrations; the deploy tool applies one per unit.`); |
| 299 | if (JSON.stringify(d1) !== JSON.stringify(unit.d1 ?? null)) { |
| 300 | out.push(`${where}: d1 is ${JSON.stringify(d1)} in its wrangler.jsonc, not ${JSON.stringify(unit.d1 ?? null)}.`); |
| 301 | } |
| 302 | const building = unit.config.build?.command ?? ""; |
| 303 | if (unit.kind === "rust-worker" && !building.includes("scripts/build-rust-worker.mjs")) { |
| 304 | out.push(`${where}: a Rust worker builds with node ../../scripts/build-rust-worker.mjs, not "${building}".`); |
| 305 | } |
| 306 | if (unit.kind !== "rust-worker" && building) out.push(`${where}: only Rust workers have a build command; ${unit.kind} builds in the deploy tool.`); |
| 307 | for (const id of (unit.config.kv_namespaces ?? []).map((kv) => kv.id)) { |
| 308 | if (!stack.resources.kv?.[id]) out.push(`${where}: KV namespace ${id} has no name under resources.kv.`); |
| 309 | } |
| 310 | const hasImage = (unit.config.containers ?? []).some((c) => !String(c.image).includes("registry")); |
| 311 | if (hasImage !== Boolean(unit.image)) out.push(`${where}: image is set exactly when its wrangler.jsonc builds a Containers image.`); |
| 312 | } |
| 313 | const listed = new Set(stack.units.map((u) => posix(join(u.path, "wrangler.jsonc")))); |
| 314 | for (const config of wranglerConfigs) { |
| 315 | if (!listed.has(config)) out.push(`${config} is not in ${STACK_FILE}: add its unit.`); |
| 316 | } |
| 317 | // Stage order follows bindings: a unit binds only to units that ship in |
| 318 | // its stage or before it. |
| 319 | for (const unit of stack.units) { |
| 320 | for (const target of unit.bindsTo) { |
| 321 | const other = byWorker.get(target); |
| 322 | if (!other) { |
| 323 | out.push(`Unit ${unit.id} binds to ${target}, which no unit deploys.`); |
| 324 | } else if (stages.indexOf(other.stage) > stages.indexOf(unit.stage)) { |
| 325 | out.push(`Unit ${unit.id} (${unit.stage}) binds to ${other.id} (${other.stage}), which ships after it.`); |
| 326 | } |
| 327 | } |
| 328 | } |
| 329 | return out; |
| 330 | } |
| 331 | |
| 332 | const SKIP_DIRS = new Set(["node_modules", "target", ".git", "build", "dist", ".wrangler", ".generated", ".astro"]); |
| 333 | |
| 334 | /** Every wrangler.jsonc or wrangler.toml checked into the repository. */ |
| 335 | export function findWranglerConfigs(root = ROOT) { |
| 336 | const found = []; |
| 337 | const walk = (dir) => { |
| 338 | for (const name of readdirSync(join(root, dir))) { |
| 339 | if (SKIP_DIRS.has(name) || name.startsWith(".")) continue; |
| 340 | const path = dir ? `${dir}/${name}` : name; |
| 341 | if (statSync(join(root, path)).isDirectory()) walk(path); |
| 342 | else if (/^wrangler\.(jsonc?|toml)$/.test(name)) found.push(path); |
| 343 | } |
| 344 | }; |
| 345 | walk(""); |
| 346 | return found.sort(); |
| 347 | } |
| 348 | |
| 349 | /** |
| 350 | * Relative imports in a unit's non-test sources that leave its folder: |
| 351 | * each { file, target }. The manifest must cover each one, through a |
| 352 | * workspace dependency or `inputs`. |
| 353 | */ |
| 354 | export function outsideImports(root, unit) { |
| 355 | const found = []; |
| 356 | const pattern = /(?:from\s+|import\s*\(\s*|import\s+)["'](\.{1,2}\/[^"']+)["']/g; |
| 357 | const walk = (dir) => { |
| 358 | for (const name of readdirSync(join(root, dir))) { |
| 359 | if (SKIP_DIRS.has(name) || name.startsWith(".")) continue; |
| 360 | const path = `${dir}/${name}`; |
| 361 | if (statSync(join(root, path)).isDirectory()) { |
| 362 | walk(path); |
| 363 | continue; |
| 364 | } |
| 365 | if (!/\.(m?[jt]sx?|astro)$/.test(name) || /\.test\.[jt]sx?$/.test(name) || name.endsWith(".d.ts")) continue; |
| 366 | const text = readFileSync(join(root, path), "utf8"); |
| 367 | for (const match of text.matchAll(pattern)) { |
| 368 | const target = posix(join(dirname(path), match[1])); |
| 369 | if (!under(target, unit.path)) found.push({ file: path, target }); |
| 370 | } |
| 371 | } |
| 372 | }; |
| 373 | walk(unit.path); |
| 374 | return found; |
| 375 | } |
| 376 | |
| 377 | /** |
| 378 | * npm ci of only what the units need: Wrangler alone for Rust workers, |
| 379 | * and each npm-built unit's workspace with the packages it uses. A CI job |
| 380 | * that deploys three Rust workers does not install the site's toolchain. |
| 381 | */ |
| 382 | export function npmCiArgs(units, npm) { |
| 383 | const workspaces = new Set(); |
| 384 | for (const unit of units) { |
| 385 | if (!unit.pkg) continue; |
| 386 | const stack = [unit.pkg]; |
| 387 | while (stack.length) { |
| 388 | const name = stack.pop(); |
| 389 | if (workspaces.has(name)) continue; |
| 390 | workspaces.add(name); |
| 391 | stack.push(...(npm.get(name)?.deps ?? [])); |
| 392 | } |
| 393 | } |
| 394 | const base = ["ci", "--no-audit", "--no-fund"]; |
| 395 | if (!workspaces.size) return [...base, "--workspaces=false"]; |
| 396 | return [...base, "--include-workspace-root", ...[...workspaces].sort().flatMap((name) => ["-w", name])]; |
| 397 | } |