607 lines
28 KiB
Python
607 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
import importlib.machinery
|
|
import importlib.util
|
|
import hashlib
|
|
import inspect
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tarfile
|
|
import tempfile
|
|
import unittest
|
|
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"
|
|
BUILDER_PATH = SCRIPT_DIR / "build-engine-n8n-private-extension-artifact.mjs"
|
|
STAGE_ARTIFACT = (
|
|
PLATFORM_ROOT
|
|
/ "infra/deploy-artifacts"
|
|
/ "nodedc-n8n-private-extension-n8n-nodes-ndc-release-20260716-003.tgz"
|
|
)
|
|
TRANSITION_ID = "20260717-004"
|
|
SEALED_RELEASE_ID = "0.1.2-05e4b38b14b4a019"
|
|
SEALED_PACKAGE_SHA256 = "05e4b38b14b4a019ce1f6eee27b9e320094cb3560903bd68074966b3a1267af5"
|
|
BUILDER_ENGINE_PATHS = (
|
|
"nodedc-source/server/assets/n8n/schema/v2.3.2/nodes.catalog.json",
|
|
"nodedc-source/server/assets/n8n/schema/v2.3.2/credentials.catalog.json",
|
|
"nodedc-source/server/assets/n8n/schema/v2.3.2/meta.json",
|
|
"nodedc-source/server/assets/n8n/icons/ndc.svg",
|
|
"nodedc-source/server/assets/n8n/icons/ndc.dark.svg",
|
|
"nodedc-source/services/n8n/private-extensions/ndc-activation.json",
|
|
"nodedc-source/services/n8n/private-extensions/docker-compose.ndc-private-extension.yml",
|
|
)
|
|
EXPECTED_NODES = [
|
|
"n8n-nodes-ndc.ndcDataProductPublish",
|
|
"n8n-nodes-ndc.ndcDataProductRead",
|
|
"n8n-nodes-ndc.ndcFoundryBinding",
|
|
]
|
|
EXPECTED_CREDENTIALS = [
|
|
"ndcDataProductWriterApi",
|
|
"ndcDataProductReaderApi",
|
|
"ndcFoundryBindingApi",
|
|
]
|
|
PRIVATE_EXTENSION_CANON_FUNCTION_SHA256 = {
|
|
"expected_engine_n8n_compose_override": "8ee93f3e7c407f5557c711119236449a440d73a7fcac1bbfa50a09e6a13c1cff",
|
|
"engine_n8n_package_loader_probe_script": "2b3b3c96fad6e5e48ebea74426b21bc7eea6b6fe0e786b537da3366d18aedd9b",
|
|
"engine_n8n_private_loader_catalog": "a2ae389b4ef215900cf967b863d01056bd2a8eec55554566d0f6005e4e0bcbac",
|
|
"validate_engine_n8n_base_compose_source": "adacc5b20e52684cbc427362ddba5a6d2e13ef2091a89859e2b8f7bbf2e20458",
|
|
"preflight_engine_n8n_transition": "da3cec853e3e3e20df4d1e301aab98b77815f93ec3d68b0b61c84c6e6a3fb335",
|
|
"accept_engine_n8n_runtime": "1a2614a465161e3833e84aca402817682da1a538bdb2aecc4af66b665497338d",
|
|
}
|
|
|
|
|
|
def load_runner():
|
|
loader = importlib.machinery.SourceFileLoader("nodedc_engine_deploy_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
|
|
|
|
|
|
def snapshot_engine_builder_paths():
|
|
snapshot = {}
|
|
for relative in BUILDER_ENGINE_PATHS:
|
|
path = ENGINE_ROOT / relative
|
|
try:
|
|
path_stat = path.lstat()
|
|
except FileNotFoundError:
|
|
snapshot[relative] = None
|
|
continue
|
|
if path.is_symlink():
|
|
content = ("symlink", os.readlink(path))
|
|
elif path.is_file():
|
|
content = ("file", path.read_bytes())
|
|
else:
|
|
content = ("other", None)
|
|
snapshot[relative] = (
|
|
path_stat.st_mode,
|
|
path_stat.st_uid,
|
|
path_stat.st_gid,
|
|
path_stat.st_ino,
|
|
path_stat.st_mtime_ns,
|
|
content,
|
|
)
|
|
return snapshot
|
|
|
|
|
|
RUNNER = load_runner()
|
|
|
|
|
|
class EngineN8nPrivateExtensionTest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.temporary = tempfile.TemporaryDirectory(prefix="nodedc-engine-n8n-policy-")
|
|
cls.root = Path(cls.temporary.name)
|
|
cls.results = []
|
|
cls.engine_snapshot_before = snapshot_engine_builder_paths()
|
|
for index in range(2):
|
|
output = cls.root / f"build-{index}"
|
|
output.mkdir()
|
|
env = os.environ.copy()
|
|
env.pop("NODEDC_N8N_TRANSITION_ID", None)
|
|
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output)
|
|
env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT)
|
|
command = ["node", str(BUILDER_PATH)]
|
|
if index == 0:
|
|
command.append(TRANSITION_ID)
|
|
else:
|
|
env["NODEDC_N8N_TRANSITION_ID"] = TRANSITION_ID
|
|
result = subprocess.run(
|
|
command,
|
|
cwd=PLATFORM_ROOT,
|
|
env=env,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
cls.results.append(json.loads(result.stdout))
|
|
cls.engine_snapshot_after = snapshot_engine_builder_paths()
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
cls.temporary.cleanup()
|
|
|
|
def artifact(self, build_index, kind):
|
|
return Path(self.results[build_index][kind]["artifact"])
|
|
|
|
def test_engine_artifacts_are_byte_reproducible(self):
|
|
for kind in ("activation", "rollback"):
|
|
first = self.artifact(0, kind).read_bytes()
|
|
second = self.artifact(1, kind).read_bytes()
|
|
self.assertEqual(first, second)
|
|
self.assertEqual(self.results[0][kind]["sha256"], self.results[1][kind]["sha256"])
|
|
self.assertEqual(first[4:8], b"\0\0\0\0")
|
|
self.assertEqual(first[3] & 0x08, 0)
|
|
|
|
def test_successful_generation_003_runner_functions_are_frozen(self):
|
|
actual = {
|
|
name: hashlib.sha256(inspect.getsource(getattr(RUNNER, name)).encode("utf-8")).hexdigest()
|
|
for name in PRIVATE_EXTENSION_CANON_FUNCTION_SHA256
|
|
}
|
|
self.assertEqual(actual, PRIVATE_EXTENSION_CANON_FUNCTION_SHA256)
|
|
|
|
def test_explicit_transition_id_does_not_mutate_sealed_release_identity(self):
|
|
expected_ids = {
|
|
"activation": f"engine-n8n-private-extension-{TRANSITION_ID}",
|
|
"rollback": f"engine-n8n-private-extension-rollback-{TRANSITION_ID}",
|
|
}
|
|
for result in self.results:
|
|
self.assertEqual(result["transitionId"], TRANSITION_ID)
|
|
self.assertEqual(result["releaseId"], SEALED_RELEASE_ID)
|
|
self.assertEqual(result["packageSha256"], SEALED_PACKAGE_SHA256)
|
|
for kind, expected_id in expected_ids.items():
|
|
self.assertEqual(result[kind]["id"], expected_id)
|
|
self.assertEqual(Path(result[kind]["artifact"]).name, f"nodedc-{expected_id}.tgz")
|
|
|
|
def test_builder_does_not_mutate_engine_source(self):
|
|
self.assertEqual(self.engine_snapshot_after, self.engine_snapshot_before)
|
|
|
|
def test_transition_id_rejects_missing_invalid_and_previously_issued_values(self):
|
|
cases = (
|
|
([], "transition_id_required"),
|
|
(["20260716-003"], "transition_id_already_issued"),
|
|
(["engine-n8n-private-extension-20260717-004"], "transition_id_invalid"),
|
|
(["20260230-004"], "transition_id_invalid"),
|
|
(["20260717-000"], "transition_id_invalid"),
|
|
([TRANSITION_ID, "unexpected-second-id"], "transition_id_argument_count_invalid"),
|
|
)
|
|
for arguments, expected_error in cases:
|
|
with self.subTest(arguments=arguments):
|
|
env = os.environ.copy()
|
|
env.pop("NODEDC_N8N_TRANSITION_ID", None)
|
|
result = subprocess.run(
|
|
["node", str(BUILDER_PATH), *arguments],
|
|
cwd=PLATFORM_ROOT,
|
|
env=env,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn(expected_error, result.stderr)
|
|
|
|
def test_existing_transition_artifact_is_never_overwritten(self):
|
|
env = os.environ.copy()
|
|
env.pop("NODEDC_N8N_TRANSITION_ID", None)
|
|
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(self.artifact(0, "activation").parent)
|
|
env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT)
|
|
result = subprocess.run(
|
|
["node", str(BUILDER_PATH), TRANSITION_ID],
|
|
cwd=PLATFORM_ROOT,
|
|
env=env,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("transition_artifact_already_exists", result.stderr)
|
|
|
|
def test_activation_and_rollback_pass_strict_runner_policy(self):
|
|
expected = {"activation": ("activate", 7), "rollback": ("rollback-inactive", 4)}
|
|
for kind, (action, entry_count) in expected.items():
|
|
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as directory:
|
|
manifest, entries, payload = RUNNER.load_artifact(
|
|
self.artifact(0, kind),
|
|
Path(directory),
|
|
)
|
|
descriptor = RUNNER.read_engine_n8n_transition_descriptor(
|
|
payload / RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL
|
|
)
|
|
self.assertEqual(manifest["component"], "engine")
|
|
self.assertEqual(descriptor["action"], action)
|
|
self.assertEqual(len(entries), entry_count)
|
|
self.assertEqual(RUNNER.component_services("engine", entries), ("n8n",))
|
|
self.assertEqual(RUNNER.component_builds("engine", entries), ())
|
|
self.assertFalse(RUNNER.component_publish_dist("engine", entries))
|
|
|
|
def test_activation_catalog_is_exact_three_without_tool_variants(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
_manifest, _entries, payload = RUNNER.load_artifact(
|
|
self.artifact(0, "activation"),
|
|
Path(directory),
|
|
)
|
|
nodes = json.loads((payload / RUNNER.ENGINE_N8N_NODES_CATALOG_REL).read_text())
|
|
credentials = json.loads((payload / RUNNER.ENGINE_N8N_CREDENTIALS_CATALOG_REL).read_text())
|
|
private_nodes = [item for item in nodes if item.get("name", "").startswith("n8n-nodes-ndc.")]
|
|
private_credentials = [item for item in credentials if item.get("name") in EXPECTED_CREDENTIALS]
|
|
self.assertEqual([item["name"] for item in private_nodes], EXPECTED_NODES)
|
|
self.assertEqual([item["name"] for item in private_credentials], EXPECTED_CREDENTIALS)
|
|
self.assertTrue(all("usableAsTool" not in item for item in private_nodes))
|
|
self.assertEqual((len(nodes), len(credentials)), (437, 388))
|
|
|
|
def test_rollback_catalog_restores_verified_inactive_baseline(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
_manifest, _entries, payload = RUNNER.load_artifact(
|
|
self.artifact(0, "rollback"),
|
|
Path(directory),
|
|
)
|
|
nodes = json.loads((payload / RUNNER.ENGINE_N8N_NODES_CATALOG_REL).read_text())
|
|
credentials = json.loads((payload / RUNNER.ENGINE_N8N_CREDENTIALS_CATALOG_REL).read_text())
|
|
self.assertEqual([item for item in nodes if item.get("name", "").startswith("n8n-nodes-ndc.")], [])
|
|
self.assertEqual([item for item in credentials if item.get("name") in EXPECTED_CREDENTIALS], [])
|
|
self.assertEqual((len(nodes), len(credentials)), (434, 385))
|
|
|
|
def test_compose_override_is_runner_derived_and_offline(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
_manifest, _entries, payload = RUNNER.load_artifact(
|
|
self.artifact(0, "activation"),
|
|
Path(directory),
|
|
)
|
|
descriptor = RUNNER.read_engine_n8n_transition_descriptor(
|
|
payload / RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL
|
|
)
|
|
override = (payload / RUNNER.ENGINE_N8N_COMPOSE_OVERRIDE_REL).read_text()
|
|
self.assertEqual(override, RUNNER.expected_engine_n8n_compose_override(descriptor))
|
|
self.assertEqual(
|
|
hashlib.sha256(override.encode("utf-8")).hexdigest(),
|
|
"a29e2f92b87dda59da2b4e3ce250bc9705ed9fa551c0fbe4e95464fca5caa3e1",
|
|
)
|
|
self.assertIn("pull_policy: never", override)
|
|
self.assertIn("N8N_USER_FOLDER: /home/node", override)
|
|
self.assertNotIn("NODE_PATH", override)
|
|
self.assertIn(":/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc:ro", override)
|
|
self.assertNotIn("N8N_CUSTOM_EXTENSIONS", override)
|
|
self.assertNotIn("build:", override)
|
|
|
|
def test_generation_003_override_is_accepted_as_installed_canon(self):
|
|
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
|
|
original_compose = RUNNER.COMPONENTS["engine"]["compose_root"]
|
|
try:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
work = Path(directory)
|
|
extracted = work / "artifact"
|
|
extracted.mkdir()
|
|
_manifest, _entries, payload = RUNNER.load_artifact(
|
|
self.artifact(0, "activation"),
|
|
extracted,
|
|
)
|
|
engine_root = work / "engine"
|
|
engine_root.mkdir()
|
|
(engine_root / "docker-compose.yml").write_text("services:\n", encoding="utf-8")
|
|
for relative in (
|
|
RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL,
|
|
RUNNER.ENGINE_N8N_COMPOSE_OVERRIDE_REL,
|
|
):
|
|
target = engine_root / relative
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes((payload / relative).read_bytes())
|
|
RUNNER.COMPONENTS["engine"]["payload_root"] = engine_root
|
|
RUNNER.COMPONENTS["engine"]["compose_root"] = engine_root
|
|
compose_files = RUNNER.component_compose_files("engine")
|
|
self.assertEqual(compose_files, (
|
|
engine_root / "docker-compose.yml",
|
|
engine_root / RUNNER.ENGINE_N8N_COMPOSE_OVERRIDE_REL,
|
|
))
|
|
finally:
|
|
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
|
|
RUNNER.COMPONENTS["engine"]["compose_root"] = original_compose
|
|
|
|
def test_loader_probe_pins_n8n_node_path(self):
|
|
container = {
|
|
"Mounts": [{
|
|
"Destination": RUNNER.ENGINE_N8N_RUNTIME_PACKAGE_PATH,
|
|
"RW": False,
|
|
}],
|
|
}
|
|
completed = subprocess.CompletedProcess(
|
|
args=[],
|
|
returncode=0,
|
|
stdout=json.dumps({
|
|
"packageName": "n8n-nodes-ndc",
|
|
"nodes": ["ndcDataProductPublish", "ndcDataProductRead", "ndcFoundryBinding"],
|
|
"credentials": EXPECTED_CREDENTIALS,
|
|
}),
|
|
stderr="",
|
|
)
|
|
with (
|
|
mock.patch.object(RUNNER, "inspect_container", return_value=container),
|
|
mock.patch.object(RUNNER.subprocess, "run", return_value=completed) as run,
|
|
):
|
|
catalog = RUNNER.engine_n8n_private_loader_catalog("n8n-container")
|
|
|
|
command = run.call_args.args[0]
|
|
self.assertEqual(command[:-1], [
|
|
str(RUNNER.DOCKER),
|
|
"exec",
|
|
"n8n-container",
|
|
"node",
|
|
"-e",
|
|
])
|
|
probe_script = command[-1]
|
|
self.assertIn(
|
|
f"process.env.NODE_PATH='{RUNNER.ENGINE_N8N_NODE_MODULES_PATH}'",
|
|
probe_script,
|
|
)
|
|
self.assertIn("Module._initPaths()", probe_script)
|
|
self.assertIn("PackageDirectoryLoader", probe_script)
|
|
self.assertEqual(catalog, {
|
|
"node_types": EXPECTED_NODES,
|
|
"credential_types": EXPECTED_CREDENTIALS,
|
|
})
|
|
|
|
def test_loader_probe_failure_emits_only_allowlisted_diagnostic(self):
|
|
container = {
|
|
"Mounts": [{
|
|
"Destination": RUNNER.ENGINE_N8N_RUNTIME_PACKAGE_PATH,
|
|
"RW": False,
|
|
}],
|
|
}
|
|
cases = (
|
|
("MODULE_NOT_FOUND", "MODULE_NOT_FOUND"),
|
|
("Bearer secret-that-must-not-be-logged", "PROBE_ERROR"),
|
|
)
|
|
for probe_stderr, expected_category in cases:
|
|
with (
|
|
self.subTest(stderr=probe_stderr),
|
|
mock.patch.object(RUNNER, "inspect_container", return_value=container),
|
|
mock.patch.object(
|
|
RUNNER.subprocess,
|
|
"run",
|
|
return_value=subprocess.CompletedProcess(
|
|
args=[],
|
|
returncode=1,
|
|
stdout="",
|
|
stderr=probe_stderr,
|
|
),
|
|
),
|
|
):
|
|
with self.assertRaises(RUNNER.DeployError) as raised:
|
|
RUNNER.engine_n8n_private_loader_catalog("n8n-container")
|
|
message = str(raised.exception)
|
|
self.assertIn(f"category={expected_category}", message)
|
|
self.assertNotIn("secret-that-must-not-be-logged", message)
|
|
|
|
def test_active_runtime_acceptance_does_not_require_node_path(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
_manifest, _entries, payload = RUNNER.load_artifact(
|
|
self.artifact(0, "activation"),
|
|
Path(directory),
|
|
)
|
|
descriptor = RUNNER.read_engine_n8n_transition_descriptor(
|
|
payload / RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL
|
|
)
|
|
container = {
|
|
"State": {"Status": "running", "StartedAt": "2026-07-16T00:00:00Z"},
|
|
"Image": "sha256:base-image",
|
|
"Config": {
|
|
"Env": [
|
|
"N8N_USER_FOLDER=/home/node",
|
|
"N8N_COMMUNITY_PACKAGES_ENABLED=true",
|
|
"N8N_COMMUNITY_PACKAGES_PREVENT_LOADING=false",
|
|
"N8N_REINSTALL_MISSING_PACKAGES=false",
|
|
],
|
|
"Labels": {
|
|
"nodedc.n8n-private-extension.release": descriptor["releaseId"],
|
|
"nodedc.n8n-private-extension.package-sha256": descriptor["packageSha256"],
|
|
},
|
|
},
|
|
"Mounts": [{
|
|
"Destination": RUNNER.ENGINE_N8N_RUNTIME_PACKAGE_PATH,
|
|
"Source": f"/volume2/nodedc-demo/{descriptor['sealedReleaseRelativePath']}",
|
|
"RW": False,
|
|
}],
|
|
"RestartCount": 0,
|
|
}
|
|
log_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
with (
|
|
mock.patch.object(
|
|
RUNNER,
|
|
"inspect_engine_n8n_base_image",
|
|
return_value={"id": "sha256:base-image"},
|
|
),
|
|
mock.patch.object(RUNNER, "engine_n8n_container_ids", return_value=("n8n-container",)),
|
|
mock.patch.object(RUNNER, "inspect_container", return_value=container),
|
|
mock.patch.object(RUNNER, "wait_engine_n8n_readiness"),
|
|
mock.patch.object(RUNNER, "engine_n8n_version", return_value=RUNNER.ENGINE_N8N_VERSION),
|
|
mock.patch.object(RUNNER.subprocess, "run", return_value=log_result),
|
|
mock.patch.object(
|
|
RUNNER,
|
|
"engine_n8n_private_loader_catalog",
|
|
return_value={
|
|
"node_types": EXPECTED_NODES,
|
|
"credential_types": EXPECTED_CREDENTIALS,
|
|
},
|
|
),
|
|
mock.patch.object(RUNNER, "validate_engine_n8n_catalog_payload"),
|
|
mock.patch.object(RUNNER.time, "sleep"),
|
|
):
|
|
accepted = RUNNER.accept_engine_n8n_runtime(descriptor)
|
|
self.assertEqual(accepted["node_types"], EXPECTED_NODES)
|
|
|
|
def test_private_extension_transition_does_not_enter_publish_grant_domain(self):
|
|
entries = (RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL,)
|
|
descriptor = {"action": "rollback-inactive"}
|
|
self.assertFalse(RUNNER.touches_engine_data_product_publish_grant(entries))
|
|
self.assertEqual(RUNNER.component_services("engine", entries), ("n8n",))
|
|
with (
|
|
mock.patch.object(RUNNER, "current_engine_n8n_transition_descriptor", return_value=descriptor),
|
|
mock.patch.object(RUNNER, "ensure_engine_edp_managed_provisioner_keypair") as keypair,
|
|
mock.patch.object(RUNNER, "ensure_engine_publish_grant_private_state") as private_state,
|
|
):
|
|
RUNNER.prepare_component_runtime("engine", entries)
|
|
keypair.assert_not_called()
|
|
private_state.assert_not_called()
|
|
|
|
def test_generic_engine_compose_artifact_keeps_legacy_service_selection(self):
|
|
self.assertEqual(
|
|
RUNNER.component_services("engine", ("docker-compose.yml",)),
|
|
RUNNER.COMPONENTS["engine"]["services"],
|
|
)
|
|
self.assertEqual(
|
|
RUNNER.component_services(
|
|
"engine",
|
|
("docker-compose.yml", "nodedc-source/server/dataProductPublishGrant/store.js"),
|
|
),
|
|
RUNNER.COMPONENTS["engine"]["services"],
|
|
)
|
|
self.assertFalse(RUNNER.is_engine_data_product_publish_grant_slice(
|
|
"engine",
|
|
("docker-compose.yml", "nodedc-source/server/dataProductPublishGrant/store.js"),
|
|
))
|
|
|
|
def test_unknown_descriptor_key_is_rejected(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
work = Path(directory)
|
|
RUNNER.safe_extract(self.artifact(0, "activation"), work)
|
|
descriptor_path = work / "payload" / RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL
|
|
descriptor = json.loads(descriptor_path.read_text())
|
|
descriptor["unexpected"] = True
|
|
descriptor_path.write_text(json.dumps(descriptor))
|
|
with self.assertRaisesRegex(RUNNER.DeployError, "keys mismatch"):
|
|
RUNNER.validate_engine_n8n_transition(
|
|
work / "payload",
|
|
RUNNER.parse_files_list(work / "files.txt"),
|
|
)
|
|
|
|
def test_core_catalog_substitution_is_rejected_even_when_counts_match(self):
|
|
cases = (
|
|
("activation", RUNNER.ENGINE_N8N_NODES_CATALOG_REL, "node"),
|
|
("activation", RUNNER.ENGINE_N8N_CREDENTIALS_CATALOG_REL, "credential"),
|
|
("rollback", RUNNER.ENGINE_N8N_NODES_CATALOG_REL, "node"),
|
|
("rollback", RUNNER.ENGINE_N8N_CREDENTIALS_CATALOG_REL, "credential"),
|
|
)
|
|
for kind, relative_path, label in cases:
|
|
with self.subTest(kind=kind, catalog=label), tempfile.TemporaryDirectory() as directory:
|
|
work = Path(directory)
|
|
RUNNER.safe_extract(self.artifact(0, kind), work)
|
|
catalog_path = work / "payload" / relative_path
|
|
catalog = json.loads(catalog_path.read_text())
|
|
if label == "node":
|
|
candidate = next(
|
|
item for item in catalog
|
|
if not item.get("name", "").startswith("n8n-nodes-ndc.")
|
|
)
|
|
candidate["name"] = "n8n-nodes-unreviewed.hiddenNode"
|
|
else:
|
|
candidate = next(
|
|
item for item in catalog
|
|
if item.get("name") not in EXPECTED_CREDENTIALS
|
|
)
|
|
candidate["name"] = "unreviewedCredential"
|
|
catalog_path.write_text(json.dumps(catalog, indent=2) + "\n")
|
|
with self.assertRaisesRegex(RUNNER.DeployError, "catalog sha256 mismatch"):
|
|
RUNNER.validate_engine_n8n_transition(
|
|
work / "payload",
|
|
RUNNER.parse_files_list(work / "files.txt"),
|
|
)
|
|
|
|
def test_old_engine_artifact_is_rejected_by_descriptor_gate(self):
|
|
old = PLATFORM_ROOT / "infra/deploy-artifacts/nodedc-engine-n8n-private-extension-20260715-002.tgz"
|
|
if not old.is_file():
|
|
self.skipTest("rejected historical artifact not present")
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
with self.assertRaisesRegex(RUNNER.DeployError, "canonical transition descriptor"):
|
|
RUNNER.load_artifact(old, Path(directory))
|
|
|
|
def test_engine_source_keeps_base_compose_and_separate_override(self):
|
|
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
|
|
original_compose = RUNNER.COMPONENTS["engine"]["compose_root"]
|
|
try:
|
|
RUNNER.COMPONENTS["engine"]["payload_root"] = ENGINE_ROOT
|
|
RUNNER.COMPONENTS["engine"]["compose_root"] = ENGINE_ROOT
|
|
RUNNER.validate_engine_n8n_base_compose_source()
|
|
finally:
|
|
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
|
|
RUNNER.COMPONENTS["engine"]["compose_root"] = original_compose
|
|
|
|
def test_additional_n8n_runtime_service_is_rejected(self):
|
|
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
|
|
original_compose = RUNNER.COMPONENTS["engine"]["compose_root"]
|
|
try:
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
engine_root = Path(directory)
|
|
compose = (ENGINE_ROOT / "docker-compose.yml").read_text()
|
|
compose += (
|
|
"\n n8n-worker-2:\n"
|
|
" image: docker.n8n.io/n8nio/n8n:${N8N_IMAGE_TAG:-2.3.2}\n"
|
|
)
|
|
(engine_root / "docker-compose.yml").write_text(compose)
|
|
RUNNER.COMPONENTS["engine"]["payload_root"] = engine_root
|
|
RUNNER.COMPONENTS["engine"]["compose_root"] = engine_root
|
|
with self.assertRaisesRegex(RUNNER.DeployError, "exact service topology mismatch"):
|
|
RUNNER.validate_engine_n8n_base_compose_source()
|
|
finally:
|
|
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
|
|
RUNNER.COMPONENTS["engine"]["compose_root"] = original_compose
|
|
|
|
def test_sealed_release_exact_set_includes_implicit_directories(self):
|
|
release_relative = Path(
|
|
"payload/releases/n8n-nodes-ndc/0.1.2-05e4b38b14b4a019"
|
|
)
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
work = Path(directory)
|
|
outer = work / "outer"
|
|
RUNNER.safe_extract(STAGE_ARTIFACT, outer)
|
|
staged_release = outer / release_relative
|
|
sealed_release = work / "sealed"
|
|
sealed_release.mkdir()
|
|
for filename in ("package.tgz", "release.json", "rollback.json"):
|
|
(sealed_release / filename).write_bytes(
|
|
(staged_release / filename).read_bytes()
|
|
)
|
|
|
|
expected_paths = {
|
|
"package",
|
|
"package.tgz",
|
|
"release.json",
|
|
"rollback.json",
|
|
}
|
|
with tarfile.open(staged_release / "package.tgz", "r:gz") as archive:
|
|
for member in archive:
|
|
RUNNER.add_engine_n8n_sealed_member_paths(
|
|
expected_paths,
|
|
member.name,
|
|
)
|
|
target = sealed_release.joinpath(*Path(member.name).parts)
|
|
if member.isdir():
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
continue
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
source = archive.extractfile(member)
|
|
self.assertIsNotNone(source)
|
|
target.write_bytes(source.read())
|
|
|
|
actual_paths = {
|
|
path.relative_to(sealed_release).as_posix()
|
|
for path in sealed_release.rglob("*")
|
|
}
|
|
self.assertEqual(actual_paths, expected_paths)
|
|
self.assertIn("package/dist/nodes", expected_paths)
|
|
self.assertIn("package/dist/credentials", expected_paths)
|
|
|
|
def test_tar_members_have_no_appledouble_or_special_types(self):
|
|
for kind in ("activation", "rollback"):
|
|
with tarfile.open(self.artifact(0, kind), "r:gz") as archive:
|
|
for member in archive:
|
|
self.assertFalse(Path(member.name).name.startswith("._"))
|
|
self.assertTrue(member.isfile() or member.isdir())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|