NODEDC_PLATFORM/infra/deploy-runner/test_engine_n8n_private_ext...

638 lines
29 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 = Path(
os.environ.get("NODEDC_DEPLOY_RUNNER_UNDER_TEST", SCRIPT_DIR / "nodedc-deploy")
).resolve()
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-replace-replay-v1-20260721-009.tgz"
)
PREDECESSOR_ARTIFACT = (
PLATFORM_ROOT
/ "infra/deploy-artifacts"
/ "nodedc-engine-n8n-private-extension-20260721-005.tgz"
)
TRANSITION_ID = "20260721-010"
SEALED_RELEASE_ID = "0.1.6-25cc2d7a52a0d0ee"
SEALED_PACKAGE_SHA256 = "25cc2d7a52a0d0ee288b7a4b3de9f436489c010f234498a94bcccd114d7355af"
PREDECESSOR_RELEASE_ID = "0.1.5-3c8ae53f010d7c88"
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",
"ndcProviderRotatingAccessApi",
]
PREDECESSOR_CREDENTIALS = EXPECTED_CREDENTIALS
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": "25bf33b3be0916a9dd82f064a710060eaf6bd7d87b3614ee0065af07ddbb446e",
"accept_engine_n8n_runtime": "bc49275adb2d5ab4fd98abd07b9059a197d023e03b40145b6fc910b70b2f738b",
}
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)
env["NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT"] = str(PREDECESSOR_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_state_aware_upgrade_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"),
(["20260718-007"], "transition_id_already_issued"),
(["20260718-008"], "transition_id_already_issued"),
(["20260721-005"], "transition_id_already_issued"),
(["20260721-008"], "transition_id_already_issued"),
(["engine-n8n-private-extension-20260721-010"], "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)
env["NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT"] = str(PREDECESSOR_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": ("activate", 7)}
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, 389))
publisher = next(item for item in private_nodes if item["name"].endswith(".ndcDataProductPublish"))
self.assertEqual(publisher["version"], [1, 2])
publish_mode = next(item for item in publisher["properties"] if item["name"] == "publishMode")
self.assertEqual([item["value"] for item in publish_mode["options"]], ["upsert", "replace"])
self.assertEqual(publish_mode["displayOptions"]["show"]["@version"], [2])
def test_rollback_catalog_restores_verified_predecessor_release(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())
descriptor = RUNNER.read_engine_n8n_transition_descriptor(
payload / RUNNER.ENGINE_N8N_TRANSITION_DESCRIPTOR_REL
)
self.assertEqual(descriptor["releaseId"], PREDECESSOR_RELEASE_ID)
self.assertEqual(descriptor["expectedCurrent"], SEALED_RELEASE_ID)
self.assertEqual(
[item.get("name") for item in nodes if item.get("name", "").startswith("n8n-nodes-ndc.")],
EXPECTED_NODES,
)
self.assertEqual(
[item.get("name") for item in credentials if item.get("name") in PREDECESSOR_CREDENTIALS],
PREDECESSOR_CREDENTIALS,
)
self.assertEqual((len(nodes), len(credentials)), (437, 389))
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(),
"7fc118de152b7f5b76a7a502aea0dfc8d7dd191f6bfd304d6de9a4bf06f8fe1e",
)
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_upgrade_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.6-25cc2d7a52a0d0ee"
)
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)