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-",