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; }