feat(deploy): add engine node intelligence transition

This commit is contained in:
Codex 2026-07-17 21:50:50 +03:00
parent 31e078d6e5
commit 02816c4352
4 changed files with 1993 additions and 5 deletions

View File

@ -253,6 +253,65 @@ become `custom` and remain exact; they are never elevated. The artifact does not
touch UI, n8n, credentials, agent tokens, workflow graphs, databases or runtime
payloads.
## Engine L2 node-intelligence transition
The node-intelligence transition is an additive Engine-owned deployment domain.
It does not merge Ontology, provider APIs or Ops into the Engine MCP. It pins the
reviewed upstream `n8n-mcp` implementation at package `2.33.2`, commit
`974a9fb3492fe2c4984ee0549085d531cdc6242a`, and exposes only the safe NDC L2
projection through the existing Engine Agent gateway. Upstream management and
write tools are not forwarded.
Production never clones, pulls, installs or builds this dependency. The builder
saves the reviewed `linux/amd64` image once, embeds that exact archive in a
data-only activation artifact and records both the archive and image-config
digests. The runner validates every inner blob, revision label, entrypoint,
command and platform before an offline `docker image load`; Compose then uses
the fixed tag with `pull_policy: never` and `--pull never`.
Build a fresh activation/rollback pair:
```bash
node infra/deploy-runner/build-engine-node-intelligence-artifacts.mjs \
YYYYMMDD-NNN
```
The activation exact set is:
- `nodedc-source/server/nodeIntelligence`
- `nodedc-source/server/routes/engineAgentGateway.js`
- `nodedc-source/services/node-intelligence`
The runtime adds only `nodedc-node-intelligence` and recreates the existing
`nodedc-backend`; n8n, UI, databases, L1, provider services and volumes are not
selected. The sidecar has no host port, runs as `11007:11007`, has a read-only
root filesystem, drops all capabilities and receives no Engine or provider
credential. Its independent MCP bearer is created by the root-owned runner as a
read-only file and mounted only into the sidecar and backend. It never appears
in an artifact, shared environment file, log or MCP response.
Acceptance requires the exact image/container/mount/security inventory,
immutable backend identity and live authenticated `get_node`, `validate_node`
and `validate_workflow` calls. Any apply failure restores source and runtime
automatically. The separate rollback artifact first removes only the sidecar
without volumes, restores the pinned inactive gateway, and recreates only the
verified backend. Loaded images and failed candidate source are retained for
audit rather than destructively deleted.
Stage the reviewed runner under a unique candidate name first:
```text
/volume1/docker/nodedc-deploy/runner-install/candidates/
nodedc-deploy.engine-node-intelligence-YYYYMMDD-NNN
```
Do not overwrite the canonical staging candidate while another deploy may be in
flight. After the no-lock/no-process gate, promote the exact candidate in a
standalone root step, run `verify-install`, then invoke a fresh process for
activation `plan` and only then `apply`. The generated plan/apply runbook carries
the runner, activation and rollback SHA-256 values and is staged beside the
unique runner candidate.
`module-foundry` is an independent, authenticated application component. Its
artifact contains source and compose infrastructure only; its live
`/volume1/docker/nodedc-platform/module-foundry/source/.env` is root-owned and

View File

@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,345 @@
#!/usr/bin/env python3
import hashlib
import importlib.machinery
import importlib.util
import json
import shutil
import tarfile
import tempfile
import unittest
from io import BytesIO
from pathlib import Path
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
ENGINE_ROOT = PLATFORM_ROOT.parent / "NODEDC_ENGINE_INFRA"
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_engine_node_intelligence_under_test",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
def sha256(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
class EngineNodeIntelligenceTest(unittest.TestCase):
def make_image_archive(self, root, runtime_env=None):
runtime_env = list(runtime_env or ["PATH=/usr/local/bin"])
config = {
"architecture": "amd64",
"os": "linux",
"config": {
"Labels": {
"org.opencontainers.image.revision": RUNNER.ENGINE_NODE_INTELLIGENCE_UPSTREAM_COMMIT,
},
"Entrypoint": ["/usr/local/bin/docker-entrypoint.sh"],
"Cmd": ["node", "dist/mcp/index.js"],
"Env": runtime_env,
},
}
config_raw = json.dumps(config, separators=(",", ":")).encode()
config_sha = hashlib.sha256(config_raw).hexdigest()
layer_raw = b"nodedc-node-intelligence-test-layer\n" * 64
layer_sha = hashlib.sha256(layer_raw).hexdigest()
manifest = [{
"Config": f"blobs/sha256/{config_sha}",
"RepoTags": [RUNNER.ENGINE_NODE_INTELLIGENCE_IMAGE],
"Layers": [f"blobs/sha256/{layer_sha}"],
}]
archive = root / "engine-node-intelligence.tar"
with tarfile.open(archive, "w") as output:
for name, value in (
("manifest.json", json.dumps(manifest, separators=(",", ":")).encode()),
(f"blobs/sha256/{config_sha}", config_raw),
(f"blobs/sha256/{layer_sha}", layer_raw),
):
member = tarfile.TarInfo(name)
member.size = len(value)
member.mode = 0o644
output.addfile(member, BytesIO(value))
return archive, config_sha
def make_activation_payload(self, root, runtime_env=None):
payload = root / "payload"
source = payload / RUNNER.ENGINE_NODE_INTELLIGENCE_SOURCE_REL
service = payload / RUNNER.ENGINE_NODE_INTELLIGENCE_SERVICE_ROOT_REL
gateway = payload / RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
source.mkdir(parents=True)
service.joinpath("image").mkdir(parents=True)
gateway.parent.mkdir(parents=True)
for name in ("catalog.js", "upstreamMcpClient.js", "upstreamProjection.js"):
shutil.copy2(
ENGINE_ROOT / RUNNER.ENGINE_NODE_INTELLIGENCE_SOURCE_REL / name,
source / name,
)
shutil.copy2(
ENGINE_ROOT / RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL,
gateway,
)
readme = service / "README.md"
readme.write_text("# exact node intelligence test\n", encoding="utf-8")
override = service / "docker-compose.immutable-runtime.yml"
override.write_text(
RUNNER.expected_engine_node_intelligence_compose_override(),
encoding="utf-8",
)
archive, config_sha = self.make_image_archive(root, runtime_env)
target_archive = payload / RUNNER.ENGINE_NODE_INTELLIGENCE_IMAGE_ARCHIVE_REL
shutil.copy2(archive, target_archive)
descriptor = {
"schemaVersion": "nodedc.engine-node-intelligence-transition/v1",
"action": "activate",
"releaseId": RUNNER.ENGINE_NODE_INTELLIGENCE_RELEASE_ID,
"expectedCurrent": "inactive",
"upstream": {
"package": "n8n-mcp",
"version": "2.33.2",
"commit": RUNNER.ENGINE_NODE_INTELLIGENCE_UPSTREAM_COMMIT,
},
"image": {
"tag": RUNNER.ENGINE_NODE_INTELLIGENCE_IMAGE,
"archiveRelativePath": RUNNER.ENGINE_NODE_INTELLIGENCE_IMAGE_ARCHIVE_REL,
"archiveSha256": sha256(target_archive),
"configSha256": config_sha,
"architecture": "amd64",
"os": "linux",
},
"source": {
"gatewaySha256": sha256(gateway),
"catalogSha256": sha256(source / "catalog.js"),
"upstreamClientSha256": sha256(source / "upstreamMcpClient.js"),
"upstreamProjectionSha256": sha256(source / "upstreamProjection.js"),
"composeOverrideSha256": sha256(override),
"readmeSha256": sha256(readme),
},
"predecessor": {
"gatewaySha256": RUNNER.ENGINE_NODE_INTELLIGENCE_PREDECESSOR_GATEWAY_SHA256,
"composeSha256": RUNNER.ENGINE_NODE_INTELLIGENCE_PREDECESSOR_COMPOSE_SHA256,
"backendRuntime": "verified-derived-retry",
},
}
(payload / RUNNER.ENGINE_NODE_INTELLIGENCE_DESCRIPTOR_REL).write_text(
json.dumps(descriptor, indent=2) + "\n",
encoding="utf-8",
)
return payload, descriptor
def test_activation_payload_and_offline_image_are_exact(self):
with tempfile.TemporaryDirectory() as directory:
payload, descriptor = self.make_activation_payload(Path(directory))
actual = RUNNER.validate_engine_node_intelligence_transition(
payload,
RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
)
self.assertEqual(actual, descriptor)
def test_archive_tamper_and_embedded_authority_fail_closed(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
payload, descriptor = self.make_activation_payload(root)
archive = payload / RUNNER.ENGINE_NODE_INTELLIGENCE_IMAGE_ARCHIVE_REL
archive.write_bytes(archive.read_bytes() + b"tamper")
with self.assertRaisesRegex(RUNNER.DeployError, "archive sha256 mismatch"):
RUNNER.validate_engine_node_intelligence_transition(
payload,
RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
)
with tempfile.TemporaryDirectory() as directory:
payload, _descriptor = self.make_activation_payload(
Path(directory),
runtime_env=["AUTH_TOKEN=forbidden"],
)
with self.assertRaisesRegex(RUNNER.DeployError, "contains runtime authority"):
RUNNER.validate_engine_node_intelligence_transition(
payload,
RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
)
def test_payload_extra_file_and_wrong_entry_order_are_rejected(self):
with tempfile.TemporaryDirectory() as directory:
payload, _descriptor = self.make_activation_payload(Path(directory))
service = payload / RUNNER.ENGINE_NODE_INTELLIGENCE_SERVICE_ROOT_REL
(service / "unexpected.txt").write_text("no\n", encoding="utf-8")
with self.assertRaisesRegex(RUNNER.DeployError, "service payload file set mismatch"):
RUNNER.validate_engine_node_intelligence_transition(
payload,
RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
)
with tempfile.TemporaryDirectory() as directory:
payload, _descriptor = self.make_activation_payload(Path(directory))
with self.assertRaisesRegex(RUNNER.DeployError, "files.txt exact set/order mismatch"):
RUNNER.validate_engine_node_intelligence_transition(
payload,
tuple(reversed(RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES)),
)
def test_predecessor_drift_is_rejected_before_apply(self):
with tempfile.TemporaryDirectory() as payload_dir, tempfile.TemporaryDirectory() as live_dir:
payload, _descriptor = self.make_activation_payload(Path(payload_dir))
live = Path(live_dir)
(live / "nodedc-source/server/routes").mkdir(parents=True)
(live / "docker-compose.yml").write_bytes(
(ENGINE_ROOT / "docker-compose.yml").read_bytes()
)
baseline = bytes.fromhex("00")
gateway = live / RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
gateway.write_bytes(baseline)
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
RUNNER.COMPONENTS["engine"]["payload_root"] = live
try:
with mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
):
with self.assertRaisesRegex(RUNNER.DeployError, "gateway drift detected"):
RUNNER.preflight_engine_node_intelligence_predecessor(payload)
finally:
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
def test_service_selection_and_compose_dispatch_are_narrow(self):
self.assertEqual(
RUNNER.component_services(
"engine",
RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
),
(RUNNER.ENGINE_NODE_INTELLIGENCE_SERVICE, "nodedc-backend"),
)
self.assertEqual(
RUNNER.component_services(
"engine",
RUNNER.ENGINE_NODE_INTELLIGENCE_ROLLBACK_ENTRIES,
),
("nodedc-backend",),
)
with (
mock.patch.object(RUNNER, "run_build") as build,
mock.patch.object(RUNNER, "prepare_component_runtime") as prepare,
mock.patch.object(RUNNER, "run_engine_node_intelligence_compose") as exact_compose,
mock.patch.object(RUNNER, "run_compose") as legacy_compose,
):
RUNNER.run_component_runtime(
"engine",
RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
(RUNNER.ENGINE_NODE_INTELLIGENCE_SERVICE, "nodedc-backend"),
)
build.assert_called_once()
prepare.assert_called_once()
exact_compose.assert_called_once()
legacy_compose.assert_not_called()
def test_exact_compose_never_recreates_dependencies(self):
services = (RUNNER.ENGINE_NODE_INTELLIGENCE_SERVICE, "nodedc-backend")
with (
mock.patch.object(
RUNNER,
"compose_base_cmd",
return_value=["docker", "compose", "-f", "exact.yml"],
),
mock.patch.object(
RUNNER,
"component_compose_root",
return_value=Path("/exact-engine-root"),
),
mock.patch.object(RUNNER.subprocess, "run") as execute,
):
RUNNER.run_engine_node_intelligence_compose(
services,
RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
)
command = execute.call_args_list[0].args[0]
self.assertIn("--pull", command)
self.assertIn("never", command)
self.assertIn("--no-deps", command)
self.assertEqual(command[-2:], list(services))
def test_backend_additive_mount_projects_into_legacy_guard(self):
descriptor = {"action": "activate"}
baseline_mounts = [
{
"Type": "bind",
"Source": "/baseline/app",
"Destination": "/app",
"RW": True,
},
]
node_mount = {
"Type": "bind",
"Source": str(RUNNER.ENGINE_NODE_INTELLIGENCE_SECRET_FILE),
"Destination": RUNNER.ENGINE_NODE_INTELLIGENCE_SECRET_CONTAINER_PATH,
"RW": False,
}
container = {"Mounts": [*baseline_mounts, node_mount]}
with (
mock.patch.object(
RUNNER,
"current_engine_node_intelligence_descriptor",
return_value=descriptor,
),
mock.patch.object(
RUNNER,
"validate_installed_engine_node_intelligence_source",
) as source_guard,
mock.patch.object(
RUNNER,
"validate_engine_backend_mounts_for_installed_runtime",
) as legacy_guard,
):
RUNNER.validate_engine_backend_node_intelligence_mounts_for_installed_runtime(
container,
node_modules_read_only=True,
)
source_guard.assert_called_once_with(descriptor)
projected = legacy_guard.call_args.args[0]
self.assertEqual(projected["Mounts"], baseline_mounts)
self.assertTrue(legacy_guard.call_args.args[1])
def test_backend_additive_mount_rejects_source_or_mode_drift(self):
descriptor = {"action": "activate"}
for source, read_write in (("/wrong/source", False), (str(RUNNER.ENGINE_NODE_INTELLIGENCE_SECRET_FILE), True)):
container = {
"Mounts": [{
"Type": "bind",
"Source": source,
"Destination": RUNNER.ENGINE_NODE_INTELLIGENCE_SECRET_CONTAINER_PATH,
"RW": read_write,
}],
}
with (
mock.patch.object(
RUNNER,
"current_engine_node_intelligence_descriptor",
return_value=descriptor,
),
mock.patch.object(
RUNNER,
"validate_installed_engine_node_intelligence_source",
),
):
with self.assertRaisesRegex(
RUNNER.DeployError,
"node-intelligence mount barrier mismatch",
):
RUNNER.validate_engine_backend_node_intelligence_mounts_for_installed_runtime(
container,
node_modules_read_only=True,
)
if __name__ == "__main__":
unittest.main()