mandrel-platform 0.20.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,364 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deploy-boot-smoke.mjs
4
+ *
5
+ * Boot-smoke probe for the shared `deploy-cloudflare.yml` workflow
6
+ * (Story #231). Extracted from the workflow's former ~140-line inline bash so
7
+ * the probe is unit-testable and reviewed as code, not as a YAML diff. The
8
+ * workflow sparse-checks this script out of dsj1984/mandrel-platform at
9
+ * `github.job_workflow_sha` — the exact commit the caller's
10
+ * `deploy-cloudflare.yml@<ref>` pin resolved to — so the script version
11
+ * always travels in lockstep with the workflow pin (same model as
12
+ * `uptime-apply.yml` → `apply-uptime-monitors.mjs`).
13
+ *
14
+ * What it does (mirrors the inline predecessor, with one deliberate fix):
15
+ *
16
+ * • Consumer-supplied `smoke-command` (SMOKE_COMMAND set): runs it once via
17
+ * `bash -c` with WORKERS (deployed csv) + SMOKE_BASE_URL exported. A
18
+ * non-zero exit fails the run and marks every deployed worker for
19
+ * rollback.
20
+ * • Built-in probe: requests each smoke path against each target with a
21
+ * 15s timeout and up to 3 retries (5s apart) on transient failures,
22
+ * failing on any non-200 final status.
23
+ * • Opt-in `verify-commit-sha` (VERIFY_COMMIT_SHA=true): parses the health
24
+ * response body as JSON and asserts its TOP-LEVEL `version` field equals
25
+ * the deployed commit SHA (EXPECTED_SHA). This replaces the former
26
+ * grep/sed extraction, which could match a `"version"` key nested
27
+ * anywhere in the body.
28
+ *
29
+ * The smoke_base_url duplication fix (Story #231): the inline predecessor
30
+ * probed `${SMOKE_BASE_URL}${path}` once **per worker**, so with a shared
31
+ * base URL and N workers every path was requested N times and a failure was
32
+ * misattributed to whichever worker's loop iteration hit it. With
33
+ * SMOKE_BASE_URL set the probe now requests each path exactly ONCE — and
34
+ * because a shared-host failure cannot be attributed to an individual
35
+ * worker, it explicitly marks ALL deployed workers for rollback.
36
+ *
37
+ * Rollback contract (unchanged): failed worker names are written (sorted,
38
+ * de-duplicated, one per line) to SMOKE_FAILED_FILE — overwriting any stale
39
+ * file from a previous run on a reused runner — and `smoke_failed=true` is
40
+ * appended to $GITHUB_ENV so the workflow's `Rollback failed workers` step
41
+ * fires. Exit code 1 on any smoke failure.
42
+ *
43
+ * Wrangler: the workers.dev subdomain derivation shells out to the
44
+ * consumer's lockfile-pinned `pnpm exec wrangler whoami` (installed by
45
+ * setup-toolchain, preflighted via its `require-wrangler` input). This
46
+ * script never fetches a registry-latest wrangler.
47
+ *
48
+ * Environment contract (all read from process.env):
49
+ * DEPLOYED_WORKERS csv of deployed worker names (required)
50
+ * SMOKE_COMMAND consumer-supplied probe command (optional)
51
+ * SMOKE_BASE_URL shared base URL override (optional, no trailing /)
52
+ * SMOKE_PATHS csv of probe paths (default "/health")
53
+ * WORKERS_DEV_SUBDOMAIN explicit workers.dev slug (optional)
54
+ * VERIFY_COMMIT_SHA 'true' to assert the health JSON version field
55
+ * EXPECTED_SHA the SHA verify-commit-sha asserts (github.sha)
56
+ * SMOKE_FAILED_FILE rollback-list path (default: a freshly-created
57
+ * private temp dir via mkdtemp — never a predictable
58
+ * world-writable path; CI sets this explicitly)
59
+ * GITHUB_ENV GitHub Actions env file (smoke_failed=true flag)
60
+ *
61
+ * Exit codes:
62
+ * 0 — every probe passed.
63
+ * 1 — a probe failed (rollback list written), or the probe target could
64
+ * not be resolved (no subdomain derivable — no rollback list).
65
+ */
66
+
67
+ import { writeFileSync, appendFileSync, mkdtempSync } from "node:fs";
68
+ import { tmpdir } from "node:os";
69
+ import { join } from "node:path";
70
+ import { execFileSync, spawnSync } from "node:child_process";
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Pure helpers (unit-tested)
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /** Split a csv into trimmed, non-empty entries. */
77
+ export function parseCsv(csv) {
78
+ return String(csv ?? "")
79
+ .split(",")
80
+ .map((s) => s.trim())
81
+ .filter(Boolean);
82
+ }
83
+
84
+ /** Parse SMOKE_PATHS into normalized paths (leading slash enforced). */
85
+ export function parseSmokePaths(csv) {
86
+ return parseCsv(csv).map((p) => (p.startsWith("/") ? p : `/${p}`));
87
+ }
88
+
89
+ /**
90
+ * Extract the workers.dev account subdomain slug from `wrangler whoami`
91
+ * output (the first `<slug>.workers.dev` token). Returns null when absent.
92
+ */
93
+ export function extractSubdomain(whoamiOutput) {
94
+ const m = String(whoamiOutput ?? "").match(/([A-Za-z0-9-]+)\.workers\.dev/);
95
+ return m ? m[1] : null;
96
+ }
97
+
98
+ /**
99
+ * Parse a health response body and return its TOP-LEVEL `version` field as a
100
+ * string, or null when the body is not JSON, not an object, or the field is
101
+ * missing / not a non-empty string. Deliberately never matches a `version`
102
+ * key nested inside a sub-object (the grep-era false positive).
103
+ */
104
+ export function parseVersionField(body) {
105
+ let parsed;
106
+ try {
107
+ parsed = JSON.parse(body);
108
+ } catch {
109
+ return null;
110
+ }
111
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
112
+ const version = parsed.version;
113
+ return typeof version === "string" && version.length > 0 ? version : null;
114
+ }
115
+
116
+ /**
117
+ * Build the probe plan: one entry per URL to request, each carrying the
118
+ * worker names a failure of that URL attributes to the rollback list.
119
+ *
120
+ * • SMOKE_BASE_URL set → each path probed ONCE against the shared base;
121
+ * a failure attributes to ALL deployed workers
122
+ * (shared host — per-worker attribution is
123
+ * impossible, so roll back everything deployed).
124
+ * • workers.dev (default) → each path probed per worker against
125
+ * https://<worker>.<subdomain>.workers.dev; a
126
+ * failure attributes to that worker only.
127
+ */
128
+ export function buildProbePlan({ workers, paths, smokeBaseUrl, subdomain }) {
129
+ if (smokeBaseUrl) {
130
+ const base = smokeBaseUrl.replace(/\/+$/, "");
131
+ return paths.map((p) => ({
132
+ url: `${base}${p}`,
133
+ label: `shared base (${workers.join(", ")})`,
134
+ attributedWorkers: [...workers],
135
+ }));
136
+ }
137
+ const plan = [];
138
+ for (const worker of workers) {
139
+ for (const p of paths) {
140
+ plan.push({
141
+ url: `https://${worker}.${subdomain}.workers.dev${p}`,
142
+ label: worker,
143
+ attributedWorkers: [worker],
144
+ });
145
+ }
146
+ }
147
+ return plan;
148
+ }
149
+
150
+ /** De-duplicate + sort a worker list for the rollback file (mirrors `sort -u`). */
151
+ export function uniqueSorted(names) {
152
+ return [...new Set(names)].sort();
153
+ }
154
+
155
+ // HTTP status codes curl's `--retry` treats as transient; the inline
156
+ // predecessor used `curl --retry 3 --retry-delay 5`.
157
+ const TRANSIENT_STATUS = new Set([408, 429, 500, 502, 503, 504]);
158
+
159
+ /**
160
+ * Probe one URL: GET with a 15s timeout, retrying transient failures
161
+ * (network error or a TRANSIENT_STATUS response) up to `retries` times with
162
+ * `retryDelayMs` between attempts. Returns { status, body } for the final
163
+ * attempt; a network failure on the final attempt returns
164
+ * { status: 0, body: "" } (the inline predecessor's `curl || echo 000`).
165
+ */
166
+ export async function probeUrl(url, { fetchImpl = fetch, retries = 3, retryDelayMs = 5000, timeoutMs = 15000, sleep = defaultSleep } = {}) {
167
+ let last = { status: 0, body: "" };
168
+ for (let attempt = 0; attempt <= retries; attempt++) {
169
+ if (attempt > 0) await sleep(retryDelayMs);
170
+ try {
171
+ // redirect: "manual" — parity with the inline predecessor's plain curl
172
+ // (no -L): a 301/302 from a health endpoint is a non-200 smoke FAILURE,
173
+ // never silently followed to whatever the redirect target returns.
174
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs), redirect: "manual" });
175
+ last = { status: res.status, body: await res.text() };
176
+ } catch {
177
+ last = { status: 0, body: "" };
178
+ continue; // network failure → transient, retry
179
+ }
180
+ if (!TRANSIENT_STATUS.has(last.status)) return last;
181
+ }
182
+ return last;
183
+ }
184
+
185
+ function defaultSleep(ms) {
186
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Probe execution (deps injectable for the test suite)
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * Run the full smoke pass. Returns { exitCode, failedWorkers } and performs
195
+ * no process.exit of its own so the test suite can drive it directly.
196
+ */
197
+ export async function runSmoke(env, deps = {}) {
198
+ const {
199
+ log = (line) => process.stdout.write(`${line}\n`),
200
+ probe = probeUrl,
201
+ runShell = defaultRunShell,
202
+ whoami = defaultWhoami,
203
+ } = deps;
204
+
205
+ const workers = parseCsv(env.DEPLOYED_WORKERS);
206
+ const smokeCommand = env.SMOKE_COMMAND ?? "";
207
+ const smokeBaseUrl = (env.SMOKE_BASE_URL ?? "").trim();
208
+ const verifySha = env.VERIFY_COMMIT_SHA === "true";
209
+ const expectedSha = env.EXPECTED_SHA ?? "";
210
+
211
+ // ----- Consumer-supplied smoke replaces the built-in probe -----
212
+ if (smokeCommand) {
213
+ log("::group::Running consumer smoke-command");
214
+ const code = runShell(smokeCommand, {
215
+ WORKERS: env.DEPLOYED_WORKERS ?? "",
216
+ SMOKE_BASE_URL: smokeBaseUrl,
217
+ });
218
+ if (code !== 0) {
219
+ log("::error::Consumer smoke-command FAILED. Triggering rollback.");
220
+ log("::endgroup::");
221
+ return { exitCode: 1, failedWorkers: uniqueSorted(workers) };
222
+ }
223
+ log("✅ consumer smoke-command passed");
224
+ log("::endgroup::");
225
+ return { exitCode: 0, failedWorkers: [] };
226
+ }
227
+
228
+ // ----- Built-in probe -----
229
+ // Resolve the workers.dev subdomain slug. NEVER the account ID (a UUID) —
230
+ // workers.dev subdomains are keyed by the account name/slug. Prefer the
231
+ // explicit input, else derive from `wrangler whoami`.
232
+ let subdomain = (env.WORKERS_DEV_SUBDOMAIN ?? "").trim();
233
+ if (!subdomain && !smokeBaseUrl) {
234
+ log("::group::Deriving workers.dev subdomain from wrangler whoami");
235
+ subdomain = extractSubdomain(whoami());
236
+ if (!subdomain) {
237
+ log(
238
+ "::error::Could not derive workers.dev subdomain from 'wrangler whoami'. " +
239
+ "Pass workers_dev_subdomain or smoke_base_url explicitly."
240
+ );
241
+ log("::endgroup::");
242
+ // No rollback list: the target could not be resolved, so nothing was
243
+ // probed (mirrors the inline predecessor, which exited before any
244
+ // failed-worker was recorded).
245
+ return { exitCode: 1, failedWorkers: [] };
246
+ }
247
+ log(`::notice::Derived workers.dev subdomain: ${subdomain}`);
248
+ log("::endgroup::");
249
+ }
250
+
251
+ const paths = parseSmokePaths(env.SMOKE_PATHS ?? "/health");
252
+ const plan = buildProbePlan({ workers, paths, smokeBaseUrl, subdomain });
253
+ const failed = [];
254
+
255
+ for (const entry of plan) {
256
+ log(`::group::Smoke-testing ${entry.label} → ${entry.url}`);
257
+ const { status, body } = await probe(entry.url);
258
+ log(`HTTP status: ${status}`);
259
+
260
+ if (status !== 200) {
261
+ log(
262
+ `::error::Smoke check FAILED for ${entry.label} at ${entry.url} (HTTP ${status}). Triggering rollback.`
263
+ );
264
+ failed.push(...entry.attributedWorkers);
265
+ log("::endgroup::");
266
+ continue;
267
+ }
268
+ log(`✅ ${entry.label} smoke check passed at ${entry.url} (HTTP 200)`);
269
+
270
+ // ----- verify-commit-sha (Story #176, opt-in) -----
271
+ // Assert the deployed worker's health response reports the SHA this run
272
+ // deployed. Contract: docs/runbooks/post-deploy-smoke.md
273
+ // #3-health-endpoint-contract — a JSON body whose top-level "version"
274
+ // field is env.GIT_COMMIT_SHA. A mismatch or unparsable field fails the
275
+ // smoke check and triggers the same auto-rollback as an HTTP failure.
276
+ if (verifySha) {
277
+ const deployedSha = parseVersionField(body);
278
+ if (deployedSha === null) {
279
+ log(
280
+ `::error::verify-commit-sha FAILED for ${entry.label} at ${entry.url} — response has no parsable top-level "version" field. Triggering rollback.`
281
+ );
282
+ failed.push(...entry.attributedWorkers);
283
+ } else if (deployedSha !== expectedSha) {
284
+ log(
285
+ `::error::verify-commit-sha FAILED for ${entry.label} — deployed SHA '${deployedSha}' does not match expected SHA '${expectedSha}'. Triggering rollback.`
286
+ );
287
+ failed.push(...entry.attributedWorkers);
288
+ } else {
289
+ log(`✅ ${entry.label} reports expected commit SHA: ${deployedSha}`);
290
+ }
291
+ }
292
+ log("::endgroup::");
293
+ }
294
+
295
+ if (failed.length > 0) {
296
+ return { exitCode: 1, failedWorkers: uniqueSorted(failed) };
297
+ }
298
+ return { exitCode: 0, failedWorkers: [] };
299
+ }
300
+
301
+ function defaultRunShell(command, extraEnv) {
302
+ const res = spawnSync("bash", ["-c", command], {
303
+ stdio: "inherit",
304
+ env: { ...process.env, ...extraEnv },
305
+ });
306
+ return res.status ?? 1;
307
+ }
308
+
309
+ function defaultWhoami() {
310
+ // Consumer lockfile-pinned wrangler (`pnpm exec wrangler`), installed and
311
+ // preflighted by setup-toolchain (require-wrangler). Failure tolerated —
312
+ // an empty output falls through to the "could not derive" error above.
313
+ try {
314
+ return execFileSync("pnpm", ["exec", "wrangler", "whoami"], {
315
+ encoding: "utf8",
316
+ stdio: ["ignore", "pipe", "ignore"],
317
+ maxBuffer: 8 * 1024 * 1024,
318
+ });
319
+ } catch {
320
+ return "";
321
+ }
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // CLI entry
326
+ // ---------------------------------------------------------------------------
327
+
328
+ /**
329
+ * Resolve the rollback-list path. When SMOKE_FAILED_FILE is set (CI always
330
+ * sets it), it wins verbatim. Otherwise fall back to a file inside a
331
+ * freshly-created private temp directory (`mkdtemp` — mode 0700, unique
332
+ * per-invocation name) rather than the fixed, predictable, world-writable
333
+ * `/tmp/smoke-failed-workers.txt`. A predictable path in a shared temp dir is
334
+ * pre-creatable / symlink-swappable by a co-tenant on a reused runner
335
+ * (CWE-377); mkdtemp closes that seam by owning a private directory no other
336
+ * process can pre-stage.
337
+ */
338
+ export function resolveFailedFile(env, mkdtempImpl = mkdtempSync) {
339
+ const explicit = (env.SMOKE_FAILED_FILE ?? "").trim();
340
+ if (explicit) return explicit;
341
+ return join(mkdtempImpl(join(tmpdir(), "deploy-boot-smoke-")), "smoke-failed-workers.txt");
342
+ }
343
+
344
+ async function main() {
345
+ const failedFile = resolveFailedFile(process.env);
346
+ const { exitCode, failedWorkers } = await runSmoke(process.env);
347
+
348
+ if (failedWorkers.length > 0) {
349
+ // Overwrite (never append): a reused self-hosted runner may carry a
350
+ // stale list from a previous run, and rolling back workers this run
351
+ // never deployed would widen the blast radius.
352
+ writeFileSync(failedFile, `${failedWorkers.join("\n")}\n`);
353
+ if (process.env.GITHUB_ENV) {
354
+ appendFileSync(process.env.GITHUB_ENV, "smoke_failed=true\n");
355
+ }
356
+ }
357
+ process.exit(exitCode);
358
+ }
359
+
360
+ const invokedDirectly =
361
+ process.argv[1] && process.argv[1].endsWith("deploy-boot-smoke.mjs");
362
+ if (invokedDirectly) {
363
+ await main();
364
+ }