UNPKG

everything-dev

Version:

A consolidated product package for building Module Federation apps with oRPC APIs.

271 lines (269 loc) • 11.3 kB
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; import process from "node:process"; //#region src/services/deploy/smoke.ts const BOOT_DEADLINE_MS = 24e4; const POLL_INTERVAL_MS = 1e3; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function run(cmd, args) { const result = spawnSync(cmd, args, { encoding: "utf8" }); if (result.error) throw result.error; return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; } const docker = (args) => run("docker", args); async function fetchStatusOk(url) { const response = await fetch(url); if (!response.ok) throw new Error(`${url} → ${response.status}`); return response; } /** Bounded poll: resolves with the check's result once it passes, rejects at the deadline. */ async function pollUntil(name, check, deadlineMs = BOOT_DEADLINE_MS) { const deadline = Date.now() + deadlineMs; let lastFailure = "no attempt"; let attempt = 0; while (Date.now() < deadline) { attempt += 1; try { const detail = await check(); console.log(` ✓ ${name} (attempt ${attempt})`); return detail; } catch (error) { lastFailure = error instanceof Error ? error.message : String(error); } await sleep(POLL_INTERVAL_MS); } throw new Error(`${name} did not pass within the boot deadline — last failure: ${lastFailure}`); } /** Resolves "exited" once the boot container has exited — the start stack * exits non-zero when a service fails to boot, so a dead container means the * remaining pollUntil time would be wasted polling nothing. Resolves * undefined when aborted. */ function watchContainerExit(container) { const controller = { aborted: false }; return { done: (async () => { while (!controller.aborted) { if ((docker([ "inspect", "-f", "{{.State.Running}}", container ]).stdout ?? "").trim() === "false") return "exited"; await sleep(2e3); } })(), abort: () => controller.aborted = true }; } /** While the container is still running, probe the CDN from inside it — a * boot failure on :3000 is either the CDN edge (network-class: unreachable, * 429/5xx, challenge) or a boot bug (module eval, shared-dependency skew, * server start), and the two need opposite fixes. Falls back to a one-off * container from the same image (equivalent network class) when the boot * container already exited. */ function probeCdnFromContainer(container, image, account, gateway) { const base = `${process.env.SMOKE_CDN_BASE ?? "https://cdn.everything.dev"}/bundles/${account}/${gateway}/host`; const script = ` const base = ${JSON.stringify(base)}; (async () => { const urls = [base + "/mf-manifest.json"]; try { const manifest = await fetch(urls[0]).then((r) => (r.ok ? r.json() : null)); const expose = manifest?.exposes?.[0] ?? {}; const files = expose.assets?.js?.sync ?? expose.files?.js?.sync ?? []; for (const f of files.slice(0, 3)) urls.push(base + "/" + f); } catch {} for (const url of urls) { try { const res = await fetch(url); const bytes = res.ok ? (await res.arrayBuffer()).byteLength : 0; console.log("probe " + url + " -> " + res.status + (bytes ? " " + bytes + "B" : "")); } catch (error) { console.log("probe " + url + " -> FETCH_FAILED " + String(error)); } } })(); `; let probe = docker([ "exec", container, "node", "-e", script ]); if (probe.status !== 0) probe = docker([ "run", "--rm", image, "node", "-e", script ]); const output = `${probe.stdout}${probe.stderr}`.trim(); console.error("--- in-container CDN probe ---"); if (output) console.error(output); else console.error(`(no output — probe exit ${probe.status}: ${probe.stderr.trim()})`); const verdicts = output.split("\n").filter((line) => line.startsWith("probe ")).map((line) => { const status = line.split(" -> ")[1] ?? ""; if (status.startsWith("FETCH_FAILED")) return "unreachable"; if (Number.parseInt(status, 10) === 200) return "ok"; return "edge-error"; }); return verdicts.length === 0 ? "probe did not run — see probe exit above" : verdicts.every((v) => v === "ok") ? "CDN reachable from inside the container — app-class boot failure; the cause is in the captured logs, not the network" : verdicts.some((v) => v === "unreachable") ? "CDN unreachable from inside the container — network-class failure (runner egress or CDN edge)" : "CDN answered non-200 from inside the container — edge or published-config mismatch (see statuses above)"; } /** Copy the stack's .bos/logs out of the container (into the CI artifact * path) and capture the full container stdout — the failure reason for a * failed service only exists inside the container, which otherwise gets * deleted, and a tail window can cut the error behind spinner output. */ function captureContainerLogs(container, dockerLogs) { const target = join(".bos", "logs", "smoke"); mkdirSync(target, { recursive: true }); const cp = docker([ "cp", `${container}:/app/.bos/logs/.`, target ]); if (cp.status !== 0) console.error(`smoke: could not copy container logs: ${cp.stderr.trim()}`); else { const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? walk(join(dir, entry.name)) : [join(dir, entry.name)]); for (const file of walk(target)) { if (!file.endsWith(".log")) continue; const lines = readFileSync(file, "utf8").split("\n"); console.error(`--- ${file} (last 60 of ${lines.length} lines) ---`); console.error(lines.slice(-60).join("\n")); } } writeFileSync(join(target, "smoke-stdout.log"), dockerLogs.join("\n")); console.error(`--- container stdout (full, ${dockerLogs.length} lines) → ${target}/smoke-stdout.log ---`); console.error(dockerLogs.slice(-100).join("\n")); } async function runSmoke(options) { const port = options.port ?? 3e3; const keep = options.keep ?? false; const baseUrl = `http://localhost:${port}`; const container = `everything-dev-smoke-${process.pid}`; const image = options.image; const envPairs = { BOS_ACCOUNT: options.account ?? process.env.SMOKE_BOS_ACCOUNT ?? "dev.everything.near", BOS_GATEWAY: options.gateway ?? process.env.SMOKE_BOS_GATEWAY ?? "everything.dev", API_DATABASE_URL: options.apiDatabaseUrl ?? process.env.SMOKE_API_DATABASE_URL ?? "postgres://everythingdev:everythingdev@host.docker.internal:5434/api_test_db", AUTH_DATABASE_URL: options.authDatabaseUrl ?? process.env.SMOKE_AUTH_DATABASE_URL ?? "postgres://everythingdev:everythingdev@host.docker.internal:5435/auth_test_db", BETTER_AUTH_SECRET: options.authSecret ?? process.env.SMOKE_BETTER_AUTH_SECRET ?? "smoke-test-secret-do-not-use-in-production" }; const up = docker([ "run", "-d", "--name", container, "-p", `${port}:${port}`, "--add-host=host.docker.internal:host-gateway", ...Object.entries(envPairs).flatMap(([key, value]) => ["-e", `${key}=${value}`]), image ]); if (up.status !== 0) return { ok: false, image, error: `docker run failed: ${up.stderr.trim()}` }; try { const exitWatch = watchContainerExit(container); const bootChecks = Promise.all([ pollUntil("health", () => fetchStatusOk(`${baseUrl}/health`).then((r) => `status ${r.status}`)), pollUntil("ssr page", async () => { const response = await fetchStatusOk(`${baseUrl}/`); const contentType = response.headers.get("content-type") ?? ""; if (!contentType.includes("text/html")) throw new Error(`/ content-type ${contentType}`); const body = await response.text(); if (!/<html/i.test(body)) throw new Error("/ did not return an HTML document"); if (body.includes("SSR unavailable")) throw new Error("/ served the client-shell fallback — the server render failed"); return `${body.length} bytes`; }), pollUntil("api spec", async () => { const spec = await (await fetchStatusOk(`${baseUrl}/api/spec.json`)).json(); if (!spec.openapi && !spec.paths) throw new Error("/api/spec.json is not an OpenAPI document"); return `${Object.keys(spec.paths ?? {}).length} paths`; }) ]); bootChecks.catch(() => {}); if (await Promise.race([bootChecks.then(() => "ready"), exitWatch.done]).finally(() => exitWatch.abort()) === "exited") throw new Error("boot container exited — a service failed to start (the cause is in the captured logs below)"); const [health, ssr, api] = await bootChecks; console.log(` ℹ health: ${health}; ssr: ${ssr}; api: ${api}`); console.log(` … bos mf check (in-image)`); const mf = await pollUntil("bos mf check", async () => { const result = docker([ "exec", container, "node", "./node_modules/everything-dev/dist/cli.mjs", "mf", "check" ]); if (result.status !== 0) throw new Error(`bos mf check failed (exit ${result.status})\n${result.stdout}${result.stderr}`); return (result.stdout.trim().split("\n").at(-1) ?? "").slice(0, 400); }, 6e4); console.log(` … platform manifest (in-image)`); const platformResult = docker([ "exec", container, "node", "-e", `const m = JSON.parse(require('fs').readFileSync('./packages/everything-dev/dist/platform-manifest.json','utf8')); if (m.kind !== 'everything-dev/platform-manifest' || !m.frameworkVersion) process.exit(1); console.log(m.frameworkVersion)` ]); if (platformResult.status !== 0) throw new Error(`in-image platform manifest missing or invalid — the image was not built with the release contract (ADR 0034)\n${platformResult.stdout}${platformResult.stderr}`); const platformVersion = platformResult.stdout.trim(); const sizeResult = docker([ "image", "inspect", "-f", "{{.Size}}", image ]); if (sizeResult.status !== 0) throw new Error(`image inspect failed: ${sizeResult.stderr}`); const mb = (Number(sizeResult.stdout.trim()) / 1048576).toFixed(1); console.log(` ℹ image size: ${mb} MB (${image})`); const summary = `health: ${health}; ssr: ${ssr}; api: ${api}; mf compat: ${mf}; platform: ${platformVersion}`; console.log("smoke: PASS"); return { ok: true, image, summary }; } catch (error) { const failure = error instanceof Error ? error.message : String(error); console.error(`smoke: FAIL — ${failure}`); const diagnosis = probeCdnFromContainer(container, image, envPairs.BOS_ACCOUNT, envPairs.BOS_GATEWAY); console.error(`smoke diagnosis: ${diagnosis}`); const stop = docker([ "stop", "-t", "15", container ]); if (stop.status !== 0) console.error(`smoke: graceful stop failed — ${stop.stderr.trim()}`); const logs = docker(["logs", container]); captureContainerLogs(container, `${logs.stdout}${logs.stderr}`.split("\n")); if (/pins no version manifest|redeploy required/i.test(`${logs.stdout}${logs.stderr}`)) console.error("smoke: the published config predates version-manifest pins — run `bos deploy` to republish with pins; the smoke gate goes green after that"); return { ok: false, image, diagnosis, error: failure }; } finally { if (!keep) docker([ "rm", "-f", container ]); else console.log(`smoke: kept container ${container}`); } } //#endregion export { runSmoke }; //# sourceMappingURL=smoke.mjs.map