From 26dbcfd262503ebf9c27f3c01cb23dd3be416937 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 21 Aug 2026 16:23:59 +0300 Subject: [PATCH 1/9] deploy: keep manager health out of plan --- infra/deploy-runner/nodedc-deploy | 51 +++++++++++--- .../test_device_plane_registry.py | 70 +++++++++++++++++++ 2 files changed, 111 insertions(+), 10 deletions(-) diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index 657e9a5..d0b2081 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -12234,7 +12234,33 @@ def validate_device_plane_manager_v3_active_baseline(descriptor): } -def validate_device_plane_manager_activation_predecessor(payload_dir): +def device_plane_manager_preserved_runtime_health_services(descriptor): + if descriptor.get("schemaVersion") == ( + "nodedc.device-plane.device-manager-release.v3" + ): + return ( + "device-control-core", + "device-postgres", + ) + return ("device-postgres",) + + +def validate_device_plane_manager_preserved_runtime_health(descriptor): + services = device_plane_manager_preserved_runtime_health_services( + descriptor + ) + for service in services: + healthcheck_compose_service("device-plane", service) + return services + + +def validate_device_plane_manager_activation_predecessor( + payload_dir, + *, + preflight_phase, +): + if preflight_phase not in ("plan", "apply"): + die("Device Manager predecessor preflight phase is invalid") descriptor = validate_device_plane_manager_release_payload( payload_dir ) @@ -12367,13 +12393,16 @@ def validate_device_plane_manager_activation_predecessor(payload_dir): ) if installed_descriptor != predecessor_descriptor: die("Device Manager release predecessor is not current") - for service in ( - "device-control-core", - "device-manager", - "device-postgres", - ): - healthcheck_compose_service("device-plane", service) - runtime = {"accepted": True} + runtime = { + "accepted": True, + "preservedRuntimeHealth": "deferred-to-apply", + } + if preflight_phase == "apply": + runtime["preservedRuntimeHealth"] = ( + validate_device_plane_manager_preserved_runtime_health( + descriptor + ) + ) v3_baseline = validate_device_plane_manager_v3_active_baseline( descriptor ) @@ -19306,7 +19335,8 @@ def plan_artifact(artifact): ): device_plane_manager_activation_preflight = ( validate_device_plane_manager_activation_predecessor( - payload_dir + payload_dir, + preflight_phase="plan", ) ) if is_device_plane_edge_core_channel_bootstrap_slice( @@ -24614,7 +24644,8 @@ def apply_artifact(artifact): entries, ): validate_device_plane_manager_activation_predecessor( - payload_dir + payload_dir, + preflight_phase="apply", ) if is_device_plane_edge_core_channel_bootstrap_slice( component, diff --git a/infra/deploy-runner/test_device_plane_registry.py b/infra/deploy-runner/test_device_plane_registry.py index 629acd6..83cc8e6 100644 --- a/infra/deploy-runner/test_device_plane_registry.py +++ b/infra/deploy-runner/test_device_plane_registry.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import importlib.machinery import importlib.util +import inspect import json import tempfile import unittest @@ -27,6 +28,75 @@ RUNNER = load_runner() class DevicePlaneRegistryTest(unittest.TestCase): + def test_manager_predecessor_health_is_phase_scoped(self): + with self.assertRaisesRegex( + RUNNER.DeployError, + "preflight phase is invalid", + ): + RUNNER.validate_device_plane_manager_activation_predecessor( + Path("/not-used"), + preflight_phase="unknown", + ) + + plan_source = inspect.getsource(RUNNER.plan_artifact) + self.assertIn('preflight_phase="plan"', plan_source) + apply_source = inspect.getsource(RUNNER.apply_artifact) + self.assertIn('preflight_phase="apply"', apply_source) + + def test_manager_v3_health_gate_checks_only_preserved_services(self): + descriptor = { + "schemaVersion": ( + "nodedc.device-plane.device-manager-release.v3" + ), + } + with mock.patch.object( + RUNNER, + "healthcheck_compose_service", + ) as healthcheck: + services = ( + RUNNER.validate_device_plane_manager_preserved_runtime_health( + descriptor + ) + ) + + self.assertEqual( + services, + ("device-control-core", "device-postgres"), + ) + self.assertEqual( + healthcheck.call_args_list, + [ + mock.call("device-plane", "device-control-core"), + mock.call("device-plane", "device-postgres"), + ], + ) + self.assertNotIn( + mock.call("device-plane", "device-manager"), + healthcheck.call_args_list, + ) + + def test_legacy_manager_health_gate_excludes_selected_services(self): + descriptor = { + "schemaVersion": ( + "nodedc.device-plane.device-manager-release.v2" + ), + } + with mock.patch.object( + RUNNER, + "healthcheck_compose_service", + ) as healthcheck: + services = ( + RUNNER.validate_device_plane_manager_preserved_runtime_health( + descriptor + ) + ) + + self.assertEqual(services, ("device-postgres",)) + healthcheck.assert_called_once_with( + "device-plane", + "device-postgres", + ) + def test_registry_has_exact_roots_project_and_stateless_services(self): component = RUNNER.COMPONENTS["device-plane"] root = Path("/volume1/docker/nodedc-device-plane") From c1c9818e3064f0cff0fcc34eb5132d0c0b51800e Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 22 Aug 2026 01:27:00 +0300 Subject: [PATCH 2/9] fix(deploy): allow unhealthy selected Core recovery --- infra/deploy-runner/nodedc-deploy | 155 +++++++++++--- .../test_device_plane_registry.py | 191 ++++++++++++++++++ 2 files changed, 323 insertions(+), 23 deletions(-) diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index d0b2081..f085a74 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -12877,7 +12877,35 @@ def validate_device_plane_edge_core_channel_upgrade_v4_predecessor( } -def validate_device_plane_control_core_release_predecessor(payload_dir): +def device_plane_control_core_preserved_runtime_health_services(descriptor): + services = tuple(descriptor.get("preservedServices") or ()) + expected = ( + "device-manager", + "device-gateway", + "device-postgres", + "device-backhaul-target", + ) + if services != expected: + die("Device Control Core preserved service set mismatch") + return services + + +def validate_device_plane_control_core_preserved_runtime_health(descriptor): + services = device_plane_control_core_preserved_runtime_health_services( + descriptor + ) + for service in services: + healthcheck_compose_service("device-plane", service) + return services + + +def validate_device_plane_control_core_release_predecessor( + payload_dir, + *, + preflight_phase, +): + if preflight_phase not in ("plan", "apply"): + die("Device Control Core predecessor preflight phase is invalid") descriptor = validate_device_plane_control_core_release_payload( payload_dir ) @@ -12981,13 +13009,16 @@ def validate_device_plane_control_core_release_predecessor(payload_dir): identity_state = inspect_device_edge_channel_core_identity_state() if identity_state != "valid-reuse-at-apply": die("Device Control Core release requires the valid active identity") - for service in ( - "device-control-core", - "device-manager", - "device-gateway", - "device-postgres", - ): - healthcheck_compose_service("device-plane", service) + selected_runtime = ( + validate_device_plane_control_core_selected_predecessor_runtime() + ) + preserved_runtime_health = "deferred-to-apply" + if preflight_phase == "apply": + preserved_runtime_health = ( + validate_device_plane_control_core_preserved_runtime_health( + descriptor + ) + ) validate_device_manager_control_plane_runtime( require_edge_channel=True, core_network_mode="private-egress", @@ -12997,6 +13028,8 @@ def validate_device_plane_control_core_release_predecessor(payload_dir): "descriptor": descriptor, "identityState": identity_state, "predecessorArtifact": artifact, + "selectedRuntime": selected_runtime, + "preservedRuntimeHealth": preserved_runtime_health, } @@ -13287,6 +13320,7 @@ def device_plane_inventory_service_names(inventory): or item.get("service") not in ( *DEVICE_PLANE_RUNTIME_SERVICES, "device-manager", + DEVICE_PLANE_BACKHAUL_TARGET_SERVICE, ) or not isinstance(item.get("containerId"), str) or not re.fullmatch(r"[a-f0-9]{64}", item["containerId"]) @@ -13311,6 +13345,80 @@ def device_plane_inventory_service_names(inventory): return tuple(names) +def validate_device_plane_control_core_selected_predecessor_runtime(): + inventory = device_plane_runtime_inventory(("device-control-core",)) + names = device_plane_inventory_service_names(inventory) + if names != ("device-control-core",): + die("Device Control Core selected predecessor runtime is missing") + selected = inventory["services"][0] + if ( + selected["status"] != "running" + or selected["running"] is not True + or selected["health"] not in ("healthy", "unhealthy") + ): + die("Device Control Core selected predecessor state is invalid") + return selected + + +def accept_device_plane_control_core_rollback_runtime(runtime_before): + expected_services = ( + "device-control-core", + "device-manager", + "device-gateway", + "device-postgres", + "device-backhaul-target", + ) + names = device_plane_inventory_service_names(runtime_before) + if set(names) != set(expected_services): + die("Device Control Core rollback predecessor inventory mismatch") + before = { + item["service"]: item + for item in runtime_before["services"] + } + selected_before = before["device-control-core"] + if ( + selected_before["status"] != "running" + or selected_before["running"] is not True + or selected_before["health"] not in ("healthy", "unhealthy") + ): + die("Device Control Core rollback predecessor state is invalid") + + for service in expected_services[1:]: + healthcheck_compose_service_with_grace("device-plane", service) + + container_id = compose_service_container_id( + "device-plane", + "device-control-core", + ) + if container_id == selected_before["containerId"]: + die("Device Control Core rollback generation was not recreated") + accepted_health = {"healthy"} + if selected_before["health"] == "unhealthy": + accepted_health.add("unhealthy") + last_status = "unknown" + for attempt in range(1, 61): + container = inspect_device_plane_container(container_id) + state = container.get("State") or {} + health = (state.get("Health") or {}).get("Status") + last_status = health or state.get("Status") or "unknown" + if ( + state.get("Status") == "running" + and state.get("Running") is True + and health in accepted_health + ): + return { + "containerId": container_id, + "health": health, + "predecessorHealth": selected_before["health"], + } + if attempt < 60: + time.sleep(5) + die( + "Device Control Core rollback state did not converge: " + f"{last_status}" + ) + + def validate_device_plane_foundation_recovery_evidence(payload_dir): backup_dir = BACKUPS_DIR / DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_ID try: @@ -19354,7 +19462,8 @@ def plan_artifact(artifact): ): device_plane_control_core_release_preflight = ( validate_device_plane_control_core_release_predecessor( - payload_dir + payload_dir, + preflight_phase="plan", ) ) if is_device_plane_manager_reconciliation_slice( @@ -21821,16 +21930,9 @@ def rollback_device_plane_apply( "device-plane", entries, ): - for service in ( - "device-control-core", - "device-manager", - "device-gateway", - "device-postgres", - ): - healthcheck_compose_service_with_grace( - "device-plane", - service, - ) + accept_device_plane_control_core_rollback_runtime( + runtime_inventory + ) validate_device_manager_control_plane_runtime( require_edge_channel=True, core_network_mode="private-egress", @@ -23595,6 +23697,7 @@ def run_healthchecks(component, entries=None, services=None): "device-manager", "device-gateway", "device-postgres", + "device-backhaul-target", ): healthcheck_compose_service_with_grace( "device-plane", @@ -24659,7 +24762,8 @@ def apply_artifact(artifact): entries, ): validate_device_plane_control_core_release_predecessor( - payload_dir + payload_dir, + preflight_phase="apply", ) if is_device_plane_manager_reconciliation_slice( component, @@ -24986,11 +25090,16 @@ def apply_artifact(artifact): component, entries, ) - or is_device_plane_edge_core_channel_bootstrap_slice( + ): + inventory_services = ( + *inventory_services, + "device-manager", + "device-backhaul-target", + ) + elif is_device_plane_edge_core_channel_bootstrap_slice( component, entries, - ) - ): + ): inventory_services = ( *inventory_services, "device-manager", diff --git a/infra/deploy-runner/test_device_plane_registry.py b/infra/deploy-runner/test_device_plane_registry.py index 83cc8e6..74cccbf 100644 --- a/infra/deploy-runner/test_device_plane_registry.py +++ b/infra/deploy-runner/test_device_plane_registry.py @@ -28,6 +28,197 @@ RUNNER = load_runner() class DevicePlaneRegistryTest(unittest.TestCase): + def test_control_core_predecessor_health_is_phase_scoped(self): + with self.assertRaisesRegex( + RUNNER.DeployError, + "preflight phase is invalid", + ): + RUNNER.validate_device_plane_control_core_release_predecessor( + Path("/not-used"), + preflight_phase="unknown", + ) + + plan_source = inspect.getsource(RUNNER.plan_artifact) + self.assertIn( + 'validate_device_plane_control_core_release_predecessor(\n' + ' payload_dir,\n' + ' preflight_phase="plan",', + plan_source, + ) + apply_source = inspect.getsource(RUNNER.apply_artifact) + self.assertIn( + 'validate_device_plane_control_core_release_predecessor(\n' + ' payload_dir,\n' + ' preflight_phase="apply",', + apply_source, + ) + + def test_control_core_preflight_health_excludes_selected_target(self): + descriptor = { + "preservedServices": [ + "device-manager", + "device-gateway", + "device-postgres", + "device-backhaul-target", + ], + } + with mock.patch.object( + RUNNER, + "healthcheck_compose_service", + ) as healthcheck: + services = ( + RUNNER.validate_device_plane_control_core_preserved_runtime_health( + descriptor + ) + ) + + self.assertEqual( + services, + ( + "device-manager", + "device-gateway", + "device-postgres", + "device-backhaul-target", + ), + ) + self.assertEqual( + healthcheck.call_args_list, + [ + mock.call("device-plane", "device-manager"), + mock.call("device-plane", "device-gateway"), + mock.call("device-plane", "device-postgres"), + mock.call("device-plane", "device-backhaul-target"), + ], + ) + self.assertNotIn( + mock.call("device-plane", "device-control-core"), + healthcheck.call_args_list, + ) + + def test_control_core_selected_predecessor_may_be_unhealthy(self): + inventory = { + "schemaVersion": "nodedc.device-plane.runtime-inventory.v1", + "composeProject": "nodedc-device-plane", + "services": [{ + "service": "device-control-core", + "containerId": "a" * 64, + "imageId": "sha256:" + "b" * 64, + "status": "running", + "running": True, + "health": "unhealthy", + "restartCount": 4, + }], + } + with mock.patch.object( + RUNNER, + "device_plane_runtime_inventory", + return_value=inventory, + ): + selected = ( + RUNNER.validate_device_plane_control_core_selected_predecessor_runtime() + ) + self.assertEqual(selected["health"], "unhealthy") + + def test_control_core_rollback_accepts_restored_unhealthy_boundary(self): + service_names = ( + "device-control-core", + "device-manager", + "device-gateway", + "device-postgres", + "device-backhaul-target", + ) + inventory = { + "schemaVersion": "nodedc.device-plane.runtime-inventory.v1", + "composeProject": "nodedc-device-plane", + "services": [ + { + "service": service, + "containerId": chr(97 + index) * 64, + "imageId": "sha256:" + str(index + 1) * 64, + "status": "running", + "running": True, + "health": "unhealthy" if index == 0 else "healthy", + "restartCount": index, + } + for index, service in enumerate(service_names) + ], + } + restored_id = "f" * 64 + restored = { + "Id": restored_id, + "State": { + "Status": "running", + "Running": True, + "Health": {"Status": "unhealthy"}, + }, + } + with ( + mock.patch.object( + RUNNER, + "healthcheck_compose_service_with_grace", + ) as preserved_health, + mock.patch.object( + RUNNER, + "compose_service_container_id", + return_value=restored_id, + ), + mock.patch.object( + RUNNER, + "inspect_device_plane_container", + return_value=restored, + ), + ): + accepted = ( + RUNNER.accept_device_plane_control_core_rollback_runtime( + inventory + ) + ) + + self.assertEqual(accepted["health"], "unhealthy") + self.assertEqual(accepted["predecessorHealth"], "unhealthy") + self.assertEqual( + preserved_health.call_args_list, + [ + mock.call("device-plane", "device-manager"), + mock.call("device-plane", "device-gateway"), + mock.call("device-plane", "device-postgres"), + mock.call("device-plane", "device-backhaul-target"), + ], + ) + + def test_control_core_post_apply_health_includes_backhaul(self): + with ( + mock.patch.object( + RUNNER, + "healthcheck_compose_service_with_grace", + ) as healthcheck, + mock.patch.object( + RUNNER, + "component_healthchecks", + return_value=(), + ), + mock.patch.object( + RUNNER, + "validate_device_manager_control_plane_runtime", + ), + ): + RUNNER.run_healthchecks( + "device-plane", + RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_ENTRIES, + ("device-control-core",), + ) + + self.assertEqual( + healthcheck.call_args_list, + [ + mock.call("device-plane", "device-control-core"), + mock.call("device-plane", "device-manager"), + mock.call("device-plane", "device-gateway"), + mock.call("device-plane", "device-postgres"), + mock.call("device-plane", "device-backhaul-target"), + ], + ) + def test_manager_predecessor_health_is_phase_scoped(self): with self.assertRaisesRegex( RUNNER.DeployError, From 94e437e8428c0ecc538728f780db7e1598cf3d39 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 22 Aug 2026 01:43:11 +0300 Subject: [PATCH 3/9] fix(deploy): accept selected Core restart states --- infra/deploy-runner/nodedc-deploy | 47 +++++++++++-------- .../test_device_plane_registry.py | 26 ++++++++++ 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index f085a74..2db792c 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -13350,14 +13350,10 @@ def validate_device_plane_control_core_selected_predecessor_runtime(): names = device_plane_inventory_service_names(inventory) if names != ("device-control-core",): die("Device Control Core selected predecessor runtime is missing") - selected = inventory["services"][0] - if ( - selected["status"] != "running" - or selected["running"] is not True - or selected["health"] not in ("healthy", "unhealthy") - ): - die("Device Control Core selected predecessor state is invalid") - return selected + # This is the selected repair target, not a preserved dependency. Its + # observed Docker state is evidence for rollback only; health and state + # acceptance belongs to the new generation after recreate. + return inventory["services"][0] def accept_device_plane_control_core_rollback_runtime(runtime_before): @@ -13376,12 +13372,11 @@ def accept_device_plane_control_core_rollback_runtime(runtime_before): for item in runtime_before["services"] } selected_before = before["device-control-core"] - if ( - selected_before["status"] != "running" - or selected_before["running"] is not True - or selected_before["health"] not in ("healthy", "unhealthy") - ): - die("Device Control Core rollback predecessor state is invalid") + predecessor_was_healthy = ( + selected_before["status"] == "running" + and selected_before["running"] is True + and selected_before["health"] == "healthy" + ) for service in expected_services[1:]: healthcheck_compose_service_with_grace("device-plane", service) @@ -13392,24 +13387,38 @@ def accept_device_plane_control_core_rollback_runtime(runtime_before): ) if container_id == selected_before["containerId"]: die("Device Control Core rollback generation was not recreated") - accepted_health = {"healthy"} - if selected_before["health"] == "unhealthy": - accepted_health.add("unhealthy") last_status = "unknown" for attempt in range(1, 61): container = inspect_device_plane_container(container_id) state = container.get("State") or {} health = (state.get("Health") or {}).get("Status") last_status = health or state.get("Status") or "unknown" - if ( + restored_healthy = ( state.get("Status") == "running" and state.get("Running") is True - and health in accepted_health + and health == "healthy" + ) + restored_repair_boundary = ( + state.get("Status") in ( + "created", + "running", + "paused", + "restarting", + "exited", + ) + and isinstance(state.get("Running"), bool) + and health in (None, "starting", "healthy", "unhealthy") + ) + if restored_healthy or ( + not predecessor_was_healthy + and restored_repair_boundary ): return { "containerId": container_id, "health": health, + "status": state.get("Status"), "predecessorHealth": selected_before["health"], + "predecessorStatus": selected_before["status"], } if attempt < 60: time.sleep(5) diff --git a/infra/deploy-runner/test_device_plane_registry.py b/infra/deploy-runner/test_device_plane_registry.py index 74cccbf..cbecfdd 100644 --- a/infra/deploy-runner/test_device_plane_registry.py +++ b/infra/deploy-runner/test_device_plane_registry.py @@ -119,6 +119,31 @@ class DevicePlaneRegistryTest(unittest.TestCase): ) self.assertEqual(selected["health"], "unhealthy") + def test_control_core_selected_predecessor_may_be_restarting(self): + inventory = { + "schemaVersion": "nodedc.device-plane.runtime-inventory.v1", + "composeProject": "nodedc-device-plane", + "services": [{ + "service": "device-control-core", + "containerId": "a" * 64, + "imageId": "sha256:" + "b" * 64, + "status": "restarting", + "running": True, + "health": "starting", + "restartCount": 5, + }], + } + with mock.patch.object( + RUNNER, + "device_plane_runtime_inventory", + return_value=inventory, + ): + selected = ( + RUNNER.validate_device_plane_control_core_selected_predecessor_runtime() + ) + self.assertEqual(selected["status"], "restarting") + self.assertEqual(selected["health"], "starting") + def test_control_core_rollback_accepts_restored_unhealthy_boundary(self): service_names = ( "device-control-core", @@ -175,6 +200,7 @@ class DevicePlaneRegistryTest(unittest.TestCase): ) self.assertEqual(accepted["health"], "unhealthy") + self.assertEqual(accepted["status"], "running") self.assertEqual(accepted["predecessorHealth"], "unhealthy") self.assertEqual( preserved_health.call_args_list, From a6fb60d38f0262acac9bb8fdb188ef65ebb33c26 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 22 Aug 2026 02:10:42 +0300 Subject: [PATCH 4/9] deploy: pin manager v3 to Core recovery --- infra/deploy-runner/nodedc-deploy | 4 ++-- .../test_device_plane_registry.py | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index 2db792c..f02a26a 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -345,10 +345,10 @@ DEVICE_PLANE_CONTROL_CORE_RELEASE_FIRST_PREDECESSOR_ARTIFACT_SHA256 = ( "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc" ) DEVICE_PLANE_MANAGER_RELEASE_V3_CONTROL_CORE_PREDECESSOR_PATCH_ID = ( - "device-control-core-release-v2-20260813-028" + "device-control-core-release-v2-20260821-030" ) DEVICE_PLANE_MANAGER_RELEASE_V3_CONTROL_CORE_PREDECESSOR_ARTIFACT_SHA256 = ( - "816ef07fd81923328c3ff54d833dcc49f873aef13256259ae4f66061e7453635" + "8459521a662541a5a87cb0188991cdcfb51727427db8ec2a232ce4846bfc3454" ) DEVICE_PLANE_MANAGER_RELEASE_V3_EDGE_CHANNEL_PREDECESSOR_PATCH_ID = ( "device-edge-core-channel-upgrade-v4-20260812-023" diff --git a/infra/deploy-runner/test_device_plane_registry.py b/infra/deploy-runner/test_device_plane_registry.py index cbecfdd..95bb259 100644 --- a/infra/deploy-runner/test_device_plane_registry.py +++ b/infra/deploy-runner/test_device_plane_registry.py @@ -28,6 +28,29 @@ RUNNER = load_runner() class DevicePlaneRegistryTest(unittest.TestCase): + def test_manager_v3_targets_applied_control_core_recovery(self): + self.assertEqual( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V3_CONTROL_CORE_PREDECESSOR_PATCH_ID, + "device-control-core-release-v2-20260821-030", + ) + self.assertEqual( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V3_CONTROL_CORE_PREDECESSOR_ARTIFACT_SHA256, + "8459521a662541a5a87cb0188991cdcfb51727427db8ec2a232ce4846bfc3454", + ) + self.assertEqual( + RUNNER.expected_device_plane_manager_release_v3_boundaries()[ + "controlCorePredecessor" + ], + { + "patchId": ( + "device-control-core-release-v2-20260821-030" + ), + "artifactSha256": ( + "8459521a662541a5a87cb0188991cdcfb51727427db8ec2a232ce4846bfc3454" + ), + }, + ) + def test_control_core_predecessor_health_is_phase_scoped(self): with self.assertRaisesRegex( RUNNER.DeployError, From 12901e0e19f3660afd3aa335e7f07923c6604635 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 22 Aug 2026 09:09:32 +0300 Subject: [PATCH 5/9] fix(deploy): accept Manager v3 private egress --- infra/deploy-runner/nodedc-deploy | 8 +++++- .../test_device_plane_registry.py | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index f02a26a..7cc7812 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -23878,7 +23878,13 @@ def run_healthchecks(component, entries=None, services=None): ) for check in component_healthchecks(component, entries, services): healthcheck_url(check) - validate_device_manager_control_plane_runtime() + if is_device_plane_manager_release_v3_slice(component, entries): + validate_device_manager_control_plane_runtime( + require_edge_channel=True, + core_network_mode="private-egress", + ) + else: + validate_device_manager_control_plane_runtime() return if is_device_plane_edge_core_channel_bootstrap_slice(component, entries): if tuple(services or ()) != ("device-control-core",): diff --git a/infra/deploy-runner/test_device_plane_registry.py b/infra/deploy-runner/test_device_plane_registry.py index 95bb259..a57cc38 100644 --- a/infra/deploy-runner/test_device_plane_registry.py +++ b/infra/deploy-runner/test_device_plane_registry.py @@ -268,6 +268,33 @@ class DevicePlaneRegistryTest(unittest.TestCase): ], ) + def test_manager_v3_post_apply_uses_private_egress_core_boundary(self): + with ( + mock.patch.object( + RUNNER, + "healthcheck_compose_service_with_grace", + ), + mock.patch.object( + RUNNER, + "component_healthchecks", + return_value=(), + ), + mock.patch.object( + RUNNER, + "validate_device_manager_control_plane_runtime", + ) as runtime_acceptance, + ): + RUNNER.run_healthchecks( + "device-plane", + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES, + ("device-manager",), + ) + + runtime_acceptance.assert_called_once_with( + require_edge_channel=True, + core_network_mode="private-egress", + ) + def test_manager_predecessor_health_is_phase_scoped(self): with self.assertRaisesRegex( RUNNER.DeployError, From 6123a065278ed16e7d3a0397a5c134d0aa853ccc Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 22 Aug 2026 09:47:57 +0300 Subject: [PATCH 6/9] deploy: register Manager v4 persistent data --- infra/deploy-runner/nodedc-deploy | 269 ++++++++++++++++-- .../test_device_plane_registry.py | 129 +++++++++ 2 files changed, 381 insertions(+), 17 deletions(-) diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index 7cc7812..26e341c 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -66,6 +66,14 @@ PROXY_CONTUR_ENV_FILE = Path("/volume1/docker/proxy-contur/.env") DC_AMD_PROXY_RUNTIME_DIR = Path("/volume1/docker/dc-amd-proxy/runtime") DEVICE_PLANE_ROOT = Path("/volume1/docker/nodedc-device-plane") DEVICE_PLANE_SECRET_DIR = DEVICE_PLANE_ROOT / "secrets" +DEVICE_PLANE_MANAGER_DATA_DIR = DEVICE_PLANE_ROOT / "data" / "device-manager" +DEVICE_PLANE_MANAGER_DATA_CONTAINER_DIR = "/var/lib/nodedc-device-manager" +DEVICE_PLANE_MANAGER_PRESENTATION_PATH = ( + f"{DEVICE_PLANE_MANAGER_DATA_CONTAINER_DIR}/device-manager-presentation.json" +) +DEVICE_PLANE_MANAGER_MEDIA_ROOT = ( + f"{DEVICE_PLANE_MANAGER_DATA_CONTAINER_DIR}/media" +) DEVICE_PLANE_POSTGRES_PASSWORD_FILE = DEVICE_PLANE_SECRET_DIR / "postgres-password" DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE = DEVICE_PLANE_SECRET_DIR / "gateway-core-token" DEVICE_PLANE_IDENTIFIER_PEPPER_FILE = DEVICE_PLANE_SECRET_DIR / "identifier-pepper" @@ -249,6 +257,9 @@ DEVICE_PLANE_MANAGER_RELEASE_V2_REL = ( DEVICE_PLANE_MANAGER_RELEASE_V3_REL = ( "deployment/device-manager-release-v3.json" ) +DEVICE_PLANE_MANAGER_RELEASE_V4_REL = ( + "deployment/device-manager-release-v4.json" +) DEVICE_PLANE_MANAGER_COMPOSE_REL = "docker-compose.device-manager.yml" DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL = ( "deployment/device-edge-core-channel-bootstrap-v1.json" @@ -356,6 +367,12 @@ DEVICE_PLANE_MANAGER_RELEASE_V3_EDGE_CHANNEL_PREDECESSOR_PATCH_ID = ( DEVICE_PLANE_MANAGER_RELEASE_V3_EDGE_CHANNEL_PREDECESSOR_ARTIFACT_SHA256 = ( "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc" ) +DEVICE_PLANE_MANAGER_RELEASE_V4_PREDECESSOR_PATCH_ID = ( + "device-manager-release-v3-20260822-032" +) +DEVICE_PLANE_MANAGER_RELEASE_V4_PREDECESSOR_ARTIFACT_SHA256 = ( + "6e0eb3a0a6f19ceab92d46832b93bffbcea21247dbdc2ea50625a51ff460e4ca" +) DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_PREDECESSOR_PATCH_ID = ( "device-edge-core-channel-bootstrap-20260812-018" ) @@ -414,6 +431,9 @@ DEVICE_PLANE_EDGE_CORE_CHANNEL_INVALID_CERTIFICATE_FINGERPRINT = ( DEVICE_PLANE_MANAGER_RELEASE_V1_COMPOSE_SHA256 = ( "4954120aaddc999798b64c304d8cf692b79714feb727d873117bd1f3434e865e" ) +DEVICE_PLANE_MANAGER_RELEASE_V4_COMPOSE_SHA256 = ( + "e7dff0f5873ad4586bd55946d3db2bb86092a5e149e886d120adc041e056c256" +) DEVICE_PLANE_MANAGER_RELEASE_V2_COMPOSE_SHA256 = ( "369a2acf9c1a1030b9e1c6c366144b1eaf8900aef0ee59bb6bf23250b7b371b9" ) @@ -492,6 +512,11 @@ DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES = ( "services/device-manager", DEVICE_PLANE_MANAGER_RELEASE_V3_REL, ) +DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES = ( + DEVICE_PLANE_MANAGER_COMPOSE_REL, + "services/device-manager", + DEVICE_PLANE_MANAGER_RELEASE_V4_REL, +) DEVICE_PLANE_MANAGER_RECONCILIATION_REL = ( "deployment/device-manager-control-plane-reconciliation-v1.json" ) @@ -3922,6 +3947,7 @@ def allowed_payload_path(component, rel): DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, DEVICE_PLANE_MANAGER_RELEASE_V2_REL, DEVICE_PLANE_MANAGER_RELEASE_V3_REL, + DEVICE_PLANE_MANAGER_RELEASE_V4_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL, @@ -9494,6 +9520,7 @@ def is_device_plane_manager_control_plane_slice(component, entries): DEVICE_PLANE_MANAGER_RELEASE_V1_SUCCESSOR_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES, + DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES, ) ) @@ -9514,6 +9541,21 @@ def is_device_plane_manager_release_v3_slice(component, entries): ) +def is_device_plane_manager_release_v4_slice(component, entries): + return ( + component == "device-plane" + and entries is not None + and tuple(entries) == DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES + ) + + +def is_device_plane_manager_only_release_slice(component, entries): + return ( + is_device_plane_manager_release_v3_slice(component, entries) + or is_device_plane_manager_release_v4_slice(component, entries) + ) + + def is_device_plane_edge_core_channel_bootstrap_slice(component, entries): return ( component == "device-plane" @@ -9702,6 +9744,31 @@ def expected_device_plane_manager_release_v3_boundaries(): } +def expected_device_plane_manager_release_v4_boundaries(): + return { + **expected_device_plane_manager_release_v3_boundaries(), + "predecessor": { + "kind": "release", + "patchId": DEVICE_PLANE_MANAGER_RELEASE_V4_PREDECESSOR_PATCH_ID, + "artifactSha256": ( + DEVICE_PLANE_MANAGER_RELEASE_V4_PREDECESSOR_ARTIFACT_SHA256 + ), + }, + "healthGate": "bounded-container-grace+core-contract+persistent-data", + "presentationPersistence": "runner-managed-host-data-bind", + "presentationDataHostPath": str(DEVICE_PLANE_MANAGER_DATA_DIR), + "presentationDataContainerPath": DEVICE_PLANE_MANAGER_DATA_CONTAINER_DIR, + "presentationDataOwnership": "uid-1000-gid-1000-mode-0750", + "presentationDataLifecycle": ( + "preserve-across-manager-recreate-and-source-rollback" + ), + "presentationPath": DEVICE_PLANE_MANAGER_PRESENTATION_PATH, + "mediaRoot": DEVICE_PLANE_MANAGER_MEDIA_ROOT, + "defaultAccentHex": "#f5f5f5", + "rollback": "restore-preapply-snapshot-preserve-manager-data", + } + + def expected_device_plane_manager_release_boundaries(): # Compatibility name for the current release builder/tests. Immutable v1 # predecessors always use expected_device_plane_manager_release_v1_boundaries. @@ -10216,6 +10283,17 @@ def validate_device_plane_manager_release_payload_contract( "device-edge-channel/peers", "name: nodedc-device-plane-egress", )) + if schema_version == "nodedc.device-plane.device-manager-release.v4": + required_compose.extend(( + "NODEDC_DEVICE_MANAGER_PRESENTATION_PATH: " + f"{DEVICE_PLANE_MANAGER_PRESENTATION_PATH}", + "NODEDC_DEVICE_MANAGER_MEDIA_ROOT: " + f"{DEVICE_PLANE_MANAGER_MEDIA_ROOT}", + f"source: {DEVICE_PLANE_MANAGER_DATA_DIR}", + f"target: {DEVICE_PLANE_MANAGER_DATA_CONTAINER_DIR}", + "read_only: false", + "create_host_path: false", + )) for required in required_compose: if required not in compose: die(f"Device Manager control-plane boundary missing: {required}") @@ -10277,6 +10355,22 @@ def validate_device_plane_manager_release_v3_payload( ) +def validate_device_plane_manager_release_v4_payload( + payload_dir, + *, + expected_release_id=None, +): + return validate_device_plane_manager_release_payload_contract( + payload_dir, + descriptor_rel=DEVICE_PLANE_MANAGER_RELEASE_V4_REL, + schema_version="nodedc.device-plane.device-manager-release.v4", + boundaries=expected_device_plane_manager_release_v4_boundaries(), + compose_sha256=DEVICE_PLANE_MANAGER_RELEASE_V4_COMPOSE_SHA256, + edge_channel=False, + expected_release_id=expected_release_id, + ) + + def validate_device_plane_manager_release_payload( payload_dir, *, @@ -10285,12 +10379,18 @@ def validate_device_plane_manager_release_payload( v1 = payload_dir / DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL v2 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V2_REL v3 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V3_REL + v4 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V4_REL present = [ - path for path in (v1, v2, v3) + path for path in (v1, v2, v3, v4) if path.exists() or path.is_symlink() ] if len(present) != 1: die("Device Manager release descriptor cardinality mismatch") + if present[0] == v4: + return validate_device_plane_manager_release_v4_payload( + payload_dir, + expected_release_id=expected_release_id, + ) if present[0] == v3: return validate_device_plane_manager_release_v3_payload( payload_dir, @@ -10568,6 +10668,7 @@ def installed_device_plane_manager_compose_sha256(): v1 = root / DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL v2 = root / DEVICE_PLANE_MANAGER_RELEASE_V2_REL v3 = root / DEVICE_PLANE_MANAGER_RELEASE_V3_REL + v4 = root / DEVICE_PLANE_MANAGER_RELEASE_V4_REL # A successful v2 overlay intentionally leaves the immutable v1 release # descriptor as predecessor evidence. Prefer the highest installed @@ -10575,6 +10676,19 @@ def installed_device_plane_manager_compose_sha256(): # the generation-specific Compose digest. During a failed v2 apply, # rollback removes the candidate-only v2 descriptor before rebuilding the # restored v1 runtime, so the same lookup follows the restored source. + if v4.exists() or v4.is_symlink(): + descriptor = read_strict_json( + v4, + "installed Device Manager release v4 descriptor", + max_bytes=16 * 1024, + ) + validate_device_plane_manager_release_descriptor( + descriptor, + schema_version="nodedc.device-plane.device-manager-release.v4", + boundaries=expected_device_plane_manager_release_v4_boundaries(), + ) + return DEVICE_PLANE_MANAGER_RELEASE_V4_COMPOSE_SHA256 + if v3.exists() or v3.is_symlink(): descriptor = read_strict_json( v3, @@ -12089,8 +12203,9 @@ def validate_device_plane_manager_reconciliation_evidence(payload_dir): def validate_device_plane_manager_v3_active_baseline(descriptor): - if descriptor.get("schemaVersion") != ( - "nodedc.device-plane.device-manager-release.v3" + if descriptor.get("schemaVersion") not in ( + "nodedc.device-plane.device-manager-release.v3", + "nodedc.device-plane.device-manager-release.v4", ): return None @@ -12235,8 +12350,9 @@ def validate_device_plane_manager_v3_active_baseline(descriptor): def device_plane_manager_preserved_runtime_health_services(descriptor): - if descriptor.get("schemaVersion") == ( - "nodedc.device-plane.device-manager-release.v3" + if descriptor.get("schemaVersion") in ( + "nodedc.device-plane.device-manager-release.v3", + "nodedc.device-plane.device-manager-release.v4", ): return ( "device-control-core", @@ -12368,6 +12484,7 @@ def validate_device_plane_manager_activation_predecessor( DEVICE_PLANE_MANAGER_RELEASE_V1_SUCCESSOR_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES, + DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES, ) ): die("Device Manager release predecessor type mismatch") @@ -12382,6 +12499,8 @@ def validate_device_plane_manager_activation_predecessor( DEVICE_PLANE_MANAGER_RELEASE_V2_REL, DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES: DEVICE_PLANE_MANAGER_RELEASE_V3_REL, + DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES: + DEVICE_PLANE_MANAGER_RELEASE_V4_REL, }.get( tuple(predecessor_entries), DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, @@ -14744,6 +14863,7 @@ def validate_device_manager_control_plane_runtime( *, require_edge_channel=None, core_network_mode=None, + require_persistent_data=None, ): core_ids = device_plane_service_container_ids("device-control-core") manager_ids = device_plane_service_container_ids("device-manager") @@ -14758,6 +14878,13 @@ def validate_device_manager_control_plane_runtime( DEVICE_PLANE_ROOT / DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL ) require_edge_channel = edge_descriptor.is_file() and not edge_descriptor.is_symlink() + if require_persistent_data is None: + data_descriptor = ( + DEVICE_PLANE_ROOT / DEVICE_PLANE_MANAGER_RELEASE_V4_REL + ) + require_persistent_data = ( + data_descriptor.is_file() and not data_descriptor.is_symlink() + ) expected_core_environment = { "DEVICE_MANAGEMENT_API_ENABLED": "true", "DEVICE_MANAGEMENT_CORE_TOKEN_FILE": ( @@ -14816,6 +14943,23 @@ def validate_device_manager_control_plane_runtime( "/run/nodedc-secrets/management-core-token" ), } + if require_persistent_data: + expected_manager_environment.update({ + "NODEDC_DEVICE_MANAGER_PRESENTATION_PATH": ( + DEVICE_PLANE_MANAGER_PRESENTATION_PATH + ), + "NODEDC_DEVICE_MANAGER_MEDIA_ROOT": ( + DEVICE_PLANE_MANAGER_MEDIA_ROOT + ), + }) + elif any( + key in manager_environment + for key in ( + "NODEDC_DEVICE_MANAGER_PRESENTATION_PATH", + "NODEDC_DEVICE_MANAGER_MEDIA_ROOT", + ) + ): + die("Device Manager runtime unexpectedly owns persistent data") if any( manager_environment.get(key) != value for key, value in expected_manager_environment.items() @@ -14900,16 +15044,23 @@ def validate_device_manager_control_plane_runtime( DEVICE_PLANE_MANAGEMENT_CORE_TOKEN_FILE ), } + if require_persistent_data: + expected_manager_mounts[DEVICE_PLANE_MANAGER_DATA_CONTAINER_DIR] = ( + DEVICE_PLANE_MANAGER_DATA_DIR + ) if set(manager_mounts) != set(expected_manager_mounts): die("Device Manager mount set mismatch") for destination, source in expected_manager_mounts.items(): mount = manager_mounts[destination] + persistent_mount = destination == DEVICE_PLANE_MANAGER_DATA_CONTAINER_DIR if ( mount.get("Type") != "bind" or mount.get("Source") != str(source) - or mount.get("RW") is not False + or mount.get("RW") is not persistent_mount ): - die("Device Manager secret mount mismatch") + die("Device Manager mount boundary mismatch") + if require_persistent_data: + validate_device_plane_manager_persistent_data_metadata() ports = (manager.get("NetworkSettings") or {}).get("Ports") or {} if any(bindings for bindings in ports.values()): die("Device Manager host port publication is forbidden") @@ -16564,7 +16715,7 @@ def component_services(component, entries=None): if is_device_plane_control_core_release_slice(component, entries): return ("device-control-core",) - if is_device_plane_manager_release_v3_slice(component, entries): + if is_device_plane_manager_only_release_slice(component, entries): return ("device-manager",) if is_device_plane_edge_core_channel_bootstrap_slice(component, entries): @@ -17164,7 +17315,7 @@ def component_builds(component, entries=None): ), ),) - if is_device_plane_manager_release_v3_slice(component, entries): + if is_device_plane_manager_only_release_slice(component, entries): return (( DEVICE_PLANE_ROOT / "services/device-manager", ( @@ -21096,7 +21247,10 @@ def plan_artifact(artifact): "build+recreate:device-manager" if device_plane_manager_activation_preflight["descriptor"].get( "schemaVersion" - ) == "nodedc.device-plane.device-manager-release.v3" + ) in ( + "nodedc.device-plane.device-manager-release.v3", + "nodedc.device-plane.device-manager-release.v4", + ) else "build+recreate:device-control-core,device-manager" ) ) @@ -21107,12 +21261,38 @@ def plan_artifact(artifact): "device-postgres,device-backhaul-target" if device_plane_manager_activation_preflight["descriptor"].get( "schemaVersion" - ) == "nodedc.device-plane.device-manager-release.v3" + ) in ( + "nodedc.device-plane.device-manager-release.v3", + "nodedc.device-plane.device-manager-release.v4", + ) else "preserved:device-gateway,device-postgres," "device-backhaul-target" ) ) - print("device_manager_health_gate=bounded-grace+contract") + manager_schema = device_plane_manager_activation_preflight[ + "descriptor" + ].get("schemaVersion") + print( + "device_manager_health_gate=" + + ( + "bounded-grace+contract+persistent-data" + if manager_schema + == "nodedc.device-plane.device-manager-release.v4" + else "bounded-grace+contract" + ) + ) + if manager_schema == ( + "nodedc.device-plane.device-manager-release.v4" + ): + print( + "device_manager_persistent_data=runner-managed-preserved:" + f"{DEVICE_PLANE_MANAGER_DATA_DIR}" + ) + print( + "device_manager_persistent_mount=read-write:" + f"{DEVICE_PLANE_MANAGER_DATA_CONTAINER_DIR}" + ) + print("device_manager_default_accent=#f5f5f5") if device_plane_manager_activation_preflight["descriptor"].get( "commandTransport" ) == "typed-service-ping-v1": @@ -21125,7 +21305,13 @@ def plan_artifact(artifact): print("device_gateway_tcp_9921=preserved:loopback-only") print( "device_plane_rollback=" - "source+reconciled-baseline-runtime" + + ( + "source+reconciled-baseline-runtime+" + "persistent-manager-data-preserved" + if manager_schema + == "nodedc.device-plane.device-manager-release.v4" + else "source+reconciled-baseline-runtime" + ) ) if device_plane_control_core_release_preflight is not None: core_release = device_plane_control_core_release_preflight @@ -22689,6 +22875,47 @@ def ensure_device_plane_backhaul_target_state(): die("Device Plane backhaul runtime trust verification failed") +def validate_device_plane_manager_persistent_data_metadata(): + try: + data_stat = DEVICE_PLANE_MANAGER_DATA_DIR.lstat() + except FileNotFoundError: + die("Device Manager persistent data directory is missing") + if ( + stat.S_ISLNK(data_stat.st_mode) + or not stat.S_ISDIR(data_stat.st_mode) + or data_stat.st_uid != 1000 + or data_stat.st_gid != 1000 + or stat.S_IMODE(data_stat.st_mode) != 0o750 + ): + die("Device Manager persistent data directory boundary mismatch") + return "uid-1000-gid-1000-mode-0750" + + +def ensure_device_plane_manager_persistent_data(): + data_parent = DEVICE_PLANE_MANAGER_DATA_DIR.parent + parent_created = False + try: + parent_stat = data_parent.lstat() + except FileNotFoundError: + data_parent.mkdir(mode=0o755) + parent_created = True + parent_stat = data_parent.lstat() + if stat.S_ISLNK(parent_stat.st_mode) or not stat.S_ISDIR( + parent_stat.st_mode + ): + die("Device Manager persistent data parent is unsafe") + if parent_created: + os.chown(data_parent, 0, 0) + data_parent.chmod(0o755) + DEVICE_PLANE_MANAGER_DATA_DIR.mkdir(exist_ok=True) + data_stat = DEVICE_PLANE_MANAGER_DATA_DIR.lstat() + if stat.S_ISLNK(data_stat.st_mode) or not stat.S_ISDIR(data_stat.st_mode): + die("Device Manager persistent data path is unsafe") + os.chown(DEVICE_PLANE_MANAGER_DATA_DIR, 1000, 1000) + DEVICE_PLANE_MANAGER_DATA_DIR.chmod(0o750) + return validate_device_plane_manager_persistent_data_metadata() + + def stop_and_remove_compose_services(component, services): if not services: return @@ -22771,6 +22998,8 @@ def prepare_component_runtime(component, entries=None): MAP_GATEWAY_SECRET_RE, "Device Core Hub handoff", ) + if is_device_plane_manager_release_v4_slice(component, entries): + ensure_device_plane_manager_persistent_data() if ( is_device_plane_control_core_release_slice(component, entries) or is_device_plane_edge_core_channel_bootstrap_slice( @@ -23093,7 +23322,7 @@ def component_healthchecks(component, entries=None, services=None): if is_device_plane_manager_control_plane_slice(component, entries): command_transport = ( "typed-service-ping-v1" - if is_device_plane_manager_release_v3_slice(component, entries) + if is_device_plane_manager_only_release_slice(component, entries) else "disabled" ) checks = ({ @@ -23107,7 +23336,7 @@ def component_healthchecks(component, entries=None, services=None): "commandTransport": command_transport, }, },) - if is_device_plane_manager_release_v3_slice(component, entries): + if is_device_plane_manager_only_release_slice(component, entries): checks += ({ "url": "http://127.0.0.1:18080/healthz", "headers": {"Host": "device.nodedc.ru"}, @@ -23866,7 +24095,7 @@ def run_healthchecks(component, entries=None, services=None): if is_device_plane_manager_control_plane_slice(component, entries): expected_services = ( ("device-manager",) - if is_device_plane_manager_release_v3_slice(component, entries) + if is_device_plane_manager_only_release_slice(component, entries) else ("device-control-core", "device-manager") ) if tuple(services or ()) != expected_services: @@ -23878,7 +24107,13 @@ def run_healthchecks(component, entries=None, services=None): ) for check in component_healthchecks(component, entries, services): healthcheck_url(check) - if is_device_plane_manager_release_v3_slice(component, entries): + if is_device_plane_manager_release_v4_slice(component, entries): + validate_device_manager_control_plane_runtime( + require_edge_channel=True, + core_network_mode="private-egress", + require_persistent_data=True, + ) + elif is_device_plane_manager_release_v3_slice(component, entries): validate_device_manager_control_plane_runtime( require_edge_channel=True, core_network_mode="private-egress", diff --git a/infra/deploy-runner/test_device_plane_registry.py b/infra/deploy-runner/test_device_plane_registry.py index a57cc38..3094457 100644 --- a/infra/deploy-runner/test_device_plane_registry.py +++ b/infra/deploy-runner/test_device_plane_registry.py @@ -3,6 +3,7 @@ import importlib.machinery import importlib.util import inspect import json +import stat import tempfile import unittest from pathlib import Path @@ -28,6 +29,30 @@ RUNNER = load_runner() class DevicePlaneRegistryTest(unittest.TestCase): + def test_manager_v4_pins_032_and_persistent_white_boundary(self): + self.assertEqual( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V4_PREDECESSOR_PATCH_ID, + "device-manager-release-v3-20260822-032", + ) + self.assertEqual( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V4_PREDECESSOR_ARTIFACT_SHA256, + "6e0eb3a0a6f19ceab92d46832b93bffbcea21247dbdc2ea50625a51ff460e4ca", + ) + self.assertEqual( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V4_COMPOSE_SHA256, + "e7dff0f5873ad4586bd55946d3db2bb86092a5e149e886d120adc041e056c256", + ) + boundaries = RUNNER.expected_device_plane_manager_release_v4_boundaries() + self.assertEqual(boundaries["defaultAccentHex"], "#f5f5f5") + self.assertEqual( + boundaries["presentationDataHostPath"], + "/volume1/docker/nodedc-device-plane/data/device-manager", + ) + self.assertEqual( + boundaries["presentationDataContainerPath"], + "/var/lib/nodedc-device-manager", + ) + def test_manager_v3_targets_applied_control_core_recovery(self): self.assertEqual( RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V3_CONTROL_CORE_PREDECESSOR_PATCH_ID, @@ -295,6 +320,110 @@ class DevicePlaneRegistryTest(unittest.TestCase): core_network_mode="private-egress", ) + def test_manager_v4_post_apply_requires_persistent_data(self): + with ( + mock.patch.object( + RUNNER, + "healthcheck_compose_service_with_grace", + ), + mock.patch.object( + RUNNER, + "component_healthchecks", + return_value=(), + ), + mock.patch.object( + RUNNER, + "validate_device_manager_control_plane_runtime", + ) as runtime_acceptance, + ): + RUNNER.run_healthchecks( + "device-plane", + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES, + ("device-manager",), + ) + + runtime_acceptance.assert_called_once_with( + require_edge_channel=True, + core_network_mode="private-egress", + require_persistent_data=True, + ) + + def test_manager_v4_prepare_owns_new_parent_and_managed_data_directory(self): + with tempfile.TemporaryDirectory( + prefix="nodedc-manager-v4-data-", + ) as directory: + data_dir = Path(directory) / "data" / "device-manager" + with ( + mock.patch.object( + RUNNER, + "DEVICE_PLANE_MANAGER_DATA_DIR", + data_dir, + ), + mock.patch.object(RUNNER.os, "chown") as chown, + mock.patch.object( + RUNNER, + "validate_device_plane_manager_persistent_data_metadata", + return_value="uid-1000-gid-1000-mode-0750", + ) as validate, + ): + result = RUNNER.ensure_device_plane_manager_persistent_data() + + self.assertEqual(result, "uid-1000-gid-1000-mode-0750") + self.assertEqual( + chown.call_args_list, + [ + mock.call(data_dir.parent, 0, 0), + mock.call(data_dir, 1000, 1000), + ], + ) + validate.assert_called_once_with() + + def test_manager_v4_prepare_preserves_existing_safe_parent_metadata(self): + with tempfile.TemporaryDirectory( + prefix="nodedc-manager-v4-parent-", + ) as directory: + data_parent = Path(directory) / "data" + data_parent.mkdir(mode=0o700) + data_dir = data_parent / "device-manager" + with ( + mock.patch.object( + RUNNER, + "DEVICE_PLANE_MANAGER_DATA_DIR", + data_dir, + ), + mock.patch.object(RUNNER.os, "chown") as chown, + mock.patch.object( + RUNNER, + "validate_device_plane_manager_persistent_data_metadata", + return_value="uid-1000-gid-1000-mode-0750", + ), + ): + RUNNER.ensure_device_plane_manager_persistent_data() + + self.assertEqual(stat.S_IMODE(data_parent.stat().st_mode), 0o700) + chown.assert_called_once_with(data_dir, 1000, 1000) + + def test_manager_v4_prepare_rejects_symlink_parent_before_child_creation(self): + with tempfile.TemporaryDirectory( + prefix="nodedc-manager-v4-symlink-", + ) as directory: + root = Path(directory) + outside = root / "outside" + outside.mkdir() + data_parent = root / "data" + data_parent.symlink_to(outside, target_is_directory=True) + with mock.patch.object( + RUNNER, + "DEVICE_PLANE_MANAGER_DATA_DIR", + data_parent / "device-manager", + ): + with self.assertRaisesRegex( + RUNNER.DeployError, + "persistent data parent is unsafe", + ): + RUNNER.ensure_device_plane_manager_persistent_data() + self.assertFalse((outside / "device-manager").exists()) + def test_manager_predecessor_health_is_phase_scoped(self): with self.assertRaisesRegex( RUNNER.DeployError, From 952290a49db50b8eed9e7deb0b892a895fb92ec3 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 22 Aug 2026 10:23:07 +0300 Subject: [PATCH 7/9] deploy: register Manager v5 landing parity --- infra/deploy-runner/nodedc-deploy | 141 ++++++++++++++++-- .../test_device_plane_registry.py | 54 +++++++ 2 files changed, 179 insertions(+), 16 deletions(-) diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index 26e341c..ca24163 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -260,6 +260,9 @@ DEVICE_PLANE_MANAGER_RELEASE_V3_REL = ( DEVICE_PLANE_MANAGER_RELEASE_V4_REL = ( "deployment/device-manager-release-v4.json" ) +DEVICE_PLANE_MANAGER_RELEASE_V5_REL = ( + "deployment/device-manager-release-v5.json" +) DEVICE_PLANE_MANAGER_COMPOSE_REL = "docker-compose.device-manager.yml" DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL = ( "deployment/device-edge-core-channel-bootstrap-v1.json" @@ -373,6 +376,12 @@ DEVICE_PLANE_MANAGER_RELEASE_V4_PREDECESSOR_PATCH_ID = ( DEVICE_PLANE_MANAGER_RELEASE_V4_PREDECESSOR_ARTIFACT_SHA256 = ( "6e0eb3a0a6f19ceab92d46832b93bffbcea21247dbdc2ea50625a51ff460e4ca" ) +DEVICE_PLANE_MANAGER_RELEASE_V5_PREDECESSOR_PATCH_ID = ( + "device-manager-release-v4-20260822-033" +) +DEVICE_PLANE_MANAGER_RELEASE_V5_PREDECESSOR_ARTIFACT_SHA256 = ( + "52ba322042f1e4f595bbfea99f8bb35630b15984e0da648dc55348bc9e5b2066" +) DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_PREDECESSOR_PATCH_ID = ( "device-edge-core-channel-bootstrap-20260812-018" ) @@ -434,6 +443,9 @@ DEVICE_PLANE_MANAGER_RELEASE_V1_COMPOSE_SHA256 = ( DEVICE_PLANE_MANAGER_RELEASE_V4_COMPOSE_SHA256 = ( "e7dff0f5873ad4586bd55946d3db2bb86092a5e149e886d120adc041e056c256" ) +DEVICE_PLANE_MANAGER_RELEASE_V5_COMPOSE_SHA256 = ( + DEVICE_PLANE_MANAGER_RELEASE_V4_COMPOSE_SHA256 +) DEVICE_PLANE_MANAGER_RELEASE_V2_COMPOSE_SHA256 = ( "369a2acf9c1a1030b9e1c6c366144b1eaf8900aef0ee59bb6bf23250b7b371b9" ) @@ -517,6 +529,11 @@ DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES = ( "services/device-manager", DEVICE_PLANE_MANAGER_RELEASE_V4_REL, ) +DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES = ( + DEVICE_PLANE_MANAGER_COMPOSE_REL, + "services/device-manager", + DEVICE_PLANE_MANAGER_RELEASE_V5_REL, +) DEVICE_PLANE_MANAGER_RECONCILIATION_REL = ( "deployment/device-manager-control-plane-reconciliation-v1.json" ) @@ -3948,6 +3965,7 @@ def allowed_payload_path(component, rel): DEVICE_PLANE_MANAGER_RELEASE_V2_REL, DEVICE_PLANE_MANAGER_RELEASE_V3_REL, DEVICE_PLANE_MANAGER_RELEASE_V4_REL, + DEVICE_PLANE_MANAGER_RELEASE_V5_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL, @@ -9521,6 +9539,7 @@ def is_device_plane_manager_control_plane_slice(component, entries): DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES, + DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES, ) ) @@ -9549,10 +9568,25 @@ def is_device_plane_manager_release_v4_slice(component, entries): ) +def is_device_plane_manager_release_v5_slice(component, entries): + return ( + component == "device-plane" + and entries is not None + and tuple(entries) == DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES + ) + + +def is_device_plane_manager_persistent_release_slice(component, entries): + return ( + is_device_plane_manager_release_v4_slice(component, entries) + or is_device_plane_manager_release_v5_slice(component, entries) + ) + + def is_device_plane_manager_only_release_slice(component, entries): return ( is_device_plane_manager_release_v3_slice(component, entries) - or is_device_plane_manager_release_v4_slice(component, entries) + or is_device_plane_manager_persistent_release_slice(component, entries) ) @@ -9769,6 +9803,20 @@ def expected_device_plane_manager_release_v4_boundaries(): } +def expected_device_plane_manager_release_v5_boundaries(): + return { + **expected_device_plane_manager_release_v4_boundaries(), + "predecessor": { + "kind": "release", + "patchId": DEVICE_PLANE_MANAGER_RELEASE_V5_PREDECESSOR_PATCH_ID, + "artifactSha256": ( + DEVICE_PLANE_MANAGER_RELEASE_V5_PREDECESSOR_ARTIFACT_SHA256 + ), + }, + "overviewLayout": "mission-core-landing-stage-v1", + } + + def expected_device_plane_manager_release_boundaries(): # Compatibility name for the current release builder/tests. Immutable v1 # predecessors always use expected_device_plane_manager_release_v1_boundaries. @@ -10283,7 +10331,10 @@ def validate_device_plane_manager_release_payload_contract( "device-edge-channel/peers", "name: nodedc-device-plane-egress", )) - if schema_version == "nodedc.device-plane.device-manager-release.v4": + if schema_version in ( + "nodedc.device-plane.device-manager-release.v4", + "nodedc.device-plane.device-manager-release.v5", + ): required_compose.extend(( "NODEDC_DEVICE_MANAGER_PRESENTATION_PATH: " f"{DEVICE_PLANE_MANAGER_PRESENTATION_PATH}", @@ -10371,6 +10422,22 @@ def validate_device_plane_manager_release_v4_payload( ) +def validate_device_plane_manager_release_v5_payload( + payload_dir, + *, + expected_release_id=None, +): + return validate_device_plane_manager_release_payload_contract( + payload_dir, + descriptor_rel=DEVICE_PLANE_MANAGER_RELEASE_V5_REL, + schema_version="nodedc.device-plane.device-manager-release.v5", + boundaries=expected_device_plane_manager_release_v5_boundaries(), + compose_sha256=DEVICE_PLANE_MANAGER_RELEASE_V5_COMPOSE_SHA256, + edge_channel=False, + expected_release_id=expected_release_id, + ) + + def validate_device_plane_manager_release_payload( payload_dir, *, @@ -10380,12 +10447,18 @@ def validate_device_plane_manager_release_payload( v2 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V2_REL v3 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V3_REL v4 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V4_REL + v5 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V5_REL present = [ - path for path in (v1, v2, v3, v4) + path for path in (v1, v2, v3, v4, v5) if path.exists() or path.is_symlink() ] if len(present) != 1: die("Device Manager release descriptor cardinality mismatch") + if present[0] == v5: + return validate_device_plane_manager_release_v5_payload( + payload_dir, + expected_release_id=expected_release_id, + ) if present[0] == v4: return validate_device_plane_manager_release_v4_payload( payload_dir, @@ -10669,6 +10742,7 @@ def installed_device_plane_manager_compose_sha256(): v2 = root / DEVICE_PLANE_MANAGER_RELEASE_V2_REL v3 = root / DEVICE_PLANE_MANAGER_RELEASE_V3_REL v4 = root / DEVICE_PLANE_MANAGER_RELEASE_V4_REL + v5 = root / DEVICE_PLANE_MANAGER_RELEASE_V5_REL # A successful v2 overlay intentionally leaves the immutable v1 release # descriptor as predecessor evidence. Prefer the highest installed @@ -10676,6 +10750,19 @@ def installed_device_plane_manager_compose_sha256(): # the generation-specific Compose digest. During a failed v2 apply, # rollback removes the candidate-only v2 descriptor before rebuilding the # restored v1 runtime, so the same lookup follows the restored source. + if v5.exists() or v5.is_symlink(): + descriptor = read_strict_json( + v5, + "installed Device Manager release v5 descriptor", + max_bytes=16 * 1024, + ) + validate_device_plane_manager_release_descriptor( + descriptor, + schema_version="nodedc.device-plane.device-manager-release.v5", + boundaries=expected_device_plane_manager_release_v5_boundaries(), + ) + return DEVICE_PLANE_MANAGER_RELEASE_V5_COMPOSE_SHA256 + if v4.exists() or v4.is_symlink(): descriptor = read_strict_json( v4, @@ -12206,6 +12293,7 @@ def validate_device_plane_manager_v3_active_baseline(descriptor): if descriptor.get("schemaVersion") not in ( "nodedc.device-plane.device-manager-release.v3", "nodedc.device-plane.device-manager-release.v4", + "nodedc.device-plane.device-manager-release.v5", ): return None @@ -12353,6 +12441,7 @@ def device_plane_manager_preserved_runtime_health_services(descriptor): if descriptor.get("schemaVersion") in ( "nodedc.device-plane.device-manager-release.v3", "nodedc.device-plane.device-manager-release.v4", + "nodedc.device-plane.device-manager-release.v5", ): return ( "device-control-core", @@ -12485,6 +12574,7 @@ def validate_device_plane_manager_activation_predecessor( DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES, + DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES, ) ): die("Device Manager release predecessor type mismatch") @@ -12501,6 +12591,8 @@ def validate_device_plane_manager_activation_predecessor( DEVICE_PLANE_MANAGER_RELEASE_V3_REL, DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES: DEVICE_PLANE_MANAGER_RELEASE_V4_REL, + DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES: + DEVICE_PLANE_MANAGER_RELEASE_V5_REL, }.get( tuple(predecessor_entries), DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, @@ -14879,11 +14971,13 @@ def validate_device_manager_control_plane_runtime( ) require_edge_channel = edge_descriptor.is_file() and not edge_descriptor.is_symlink() if require_persistent_data is None: - data_descriptor = ( - DEVICE_PLANE_ROOT / DEVICE_PLANE_MANAGER_RELEASE_V4_REL + data_descriptors = ( + DEVICE_PLANE_ROOT / DEVICE_PLANE_MANAGER_RELEASE_V5_REL, + DEVICE_PLANE_ROOT / DEVICE_PLANE_MANAGER_RELEASE_V4_REL, ) - require_persistent_data = ( - data_descriptor.is_file() and not data_descriptor.is_symlink() + require_persistent_data = any( + descriptor.is_file() and not descriptor.is_symlink() + for descriptor in data_descriptors ) expected_core_environment = { "DEVICE_MANAGEMENT_API_ENABLED": "true", @@ -21250,6 +21344,7 @@ def plan_artifact(artifact): ) in ( "nodedc.device-plane.device-manager-release.v3", "nodedc.device-plane.device-manager-release.v4", + "nodedc.device-plane.device-manager-release.v5", ) else "build+recreate:device-control-core,device-manager" ) @@ -21264,6 +21359,7 @@ def plan_artifact(artifact): ) in ( "nodedc.device-plane.device-manager-release.v3", "nodedc.device-plane.device-manager-release.v4", + "nodedc.device-plane.device-manager-release.v5", ) else "preserved:device-gateway,device-postgres," "device-backhaul-target" @@ -21272,18 +21368,19 @@ def plan_artifact(artifact): manager_schema = device_plane_manager_activation_preflight[ "descriptor" ].get("schemaVersion") + manager_persistent = manager_schema in ( + "nodedc.device-plane.device-manager-release.v4", + "nodedc.device-plane.device-manager-release.v5", + ) print( "device_manager_health_gate=" + ( "bounded-grace+contract+persistent-data" - if manager_schema - == "nodedc.device-plane.device-manager-release.v4" + if manager_persistent else "bounded-grace+contract" ) ) - if manager_schema == ( - "nodedc.device-plane.device-manager-release.v4" - ): + if manager_persistent: print( "device_manager_persistent_data=runner-managed-preserved:" f"{DEVICE_PLANE_MANAGER_DATA_DIR}" @@ -21293,6 +21390,13 @@ def plan_artifact(artifact): f"{DEVICE_PLANE_MANAGER_DATA_CONTAINER_DIR}" ) print("device_manager_default_accent=#f5f5f5") + if manager_schema == ( + "nodedc.device-plane.device-manager-release.v5" + ): + print( + "device_manager_overview_layout=" + "mission-core-landing-stage-v1" + ) if device_plane_manager_activation_preflight["descriptor"].get( "commandTransport" ) == "typed-service-ping-v1": @@ -21308,8 +21412,7 @@ def plan_artifact(artifact): + ( "source+reconciled-baseline-runtime+" "persistent-manager-data-preserved" - if manager_schema - == "nodedc.device-plane.device-manager-release.v4" + if manager_persistent else "source+reconciled-baseline-runtime" ) ) @@ -22998,7 +23101,10 @@ def prepare_component_runtime(component, entries=None): MAP_GATEWAY_SECRET_RE, "Device Core Hub handoff", ) - if is_device_plane_manager_release_v4_slice(component, entries): + if is_device_plane_manager_persistent_release_slice( + component, + entries, + ): ensure_device_plane_manager_persistent_data() if ( is_device_plane_control_core_release_slice(component, entries) @@ -24107,7 +24213,10 @@ def run_healthchecks(component, entries=None, services=None): ) for check in component_healthchecks(component, entries, services): healthcheck_url(check) - if is_device_plane_manager_release_v4_slice(component, entries): + if is_device_plane_manager_persistent_release_slice( + component, + entries, + ): validate_device_manager_control_plane_runtime( require_edge_channel=True, core_network_mode="private-egress", diff --git a/infra/deploy-runner/test_device_plane_registry.py b/infra/deploy-runner/test_device_plane_registry.py index 3094457..afd007e 100644 --- a/infra/deploy-runner/test_device_plane_registry.py +++ b/infra/deploy-runner/test_device_plane_registry.py @@ -29,6 +29,32 @@ RUNNER = load_runner() class DevicePlaneRegistryTest(unittest.TestCase): + def test_manager_v5_pins_033_and_mission_core_overview_layout(self): + self.assertEqual( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V5_PREDECESSOR_PATCH_ID, + "device-manager-release-v4-20260822-033", + ) + self.assertEqual( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V5_PREDECESSOR_ARTIFACT_SHA256, + "52ba322042f1e4f595bbfea99f8bb35630b15984e0da648dc55348bc9e5b2066", + ) + self.assertEqual( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V5_COMPOSE_SHA256, + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V4_COMPOSE_SHA256, + ) + boundaries = RUNNER.expected_device_plane_manager_release_v5_boundaries() + self.assertEqual( + boundaries["overviewLayout"], + "mission-core-landing-stage-v1", + ) + self.assertEqual( + RUNNER.component_services( + "device-plane", + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES, + ), + ("device-manager",), + ) + def test_manager_v4_pins_032_and_persistent_white_boundary(self): self.assertEqual( RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V4_PREDECESSOR_PATCH_ID, @@ -348,6 +374,34 @@ class DevicePlaneRegistryTest(unittest.TestCase): require_persistent_data=True, ) + def test_manager_v5_post_apply_preserves_persistent_data_gate(self): + with ( + mock.patch.object( + RUNNER, + "healthcheck_compose_service_with_grace", + ), + mock.patch.object( + RUNNER, + "component_healthchecks", + return_value=(), + ), + mock.patch.object( + RUNNER, + "validate_device_manager_control_plane_runtime", + ) as runtime_acceptance, + ): + RUNNER.run_healthchecks( + "device-plane", + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES, + ("device-manager",), + ) + + runtime_acceptance.assert_called_once_with( + require_edge_channel=True, + core_network_mode="private-egress", + require_persistent_data=True, + ) + def test_manager_v4_prepare_owns_new_parent_and_managed_data_directory(self): with tempfile.TemporaryDirectory( prefix="nodedc-manager-v4-data-", From 87a1e648071d71ba3c234fffe934a93b5f79cb0b Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 22 Aug 2026 10:50:03 +0300 Subject: [PATCH 8/9] deploy: register Manager v6 favicon release --- infra/deploy-runner/nodedc-deploy | 156 +++++++++++++++++- .../test_device_plane_registry.py | 55 ++++++ 2 files changed, 208 insertions(+), 3 deletions(-) diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index ca24163..a96358a 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -263,6 +263,9 @@ DEVICE_PLANE_MANAGER_RELEASE_V4_REL = ( DEVICE_PLANE_MANAGER_RELEASE_V5_REL = ( "deployment/device-manager-release-v5.json" ) +DEVICE_PLANE_MANAGER_RELEASE_V6_REL = ( + "deployment/device-manager-release-v6.json" +) DEVICE_PLANE_MANAGER_COMPOSE_REL = "docker-compose.device-manager.yml" DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL = ( "deployment/device-edge-core-channel-bootstrap-v1.json" @@ -382,6 +385,12 @@ DEVICE_PLANE_MANAGER_RELEASE_V5_PREDECESSOR_PATCH_ID = ( DEVICE_PLANE_MANAGER_RELEASE_V5_PREDECESSOR_ARTIFACT_SHA256 = ( "52ba322042f1e4f595bbfea99f8bb35630b15984e0da648dc55348bc9e5b2066" ) +DEVICE_PLANE_MANAGER_RELEASE_V6_PREDECESSOR_PATCH_ID = ( + "device-manager-release-v5-20260822-034" +) +DEVICE_PLANE_MANAGER_RELEASE_V6_PREDECESSOR_ARTIFACT_SHA256 = ( + "acc1d2ae2cda66861054826928c25d01a2428e688cc8132a9c381831bf29ab5a" +) DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_PREDECESSOR_PATCH_ID = ( "device-edge-core-channel-bootstrap-20260812-018" ) @@ -446,6 +455,32 @@ DEVICE_PLANE_MANAGER_RELEASE_V4_COMPOSE_SHA256 = ( DEVICE_PLANE_MANAGER_RELEASE_V5_COMPOSE_SHA256 = ( DEVICE_PLANE_MANAGER_RELEASE_V4_COMPOSE_SHA256 ) +DEVICE_PLANE_MANAGER_RELEASE_V6_COMPOSE_SHA256 = ( + DEVICE_PLANE_MANAGER_RELEASE_V5_COMPOSE_SHA256 +) +DEVICE_PLANE_MANAGER_FAVICON_SHA256 = { + "favicon.ico": ( + "f8933114a85646335ea5c94944f56d3cd8c48a6032016719f7905ff244ec0aa2" + ), + "favicon/favicon.ico": ( + "f8933114a85646335ea5c94944f56d3cd8c48a6032016719f7905ff244ec0aa2" + ), + "favicon/icon-adaptive.svg": ( + "481984e83997d786bb0a72ad1ee80037db13aef3a0792ab3109df95c2199b38e" + ), + "favicon/apple-touch-icon.png": ( + "afdccc28152a566e264e533ca218362f05d5bcec936c647f9a54f414c0bd4763" + ), + "favicon/icon-192.png": ( + "5b10a24feb4754f15c69761cef42f91a01f885d04095156b1a12e254875fdd4d" + ), + "favicon/icon-512.png": ( + "f98bac3dba59b7eefbe89f8bb8abc25567226a54ab7ed3b6b1a4caffbdd9ee15" + ), + "favicon/manifest.webmanifest.json": ( + "2a8ecdc6e6c64833f812ae02bbc0c7bd9b435e0cfa75cc21d6edf054d41275fc" + ), +} DEVICE_PLANE_MANAGER_RELEASE_V2_COMPOSE_SHA256 = ( "369a2acf9c1a1030b9e1c6c366144b1eaf8900aef0ee59bb6bf23250b7b371b9" ) @@ -534,6 +569,11 @@ DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES = ( "services/device-manager", DEVICE_PLANE_MANAGER_RELEASE_V5_REL, ) +DEVICE_PLANE_MANAGER_RELEASE_V6_ENTRIES = ( + DEVICE_PLANE_MANAGER_COMPOSE_REL, + "services/device-manager", + DEVICE_PLANE_MANAGER_RELEASE_V6_REL, +) DEVICE_PLANE_MANAGER_RECONCILIATION_REL = ( "deployment/device-manager-control-plane-reconciliation-v1.json" ) @@ -3966,6 +4006,7 @@ def allowed_payload_path(component, rel): DEVICE_PLANE_MANAGER_RELEASE_V3_REL, DEVICE_PLANE_MANAGER_RELEASE_V4_REL, DEVICE_PLANE_MANAGER_RELEASE_V5_REL, + DEVICE_PLANE_MANAGER_RELEASE_V6_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL, @@ -9540,6 +9581,7 @@ def is_device_plane_manager_control_plane_slice(component, entries): DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES, + DEVICE_PLANE_MANAGER_RELEASE_V6_ENTRIES, ) ) @@ -9576,10 +9618,19 @@ def is_device_plane_manager_release_v5_slice(component, entries): ) +def is_device_plane_manager_release_v6_slice(component, entries): + return ( + component == "device-plane" + and entries is not None + and tuple(entries) == DEVICE_PLANE_MANAGER_RELEASE_V6_ENTRIES + ) + + def is_device_plane_manager_persistent_release_slice(component, entries): return ( is_device_plane_manager_release_v4_slice(component, entries) or is_device_plane_manager_release_v5_slice(component, entries) + or is_device_plane_manager_release_v6_slice(component, entries) ) @@ -9817,6 +9868,20 @@ def expected_device_plane_manager_release_v5_boundaries(): } +def expected_device_plane_manager_release_v6_boundaries(): + return { + **expected_device_plane_manager_release_v5_boundaries(), + "predecessor": { + "kind": "release", + "patchId": DEVICE_PLANE_MANAGER_RELEASE_V6_PREDECESSOR_PATCH_ID, + "artifactSha256": ( + DEVICE_PLANE_MANAGER_RELEASE_V6_PREDECESSOR_ARTIFACT_SHA256 + ), + }, + "faviconSet": "nodedc-adaptive-v1", + } + + def expected_device_plane_manager_release_boundaries(): # Compatibility name for the current release builder/tests. Immutable v1 # predecessors always use expected_device_plane_manager_release_v1_boundaries. @@ -10334,6 +10399,7 @@ def validate_device_plane_manager_release_payload_contract( if schema_version in ( "nodedc.device-plane.device-manager-release.v4", "nodedc.device-plane.device-manager-release.v5", + "nodedc.device-plane.device-manager-release.v6", ): required_compose.extend(( "NODEDC_DEVICE_MANAGER_PRESENTATION_PATH: " @@ -10438,6 +10504,56 @@ def validate_device_plane_manager_release_v5_payload( ) +def validate_device_plane_manager_v6_favicon_bundle(payload_dir): + dist = payload_dir / "services/device-manager/dist" + for relative_path, expected_sha256 in ( + DEVICE_PLANE_MANAGER_FAVICON_SHA256.items() + ): + path = dist / relative_path + if ( + not path.is_file() + or path.is_symlink() + or sha256_file(path) != expected_sha256 + ): + die( + "Device Manager canonical favicon mismatch: " + f"{relative_path}" + ) + index_path = dist / "index.html" + if not index_path.is_file() or index_path.is_symlink(): + die("Device Manager favicon HTML entrypoint mismatch") + index_html = index_path.read_text(encoding="utf-8") + for required_link in ( + 'href="/favicon/icon-adaptive.svg"', + 'href="/favicon/favicon.ico"', + 'href="/favicon/apple-touch-icon.png"', + 'href="/favicon/icon-192.png"', + 'href="/favicon/icon-512.png"', + 'href="/favicon/manifest.webmanifest.json"', + ): + if required_link not in index_html: + die(f"Device Manager favicon link missing: {required_link}") + return "nodedc-adaptive-v1" + + +def validate_device_plane_manager_release_v6_payload( + payload_dir, + *, + expected_release_id=None, +): + descriptor = validate_device_plane_manager_release_payload_contract( + payload_dir, + descriptor_rel=DEVICE_PLANE_MANAGER_RELEASE_V6_REL, + schema_version="nodedc.device-plane.device-manager-release.v6", + boundaries=expected_device_plane_manager_release_v6_boundaries(), + compose_sha256=DEVICE_PLANE_MANAGER_RELEASE_V6_COMPOSE_SHA256, + edge_channel=False, + expected_release_id=expected_release_id, + ) + validate_device_plane_manager_v6_favicon_bundle(payload_dir) + return descriptor + + def validate_device_plane_manager_release_payload( payload_dir, *, @@ -10448,12 +10564,18 @@ def validate_device_plane_manager_release_payload( v3 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V3_REL v4 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V4_REL v5 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V5_REL + v6 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V6_REL present = [ - path for path in (v1, v2, v3, v4, v5) + path for path in (v1, v2, v3, v4, v5, v6) if path.exists() or path.is_symlink() ] if len(present) != 1: die("Device Manager release descriptor cardinality mismatch") + if present[0] == v6: + return validate_device_plane_manager_release_v6_payload( + payload_dir, + expected_release_id=expected_release_id, + ) if present[0] == v5: return validate_device_plane_manager_release_v5_payload( payload_dir, @@ -10743,6 +10865,7 @@ def installed_device_plane_manager_compose_sha256(): v3 = root / DEVICE_PLANE_MANAGER_RELEASE_V3_REL v4 = root / DEVICE_PLANE_MANAGER_RELEASE_V4_REL v5 = root / DEVICE_PLANE_MANAGER_RELEASE_V5_REL + v6 = root / DEVICE_PLANE_MANAGER_RELEASE_V6_REL # A successful v2 overlay intentionally leaves the immutable v1 release # descriptor as predecessor evidence. Prefer the highest installed @@ -10750,6 +10873,19 @@ def installed_device_plane_manager_compose_sha256(): # the generation-specific Compose digest. During a failed v2 apply, # rollback removes the candidate-only v2 descriptor before rebuilding the # restored v1 runtime, so the same lookup follows the restored source. + if v6.exists() or v6.is_symlink(): + descriptor = read_strict_json( + v6, + "installed Device Manager release v6 descriptor", + max_bytes=16 * 1024, + ) + validate_device_plane_manager_release_descriptor( + descriptor, + schema_version="nodedc.device-plane.device-manager-release.v6", + boundaries=expected_device_plane_manager_release_v6_boundaries(), + ) + return DEVICE_PLANE_MANAGER_RELEASE_V6_COMPOSE_SHA256 + if v5.exists() or v5.is_symlink(): descriptor = read_strict_json( v5, @@ -12294,6 +12430,7 @@ def validate_device_plane_manager_v3_active_baseline(descriptor): "nodedc.device-plane.device-manager-release.v3", "nodedc.device-plane.device-manager-release.v4", "nodedc.device-plane.device-manager-release.v5", + "nodedc.device-plane.device-manager-release.v6", ): return None @@ -12442,6 +12579,7 @@ def device_plane_manager_preserved_runtime_health_services(descriptor): "nodedc.device-plane.device-manager-release.v3", "nodedc.device-plane.device-manager-release.v4", "nodedc.device-plane.device-manager-release.v5", + "nodedc.device-plane.device-manager-release.v6", ): return ( "device-control-core", @@ -12575,6 +12713,7 @@ def validate_device_plane_manager_activation_predecessor( DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES, + DEVICE_PLANE_MANAGER_RELEASE_V6_ENTRIES, ) ): die("Device Manager release predecessor type mismatch") @@ -12593,6 +12732,8 @@ def validate_device_plane_manager_activation_predecessor( DEVICE_PLANE_MANAGER_RELEASE_V4_REL, DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES: DEVICE_PLANE_MANAGER_RELEASE_V5_REL, + DEVICE_PLANE_MANAGER_RELEASE_V6_ENTRIES: + DEVICE_PLANE_MANAGER_RELEASE_V6_REL, }.get( tuple(predecessor_entries), DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, @@ -14972,6 +15113,7 @@ def validate_device_manager_control_plane_runtime( require_edge_channel = edge_descriptor.is_file() and not edge_descriptor.is_symlink() if require_persistent_data is None: data_descriptors = ( + DEVICE_PLANE_ROOT / DEVICE_PLANE_MANAGER_RELEASE_V6_REL, DEVICE_PLANE_ROOT / DEVICE_PLANE_MANAGER_RELEASE_V5_REL, DEVICE_PLANE_ROOT / DEVICE_PLANE_MANAGER_RELEASE_V4_REL, ) @@ -21345,6 +21487,7 @@ def plan_artifact(artifact): "nodedc.device-plane.device-manager-release.v3", "nodedc.device-plane.device-manager-release.v4", "nodedc.device-plane.device-manager-release.v5", + "nodedc.device-plane.device-manager-release.v6", ) else "build+recreate:device-control-core,device-manager" ) @@ -21360,6 +21503,7 @@ def plan_artifact(artifact): "nodedc.device-plane.device-manager-release.v3", "nodedc.device-plane.device-manager-release.v4", "nodedc.device-plane.device-manager-release.v5", + "nodedc.device-plane.device-manager-release.v6", ) else "preserved:device-gateway,device-postgres," "device-backhaul-target" @@ -21371,6 +21515,7 @@ def plan_artifact(artifact): manager_persistent = manager_schema in ( "nodedc.device-plane.device-manager-release.v4", "nodedc.device-plane.device-manager-release.v5", + "nodedc.device-plane.device-manager-release.v6", ) print( "device_manager_health_gate=" @@ -21390,13 +21535,18 @@ def plan_artifact(artifact): f"{DEVICE_PLANE_MANAGER_DATA_CONTAINER_DIR}" ) print("device_manager_default_accent=#f5f5f5") - if manager_schema == ( - "nodedc.device-plane.device-manager-release.v5" + if manager_schema in ( + "nodedc.device-plane.device-manager-release.v5", + "nodedc.device-plane.device-manager-release.v6", ): print( "device_manager_overview_layout=" "mission-core-landing-stage-v1" ) + if manager_schema == ( + "nodedc.device-plane.device-manager-release.v6" + ): + print("device_manager_favicon_set=nodedc-adaptive-v1") if device_plane_manager_activation_preflight["descriptor"].get( "commandTransport" ) == "typed-service-ping-v1": diff --git a/infra/deploy-runner/test_device_plane_registry.py b/infra/deploy-runner/test_device_plane_registry.py index afd007e..d489714 100644 --- a/infra/deploy-runner/test_device_plane_registry.py +++ b/infra/deploy-runner/test_device_plane_registry.py @@ -29,6 +29,33 @@ RUNNER = load_runner() class DevicePlaneRegistryTest(unittest.TestCase): + def test_manager_v6_pins_034_and_canonical_favicon_boundary(self): + self.assertEqual( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V6_PREDECESSOR_PATCH_ID, + "device-manager-release-v5-20260822-034", + ) + self.assertEqual( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V6_PREDECESSOR_ARTIFACT_SHA256, + "acc1d2ae2cda66861054826928c25d01a2428e688cc8132a9c381831bf29ab5a", + ) + self.assertEqual( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V6_COMPOSE_SHA256, + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V5_COMPOSE_SHA256, + ) + boundaries = RUNNER.expected_device_plane_manager_release_v6_boundaries() + self.assertEqual(boundaries["faviconSet"], "nodedc-adaptive-v1") + self.assertEqual( + boundaries["overviewLayout"], + "mission-core-landing-stage-v1", + ) + self.assertEqual( + RUNNER.component_services( + "device-plane", + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V6_ENTRIES, + ), + ("device-manager",), + ) + def test_manager_v5_pins_033_and_mission_core_overview_layout(self): self.assertEqual( RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V5_PREDECESSOR_PATCH_ID, @@ -402,6 +429,34 @@ class DevicePlaneRegistryTest(unittest.TestCase): require_persistent_data=True, ) + def test_manager_v6_post_apply_preserves_persistent_data_gate(self): + with ( + mock.patch.object( + RUNNER, + "healthcheck_compose_service_with_grace", + ), + mock.patch.object( + RUNNER, + "component_healthchecks", + return_value=(), + ), + mock.patch.object( + RUNNER, + "validate_device_manager_control_plane_runtime", + ) as runtime_acceptance, + ): + RUNNER.run_healthchecks( + "device-plane", + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V6_ENTRIES, + ("device-manager",), + ) + + runtime_acceptance.assert_called_once_with( + require_edge_channel=True, + core_network_mode="private-egress", + require_persistent_data=True, + ) + def test_manager_v4_prepare_owns_new_parent_and_managed_data_directory(self): with tempfile.TemporaryDirectory( prefix="nodedc-manager-v4-data-", From a6771b2fbf8741978473dedc862afbcf5fe97ce6 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 22 Aug 2026 13:08:38 +0300 Subject: [PATCH 9/9] deploy: register Manager v7 VPS health projection --- infra/deploy-runner/nodedc-deploy | 130 +++++++++++++++++- .../test_device_plane_registry.py | 38 +++++ 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index a96358a..cf33291 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -266,6 +266,9 @@ DEVICE_PLANE_MANAGER_RELEASE_V5_REL = ( DEVICE_PLANE_MANAGER_RELEASE_V6_REL = ( "deployment/device-manager-release-v6.json" ) +DEVICE_PLANE_MANAGER_RELEASE_V7_REL = ( + "deployment/device-manager-release-v7.json" +) DEVICE_PLANE_MANAGER_COMPOSE_REL = "docker-compose.device-manager.yml" DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL = ( "deployment/device-edge-core-channel-bootstrap-v1.json" @@ -391,6 +394,18 @@ DEVICE_PLANE_MANAGER_RELEASE_V6_PREDECESSOR_PATCH_ID = ( DEVICE_PLANE_MANAGER_RELEASE_V6_PREDECESSOR_ARTIFACT_SHA256 = ( "acc1d2ae2cda66861054826928c25d01a2428e688cc8132a9c381831bf29ab5a" ) +DEVICE_PLANE_MANAGER_RELEASE_V7_PREDECESSOR_PATCH_ID = ( + "device-manager-release-v6-20260822-035" +) +DEVICE_PLANE_MANAGER_RELEASE_V7_PREDECESSOR_ARTIFACT_SHA256 = ( + "193faabe930e2b3f212f8eb45288e39f850ec714528b28881095be654baf9a80" +) +DEVICE_PLANE_MANAGER_RELEASE_V7_CONTROL_CORE_PREDECESSOR_PATCH_ID = ( + "device-control-core-release-v2-20260822-036" +) +DEVICE_PLANE_MANAGER_RELEASE_V7_CONTROL_CORE_PREDECESSOR_ARTIFACT_SHA256 = ( + "8708cc4b59fa0cd5e9c6e6a7b2654ba01ea60271549167aca2631f94000d3da3" +) DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_PREDECESSOR_PATCH_ID = ( "device-edge-core-channel-bootstrap-20260812-018" ) @@ -458,6 +473,9 @@ DEVICE_PLANE_MANAGER_RELEASE_V5_COMPOSE_SHA256 = ( DEVICE_PLANE_MANAGER_RELEASE_V6_COMPOSE_SHA256 = ( DEVICE_PLANE_MANAGER_RELEASE_V5_COMPOSE_SHA256 ) +DEVICE_PLANE_MANAGER_RELEASE_V7_COMPOSE_SHA256 = ( + DEVICE_PLANE_MANAGER_RELEASE_V6_COMPOSE_SHA256 +) DEVICE_PLANE_MANAGER_FAVICON_SHA256 = { "favicon.ico": ( "f8933114a85646335ea5c94944f56d3cd8c48a6032016719f7905ff244ec0aa2" @@ -574,6 +592,11 @@ DEVICE_PLANE_MANAGER_RELEASE_V6_ENTRIES = ( "services/device-manager", DEVICE_PLANE_MANAGER_RELEASE_V6_REL, ) +DEVICE_PLANE_MANAGER_RELEASE_V7_ENTRIES = ( + DEVICE_PLANE_MANAGER_COMPOSE_REL, + "services/device-manager", + DEVICE_PLANE_MANAGER_RELEASE_V7_REL, +) DEVICE_PLANE_MANAGER_RECONCILIATION_REL = ( "deployment/device-manager-control-plane-reconciliation-v1.json" ) @@ -4007,6 +4030,7 @@ def allowed_payload_path(component, rel): DEVICE_PLANE_MANAGER_RELEASE_V4_REL, DEVICE_PLANE_MANAGER_RELEASE_V5_REL, DEVICE_PLANE_MANAGER_RELEASE_V6_REL, + DEVICE_PLANE_MANAGER_RELEASE_V7_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL, @@ -9582,6 +9606,7 @@ def is_device_plane_manager_control_plane_slice(component, entries): DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V6_ENTRIES, + DEVICE_PLANE_MANAGER_RELEASE_V7_ENTRIES, ) ) @@ -9626,11 +9651,20 @@ def is_device_plane_manager_release_v6_slice(component, entries): ) +def is_device_plane_manager_release_v7_slice(component, entries): + return ( + component == "device-plane" + and entries is not None + and tuple(entries) == DEVICE_PLANE_MANAGER_RELEASE_V7_ENTRIES + ) + + def is_device_plane_manager_persistent_release_slice(component, entries): return ( is_device_plane_manager_release_v4_slice(component, entries) or is_device_plane_manager_release_v5_slice(component, entries) or is_device_plane_manager_release_v6_slice(component, entries) + or is_device_plane_manager_release_v7_slice(component, entries) ) @@ -9882,6 +9916,33 @@ def expected_device_plane_manager_release_v6_boundaries(): } +def expected_device_plane_manager_release_v7_boundaries(): + return { + **expected_device_plane_manager_release_v6_boundaries(), + "predecessor": { + "kind": "release", + "patchId": DEVICE_PLANE_MANAGER_RELEASE_V7_PREDECESSOR_PATCH_ID, + "artifactSha256": ( + DEVICE_PLANE_MANAGER_RELEASE_V7_PREDECESSOR_ARTIFACT_SHA256 + ), + }, + "controlCorePredecessor": { + "patchId": ( + DEVICE_PLANE_MANAGER_RELEASE_V7_CONTROL_CORE_PREDECESSOR_PATCH_ID + ), + "artifactSha256": ( + DEVICE_PLANE_MANAGER_RELEASE_V7_CONTROL_CORE_PREDECESSOR_ARTIFACT_SHA256 + ), + }, + "commandFormLayout": "aligned-control-row-v1", + "secondaryEmptyTypography": "help-text-sm-v1", + "infrastructureHostProjection": ( + "edge-registration-live-channel-v1" + ), + "ontologyStatus": "generic-host-domain-candidate-not-canonical", + } + + def expected_device_plane_manager_release_boundaries(): # Compatibility name for the current release builder/tests. Immutable v1 # predecessors always use expected_device_plane_manager_release_v1_boundaries. @@ -10400,6 +10461,7 @@ def validate_device_plane_manager_release_payload_contract( "nodedc.device-plane.device-manager-release.v4", "nodedc.device-plane.device-manager-release.v5", "nodedc.device-plane.device-manager-release.v6", + "nodedc.device-plane.device-manager-release.v7", ): required_compose.extend(( "NODEDC_DEVICE_MANAGER_PRESENTATION_PATH: " @@ -10554,6 +10616,24 @@ def validate_device_plane_manager_release_v6_payload( return descriptor +def validate_device_plane_manager_release_v7_payload( + payload_dir, + *, + expected_release_id=None, +): + descriptor = validate_device_plane_manager_release_payload_contract( + payload_dir, + descriptor_rel=DEVICE_PLANE_MANAGER_RELEASE_V7_REL, + schema_version="nodedc.device-plane.device-manager-release.v7", + boundaries=expected_device_plane_manager_release_v7_boundaries(), + compose_sha256=DEVICE_PLANE_MANAGER_RELEASE_V7_COMPOSE_SHA256, + edge_channel=False, + expected_release_id=expected_release_id, + ) + validate_device_plane_manager_v6_favicon_bundle(payload_dir) + return descriptor + + def validate_device_plane_manager_release_payload( payload_dir, *, @@ -10565,12 +10645,18 @@ def validate_device_plane_manager_release_payload( v4 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V4_REL v5 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V5_REL v6 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V6_REL + v7 = payload_dir / DEVICE_PLANE_MANAGER_RELEASE_V7_REL present = [ - path for path in (v1, v2, v3, v4, v5, v6) + path for path in (v1, v2, v3, v4, v5, v6, v7) if path.exists() or path.is_symlink() ] if len(present) != 1: die("Device Manager release descriptor cardinality mismatch") + if present[0] == v7: + return validate_device_plane_manager_release_v7_payload( + payload_dir, + expected_release_id=expected_release_id, + ) if present[0] == v6: return validate_device_plane_manager_release_v6_payload( payload_dir, @@ -10866,6 +10952,7 @@ def installed_device_plane_manager_compose_sha256(): v4 = root / DEVICE_PLANE_MANAGER_RELEASE_V4_REL v5 = root / DEVICE_PLANE_MANAGER_RELEASE_V5_REL v6 = root / DEVICE_PLANE_MANAGER_RELEASE_V6_REL + v7 = root / DEVICE_PLANE_MANAGER_RELEASE_V7_REL # A successful v2 overlay intentionally leaves the immutable v1 release # descriptor as predecessor evidence. Prefer the highest installed @@ -10873,6 +10960,19 @@ def installed_device_plane_manager_compose_sha256(): # the generation-specific Compose digest. During a failed v2 apply, # rollback removes the candidate-only v2 descriptor before rebuilding the # restored v1 runtime, so the same lookup follows the restored source. + if v7.exists() or v7.is_symlink(): + descriptor = read_strict_json( + v7, + "installed Device Manager release v7 descriptor", + max_bytes=16 * 1024, + ) + validate_device_plane_manager_release_descriptor( + descriptor, + schema_version="nodedc.device-plane.device-manager-release.v7", + boundaries=expected_device_plane_manager_release_v7_boundaries(), + ) + return DEVICE_PLANE_MANAGER_RELEASE_V7_COMPOSE_SHA256 + if v6.exists() or v6.is_symlink(): descriptor = read_strict_json( v6, @@ -12431,6 +12531,7 @@ def validate_device_plane_manager_v3_active_baseline(descriptor): "nodedc.device-plane.device-manager-release.v4", "nodedc.device-plane.device-manager-release.v5", "nodedc.device-plane.device-manager-release.v6", + "nodedc.device-plane.device-manager-release.v7", ): return None @@ -12580,6 +12681,7 @@ def device_plane_manager_preserved_runtime_health_services(descriptor): "nodedc.device-plane.device-manager-release.v4", "nodedc.device-plane.device-manager-release.v5", "nodedc.device-plane.device-manager-release.v6", + "nodedc.device-plane.device-manager-release.v7", ): return ( "device-control-core", @@ -12714,6 +12816,7 @@ def validate_device_plane_manager_activation_predecessor( DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES, DEVICE_PLANE_MANAGER_RELEASE_V6_ENTRIES, + DEVICE_PLANE_MANAGER_RELEASE_V7_ENTRIES, ) ): die("Device Manager release predecessor type mismatch") @@ -12734,6 +12837,8 @@ def validate_device_plane_manager_activation_predecessor( DEVICE_PLANE_MANAGER_RELEASE_V5_REL, DEVICE_PLANE_MANAGER_RELEASE_V6_ENTRIES: DEVICE_PLANE_MANAGER_RELEASE_V6_REL, + DEVICE_PLANE_MANAGER_RELEASE_V7_ENTRIES: + DEVICE_PLANE_MANAGER_RELEASE_V7_REL, }.get( tuple(predecessor_entries), DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, @@ -15113,6 +15218,7 @@ def validate_device_manager_control_plane_runtime( require_edge_channel = edge_descriptor.is_file() and not edge_descriptor.is_symlink() if require_persistent_data is None: data_descriptors = ( + DEVICE_PLANE_ROOT / DEVICE_PLANE_MANAGER_RELEASE_V7_REL, DEVICE_PLANE_ROOT / DEVICE_PLANE_MANAGER_RELEASE_V6_REL, DEVICE_PLANE_ROOT / DEVICE_PLANE_MANAGER_RELEASE_V5_REL, DEVICE_PLANE_ROOT / DEVICE_PLANE_MANAGER_RELEASE_V4_REL, @@ -21488,6 +21594,7 @@ def plan_artifact(artifact): "nodedc.device-plane.device-manager-release.v4", "nodedc.device-plane.device-manager-release.v5", "nodedc.device-plane.device-manager-release.v6", + "nodedc.device-plane.device-manager-release.v7", ) else "build+recreate:device-control-core,device-manager" ) @@ -21504,6 +21611,7 @@ def plan_artifact(artifact): "nodedc.device-plane.device-manager-release.v4", "nodedc.device-plane.device-manager-release.v5", "nodedc.device-plane.device-manager-release.v6", + "nodedc.device-plane.device-manager-release.v7", ) else "preserved:device-gateway,device-postgres," "device-backhaul-target" @@ -21516,6 +21624,7 @@ def plan_artifact(artifact): "nodedc.device-plane.device-manager-release.v4", "nodedc.device-plane.device-manager-release.v5", "nodedc.device-plane.device-manager-release.v6", + "nodedc.device-plane.device-manager-release.v7", ) print( "device_manager_health_gate=" @@ -21538,15 +21647,30 @@ def plan_artifact(artifact): if manager_schema in ( "nodedc.device-plane.device-manager-release.v5", "nodedc.device-plane.device-manager-release.v6", + "nodedc.device-plane.device-manager-release.v7", ): print( "device_manager_overview_layout=" "mission-core-landing-stage-v1" ) - if manager_schema == ( - "nodedc.device-plane.device-manager-release.v6" + if manager_schema in ( + "nodedc.device-plane.device-manager-release.v6", + "nodedc.device-plane.device-manager-release.v7", ): print("device_manager_favicon_set=nodedc-adaptive-v1") + if manager_schema == ( + "nodedc.device-plane.device-manager-release.v7" + ): + print("device_manager_command_form=aligned-control-row-v1") + print("device_manager_secondary_empty_text=help-text-sm-v1") + print( + "device_manager_infrastructure_hosts=" + "edge-registration-live-channel-v1" + ) + print( + "device_manager_host_ontology=" + "candidate-not-canonical" + ) if device_plane_manager_activation_preflight["descriptor"].get( "commandTransport" ) == "typed-service-ping-v1": diff --git a/infra/deploy-runner/test_device_plane_registry.py b/infra/deploy-runner/test_device_plane_registry.py index d489714..315cf12 100644 --- a/infra/deploy-runner/test_device_plane_registry.py +++ b/infra/deploy-runner/test_device_plane_registry.py @@ -29,6 +29,44 @@ RUNNER = load_runner() class DevicePlaneRegistryTest(unittest.TestCase): + def test_manager_v7_pins_v6_and_live_edge_host_projection(self): + self.assertEqual( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V7_PREDECESSOR_PATCH_ID, + "device-manager-release-v6-20260822-035", + ) + self.assertEqual( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V7_PREDECESSOR_ARTIFACT_SHA256, + "193faabe930e2b3f212f8eb45288e39f850ec714528b28881095be654baf9a80", + ) + self.assertEqual( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V7_CONTROL_CORE_PREDECESSOR_PATCH_ID, + "device-control-core-release-v2-20260822-036", + ) + self.assertEqual( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V7_CONTROL_CORE_PREDECESSOR_ARTIFACT_SHA256, + "8708cc4b59fa0cd5e9c6e6a7b2654ba01ea60271549167aca2631f94000d3da3", + ) + self.assertEqual( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V7_COMPOSE_SHA256, + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V6_COMPOSE_SHA256, + ) + boundaries = RUNNER.expected_device_plane_manager_release_v7_boundaries() + self.assertEqual( + boundaries["infrastructureHostProjection"], + "edge-registration-live-channel-v1", + ) + self.assertEqual( + boundaries["ontologyStatus"], + "generic-host-domain-candidate-not-canonical", + ) + self.assertEqual( + RUNNER.component_services( + "device-plane", + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V7_ENTRIES, + ), + ("device-manager",), + ) + def test_manager_v6_pins_034_and_canonical_favicon_boundary(self): self.assertEqual( RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V6_PREDECESSOR_PATCH_ID,