Files
projectbrain-Public/scripts/release-rollback-drill.mjs
T

228 lines
7.8 KiB
JavaScript

import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const baseUrl = process.env.PROJECTBRAIN_DEPLOY_URL ?? process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:1209";
const gitRemote = process.env.PROJECTBRAIN_RELEASE_REMOTE ?? "gitea";
const gitBranch = process.env.PROJECTBRAIN_RELEASE_BRANCH ?? "main";
const sshTarget = process.env.PROJECTBRAIN_DEPLOY_SSH_TARGET ?? "root@127.0.0.1";
const deployPath = process.env.PROJECTBRAIN_DEPLOY_PATH ?? "/mnt/pool/appdata/brain";
const sshKey = process.env.PROJECTBRAIN_DEPLOY_SSH_KEY ?? path.join(os.homedir(), ".ssh", "widefrog_unraid_deploy");
const requestedTag = process.env.PROJECTBRAIN_ROLLBACK_TAG;
const shouldWrite = process.argv.includes("--write") || process.env.PROJECTBRAIN_ROLLBACK_DRILL_WRITE === "1";
const explicitOutputArg = process.argv.find((arg) => arg.startsWith("--out="));
const explicitOutputPath = explicitOutputArg?.slice("--out=".length) || process.env.PROJECTBRAIN_ROLLBACK_DRILL_OUT;
const run = (command, args, options = {}) => spawnSync(command, args, {
encoding: "utf8",
shell: process.platform === "win32",
...options,
});
const git = (args) => {
const result = spawnSync("git", args, {
encoding: "utf8",
shell: false,
});
return {
ok: result.status === 0,
value: result.stdout.trim(),
error: result.stderr.trim(),
};
};
const runSsh = (remoteCommand) => {
if (!fs.existsSync(sshKey)) {
return {
ok: false,
stdout: "",
stderr: `SSH key not found: ${sshKey}`,
};
}
const result = spawnSync("ssh", [
"-i",
sshKey,
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=8",
sshTarget,
remoteCommand,
], {
encoding: "utf8",
shell: false,
});
return {
ok: result.status === 0,
stdout: result.stdout.trim(),
stderr: result.stderr.trim(),
};
};
const checkHttp = async (urlPath) => {
try {
const response = await fetch(`${baseUrl}${urlPath}`);
const body = await response.text();
return {
ok: response.ok,
status: String(response.status),
body: body.slice(0, 220).replace(/\s+/g, " ").trim(),
};
} catch (error) {
return {
ok: false,
status: "unreachable",
body: error instanceof Error ? error.message : String(error),
};
}
};
const latestReleaseTag = () => {
const result = git(["tag", "--list", "projectbrain-rc-*", "--sort=-creatordate"]);
return result.value.split(/\r?\n/).find(Boolean) ?? "";
};
const createDefaultReportPath = (tagName) => {
const safeTag = tagName.replace(/[^a-zA-Z0-9._-]/g, "-");
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return `checkpoints/rollback-drill/projectbrain-rollback-drill-${safeTag}-${stamp}.md`;
};
run("git", ["fetch", "--tags", gitRemote]);
const tagName = requestedTag || latestReleaseTag();
const currentCommit = git(["rev-parse", "--short", "HEAD"]).value || "unknown";
const tagCommit = tagName ? git(["rev-parse", "--short", `${tagName}^{commit}`]) : { ok: false, value: "", error: "No release tag found." };
const tagFullCommit = tagName ? git(["rev-parse", `${tagName}^{commit}`]) : { ok: false, value: "", error: "No release tag found." };
const remoteTag = tagName ? run("git", ["ls-remote", "--tags", gitRemote, tagName]) : { status: 1, stdout: "", stderr: "No release tag found." };
const status = git(["status", "--porcelain"]);
const health = await checkHttp("/api/health");
const tower = tagName
? runSsh([
`git -C ${deployPath} fetch --tags ${gitRemote}`,
`echo tower_head=$(git -C ${deployPath} rev-parse --short HEAD 2>/dev/null || echo unknown)`,
`echo tower_tag=$(git -C ${deployPath} rev-parse --short ${tagName}^{commit} 2>/dev/null || echo unknown)`,
"echo containers_begin",
"docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep projectbrain || true",
"echo containers_end",
].join(" && "))
: { ok: false, stdout: "", stderr: "No release tag found." };
const towerHead = tower.stdout.match(/^tower_head=(.+)$/m)?.[1]?.trim() ?? "unknown";
const towerTag = tower.stdout.match(/^tower_tag=(.+)$/m)?.[1]?.trim() ?? "unknown";
const containerBlock = tower.stdout.match(/containers_begin\n([\s\S]*?)\n?containers_end/)?.[1]?.trim() ?? "";
const hasFrontend = containerBlock.includes("projectbrain-frontend") && containerBlock.includes("1209->80/tcp");
const hasApi = containerBlock.includes("projectbrain-api") && /projectbrain-api\s+Up/.test(containerBlock);
const hasPostgres = containerBlock.includes("projectbrain-postgres") && /projectbrain-postgres\s+Up/.test(containerBlock);
const reportPath = createDefaultReportPath(tagName || "missing-tag");
const outputPath = explicitOutputPath || reportPath;
const gates = [
{
label: "Release tag selected",
ok: Boolean(tagName),
detail: tagName || "missing",
},
{
label: "Local release tag resolves",
ok: Boolean(tagName) && tagCommit.ok,
detail: tagCommit.value || tagCommit.error || "missing",
},
{
label: "Gitea release tag exists",
ok: remoteTag.status === 0 && remoteTag.stdout.includes(`refs/tags/${tagName}`),
detail: remoteTag.status === 0 ? "present" : remoteTag.stderr.trim(),
},
{
label: "Tower release tag resolves",
ok: tower.ok && towerTag === tagCommit.value,
detail: `${towerTag} tower / ${tagCommit.value || "unknown"} local`,
},
{
label: "Tower HEAD captured",
ok: tower.ok && towerHead !== "unknown",
detail: `${towerHead} tower / ${tagCommit.value || "unknown"} rollback target`,
},
{
label: "Tracked worktree clean",
ok: status.ok && status.value.trim() === "",
detail: status.value.trim() || "clean",
},
{
label: "Live health reachable",
ok: health.ok,
detail: `${health.status} ${health.body}`,
},
{
label: "ProjectBrain containers running",
ok: hasFrontend && hasApi && hasPostgres,
detail: `frontend=${hasFrontend ? "up" : "missing"} api=${hasApi ? "up" : "missing"} postgres=${hasPostgres ? "up" : "missing"}`,
},
];
const passed = gates.every((gate) => gate.ok);
const rollbackCommand = `ssh -i ~/.ssh/widefrog_unraid_deploy ${sshTarget} "cd ${deployPath} && git fetch --tags ${gitRemote} && git switch --detach ${tagName} && docker compose build --no-cache frontend && docker compose up -d"`;
const restoreMainCommand = `ssh -i ~/.ssh/widefrog_unraid_deploy ${sshTarget} "cd ${deployPath} && git switch ${gitBranch} && git pull --ff-only && docker compose build --no-cache frontend && docker compose up -d"`;
const lines = [
"# ProjectBrain Rollback Drill",
"",
`Generated: ${new Date().toISOString()}`,
`Selected tag: ${tagName || "missing"}`,
`Tag commit: ${tagCommit.value || "unknown"}`,
`Current commit: ${currentCommit}`,
`Tower HEAD: ${towerHead}`,
`Deployment URL: ${baseUrl}`,
`Verdict: ${passed ? "PASS" : "ATTENTION REQUIRED"}`,
"",
"## Gates",
"",
"| Gate | Result | Detail |",
"| --- | --- | --- |",
...gates.map((gate) => `| ${gate.label} | ${gate.ok ? "PASS" : "FAIL"} | ${String(gate.detail).replace(/\|/g, "\\|")} |`),
"",
"## Container Snapshot",
"",
"```text",
containerBlock || tower.stderr || "No container output captured.",
"```",
"",
"## Rollback Commands",
"",
"These commands are printed for operator review. This drill does not execute checkout, rebuild or rollback.",
"",
"Rollback/redeploy selected tag:",
"",
"```bash",
rollbackCommand,
"```",
"",
"Restore main after drill:",
"",
"```bash",
restoreMainCommand,
"```",
"",
"## Local Verification",
"",
"```bash",
"pnpm run release:deployment-proof",
"pnpm run release:tag-gate",
"```",
].join("\n");
console.log(lines);
if (shouldWrite) {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, `${lines}\n`, "utf8");
console.log(`\nRollback drill report written to ${outputPath}`);
}
if (!passed) {
process.exitCode = 1;
}