feat(deploy): add engine node intelligence transition
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
#!/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;
|
||||
}
|
||||
Reference in New Issue
Block a user