228 lines
7.8 KiB
JavaScript
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;
|
|
}
|