diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index e273b6b..b7e15e5 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -72,6 +72,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" @@ -255,6 +263,18 @@ 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_RELEASE_V5_REL = ( + "deployment/device-manager-release-v5.json" +) +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" @@ -351,10 +371,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" @@ -362,6 +382,36 @@ 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_MANAGER_RELEASE_V5_PREDECESSOR_PATCH_ID = ( + "device-manager-release-v4-20260822-033" +) +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_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" ) @@ -420,6 +470,41 @@ 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_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_RELEASE_V7_COMPOSE_SHA256 = ( + DEVICE_PLANE_MANAGER_RELEASE_V6_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" ) @@ -498,6 +583,26 @@ 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_RELEASE_V5_ENTRIES = ( + DEVICE_PLANE_MANAGER_COMPOSE_REL, + "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_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" ) @@ -4536,6 +4641,10 @@ 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_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, @@ -11473,6 +11582,10 @@ 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, + DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES, + DEVICE_PLANE_MANAGER_RELEASE_V6_ENTRIES, + DEVICE_PLANE_MANAGER_RELEASE_V7_ENTRIES, ) ) @@ -11493,6 +11606,54 @@ 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_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_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_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) + ) + + +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_persistent_release_slice(component, entries) + ) + + def is_device_plane_edge_core_channel_bootstrap_slice(component, entries): return ( component == "device-plane" @@ -11681,6 +11842,86 @@ 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_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_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_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. @@ -12195,6 +12436,22 @@ def validate_device_plane_manager_release_payload_contract( "device-edge-channel/peers", "name: nodedc-device-plane-egress", )) + 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", + "nodedc.device-plane.device-manager-release.v7", + ): + 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}") @@ -12256,6 +12513,106 @@ 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_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_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_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, *, @@ -12264,12 +12621,36 @@ 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 + 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) + 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, + expected_release_id=expected_release_id, + ) + 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, + expected_release_id=expected_release_id, + ) if present[0] == v3: return validate_device_plane_manager_release_v3_payload( payload_dir, @@ -12547,6 +12928,10 @@ 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 + 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 @@ -12554,6 +12939,58 @@ 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, + "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, + "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, + "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, @@ -14068,8 +14505,12 @@ 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", + "nodedc.device-plane.device-manager-release.v5", + "nodedc.device-plane.device-manager-release.v6", + "nodedc.device-plane.device-manager-release.v7", ): return None @@ -14213,7 +14654,37 @@ 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") in ( + "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", + "nodedc.device-plane.device-manager-release.v7", + ): + 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 ) @@ -14321,6 +14792,10 @@ def validate_device_plane_manager_activation_predecessor(payload_dir): 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, + 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") @@ -14335,6 +14810,14 @@ def validate_device_plane_manager_activation_predecessor(payload_dir): 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, + DEVICE_PLANE_MANAGER_RELEASE_V5_ENTRIES: + 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, @@ -14346,13 +14829,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 ) @@ -14827,7 +15313,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 ) @@ -14931,13 +15445,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", @@ -14947,6 +15464,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, } @@ -15237,6 +15756,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"]) @@ -15261,6 +15781,89 @@ 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") + # 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): + 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"] + 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) + + 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") + 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" + restored_healthy = ( + state.get("Status") == "running" + and state.get("Running") is True + 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) + 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: @@ -16577,6 +17180,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") @@ -16591,6 +17195,17 @@ 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_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, + ) + 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", "DEVICE_MANAGEMENT_CORE_TOKEN_FILE": ( @@ -16649,6 +17264,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() @@ -16733,16 +17365,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") @@ -18397,7 +19036,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): @@ -18997,7 +19636,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", ( @@ -27411,7 +28050,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( @@ -27429,7 +28069,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( @@ -29203,7 +29844,13 @@ 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", + "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" ) ) @@ -29214,12 +29861,71 @@ 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", + "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" ) ) - print("device_manager_health_gate=bounded-grace+contract") + 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", + "nodedc.device-plane.device-manager-release.v6", + "nodedc.device-plane.device-manager-release.v7", + ) + print( + "device_manager_health_gate=" + + ( + "bounded-grace+contract+persistent-data" + if manager_persistent + else "bounded-grace+contract" + ) + ) + if manager_persistent: + 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 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 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": @@ -29232,7 +29938,12 @@ 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_persistent + else "source+reconciled-baseline-runtime" + ) ) if device_plane_control_core_release_preflight is not None: core_release = device_plane_control_core_release_preflight @@ -30046,16 +30757,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", @@ -30914,6 +31618,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 @@ -31097,6 +31842,11 @@ def prepare_component_runtime(component, entries=None): MAP_GATEWAY_SECRET_RE, "Device Core Hub handoff", ) + 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) or is_device_plane_edge_core_channel_bootstrap_slice( @@ -31545,7 +32295,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 = ({ @@ -31559,7 +32309,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"}, @@ -32553,6 +33303,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", @@ -32712,7 +33463,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: @@ -32724,7 +33475,22 @@ 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_persistent_release_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", + ) + 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",): @@ -33627,7 +34393,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, @@ -33641,7 +34408,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, @@ -33975,11 +34743,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 629acd6..315cf12 100644 --- a/infra/deploy-runner/test_device_plane_registry.py +++ b/infra/deploy-runner/test_device_plane_registry.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 import importlib.machinery import importlib.util +import inspect import json +import stat import tempfile import unittest from pathlib import Path @@ -27,6 +29,617 @@ 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, + "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, + "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, + "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, + "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, + "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_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", + "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["status"], "running") + 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_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_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_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_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-", + ) 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, + "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")