#!/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; }