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
|
touch UI, n8n, credentials, agent tokens, workflow graphs, databases or runtime
|
||||||
payloads.
|
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
|
`module-foundry` is an independent, authenticated application component. Its
|
||||||
artifact contains source and compose infrastructure only; its live
|
artifact contains source and compose infrastructure only; its live
|
||||||
`/volume1/docker/nodedc-platform/module-foundry/source/.env` is root-owned and
|
`/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