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,