Files
descrybe/scripts/cutover-local-rehearsal.mjs
T

272 lines
7.7 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* Local cutover rehearsal (read-only / dry-run).
*
* Runs (in order):
* 1) cutover-deploy-check (goose 039+040+041+042, /readyz, adapter-node notes)
* 2) migrator -list-legacy-emails (no patch / export)
* 3) migrator -list-member-memberships
* 4) migrator -list-companies-without-plans
* 5) orphan-processed cleanup POST without confirm (report-only)
*
* Excludes Clerk patch, SMTP/mailhooks, Stripe, assign/promote -confirm writes.
* Assign / promote / orphan live deletes stay separate operator commands
* (-confirm / confirm=true). This script never passes those flags.
*
* Usage:
* node scripts/cutover-local-rehearsal.mjs
* node scripts/cutover-local-rehearsal.mjs --skip-deploy-check
* node scripts/cutover-local-rehearsal.mjs --skip-orphan
* make cutover-rehearsal
*
* Orphan step needs a platform-admin session: set DESCRYBE_SMOKE_PASS (or
* CUTOVER_REHEARSAL_PASS) and optional CUTOVER_REHEARSAL_EMAIL (default
* demo@descrybe.local). See docs/demo-user.md.
*/
import { spawnSync } from "node:child_process";
import path from "node:path";
import { DEV_API_PORT, DEV_PUBLIC_API_URL } from "./dev-ports.mjs";
import { loadRootEnv, repoRoot } from "./root-env.mjs";
const args = new Set(process.argv.slice(2));
const wantHelp = args.has("-h") || args.has("--help");
const skipDeploy = args.has("--skip-deploy-check");
const skipLists = args.has("--skip-lists");
const skipOrphan = args.has("--skip-orphan");
const deployPassthrough = [];
if (args.has("--skip-goose")) deployPassthrough.push("--skip-goose");
if (args.has("--skip-readyz")) deployPassthrough.push("--skip-readyz");
if (wantHelp) {
console.log(`Usage: node scripts/cutover-local-rehearsal.mjs [flags]
Local dry-run rehearsal (no Clerk patch / SMTP / Stripe).
--skip-deploy-check skip scripts/cutover-deploy-check.mjs
--skip-lists skip migrator list-* steps
--skip-orphan skip orphan-processed dry-run
--skip-goose forward to deploy-check
--skip-readyz forward to deploy-check
Env: DATABASE_URL (lists), PUBLIC_API_URL / HEALTH_BASE_URL (orphan),
DESCRYBE_SMOKE_PASS or CUTOVER_REHEARSAL_PASS (+ optional CUTOVER_REHEARSAL_EMAIL).
Never passes -confirm / confirm=true (assign/promote/orphan deletes stay separate).`);
process.exit(0);
}
const env = loadRootEnv(process.env);
const apiDir = path.join(repoRoot, "apps", "api");
const failures = [];
function section(title) {
console.log(`\n==> ${title}`);
}
function pass(msg) {
console.log(`PASS ${msg}`);
}
function fail(msg) {
console.log(`FAIL ${msg}`);
failures.push(msg);
}
function info(msg) {
console.log(`NOTE ${msg}`);
}
function run(cmd, argv, opts = {}) {
const r = spawnSync(cmd, argv, {
cwd: opts.cwd ?? repoRoot,
env,
encoding: "utf8",
shell: process.platform === "win32",
stdio: "inherit",
});
if (r.error) {
fail(`${opts.label ?? cmd}: spawn failed: ${r.error.message}`);
return false;
}
if (r.status !== 0) {
fail(`${opts.label ?? cmd}: exited ${r.status}`);
return false;
}
return true;
}
function runMigratorList(flag, label) {
section(label);
if (!env.DATABASE_URL) {
fail(`DATABASE_URL unset - cannot run ${flag}`);
return;
}
const ok = run(
"go",
["run", "./cmd/migrator", flag, "-postgres", env.DATABASE_URL],
{ cwd: apiDir, label: flag },
);
if (ok) pass(`${flag} ok`);
}
function cookieMapFromResponse(res, jar) {
const raw =
typeof res.headers.getSetCookie === "function"
? res.headers.getSetCookie()
: [];
const single = res.headers.get("set-cookie");
const lines = raw.length > 0 ? raw : single ? [single] : [];
for (const line of lines) {
const part = String(line).split(";")[0];
const eq = part.indexOf("=");
if (eq <= 0) continue;
jar.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
}
return jar;
}
function cookieHeader(jar) {
return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
}
async function platformLogin(base) {
const email =
env.CUTOVER_REHEARSAL_EMAIL || env.DEMO_EMAIL || "demo@descrybe.local";
const password = env.CUTOVER_REHEARSAL_PASS || env.DESCRYBE_SMOKE_PASS || "";
if (!password) {
return {
ok: false,
reason:
"set DESCRYBE_SMOKE_PASS or CUTOVER_REHEARSAL_PASS for platform-admin orphan dry-run",
};
}
const jar = new Map();
const me = await fetch(`${base}/api/auth/me`, {
headers: { Cookie: cookieHeader(jar) },
});
cookieMapFromResponse(me, jar);
const csrf = jar.get("descrybe_csrf");
if (!csrf) {
return { ok: false, reason: "missing descrybe_csrf cookie from /api/auth/me" };
}
const login = await fetch(`${base}/api/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
Cookie: cookieHeader(jar),
},
body: JSON.stringify({ email, password }),
});
cookieMapFromResponse(login, jar);
const text = await login.text();
if (!login.ok) {
return {
ok: false,
reason: `login ${login.status}: ${text.slice(0, 200)}`,
};
}
return { ok: true, jar, email };
}
async function orphanDryRun() {
section("orphan-processed dry-run (no confirm)");
if (skipOrphan) {
info("skipped (--skip-orphan)");
return;
}
const base = (
env.HEALTH_BASE_URL ||
env.PUBLIC_API_URL ||
DEV_PUBLIC_API_URL ||
`http://127.0.0.1:${DEV_API_PORT}`
).replace(/\/$/, "");
let session;
try {
session = await platformLogin(base);
} catch (err) {
fail(
`orphan login failed: ${err instanceof Error ? err.message : err} (is API up?)`,
);
return;
}
if (!session.ok) {
fail(`orphan dry-run skipped: ${session.reason}`);
info(
`Manual: GET ${base}/api/admin/jobs/orphan-processed or POST …/orphan-processed-cleanup (no confirm=true)`,
);
return;
}
const csrf = session.jar.get("descrybe_csrf");
const url = `${base}/api/admin/jobs/orphan-processed-cleanup`;
let res;
let text;
try {
res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf || "",
Cookie: cookieHeader(session.jar),
},
body: "{}",
});
text = await res.text();
} catch (err) {
fail(`orphan POST failed: ${err instanceof Error ? err.message : err}`);
return;
}
console.log(`POST ${url} -> ${res.status}`);
console.log(text);
if (!res.ok) {
fail(`orphan cleanup HTTP ${res.status}`);
return;
}
pass(`orphan dry-run report-only (as ${session.email}; no confirm)`);
}
console.log(
"cutover-local-rehearsal (deploy-check + list-* + orphan dry-run; no Clerk/SMTP/Stripe)",
);
if (!skipDeploy) {
section("deploy-check");
const ok = run(
"node",
[path.join("scripts", "cutover-deploy-check.mjs"), ...deployPassthrough],
{ label: "cutover-deploy-check" },
);
if (ok) pass("deploy-check ok");
} else {
section("deploy-check");
info("skipped (--skip-deploy-check)");
}
if (!skipLists) {
runMigratorList("-list-legacy-emails", "list-legacy-emails (no patch)");
runMigratorList("-list-member-memberships", "list-member-memberships");
runMigratorList(
"-list-companies-without-plans",
"list-companies-without-plans",
);
} else {
section("migrator lists");
info("skipped (--skip-lists)");
}
await orphanDryRun();
section("summary");
if (failures.length === 0) {
console.log(
"ok - local rehearsal green (lists + deploy-check + orphan dry-run).",
);
console.log(
"Not included: Clerk patch, SMTP/mailhooks, Stripe, assign/promote -confirm.",
);
process.exit(0);
}
console.log(`${failures.length} failure(s):`);
for (const f of failures) console.log(` - ${f}`);
process.exit(1);