152 lines
5.0 KiB
JavaScript
152 lines
5.0 KiB
JavaScript
import { spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { compareDeploymentCommits, isRunningContainer } from "./deployment-proof-state.mjs";
|
|
|
|
const baseUrl = process.env.PROJECTBRAIN_DEPLOY_URL ?? process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:1209";
|
|
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 shouldWrite = process.argv.includes("--write") || process.env.PROJECTBRAIN_DEPLOY_PROOF_WRITE === "1";
|
|
const explicitOutputArg = process.argv.find((arg) => arg.startsWith("--out="));
|
|
const explicitOutputPath = explicitOutputArg?.slice("--out=".length) || process.env.PROJECTBRAIN_DEPLOY_PROOF_OUT;
|
|
|
|
const run = (command, args) => spawnSync(command, args, {
|
|
encoding: "utf8",
|
|
shell: false,
|
|
timeout: 10_000,
|
|
});
|
|
|
|
const getGitCommit = () => {
|
|
const result = run("git", ["rev-parse", "--short", "HEAD"]);
|
|
return result.status === 0 ? result.stdout.trim() : "unknown";
|
|
};
|
|
|
|
const createDefaultProofPath = (commit) => {
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
return `checkpoints/deployment-proof/projectbrain-deployment-proof-${commit}-${stamp}.md`;
|
|
};
|
|
|
|
const checkHttp = async (urlPath) => {
|
|
try {
|
|
const response = await fetch(`${baseUrl}${urlPath}`, { signal: AbortSignal.timeout(10_000) });
|
|
const body = await response.text();
|
|
|
|
return {
|
|
ok: response.ok,
|
|
status: String(response.status),
|
|
body: body.slice(0, 260).replace(/\s+/g, " ").trim(),
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
status: "unreachable",
|
|
body: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
};
|
|
|
|
const runSsh = (remoteCommand) => {
|
|
if (!fs.existsSync(sshKey)) {
|
|
return {
|
|
ok: false,
|
|
status: "missing-key",
|
|
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,
|
|
timeout: 20_000,
|
|
});
|
|
|
|
return {
|
|
ok: result.status === 0,
|
|
status: String(result.status),
|
|
stdout: result.stdout?.trim() ?? "",
|
|
stderr: result.stderr?.trim() || result.error?.message || "",
|
|
};
|
|
};
|
|
|
|
const currentCommit = getGitCommit();
|
|
const health = await checkHttp("/api/health");
|
|
const shellProof = runSsh([
|
|
`echo deployed_commit=$(git -C ${deployPath} rev-parse --short HEAD 2>/dev/null || echo unknown)`,
|
|
"echo containers_begin",
|
|
"docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep projectbrain || true",
|
|
"echo containers_end",
|
|
].join(" && "));
|
|
|
|
const deployedCommitMatch = shellProof.stdout.match(/^deployed_commit=(.+)$/m);
|
|
const deployedCommit = deployedCommitMatch?.[1]?.trim() ?? "unknown";
|
|
const containerBlock = shellProof.stdout.match(/containers_begin\n([\s\S]*?)\n?containers_end/)?.[1]?.trim() ?? "";
|
|
const hasFrontend = isRunningContainer(containerBlock, "projectbrain-frontend", "1209->80/tcp");
|
|
const hasApi = isRunningContainer(containerBlock, "projectbrain-api");
|
|
const hasPostgres = isRunningContainer(containerBlock, "projectbrain-postgres");
|
|
const commitFreshness = compareDeploymentCommits(currentCommit, deployedCommit);
|
|
const verdict = health.ok && shellProof.ok && commitFreshness === "current" && hasFrontend && hasApi && hasPostgres
|
|
? "PASS"
|
|
: "ATTENTION REQUIRED";
|
|
const proofPath = explicitOutputPath || createDefaultProofPath(currentCommit);
|
|
|
|
const lines = [
|
|
"# ProjectBrain Remote Deployment Proof",
|
|
"",
|
|
`Generated: ${new Date().toISOString()}`,
|
|
`Local commit: ${currentCommit}`,
|
|
`Deployment URL: ${baseUrl}`,
|
|
`SSH target: ${sshTarget}`,
|
|
`Deployment path: ${deployPath}`,
|
|
`Remote commit: ${deployedCommit}`,
|
|
`Commit freshness: ${commitFreshness}`,
|
|
`Verdict: ${verdict}`,
|
|
"",
|
|
"## HTTP Health",
|
|
"",
|
|
`- Status: ${health.status}`,
|
|
`- Result: ${health.ok ? "reachable" : "not reachable"}`,
|
|
`- Body: ${health.body}`,
|
|
"",
|
|
"## Remote Runtime",
|
|
"",
|
|
`- SSH result: ${shellProof.ok ? "reachable" : shellProof.status}`,
|
|
`- Frontend container: ${hasFrontend ? "up" : "not proven"}`,
|
|
`- API container: ${hasApi ? "up" : "not proven"}`,
|
|
`- Postgres container: ${hasPostgres ? "up" : "not proven"}`,
|
|
"",
|
|
"```text",
|
|
containerBlock || shellProof.stderr || "No remote container output captured.",
|
|
"```",
|
|
"",
|
|
"## Operator Commands",
|
|
"",
|
|
"```bash",
|
|
"PROJECTBRAIN_DEPLOY_PROOF_WRITE=1 pnpm run release:deployment-proof",
|
|
"PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF=1 pnpm run release:bundle",
|
|
"```",
|
|
].join("\n");
|
|
|
|
console.log(lines);
|
|
|
|
if (shouldWrite) {
|
|
fs.mkdirSync(path.dirname(proofPath), { recursive: true });
|
|
fs.writeFileSync(proofPath, `${lines}\n`, "utf8");
|
|
console.log(`\nRemote deployment proof written to ${proofPath}`);
|
|
}
|
|
|
|
if (verdict !== "PASS") {
|
|
process.exitCode = 1;
|
|
}
|