375 lines
16 KiB
Python
375 lines
16 KiB
Python
#!/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_gateway_accepts_registered_0_8_and_rejects_unknown_successors(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
payload, descriptor = self.make_activation_payload(Path(directory))
|
|
gateway = payload / RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
|
|
gateway_text = gateway.read_text(encoding="utf-8")
|
|
self.assertIn("const ENGINE_AGENT_MCP_VERSION = '0.8.0'", gateway_text)
|
|
gateway.write_text(
|
|
gateway_text.replace(
|
|
"const ENGINE_AGENT_MCP_VERSION = '0.8.0'",
|
|
"const ENGINE_AGENT_MCP_VERSION = '9.9.9'",
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
descriptor["source"]["gatewaySha256"] = sha256(gateway)
|
|
(
|
|
payload / RUNNER.ENGINE_NODE_INTELLIGENCE_DESCRIPTOR_REL
|
|
).write_text(
|
|
json.dumps(descriptor, indent=2) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
with self.assertRaisesRegex(
|
|
RUNNER.DeployError,
|
|
"gateway MCP version is not registered",
|
|
):
|
|
RUNNER.validate_engine_node_intelligence_transition(
|
|
payload,
|
|
RUNNER.ENGINE_NODE_INTELLIGENCE_ARTIFACT_ENTRIES,
|
|
)
|
|
|
|
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()
|