415 lines
16 KiB
JavaScript
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;
|
|
}
|