g1t/scripts/deploy/plan.mjs

170 lines6,810 bytesCodeBlame
1// What a deploy would do: for each unit, the commit it runs, what changed
2// since, and its pending migrations. Git and Wrangler are passed in, so the
3// tests can give their own.
4
5import { execFileSync } from "node:child_process";
6
7import { changedNames, lockRoots, parseCargoLock, parseNpmLock, reaches } from "./lockfiles.mjs";
8import { ROOT, byStage, buildGroups, codeStages, touches, touchesImage } from "./stack.mjs";
9
10/** Git, read-only. */
11export const git = {
12 head: () => run(["rev-parse", "HEAD"]).trim(),
13 resolve: (rev) => run(["rev-parse", "--verify", `${rev}^{commit}`]).trim(),
14 subject: () => run(["log", "-1", "--format=%s"]).trim(),
15 /** Whether a commit is in this checkout's history. */
16 has: (sha) => {
17 try {
18 run(["cat-file", "-e", `${sha}^{commit}`]);
19 return true;
20 } catch {
21 return false;
22 }
23 },
24 /** Files changed between two commits. */
25 /** A file's text at a commit, or "" if it is not there. */
26 show: (sha, path) => {
27 try {
28 return run(["show", `${sha}:${path}`]);
29 } catch {
30 return "";
31 }
32 },
33 changed: (from, to) => run(["diff", "--name-only", "--no-renames", from, to]).split("\n").filter(Boolean),
34 /** Uncommitted and untracked files (not ignored ones). */
35 dirty: () =>
36 run(["status", "--porcelain", "--untracked-files=all"])
37 .split("\n")
38 .filter(Boolean)
39 .map((line) => line.slice(3).replace(/^"|"$/g, "").split(" -> ").pop()),
40};
41
42function run(args) {
43 return execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
44}
45
46/**
47 * Decides, for each unit in `units`, whether it deploys and why.
48 *
49 * live: unit id -> { sha, why? } (what readLive found)
50 * head: the commit being deployed
51 * force: deploy even what has not changed
52 * gitApi: { has, changed }
53 *
54 * Each unit gets { deploy, reason, since, files, image }: `files` are the
55 * changed files that touch it; `image` says whether its Containers image
56 * must be built.
57 */
58export function decide(units, { live, head, force = false, gitApi = git }) {
59 const diffs = new Map();
60 const changedSince = (sha) => {
61 if (!diffs.has(sha)) diffs.set(sha, gitApi.changed(sha, head));
62 return diffs.get(sha);
63 };
64 // A lockfile change counts for a unit only if a package it uses changed.
65 const locks = new Map();
66 const lockChange = (sha, file) => {
67 const key = `${sha}:${file}`;
68 if (!locks.has(key)) {
69 const parse = file === "Cargo.lock" ? parseCargoLock : parseNpmLock;
70 const after = parse(gitApi.show(head, file));
71 locks.set(key, { after, names: changedNames(parse(gitApi.show(sha, file)), after) });
72 }
73 return locks.get(key);
74 };
75 const relevant = (unit, sha, files) =>
76 files.filter((file) => {
77 if (file !== "Cargo.lock" && file !== "package-lock.json") return true;
78 const { after, names } = lockChange(sha, file);
79 const roots = lockRoots(unit)[file === "Cargo.lock" ? "cargo" : "npm"];
80 return reaches(after, roots, names);
81 });
82 return units.map((unit) => {
83 const found = live[unit.id] ?? { sha: null, why: "not read" };
84 const decision = { unit, since: found.sha, deploy: false, reason: "", files: [], image: false };
85 if (!found.sha) {
86 decision.deploy = true;
87 decision.reason = found.error ? `could not read what it runs: ${firstLine(found.error)}` : `no known commit (${found.why ?? "unknown"})`;
88 decision.image = Boolean(unit.image);
89 return decision;
90 }
91 if (found.sha === head) {
92 decision.deploy = force;
93 decision.reason = force ? "forced; already at this commit" : "up to date";
94 return decision;
95 }
96 if (!gitApi.has(found.sha)) {
97 decision.deploy = true;
98 decision.reason = `runs ${found.sha.slice(0, 12)}, which this checkout does not have (fetch full history)`;
99 decision.image = Boolean(unit.image);
100 return decision;
101 }
102 const files = relevant(unit, found.sha, changedSince(found.sha));
103 const hit = touches(unit, files);
104 decision.files = hit ? files.filter((file) => touches(unit, [file])) : [];
105 decision.image = touchesImage(unit, files);
106 if (hit) {
107 decision.deploy = true;
108 decision.reason = `${decision.files.length} changed file${decision.files.length === 1 ? "" : "s"} (${hit.file}${hit.via === "its own folder" ? "" : ` via ${hit.via}`})`;
109 } else {
110 decision.deploy = force;
111 decision.reason = force ? "forced; nothing it is built from changed" : "nothing it is built from changed";
112 }
113 return decision;
114 });
115}
116
117const firstLine = (text) => String(text).trim().split("\n").find((line) => /error|\[ERROR\]|X /i.test(line)) ?? String(text).trim().split("\n")[0];
118
119/**
120 * The plan as data, for `plan --json` and the workflow: the migrations to
121 * apply, and each stage's units split into the jobs that build them.
122 */
123export function planJson(stack, decisions, migrations, head) {
124 const deploying = decisions.filter((d) => d.deploy).map((d) => d.unit);
125 const stages = {};
126 for (const stage of codeStages(stack)) {
127 const units = deploying.filter((u) => u.stage === stage);
128 stages[stage] = { units: units.map((u) => u.id), jobs: buildGroups(units, decisions.filter((d) => d.deploy && d.image).map((d) => d.unit.id)) };
129 }
130 return {
131 commit: head,
132 migrations: Object.entries(migrations)
133 .filter(([, m]) => m.pending?.length)
134 .map(([id, m]) => ({ unit: id, database: stack.units.find((u) => u.id === id).d1.database, pending: m.pending })),
135 migration_errors: Object.entries(migrations)
136 .filter(([, m]) => m.error)
137 .map(([id, m]) => ({ unit: id, error: m.error })),
138 stages,
139 units: decisions.map((d) => ({
140 unit: d.unit.id,
141 stage: d.unit.stage,
142 deploy: d.deploy,
143 reason: d.reason,
144 live_commit: d.since,
145 image: d.image,
146 })),
147 stage_order: byStage(stack, deploying).map((g) => g.stage),
148 };
149}
150
151/** A plain table. */
152export function table(rows, headers) {
153 const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] ?? "").length)));
154 const line = (cells) => cells.map((c, i) => String(c ?? "").padEnd(widths[i])).join(" ").trimEnd();
155 return [line(headers), line(widths.map((w) => "-".repeat(w))), ...rows.map(line)].join("\n");
156}
157
158/** Runs `task` over `items`, at most `limit` at once, in order of start. */
159export async function pool(items, limit, task) {
160 const results = new Array(items.length);
161 let next = 0;
162 const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
163 while (next < items.length) {
164 const index = next++;
165 results[index] = await task(items[index], index);
166 }
167 });
168 await Promise.all(workers);
169 return results;
170}