NODEDC_PLATFORM/infra/deploy-runner/build-engine-node-intellige...

415 lines
16 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { createReadStream } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(here, "../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_ROOT || join(platformRoot, "../NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(here, "../deploy-artifacts"),
);
const releaseId = "2.33.2-974a9fb3492f";
const upstreamCommit = "974a9fb3492fe2c4984ee0549085d531cdc6242a";
const imageTag = `nodedc/engine-node-intelligence:${releaseId}`;
const predecessorGatewaySha256 = "6b8c80fa997ef7c438d199a6ee942c5e37aebc4057667af417897fa636131db4";
const predecessorComposeSha256 = "258cebb64ff1943c939655cc55bdce00fc5c4dced67ec291d84d6df066ace50e";
const sourceRootRel = "nodedc-source/server/nodeIntelligence";
const gatewayRel = "nodedc-source/server/routes/engineAgentGateway.js";
const serviceRootRel = "nodedc-source/services/node-intelligence";
const overrideRel = `${serviceRootRel}/docker-compose.immutable-runtime.yml`;
const descriptorRel = `${serviceRootRel}/activation.json`;
const imageArchiveRel = `${serviceRootRel}/image/engine-node-intelligence.tar`;
const activationEntries = [sourceRootRel, gatewayRel, serviceRootRel];
const rollbackEntries = [gatewayRel, serviceRootRel];
const transitionId = readTransitionId(process.argv.slice(2));
const activationId = `engine-node-intelligence-${transitionId}`;
const rollbackId = `engine-node-intelligence-rollback-${transitionId}`;
const activationTarget = join(artifactRoot, `nodedc-${activationId}.tgz`);
const rollbackTarget = join(artifactRoot, `nodedc-${rollbackId}.tgz`);
const commandsTarget = join(
artifactRoot,
`engine-node-intelligence-${transitionId}-plan-apply.txt`,
);
await mkdir(artifactRoot, { recursive: true });
for (const path of [
activationTarget,
`${activationTarget}.sha256`,
rollbackTarget,
`${rollbackTarget}.sha256`,
commandsTarget,
]) {
await assertFresh(path);
}
await assertEngineInputs();
const image = inspectLocalImage();
const work = await mkdtemp(join(tmpdir(), "nodedc-engine-node-intelligence-"));
try {
const imageArchive = join(work, "engine-node-intelligence.tar");
run("docker", ["image", "save", "--output", imageArchive, imageTag]);
const archive = inspectImageArchive(imageArchive);
const archiveSha256 = await hashFile(imageArchive);
const source = {
gatewaySha256: await hashFile(join(engineRoot, gatewayRel)),
catalogSha256: await hashFile(join(engineRoot, sourceRootRel, "catalog.js")),
upstreamClientSha256: await hashFile(join(engineRoot, sourceRootRel, "upstreamMcpClient.js")),
upstreamProjectionSha256: await hashFile(join(engineRoot, sourceRootRel, "upstreamProjection.js")),
composeOverrideSha256: await hashFile(join(engineRoot, overrideRel)),
readmeSha256: await hashFile(join(engineRoot, serviceRootRel, "README.md")),
};
const activationDescriptor = descriptor({
action: "activate",
expectedCurrent: "inactive",
gatewayPredecessor: predecessorGatewaySha256,
source,
image: {
tag: imageTag,
archiveRelativePath: imageArchiveRel,
archiveSha256,
configSha256: archive.configSha256,
architecture: "amd64",
os: "linux",
},
});
await buildArtifact(
work,
activationId,
activationEntries,
activationTarget,
async (payload) => {
await copyExactDirectory(join(engineRoot, sourceRootRel), join(payload, sourceRootRel));
await copyExactFile(join(engineRoot, gatewayRel), join(payload, gatewayRel));
await copyExactFile(join(engineRoot, serviceRootRel, "README.md"), join(payload, serviceRootRel, "README.md"));
await copyExactFile(join(engineRoot, overrideRel), join(payload, overrideRel));
await copyExactFile(imageArchive, join(payload, imageArchiveRel));
await writeJson(join(payload, descriptorRel), activationDescriptor);
},
);
const rollbackReadme = [
"# NDC Engine node intelligence (inactive rollback)",
"",
`This exact runner-owned descriptor deactivates ${releaseId}.`,
"The pinned image and source are retained for audit; the Compose overlay is absent,",
"the sidecar is removed, and the verified immutable backend is recreated alone.",
"",
].join("\n");
const rollbackReadmeSha256 = hashBytes(Buffer.from(rollbackReadme, "utf8"));
const rollbackDescriptor = descriptor({
action: "rollback-inactive",
expectedCurrent: releaseId,
gatewayPredecessor: source.gatewaySha256,
source: {
gatewaySha256: predecessorGatewaySha256,
readmeSha256: rollbackReadmeSha256,
},
image: {
tag: imageTag,
configSha256: archive.configSha256,
architecture: "amd64",
os: "linux",
},
});
await buildArtifact(
work,
rollbackId,
rollbackEntries,
rollbackTarget,
async (payload) => {
await writeGitFile(gatewayRel, join(payload, gatewayRel));
await mkdir(join(payload, serviceRootRel), { recursive: true });
await writeFile(join(payload, serviceRootRel, "README.md"), rollbackReadme, "utf8");
await writeJson(join(payload, descriptorRel), rollbackDescriptor);
},
);
const runnerSha256 = await hashFile(join(here, "nodedc-deploy"));
const activationSha256 = await hashFile(activationTarget);
const rollbackSha256 = await hashFile(rollbackTarget);
await writeFile(
`${activationTarget}.sha256`,
`${activationSha256} ${activationTarget.split("/").at(-1)}\n`,
"utf8",
);
await writeFile(
`${rollbackTarget}.sha256`,
`${rollbackSha256} ${rollbackTarget.split("/").at(-1)}\n`,
"utf8",
);
await writeFile(
commandsTarget,
commandPlan({ runnerSha256, activationSha256, rollbackSha256 }),
"utf8",
);
process.stdout.write(`${JSON.stringify({
ok: true,
transitionId,
releaseId,
upstreamCommit,
image: {
tag: imageTag,
localId: image.Id,
archiveSha256,
configSha256: archive.configSha256,
architecture: archive.architecture,
os: archive.os,
},
runner: { sha256: runnerSha256 },
activation: { artifact: activationTarget, sha256: activationSha256, entries: activationEntries },
rollback: { artifact: rollbackTarget, sha256: rollbackSha256, entries: rollbackEntries },
commands: commandsTarget,
}, null, 2)}\n`);
} finally {
await rm(work, { recursive: true, force: true });
}
function readTransitionId(args) {
if (args.length !== 1) throw new Error("usage: build-engine-node-intelligence-artifacts.mjs YYYYMMDD-NNN");
const value = String(args[0] || "").trim();
const match = /^(\d{4})(\d{2})(\d{2})-([0-9]{3})$/.exec(value);
if (!match || match[4] === "000") throw new Error("transition_id_invalid");
const parsed = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
if (
parsed.getUTCFullYear() !== Number(match[1])
|| parsed.getUTCMonth() !== Number(match[2]) - 1
|| parsed.getUTCDate() !== Number(match[3])
) throw new Error("transition_id_invalid");
return value;
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error(`target_already_exists:${path}`);
}
async function assertEngineInputs() {
const baselineGateway = Buffer.from(run("git", ["show", `HEAD:${gatewayRel}`], engineRoot).stdout, "utf8");
if (hashBytes(baselineGateway) !== predecessorGatewaySha256) throw new Error("gateway_predecessor_mismatch");
if (await hashFile(join(engineRoot, "docker-compose.yml")) !== predecessorComposeSha256) {
throw new Error("compose_predecessor_mismatch");
}
const sourceFiles = (await readdir(join(engineRoot, sourceRootRel))).sort();
if (JSON.stringify(sourceFiles) !== JSON.stringify(["catalog.js", "upstreamMcpClient.js", "upstreamProjection.js"])) {
throw new Error("node_intelligence_source_exact_set_mismatch");
}
const serviceFiles = (await readdir(join(engineRoot, serviceRootRel))).sort();
if (JSON.stringify(serviceFiles) !== JSON.stringify(["README.md", "docker-compose.immutable-runtime.yml"])) {
throw new Error("node_intelligence_service_exact_set_mismatch");
}
const gateway = await readFile(join(engineRoot, gatewayRel), "utf8");
for (const tool of [
"engine_get_node_intelligence_status",
"engine_get_node_guidance",
"engine_validate_node_configuration",
"engine_validate_l2_deep",
]) {
if (!gateway.includes(tool)) throw new Error(`gateway_tool_missing:${tool}`);
}
const override = await readFile(join(engineRoot, overrideRel), "utf8");
for (const exact of [
`image: ${imageTag}`,
"pull_policy: never",
'user: "11007:11007"',
"AUTH_TOKEN_FILE: /run/nodedc-secrets/engine-node-intelligence-auth-token",
"ENGINE_NODE_INTELLIGENCE_AUTH_TOKEN_FILE: /run/nodedc-secrets/engine-node-intelligence-auth-token",
"read_only: true",
"no-new-privileges:true",
]) {
if (!override.includes(exact)) throw new Error(`compose_contract_missing:${exact}`);
}
if (/^\s*(?:AUTH_TOKEN|N8N_API_URL|N8N_API_KEY):/m.test(override)) {
throw new Error("compose_runtime_authority_forbidden");
}
}
function inspectLocalImage() {
const raw = run("docker", ["image", "inspect", imageTag]).stdout;
const images = JSON.parse(raw);
if (!Array.isArray(images) || images.length !== 1) throw new Error("image_inspect_shape_mismatch");
const image = images[0];
const config = image.Config || {};
const labels = config.Labels || {};
if (
!/^sha256:[a-f0-9]{64}$/.test(String(image.Id || ""))
|| image.Architecture !== "amd64"
|| image.Os !== "linux"
|| !(image.RepoTags || []).includes(imageTag)
|| labels["org.opencontainers.image.revision"] !== upstreamCommit
|| JSON.stringify(config.Entrypoint) !== JSON.stringify(["/usr/local/bin/docker-entrypoint.sh"])
|| JSON.stringify(config.Cmd) !== JSON.stringify(["node", "dist/mcp/index.js"])
|| (config.Env || []).some((value) => /^(?:AUTH_TOKEN|N8N_API_URL|N8N_API_KEY)=/.test(String(value)))
) throw new Error("image_identity_mismatch");
return image;
}
function inspectImageArchive(path) {
const script = [
"import hashlib,json,pathlib,sys,tarfile",
"p=pathlib.Path(sys.argv[1])",
"with tarfile.open(p,'r:') as tf:",
" m=json.loads(tf.extractfile('manifest.json').read())",
" if not isinstance(m,list) or len(m)!=1: raise SystemExit('manifest-set')",
" r=m[0]",
` if r.get('RepoTags')!=[${JSON.stringify(imageTag)}]: raise SystemExit('tag')`,
" n=r.get('Config','')",
" raw=tf.extractfile(n).read()",
" digest=n.rsplit('/',1)[-1]",
" if hashlib.sha256(raw).hexdigest()!=digest: raise SystemExit('config-digest')",
" c=json.loads(raw)",
" cfg=c.get('config') or {}",
` if c.get('architecture')!='amd64' or c.get('os')!='linux' or (cfg.get('Labels') or {}).get('org.opencontainers.image.revision')!=${JSON.stringify(upstreamCommit)}: raise SystemExit('identity')`,
" if cfg.get('Entrypoint')!=['/usr/local/bin/docker-entrypoint.sh'] or cfg.get('Cmd')!=['node','dist/mcp/index.js']: raise SystemExit('command')",
" print(json.dumps({'configSha256':digest,'architecture':c['architecture'],'os':c['os']}))",
].join("\n");
return JSON.parse(run("python3", ["-c", script, path]).stdout);
}
function descriptor({ action, expectedCurrent, gatewayPredecessor, source, image }) {
return {
schemaVersion: "nodedc.engine-node-intelligence-transition/v1",
action,
releaseId,
expectedCurrent,
upstream: {
package: "n8n-mcp",
version: "2.33.2",
commit: upstreamCommit,
},
image,
source,
predecessor: {
gatewaySha256: gatewayPredecessor,
composeSha256: predecessorComposeSha256,
backendRuntime: "verified-derived-retry",
},
};
}
async function buildArtifact(workRoot, id, entries, target, populate) {
const stage = join(workRoot, id);
const payload = join(stage, "payload");
await mkdir(payload, { recursive: true });
await populate(payload);
await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
run("python3", ["-c", canonicalTarScript(), target, stage]);
}
async function copyExactDirectory(source, target) {
await mkdir(target, { recursive: true });
for (const name of (await readdir(source)).sort()) {
await copyExactFile(join(source, name), join(target, name));
}
}
async function copyExactFile(source, target) {
const sourceStat = await lstat(source);
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) throw new Error(`source_file_unsafe:${source}`);
await mkdir(dirname(target), { recursive: true });
await copyFile(source, target, 0);
}
async function writeGitFile(relativePath, target) {
const value = run("git", ["show", `HEAD:${relativePath}`], engineRoot).stdout;
await mkdir(dirname(target), { recursive: true });
await writeFile(target, value, "utf8");
}
async function writeJson(path, value) {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function commandPlan({ runnerSha256, activationSha256, rollbackSha256 }) {
return [
`transition=${transitionId}`,
`runner_sha256=${runnerSha256}`,
`activation_sha256=${activationSha256}`,
`rollback_sha256=${rollbackSha256}`,
"",
"# 1. Read-only gate: no deploy process and no state/deploy.lock.",
"# 2. Promote the exact staged runner in a standalone sudo step:",
`sudo sha256sum /volume1/docker/nodedc-deploy/runner-install/candidates/nodedc-deploy.${activationId}`,
"sudo install -o root -g root -m 0755 \\",
` /volume1/docker/nodedc-deploy/runner-install/candidates/nodedc-deploy.${activationId} \\`,
" /usr/local/sbin/nodedc-deploy",
"sudo /usr/local/sbin/nodedc-deploy verify-install",
"",
"# 3. Activation is always plan, then explicit apply:",
`sudo /usr/local/sbin/nodedc-deploy plan /volume1/docker/nodedc-deploy/inbox/${activationTarget.split("/").at(-1)}`,
`sudo /usr/local/sbin/nodedc-deploy apply /volume1/docker/nodedc-deploy/inbox/${activationTarget.split("/").at(-1)}`,
"",
"# 4. Rollback is operator-invoked only after its own plan:",
`sudo /usr/local/sbin/nodedc-deploy plan /volume1/docker/nodedc-deploy/inbox/${rollbackTarget.split("/").at(-1)}`,
`sudo /usr/local/sbin/nodedc-deploy apply /volume1/docker/nodedc-deploy/inbox/${rollbackTarget.split("/").at(-1)}`,
"",
].join("\n");
}
function hashBytes(value) {
return createHash("sha256").update(value).digest("hex");
}
function hashFile(path) {
return new Promise((resolveHash, rejectHash) => {
const digest = createHash("sha256");
const input = createReadStream(path);
input.on("data", (chunk) => digest.update(chunk));
input.on("error", rejectHash);
input.on("end", () => resolveHash(digest.digest("hex")));
});
}
function run(command, args, cwd) {
const result = spawnSync(command, args, {
cwd,
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
return result;
}