feat(deploy): register managed platform runtime components
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env python3
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
EXPECTED_NODES = [
|
||||
"n8n-nodes-ndc.ndcDataProductPublish",
|
||||
"n8n-nodes-ndc.ndcDataProductRead",
|
||||
"n8n-nodes-ndc.ndcFoundryBinding",
|
||||
]
|
||||
EXPECTED_CREDENTIALS = [
|
||||
"ndcDataProductWriterApi",
|
||||
"ndcDataProductReaderApi",
|
||||
"ndcFoundryBindingApi",
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 = []
|
||||
for index in range(2):
|
||||
output = cls.root / f"build-{index}"
|
||||
output.mkdir()
|
||||
env = os.environ.copy()
|
||||
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output)
|
||||
env["NODEDC_N8N_EXTENSION_STAGE_ARTIFACT"] = str(STAGE_ARTIFACT)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER_PATH)],
|
||||
cwd=PLATFORM_ROOT,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
cls.results.append(json.loads(result.stdout))
|
||||
|
||||
@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_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.assertIn("pull_policy: never", override)
|
||||
self.assertIn("N8N_USER_FOLDER: /home/node", 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_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)
|
||||
Reference in New Issue
Block a user