NODEDC_PLATFORM/infra/deploy-runner/test_platform_registry.py

1107 lines
49 KiB
Python

#!/usr/bin/env python3
import importlib.machinery
import importlib.util
import hashlib
import inspect
import json
import stat
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_platform_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()
LEGACY_CREDENTIAL_RUNTIME_CANON_FUNCTION_SHA256 = {
"build_canonical_engine_backend_rootfs": "7f8fca6380632d717ee38a3d58b5bbc58d34b0e6c81e2764a759ddd63bfd07d0",
"engine_backend_container_id": "08dc25b6fee37e8c0d942df91fc575d6766b99c306ad06c2c14afded8de3b375",
"engine_backend_immutable_runtime_is_current": "9c6b9a73ac5004830b1f06f56bfc1f25336d11001a97a5de7a04388e10a20990",
"engine_backend_node_modules_tree_sha256": "01749704e1624d247bdf41242aa71961f5e5bc092ffa906b69cdb911907b70c4",
"engine_backend_rootfs_member_allowed": "62342e5891a1ea885f1ff02169a100f9ca94464eb7dfb62a7cbe67750ebcbb91",
"engine_backend_rootfs_toolchain_link_is_omitted": "83772bc1a8aa654e5c1d0757d43b650ee978dc73aef7ae8fac567dbacc8c7c49",
"engine_backend_tool_versions": "c21d7c11ee8c40ac5e3e5c6c24c441d1ce52c9dd2c22fc1d8f26e121138fa78a",
"ensure_engine_backend_release_image": "f65a5f8573b911980f252dc3beed88cd6ee14e0f0c519c333ea90b78c8910953",
"expected_engine_credential_backend_override": "b7c9db0059a4d262b8fe61dc0745b8ee1a4b75d56d227c39ba08115c7e414486",
"inspect_engine_backend_derived_image": "2336ce8068bd321b6c8ac6e62b430dbcbf991201cb9b4178589294d1baf7e818",
"preflight_engine_credential_sink_ready": "3f084ade6346644c86aa9540fbcf70b3a44e32c7add0876935f45177569aad6d",
"prepare_engine_credential_backend_runtime": "0def464639767dcf85acd7978459b2f5c1ebf963424dd401d4c29aae7bb1aaa5",
"quarantine_engine_backend_partial_runtime": "57e455f874c681815cf42bf529d0b8620ae8317e82551d784cbd6dae913603d2",
"read_engine_backend_runtime_metadata": "7ee0ec2c72eb699db4b6a7d3d8d912f1b0c0c724c2a054eee223fba3ec0a1cb2",
"run_compose": "8425528d4934f82e79ebf3551c4246505e4a3cdf3f450949d7618a4312fee169",
"run_engine_backend_probe": "f0db7210b64cf3c80c1f0a00165f27dcab6962781a69dc2050d84a5373cedaa9",
"touches_engine_credential_sink": "5d0e0d680d1a8508fb1ac21d8c8b3aa92466fe8b219d2ddc1fe5411f94fca912",
"validate_engine_backend_activation_marker": "66c4d5d9b0b608c030c2c9c97e2e46e1f1634b7ba024dc107c15478091765483",
"validate_engine_backend_dependencies": "ca1383dffd5a6397ecfeedcc8f88ab59e90a33d5089590f0249f789b05ed29e2",
"validate_engine_backend_mounts": "5614267d1d0f734dd68ed7565292a53f34777b6dc3640137034f6db093ef7431",
"validate_engine_backend_runtime_override_file": "eae1d32ab75cc59916dbd1d4cd73a072c156abf9014d8ef234fcd2333748139d",
"validate_engine_credential_sink_slice": "87e6571baf47c30af120da32a327a88164415bf65b198e7190c28df7365bcfd5",
}
class CanonicalPlatformRegistryTest(unittest.TestCase):
def test_legacy_credential_runtime_functions_match_last_successful_canon(self):
actual = {
name: hashlib.sha256(
inspect.getsource(getattr(RUNNER, name)).encode("utf-8"),
).hexdigest()
for name in LEGACY_CREDENTIAL_RUNTIME_CANON_FUNCTION_SHA256
}
self.assertEqual(actual, LEGACY_CREDENTIAL_RUNTIME_CANON_FUNCTION_SHA256)
def test_publish_mount_extension_delegates_baseline_inventory_to_legacy_guard(self):
baseline_mounts = [
{
"Type": "bind",
"Source": "/baseline/app",
"Destination": "/app",
"RW": True,
},
]
publish_mounts = [
{
"Type": "bind",
"Source": str(RUNNER.ENGINE_PUBLISH_GRANT_STATE_PATH),
"Destination": RUNNER.ENGINE_PUBLISH_GRANT_CONTAINER_PATH,
"RW": True,
},
{
"Type": "bind",
"Source": str(RUNNER.ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE),
"Destination": RUNNER.ENGINE_EDP_PRIVATE_KEY_CONTAINER_PATH,
"RW": False,
},
]
container = {"Mounts": [*baseline_mounts, *publish_mounts]}
with tempfile.TemporaryDirectory(prefix="nodedc-publish-mounts-") as directory:
root = Path(directory)
override = root / RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL
override.parent.mkdir(parents=True)
override.write_text(
RUNNER.expected_engine_data_product_publish_grant_override(),
encoding="utf-8",
)
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
RUNNER.COMPONENTS["engine"]["payload_root"] = root
try:
with mock.patch.object(
RUNNER,
"validate_engine_backend_mounts",
) as legacy_guard:
RUNNER.validate_engine_backend_mounts_for_installed_runtime(
container,
True,
)
legacy_guard.assert_called_once_with(
{"Mounts": baseline_mounts},
True,
)
wrong_source = {
"Mounts": [
*baseline_mounts,
{**publish_mounts[0], "Source": "/wrong"},
publish_mounts[1],
],
}
with self.assertRaisesRegex(
RUNNER.DeployError,
"Publish mount barrier mismatch",
):
RUNNER.validate_engine_backend_mounts_for_installed_runtime(
wrong_source,
True,
)
finally:
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
def test_publish_mount_extension_accepts_exact_complete_runtime_inventory(self):
baseline = {
"/app": (True, "/engine/source"),
"/app/deploy/docker-compose.yml": (False, "/engine/docker-compose.yml"),
"/seed-api": (False, "/engine/seed-api"),
"/seed-data": (False, "/engine/seed-data"),
"/app/node_modules": (False, str(RUNNER.ENGINE_CREDENTIAL_BACKEND_NODE_MODULES_DIR)),
"/app/server/data": (True, "/engine/data"),
"/app/server/storage": (True, "/engine/storage"),
"/app/server/logs": (True, "/engine/logs"),
"/var/run/docker.sock": (True, "/var/run/docker.sock"),
}
mounts = [
{
"Type": "bind",
"Source": source,
"Destination": destination,
"RW": writable,
}
for destination, (writable, source) in baseline.items()
]
mounts.extend((
{
"Type": "bind",
"Source": str(RUNNER.ENGINE_PUBLISH_GRANT_STATE_PATH),
"Destination": RUNNER.ENGINE_PUBLISH_GRANT_CONTAINER_PATH,
"RW": True,
},
{
"Type": "bind",
"Source": str(RUNNER.ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE),
"Destination": RUNNER.ENGINE_EDP_PRIVATE_KEY_CONTAINER_PATH,
"RW": False,
},
))
with tempfile.TemporaryDirectory(prefix="nodedc-publish-full-mounts-") as directory:
root = Path(directory)
override = root / RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL
override.parent.mkdir(parents=True)
override.write_text(
RUNNER.expected_engine_data_product_publish_grant_override(),
encoding="utf-8",
)
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
RUNNER.COMPONENTS["engine"]["payload_root"] = root
try:
RUNNER.validate_engine_backend_mounts_for_installed_runtime(
{"Mounts": mounts},
True,
)
finally:
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
def test_absent_publish_overlay_uses_legacy_mount_guard_unchanged(self):
container = {"Mounts": [{"Destination": "/app"}]}
with tempfile.TemporaryDirectory(prefix="nodedc-no-publish-mounts-") as directory:
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
RUNNER.COMPONENTS["engine"]["payload_root"] = Path(directory)
try:
with mock.patch.object(
RUNNER,
"validate_engine_backend_mounts",
) as legacy_guard:
RUNNER.validate_engine_backend_mounts_for_installed_runtime(
container,
False,
)
legacy_guard.assert_called_once_with(container, False)
finally:
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
def test_runner_does_not_reinterpret_compose_schema(self):
self.assertFalse(hasattr(RUNNER, "render_compose_model"))
self.assertFalse(hasattr(RUNNER, "validate_platform_compose_model"))
self.assertFalse(hasattr(RUNNER, "validate_engine_compose_model"))
self.assertFalse(hasattr(RUNNER, "preflight_platform_compose_candidate"))
self.assertFalse(hasattr(RUNNER, "preflight_engine_compose_candidate"))
def test_platform_edp_change_recreates_only_application(self):
entries = (
"platform/docker-compose.external-data-plane.yml",
"platform/services/external-data-plane",
)
services = RUNNER.component_services("platform", entries)
self.assertEqual(services, ("external-data-plane",))
self.assertNotIn("external-data-plane-postgres", services)
builds = RUNNER.component_builds("platform", entries)
self.assertEqual(len(builds), 1)
build_root, build_args = builds[0]
self.assertEqual(
build_root,
Path("/volume1/docker/nodedc-platform/platform"),
)
self.assertEqual(
build_args,
(
"build",
"--no-cache",
"-f",
"services/external-data-plane/Dockerfile",
"-t",
RUNNER.EXTERNAL_DATA_PLANE_IMAGE,
".",
),
)
def test_provider_catalog_is_metadata_only(self):
entries = (
"platform/packages/external-provider-contract/providers/gelios/v1/provider.json",
)
self.assertFalse(RUNNER.touches_external_data_plane_files(entries))
self.assertEqual(RUNNER.component_services("platform", entries), ())
self.assertEqual(RUNNER.component_builds("platform", entries), ())
self.assertEqual(RUNNER.component_healthchecks("platform", entries), ())
with (
mock.patch.object(RUNNER, "run_build") as build,
mock.patch.object(RUNNER, "prepare_component_runtime") as prepare,
mock.patch.object(RUNNER, "run_compose") as compose,
mock.patch.object(RUNNER, "healthcheck_url") as healthcheck,
):
RUNNER.run_component_runtime("platform", entries, ())
RUNNER.run_healthchecks("platform", entries, ())
build.assert_not_called()
prepare.assert_not_called()
compose.assert_not_called()
healthcheck.assert_not_called()
def test_generic_engine_compose_change_keeps_legacy_service_selection(self):
services = RUNNER.component_services(
"engine",
(
"docker-compose.yml",
"nodedc-source/server/index.js",
"nodedc-source/server/dataProductPublishGrant/store.js",
),
)
self.assertEqual(services, ("nodedc-backend", "app"))
self.assertNotIn("n8n-postgres", services)
self.assertNotIn("postgresql", services)
self.assertNotIn("n8n", services)
def test_engine_runtime_prepares_managed_private_state(self):
with (
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",
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES,
)
keypair.assert_called_once_with()
private_state.assert_called_once_with()
def test_module_foundry_runtime_prepares_separate_edp_identity_and_grant_roots(self):
with (
mock.patch.object(RUNNER, "ensure_map_gateway_admin_secret") as map_secret,
mock.patch.object(RUNNER, "ensure_foundry_edp_managed_provisioner_keypair") as foundry_keypair,
mock.patch.object(RUNNER, "ensure_root_owned_grant_directory") as grant_directory,
):
RUNNER.prepare_component_runtime("module-foundry", ("server/catalog-server.mjs",))
map_secret.assert_called_once_with()
foundry_keypair.assert_called_once_with()
self.assertEqual(
grant_directory.call_args_list,
[
mock.call(
RUNNER.EXTERNAL_DATA_PLANE_READER_GRANTS_DIR,
"external data plane reader grants",
),
mock.call(
RUNNER.FOUNDRY_BINDING_GRANTS_DIR,
"foundry binding grants",
),
],
)
def test_edp_runtime_prepares_engine_and_foundry_public_trust_independently(self):
entries = ("platform/services/external-data-plane/src/server.mjs",)
with (
mock.patch.object(RUNNER, "ensure_external_data_plane_provisioner_secret") as legacy_secret,
mock.patch.object(RUNNER, "ensure_engine_edp_managed_provisioner_keypair") as engine_keypair,
mock.patch.object(RUNNER, "ensure_foundry_edp_managed_provisioner_keypair") as foundry_keypair,
mock.patch.object(RUNNER, "ensure_root_owned_grant_directory") as grant_directory,
):
RUNNER.prepare_component_runtime("platform", entries)
legacy_secret.assert_called_once_with()
engine_keypair.assert_called_once_with()
foundry_keypair.assert_called_once_with()
grant_directory.assert_called_once_with(
RUNNER.EXTERNAL_DATA_PLANE_READER_GRANTS_DIR,
"external data plane reader grants",
)
def test_partial_publish_path_has_no_publish_runtime_side_effects(self):
entries = ("nodedc-source/server/dataProductPublishGrant/store.js",)
self.assertTrue(RUNNER.touches_engine_data_product_publish_grant(entries))
self.assertFalse(RUNNER.is_engine_data_product_publish_grant_slice(
"engine",
entries,
))
with (
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_publish_predecessor_guard_checks_every_pinned_file_before_mutation(self):
with tempfile.TemporaryDirectory(prefix="nodedc-publish-predecessor-") as directory:
root = Path(directory)
files = {
"docker-compose.yml": b"services: {}\n",
"nodedc-source/server/routes/n8n.js": b"export const baseline = true\n",
}
expected = {}
for relative_path, value in files.items():
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(value)
expected[relative_path] = hashlib.sha256(value).hexdigest()
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
original_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256
original_installed_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256
RUNNER.COMPONENTS["engine"]["payload_root"] = root
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = expected
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = expected
try:
self.assertEqual(
RUNNER.preflight_engine_data_product_publish_grant_predecessor(),
{
"mode": "credential-sink-initial-transition",
"compose_sha256": expected["docker-compose.yml"],
"changed_paths": (),
},
)
(root / "nodedc-source/server/routes/n8n.js").write_bytes(b"drift\n")
with self.assertRaisesRegex(
RUNNER.DeployError,
"path=nodedc-source/server/routes/n8n.js",
):
RUNNER.preflight_engine_data_product_publish_grant_predecessor()
finally:
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = original_hashes
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = original_installed_hashes
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
def test_installed_publish_update_preserves_foundation_and_allows_only_grant_source_changes(self):
with tempfile.TemporaryDirectory(prefix="nodedc-publish-update-") as directory:
base = Path(directory)
root = base / "installed"
payload = base / "payload"
root.mkdir()
payload.mkdir()
compose_bytes = b"services: {}\n"
compose = root / "docker-compose.yml"
compose.write_bytes(compose_bytes)
expected = {
"docker-compose.yml": hashlib.sha256(compose_bytes).hexdigest(),
}
shared_files = {
"nodedc-source/server/assets/provider-packages/v1/catalog.json": "{}\n",
"nodedc-source/server/engineAgents/store.js": "export const profile = 'full-developer'\n",
"nodedc-source/server/routes/engineAgentGateway.js": "\n".join((
"engine_plan_data_product_publish_grant",
"engine_apply_data_product_publish_grant",
"engine_accept_data_product_publish_grant",
"engine_rollback_data_product_publish_grant",
)),
"nodedc-source/server/routes/n8n.js": (
"engineCredentialSinkN8nAdapter\n"
"engineDataProductPublishGrantN8nAdapter\n"
),
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL: (
RUNNER.expected_engine_data_product_publish_grant_override()
),
}
for name in RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_SOURCE_FILES:
shared_files[f"nodedc-source/server/dataProductPublishGrant/{name}"] = (
f"export const source = '{name}'\n"
)
for relative_path, value in shared_files.items():
for destination in (root, payload):
path = destination / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(value, encoding="utf-8")
acceptance_rel = "nodedc-source/server/dataProductPublishGrant/acceptance.js"
(payload / acceptance_rel).write_text(
"export const source = 'acceptance-fixed'\n",
encoding="utf-8",
)
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
original_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256
original_installed_hashes = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256
RUNNER.COMPONENTS["engine"]["payload_root"] = root
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = expected
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = expected
try:
self.assertEqual(
RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload),
{
"mode": "installed-source-update",
"compose_sha256": expected["docker-compose.yml"],
"changed_paths": (acceptance_rel,),
},
)
gateway_rel = "nodedc-source/server/routes/engineAgentGateway.js"
(payload / gateway_rel).write_text("cross-boundary change\n", encoding="utf-8")
with self.assertRaisesRegex(
RUNNER.DeployError,
"crosses its source boundary",
):
RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload)
(payload / gateway_rel).write_text(shared_files[gateway_rel], encoding="utf-8")
compose.write_text("drift\n", encoding="utf-8")
with self.assertRaisesRegex(
RUNNER.DeployError,
"foundation drift",
):
RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload)
finally:
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_PREDECESSOR_SHA256 = original_hashes
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_INSTALLED_FOUNDATION_SHA256 = original_installed_hashes
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
def test_composite_provider_update_requires_exact_v3_to_v4_catalog_and_four_paths(self):
engine_source = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
current_target_sha256 = {
rel: hashlib.sha256((engine_source / rel).read_bytes()).hexdigest()
for rel in RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES
}
if current_target_sha256 != RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256:
self.skipTest("historical v3-to-v4 fixture source has advanced to its exact successor")
with tempfile.TemporaryDirectory(prefix="nodedc-composite-provider-update-") as directory:
base = Path(directory)
root = base / "installed"
payload = base / "payload"
root.mkdir()
payload.mkdir()
v3_catalog = {
"schemaVersion": "nodedc.engine.provider-security-catalog/v1",
"packages": [{
"id": "gelios.provider.v3",
"version": "3.0.0",
"providerId": "gelios",
"providerCredential": {
"authModeId": "gelios.rest-rotating-bearer.v3",
"credentialType": "httpBearerAuth",
},
"capabilities": [{
"id": "gelios.units.current.read",
"classification": "read",
"status": "implemented",
"request": {
"method": "GET",
"url": "https://api.geliospro.com/api/v1/units",
},
"dataProductIds": ["fleet.positions.current.v2"],
}],
"publisher": {
"nodeType": "n8n-nodes-ndc.ndcDataProductPublish",
"credentialType": "ndcDataProductWriterApi",
},
}],
}
installed_catalog = f"{json.dumps(v3_catalog, indent=2)}\n"
self.assertEqual(
hashlib.sha256(installed_catalog.encode()).hexdigest(),
RUNNER.ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CATALOG_PREDECESSOR_SHA256,
)
for rel in RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES:
if rel == "nodedc-source/server/dataProductPublishGrant":
continue
for destination in (root, payload):
path = destination / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"shared:{rel}\n", encoding="utf-8")
for name in RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_SOURCE_FILES:
rel = f"nodedc-source/server/dataProductPublishGrant/{name}"
for destination in (root, payload):
path = destination / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"installed:{name}\n", encoding="utf-8")
catalog_rel = RUNNER.ENGINE_PROVIDER_SECURITY_CATALOG_REL
(root / catalog_rel).write_text(installed_catalog, encoding="utf-8")
for rel in RUNNER.ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CHANGED_PATHS:
source = engine_source / rel
(payload / rel).write_bytes(source.read_bytes())
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
RUNNER.COMPONENTS["engine"]["payload_root"] = root
try:
with mock.patch.object(
RUNNER,
"validate_installed_engine_data_product_publish_grant_foundation",
return_value="compose-sha256",
):
self.assertEqual(
RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload),
{
"mode": "installed-composite-provider-update",
"compose_sha256": "compose-sha256",
"changed_paths": RUNNER.ENGINE_PUBLISH_GRANT_COMPOSITE_PROVIDER_CHANGED_PATHS,
},
)
(root / catalog_rel).write_text("{}\n", encoding="utf-8")
with self.assertRaisesRegex(
RUNNER.DeployError,
"composite provider catalog transition mismatch",
):
RUNNER.preflight_engine_data_product_publish_grant_predecessor(payload)
finally:
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
def test_edp_healthcheck_requires_database_and_managed_provisioning(self):
checks = RUNNER.component_healthchecks(
"platform",
("platform/services/external-data-plane/src/server.mjs",),
("external-data-plane",),
)
self.assertEqual(
checks,
(RUNNER.external_data_plane_healthcheck(),),
)
def test_edp_healthcheck_requires_foundry_provisioner_for_the_new_source(self):
with tempfile.TemporaryDirectory(prefix="nodedc-edp-foundry-health-") as directory:
root = Path(directory)
source = root / "platform/services/external-data-plane/src/server.mjs"
source.parent.mkdir(parents=True)
source.write_text(
'foundryReaderBindingProvisioning: config.foundryProvisionerApiEnabled ? "digest+server-resolved-source" : "disabled"\n',
encoding="utf-8",
)
original_root = RUNNER.COMPONENTS["platform"]["payload_root"]
RUNNER.COMPONENTS["platform"]["payload_root"] = root
try:
expected = RUNNER.external_data_plane_healthcheck()["expected_json"]
finally:
RUNNER.COMPONENTS["platform"]["payload_root"] = original_root
self.assertEqual(
expected["foundryReaderBindingProvisioning"],
"digest+server-resolved-source",
)
self.assertEqual(expected["foundryReaderBindingLifetime"], "explicit-revoke")
def test_module_foundry_healthcheck_tracks_candidate_and_rollback_source(self):
with tempfile.TemporaryDirectory(prefix="nodedc-foundry-health-") as directory:
root = Path(directory)
source = root / "server/catalog-server.mjs"
source.parent.mkdir(parents=True)
original_root = RUNNER.COMPONENTS["module-foundry"]["payload_root"]
RUNNER.COMPONENTS["module-foundry"]["payload_root"] = root
try:
source.write_text("const dataProductConsumerProvisioner = true;\n", encoding="utf-8")
candidate = RUNNER.module_foundry_healthcheck()["expected_json"]
source.write_text("const previous = true;\n", encoding="utf-8")
rollback = RUNNER.module_foundry_healthcheck()["expected_json"]
finally:
RUNNER.COMPONENTS["module-foundry"]["payload_root"] = original_root
self.assertEqual(candidate["dataProductConsumerProvisioner"]["configured"], True)
self.assertNotIn("dataProductConsumerProvisioner", rollback)
def test_edp_runtime_never_probes_unselected_platform_services(self):
entries = ("platform/services/external-data-plane/src/server.mjs",)
services = ("external-data-plane",)
with mock.patch.object(RUNNER, "healthcheck_url") as healthcheck:
RUNNER.run_healthchecks("platform", entries, services)
healthcheck.assert_called_once_with(
RUNNER.external_data_plane_healthcheck(),
)
def test_json_healthcheck_accepts_expected_contract(self):
class Response:
status = 200
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self, _limit):
return json.dumps({
"ok": True,
"database": "ready",
}).encode("utf-8")
with mock.patch.object(
RUNNER.NO_REDIRECT_OPENER,
"open",
return_value=Response(),
):
RUNNER.healthcheck_url({
"url": "http://127.0.0.1:18106/healthz",
"expected_json": {
"ok": True,
"database": "ready",
},
})
def test_json_healthcheck_accepts_restored_edp_baseline_contract(self):
class Response:
status = 200
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self, _limit):
return json.dumps({
"ok": True,
"service": "nodedc-external-data-plane",
"database": "ready",
}).encode("utf-8")
with mock.patch.object(
RUNNER.NO_REDIRECT_OPENER,
"open",
return_value=Response(),
):
RUNNER.healthcheck_url(
RUNNER.external_data_plane_healthcheck(require_managed=False),
)
def test_json_healthcheck_does_not_accept_redirect(self):
class Response:
status = 200
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self, _limit):
return json.dumps({
"ok": True,
"database": "ready",
}).encode("utf-8")
url = "http://127.0.0.1:18106/healthz"
redirect = RUNNER.urllib.error.HTTPError(
url,
302,
"Found",
{},
None,
)
with (
mock.patch.object(
RUNNER.NO_REDIRECT_OPENER,
"open",
side_effect=(redirect, Response()),
) as opener,
mock.patch.object(RUNNER.time, "sleep"),
):
RUNNER.healthcheck_url({
"url": url,
"expected_json": {
"ok": True,
"database": "ready",
},
})
self.assertEqual(opener.call_count, 2)
def test_publish_grant_healthcheck_does_not_enter_n8n_transition(self):
services = ("nodedc-backend",)
with (
mock.patch.object(RUNNER, "accept_engine_n8n_runtime") as accept,
mock.patch.object(RUNNER, "healthcheck_compose_service") as compose_health,
mock.patch.object(RUNNER, "healthcheck_url") as healthcheck,
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
) as backend_preflight,
):
RUNNER.run_healthchecks(
"engine",
RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES,
services,
)
accept.assert_not_called()
compose_health.assert_called_once_with("engine", "nodedc-backend")
healthcheck.assert_called_once_with(
"http://127.0.0.1:3001/health",
)
backend_preflight.assert_called_once_with()
def test_engine_ui_healthcheck_requires_selected_app(self):
grant_entries = RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_ARTIFACT_ENTRIES
self.assertEqual(
RUNNER.component_healthchecks(
"engine",
grant_entries,
("nodedc-backend",),
),
("http://127.0.0.1:3001/health",),
)
self.assertEqual(
RUNNER.component_healthchecks(
"engine",
("docker-compose.yml",),
("nodedc-backend", "app"),
),
(
"http://127.0.0.1:8080/",
"http://127.0.0.1:3001/health",
),
)
def test_optional_edp_overlay_is_omitted_until_installed(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-compose-files-",
) as directory:
temporary = Path(directory)
base = temporary / "docker-compose.platform-http.yml"
edp = temporary / "docker-compose.external-data-plane.yml"
base.write_text(
"name: nodedc-platform\n",
encoding="utf-8",
)
original = RUNNER.COMPONENTS["platform"]["compose_files"]
RUNNER.COMPONENTS["platform"]["compose_files"] = (base, edp)
try:
self.assertEqual(
RUNNER.component_compose_files("platform"),
(base,),
)
finally:
RUNNER.COMPONENTS["platform"]["compose_files"] = original
class PlatformOverlayRollbackTest(unittest.TestCase):
def test_engine_runtime_failure_restores_source_and_recreates_exact_services(self):
with tempfile.TemporaryDirectory(prefix="nodedc-engine-runtime-rollback-") as directory:
temporary = Path(directory)
root = temporary / "engine"
backup = temporary / "backup"
runner_tmp = temporary / "tmp"
compose = root / "docker-compose.yml"
backend = root / "nodedc-source/server/index.js"
compose.parent.mkdir(parents=True)
backend.parent.mkdir(parents=True)
backup.mkdir()
runner_tmp.mkdir()
compose.write_text("name: baseline\n", encoding="utf-8")
backend.write_text("export const version = 1;\n", encoding="utf-8")
entries = ["docker-compose.yml", "nodedc-source/server/index.js"]
services = ("n8n", "nodedc-backend", "app")
RUNNER.create_backup(root, backup, entries, include_nginx_html=False)
compose.write_text("name: candidate\n", encoding="utf-8")
backend.write_text("export const version = 2;\n", encoding="utf-8")
def assert_restored(component, rollback_entries, rollback_services):
self.assertEqual(component, "engine")
self.assertEqual(rollback_entries, entries)
self.assertEqual(rollback_services, services)
self.assertEqual(compose.read_text(encoding="utf-8"), "name: baseline\n")
self.assertEqual(
backend.read_text(encoding="utf-8"),
"export const version = 1;\n",
)
with (
mock.patch.object(RUNNER, "TMP_DIR", runner_tmp),
mock.patch.object(
RUNNER,
"run_component_runtime",
side_effect=assert_restored,
) as runtime,
mock.patch.object(RUNNER, "run_healthchecks") as healthchecks,
):
status = RUNNER.rollback_engine_apply(
root,
backup,
entries,
"test-stamp",
runtime_started=True,
applied_services=services,
)
self.assertEqual(status, "source+runtime-restored:2")
runtime.assert_called_once()
healthchecks.assert_called_once_with("engine", entries, services)
def test_candidate_only_service_cleanup_never_requests_volume_removal(self):
with (
mock.patch.object(RUNNER, "component_compose_root", return_value=Path("/live/platform")),
mock.patch.object(RUNNER, "compose_base_cmd", return_value=["docker", "compose"]),
mock.patch.object(RUNNER.subprocess, "run") as run,
):
RUNNER.stop_and_remove_compose_services("platform", ("external-data-plane",))
command = run.call_args.args[0]
self.assertEqual(
command,
["docker", "compose", "rm", "--stop", "--force", "external-data-plane"],
)
self.assertNotIn("--volumes", command)
def test_failed_first_edp_activation_removes_containers_without_deleting_volume(self):
with tempfile.TemporaryDirectory(prefix="nodedc-platform-edp-rollback-") as directory:
temporary = Path(directory)
root = temporary / "root"
backup = temporary / "backup"
runner_tmp = temporary / "tmp"
overlay = root / RUNNER.PLATFORM_EXTERNAL_DATA_PLANE_COMPOSE_REL
overlay.parent.mkdir(parents=True)
backup.mkdir()
runner_tmp.mkdir()
entries = [RUNNER.PLATFORM_EXTERNAL_DATA_PLANE_COMPOSE_REL]
RUNNER.create_backup(root, backup, entries, include_nginx_html=False)
overlay.write_text("name: nodedc-platform\n", encoding="utf-8")
with (
mock.patch.object(RUNNER, "TMP_DIR", runner_tmp),
mock.patch.object(RUNNER, "stop_and_remove_compose_services") as remove,
mock.patch.object(RUNNER, "run_component_runtime") as runtime,
mock.patch.object(RUNNER, "run_healthchecks") as healthchecks,
):
status = RUNNER.rollback_platform_apply(
root,
backup,
entries,
"test-stamp",
runtime_started=True,
applied_services=("external-data-plane",),
)
remove.assert_called_once_with(
"platform",
("external-data-plane",),
)
runtime.assert_not_called()
healthchecks.assert_not_called()
self.assertFalse(overlay.exists())
self.assertEqual(status, "source+runtime-restored:1")
def test_candidate_cleanup_failure_still_restores_source_before_reporting_failure(self):
with tempfile.TemporaryDirectory(prefix="nodedc-platform-edp-rollback-") as directory:
temporary = Path(directory)
root = temporary / "root"
backup = temporary / "backup"
runner_tmp = temporary / "tmp"
overlay = root / RUNNER.PLATFORM_EXTERNAL_DATA_PLANE_COMPOSE_REL
overlay.parent.mkdir(parents=True)
backup.mkdir()
runner_tmp.mkdir()
entries = [RUNNER.PLATFORM_EXTERNAL_DATA_PLANE_COMPOSE_REL]
RUNNER.create_backup(root, backup, entries, include_nginx_html=False)
overlay.write_text("name: rejected\n", encoding="utf-8")
with (
mock.patch.object(RUNNER, "TMP_DIR", runner_tmp),
mock.patch.object(
RUNNER,
"stop_and_remove_compose_services",
side_effect=RuntimeError("simulated cleanup failure"),
),
):
with self.assertRaisesRegex(RUNNER.DeployError, "cleanup failed after source restore"):
RUNNER.rollback_platform_apply(
root,
backup,
entries,
"test-stamp",
runtime_started=True,
applied_services=("external-data-plane",),
)
self.assertFalse(overlay.exists())
def test_runtime_failure_rebuilds_recreates_and_accepts_restored_overlay(self):
with tempfile.TemporaryDirectory(prefix="nodedc-platform-runtime-rollback-") as directory:
temporary = Path(directory)
root = temporary / "root"
backup = temporary / "backup"
runner_tmp = temporary / "tmp"
gateway = root / "platform/gelios-gateway"
gateway.mkdir(parents=True)
backup.mkdir()
runner_tmp.mkdir()
source = gateway / "server.mjs"
source.write_text("export const version = 1;\n", encoding="utf-8")
entries = ["platform/gelios-gateway"]
RUNNER.create_backup(root, backup, entries, include_nginx_html=False)
source.write_text("export const version = 2;\n", encoding="utf-8")
def assert_restored_runtime(component, rollback_entries, services):
self.assertEqual(component, "platform")
self.assertEqual(rollback_entries, entries)
self.assertEqual(services, ("gelios-postgres", "gelios-gateway"))
self.assertEqual(source.read_text(encoding="utf-8"), "export const version = 1;\n")
with (
mock.patch.object(RUNNER, "TMP_DIR", runner_tmp),
mock.patch.object(
RUNNER,
"run_component_runtime",
side_effect=assert_restored_runtime,
) as runtime,
mock.patch.object(RUNNER, "run_healthchecks") as healthchecks,
):
status = RUNNER.rollback_platform_apply(
root,
backup,
entries,
"test-stamp",
runtime_started=True,
applied_services=("gelios-postgres", "gelios-gateway"),
)
self.assertEqual(status, "source+runtime-restored:1")
runtime.assert_called_once()
healthchecks.assert_called_once_with(
"platform",
entries,
("gelios-postgres", "gelios-gateway"),
)
def test_existing_edp_rollback_accepts_only_restored_edp(self):
with tempfile.TemporaryDirectory(prefix="nodedc-platform-edp-runtime-rollback-") as directory:
temporary = Path(directory)
root = temporary / "root"
backup = temporary / "backup"
runner_tmp = temporary / "tmp"
service = root / "platform/services/external-data-plane"
service.mkdir(parents=True)
backup.mkdir()
runner_tmp.mkdir()
source = service / "server.mjs"
source.write_text("export const version = 1;\n", encoding="utf-8")
entries = ["platform/services/external-data-plane"]
RUNNER.create_backup(root, backup, entries, include_nginx_html=False)
source.write_text("export const version = 2;\n", encoding="utf-8")
with (
mock.patch.object(RUNNER, "TMP_DIR", runner_tmp),
mock.patch.object(RUNNER, "run_component_runtime") as runtime,
mock.patch.object(RUNNER, "healthcheck_url") as healthcheck,
):
status = RUNNER.rollback_platform_apply(
root,
backup,
entries,
"test-stamp",
runtime_started=True,
applied_services=("external-data-plane",),
)
self.assertEqual(status, "source+runtime-restored:1")
self.assertEqual(
source.read_text(encoding="utf-8"),
"export const version = 1;\n",
)
runtime.assert_called_once_with(
"platform",
entries,
("external-data-plane",),
)
healthcheck.assert_called_once_with(
RUNNER.external_data_plane_healthcheck(require_managed=False),
)
def test_restores_existing_overlay_and_removes_new_paths(self):
with tempfile.TemporaryDirectory(prefix="nodedc-platform-rollback-test-") as directory:
temporary = Path(directory)
root = temporary / "root"
backup = temporary / "backup"
runner_tmp = temporary / "tmp"
root.mkdir()
backup.mkdir()
runner_tmp.mkdir()
compose = root / "platform/docker-compose.platform-http.yml"
gateway = root / "platform/gelios-gateway"
new_service = root / "platform/services/new-service"
unrelated = root / "platform/unrelated.txt"
compose.parent.mkdir(parents=True)
gateway.mkdir()
compose.write_text("name: nodedc-platform\n", encoding="utf-8")
executable = gateway / "run.sh"
executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
executable.chmod(0o755)
(gateway / "server.mjs").write_text("export const version = 1;\n", encoding="utf-8")
unrelated.write_text("untouched\n", encoding="utf-8")
entries = [
"platform/docker-compose.platform-http.yml",
"platform/gelios-gateway",
"platform/services/new-service",
]
RUNNER.create_backup(root, backup, entries, include_nginx_html=False)
compose.write_text("name: broken\n", encoding="utf-8")
(gateway / "server.mjs").write_text("export const version = 2;\n", encoding="utf-8")
executable.chmod(0o644)
new_service.mkdir(parents=True)
(new_service / "bad.mjs").write_text("broken\n", encoding="utf-8")
with mock.patch.object(RUNNER, "TMP_DIR", runner_tmp):
restored = RUNNER.restore_platform_overlay(root, backup, entries, "test-stamp")
self.assertEqual(restored, len(entries))
self.assertEqual(compose.read_text(encoding="utf-8"), "name: nodedc-platform\n")
self.assertEqual(
(gateway / "server.mjs").read_text(encoding="utf-8"),
"export const version = 1;\n",
)
self.assertTrue(stat.S_IMODE((gateway / "run.sh").stat().st_mode) & stat.S_IXUSR)
self.assertFalse(new_service.exists())
self.assertEqual(unrelated.read_text(encoding="utf-8"), "untouched\n")
def test_rejects_inconsistent_backup_partition_before_restore(self):
with tempfile.TemporaryDirectory(prefix="nodedc-platform-rollback-test-") as directory:
temporary = Path(directory)
root = temporary / "root"
backup = temporary / "backup"
runner_tmp = temporary / "tmp"
target = root / "platform/docker-compose.platform-http.yml"
target.parent.mkdir(parents=True)
target.write_text("original\n", encoding="utf-8")
backup.mkdir()
runner_tmp.mkdir()
entries = ["platform/docker-compose.platform-http.yml"]
RUNNER.create_backup(root, backup, entries, include_nginx_html=False)
(backup / "missing-files.txt").write_text(
"platform/docker-compose.platform-http.yml\n",
encoding="utf-8",
)
target.write_text("candidate\n", encoding="utf-8")
with mock.patch.object(RUNNER, "TMP_DIR", runner_tmp):
with self.assertRaisesRegex(RUNNER.DeployError, "backup entry set mismatch"):
RUNNER.restore_platform_overlay(root, backup, entries, "test-stamp")
self.assertEqual(target.read_text(encoding="utf-8"), "candidate\n")
if __name__ == "__main__":
unittest.main(verbosity=2)