#!/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)