feat(deploy): add engine node intelligence transition
This commit is contained in:
parent
31e078d6e5
commit
02816c4352
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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()
|
||||
Loading…
Reference in New Issue