From 8defd55715a7ab8355b87dd6502d65b45305ba59 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 11 Aug 2026 01:12:44 +0300 Subject: [PATCH] fix(device-plane): activate manager from reconciled baseline --- .../device-manager-control-plane-v2.json | 17 ++ ...-device-manager-control-plane-artifact.mjs | 15 +- infra/deploy-runner/nodedc-deploy | 220 +++++++++++++++++- ..._device_manager_control_plane_artifacts.py | 62 +++++ ...r_control_plane_reconciliation_artifact.py | 22 +- 5 files changed, 322 insertions(+), 14 deletions(-) create mode 100644 device-plane/deployment/device-manager-control-plane-v2.json diff --git a/device-plane/deployment/device-manager-control-plane-v2.json b/device-plane/deployment/device-manager-control-plane-v2.json new file mode 100644 index 0000000..ec3ccc1 --- /dev/null +++ b/device-plane/deployment/device-manager-control-plane-v2.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": "nodedc.device-plane.device-manager-control-plane.v2", + "action": "activate", + "predecessor": { + "patchId": "device-manager-control-plane-reconciliation-20260811-002", + "artifactSha256": "dd86dd58e4f649db0981db5089e003caf3961356179f2abb514662351487e1e6", + "mode": "failed-control-plane-baseline-adoption" + }, + "service": "device-manager", + "publicIngress": "reverse-proxy-only", + "deviceCoreManagementApi": "file-token-authenticated", + "launcherTrust": "file-token-scoped-to-device-core-handoff", + "healthGate": "bounded-container-grace+core-contract", + "commandTransport": "disabled", + "gelios": "untouched", + "rollback": "restore-reconciled-baseline" +} diff --git a/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs b/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs index 23b07b8..d41dc9a 100644 --- a/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs +++ b/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs @@ -12,7 +12,7 @@ const devicePlaneRoot = resolve(platformRoot, "device-plane"); const designRoot = resolve(process.env.NODEDC_DEVICE_MANAGER_SOURCE_ROOT || resolve(platformRoot, "../NODEDC_DESIGN_GUIDELINE")); const managerRoot = resolve(designRoot, "apps/device-manager"); const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); -const [patchId = "device-manager-control-plane-20260810-001", ...extra] = process.argv.slice(2); +const [patchId = "device-manager-control-plane-20260811-003", ...extra] = process.argv.slice(2); if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-device-manager-control-plane-artifact.mjs [patch-id]"); const entries = [ @@ -26,7 +26,7 @@ const entries = [ "services/device-gateway/package.json", "services/device-edge-relay/package.json", "services/device-manager", - "deployment/device-manager-control-plane-v1.json", + "deployment/device-manager-control-plane-v2.json", ]; const stage = await mkdtemp(join(tmpdir(), "nodedc-device-manager-control-plane-")); const payload = join(stage, "payload"); @@ -67,6 +67,17 @@ try { ]) { if (compose.includes(forbidden)) throw new Error(`device_manager_compose_boundary_violation:${forbidden}`); } + const descriptor = JSON.parse(await readFile( + join(payload, "deployment/device-manager-control-plane-v2.json"), + "utf8", + )); + if ( + descriptor.schemaVersion !== "nodedc.device-plane.device-manager-control-plane.v2" + || descriptor.predecessor?.patchId !== "device-manager-control-plane-reconciliation-20260811-002" + || descriptor.predecessor?.artifactSha256 !== "dd86dd58e4f649db0981db5089e003caf3961356179f2abb514662351487e1e6" + || descriptor.healthGate !== "bounded-container-grace+core-contract" + || descriptor.rollback !== "restore-reconciled-baseline" + ) throw new Error("device_manager_activation_successor_contract_mismatch"); await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, "utf8"); await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8"); await mkdir(artifactDir, { recursive: true }); diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index bd01218..4879c5a 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -210,13 +210,29 @@ DEVICE_PLANE_FOUNDATION_ENTRIES = ( "services/device-control-core", "services/device-gateway", ) -DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL = ( +DEVICE_PLANE_MANAGER_FAILED_CONTROL_PLANE_REL = ( "deployment/device-manager-control-plane-v1.json" ) +DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL = ( + "deployment/device-manager-control-plane-v2.json" +) DEVICE_PLANE_MANAGER_COMPOSE_REL = "docker-compose.device-manager.yml" DEVICE_PLANE_MANAGER_COMPOSE_SHA256 = ( "4954120aaddc999798b64c304d8cf692b79714feb727d873117bd1f3434e865e" ) +DEVICE_PLANE_MANAGER_FAILED_CONTROL_PLANE_ENTRIES = ( + ".dockerignore", + "package.json", + "package-lock.json", + DEVICE_PLANE_MANAGER_COMPOSE_REL, + "packages/device-protocol-contract", + "packages/arusnavi-b2-adapter", + "services/device-control-core", + "services/device-gateway/package.json", + "services/device-edge-relay/package.json", + "services/device-manager", + DEVICE_PLANE_MANAGER_FAILED_CONTROL_PLANE_REL, +) DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES = ( ".dockerignore", "package.json", @@ -246,6 +262,20 @@ DEVICE_PLANE_MANAGER_FAILED_ARTIFACT = ( DEVICE_PLANE_MANAGER_FAILED_ARTIFACT_SHA256 = ( "50e275c1085286bcb3bb2b273aefc8bbba70f446ca2c7bd464dc745710a291a6" ) +DEVICE_PLANE_MANAGER_RECONCILIATION_PATCH_ID = ( + "device-manager-control-plane-reconciliation-20260811-002" +) +DEVICE_PLANE_MANAGER_RECONCILIATION_ARTIFACT = ( + "nodedc-device-plane-device-manager-control-plane-reconciliation-" + "20260811-002.tgz" +) +DEVICE_PLANE_MANAGER_RECONCILIATION_ARTIFACT_SHA256 = ( + "dd86dd58e4f649db0981db5089e003caf3961356179f2abb514662351487e1e6" +) +DEVICE_PLANE_MANAGER_RECONCILIATION_APPLY_BACKUP_ID = ( + "device-plane-device-manager-control-plane-reconciliation-" + "20260811-002-20260811-004839" +) DEVICE_PLANE_MANAGER_RECONCILIATION_BACKUP_ID = ( "device-plane-device-manager-control-plane-20260810-001-" "20260811-000321" @@ -283,7 +313,7 @@ DEVICE_PLANE_MANAGER_RECONCILIATION_EXISTING = ( DEVICE_PLANE_MANAGER_RECONCILIATION_MISSING = ( DEVICE_PLANE_MANAGER_COMPOSE_REL, "services/device-manager", - DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, + DEVICE_PLANE_MANAGER_FAILED_CONTROL_PLANE_REL, ) DEVICE_PLANE_FOUNDATION_RECOVERY_REL = ( "deployment/device-plane-foundation-recovery-v1.json" @@ -3169,6 +3199,7 @@ def allowed_payload_path(component, rel): DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL, DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_REL, DEVICE_PLANE_BACKHAUL_TARGET_REL, + DEVICE_PLANE_MANAGER_FAILED_CONTROL_PLANE_REL, DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, DEVICE_PLANE_MANAGER_RECONCILIATION_REL, "packages/device-protocol-contract", @@ -8216,6 +8247,13 @@ def load_artifact(artifact, work_dir): entries, ): validate_device_plane_manager_control_plane_payload(payload_dir) + if is_device_plane_manager_failed_control_plane_slice( + manifest["component"], + entries, + ): + validate_device_plane_manager_failed_control_plane_payload( + payload_dir + ) if is_device_plane_manager_reconciliation_slice( manifest["component"], entries, @@ -8555,14 +8593,22 @@ def reject_terminal_device_plane_foundation_artifact(manifest, sha256): ) -def reject_terminal_device_plane_manager_artifact(manifest, sha256): +def reject_terminal_device_plane_manager_artifact( + manifest, + sha256, + entries=None, +): if ( manifest.get("id") == DEVICE_PLANE_MANAGER_FAILED_PATCH_ID or sha256 == DEVICE_PLANE_MANAGER_FAILED_ARTIFACT_SHA256 + or is_device_plane_manager_failed_control_plane_slice( + manifest.get("component"), + entries, + ) ): die( "Device Manager control-plane 001 is terminal failed; " - "use the exact registered reconciliation successor" + "use reconciliation followed by the exact v2 activation successor" ) @@ -8627,6 +8673,15 @@ def is_device_plane_manager_control_plane_slice(component, entries): ) +def is_device_plane_manager_failed_control_plane_slice(component, entries): + return ( + component == "device-plane" + and entries is not None + and tuple(entries) + == DEVICE_PLANE_MANAGER_FAILED_CONTROL_PLANE_ENTRIES + ) + + def is_device_plane_manager_reconciliation_slice(component, entries): return ( component == "device-plane" @@ -8658,6 +8713,30 @@ def expected_platform_device_manager_public_route_descriptor(): def expected_device_plane_manager_control_plane_descriptor(): + return { + "schemaVersion": ( + "nodedc.device-plane.device-manager-control-plane.v2" + ), + "action": "activate", + "predecessor": { + "patchId": DEVICE_PLANE_MANAGER_RECONCILIATION_PATCH_ID, + "artifactSha256": ( + DEVICE_PLANE_MANAGER_RECONCILIATION_ARTIFACT_SHA256 + ), + "mode": "failed-control-plane-baseline-adoption", + }, + "service": "device-manager", + "publicIngress": "reverse-proxy-only", + "deviceCoreManagementApi": "file-token-authenticated", + "launcherTrust": "file-token-scoped-to-device-core-handoff", + "healthGate": "bounded-container-grace+core-contract", + "commandTransport": "disabled", + "gelios": "untouched", + "rollback": "restore-reconciled-baseline", + } + + +def expected_device_plane_manager_failed_control_plane_descriptor(): return { "schemaVersion": ( "nodedc.device-plane.device-manager-control-plane.v1" @@ -8782,6 +8861,23 @@ def validate_device_plane_manager_control_plane_payload(payload_dir): return descriptor +def validate_device_plane_manager_failed_control_plane_payload(payload_dir): + descriptor = read_strict_json( + payload_dir / DEVICE_PLANE_MANAGER_FAILED_CONTROL_PLANE_REL, + "failed Device Manager control-plane descriptor", + max_bytes=16 * 1024, + ) + if ( + descriptor + != expected_device_plane_manager_failed_control_plane_descriptor() + ): + die("failed Device Manager control-plane descriptor mismatch") + compose_path = payload_dir / DEVICE_PLANE_MANAGER_COMPOSE_REL + if sha256_file(compose_path) != DEVICE_PLANE_MANAGER_COMPOSE_SHA256: + die("failed Device Manager control-plane Compose mismatch") + return descriptor + + def validate_device_plane_manager_reconciliation_payload(payload_dir): descriptor = read_strict_json( payload_dir / DEVICE_PLANE_MANAGER_RECONCILIATION_REL, @@ -10049,7 +10145,7 @@ def validate_device_plane_manager_reconciliation_backup(): backup_dir / "missing-files.txt" )) validate_backup_partition( - DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES, + DEVICE_PLANE_MANAGER_FAILED_CONTROL_PLANE_ENTRIES, existing, missing, "Device Manager reconciliation", @@ -10090,7 +10186,10 @@ def validate_device_plane_manager_reconciled_baseline( ) if live_source != backup_source: die("Device Manager rollback source does not match backup") - for rel in DEVICE_PLANE_MANAGER_RECONCILIATION_MISSING: + for rel in ( + *DEVICE_PLANE_MANAGER_RECONCILIATION_MISSING, + DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, + ): path = root / rel if path.exists() or path.is_symlink(): die( @@ -10200,7 +10299,7 @@ def validate_device_plane_manager_reconciliation_evidence(payload_dir): or failed_manifest.get("component") != "device-plane" or failed_manifest.get("type") != "app-overlay" or tuple(failed_entries) - != DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES + != DEVICE_PLANE_MANAGER_FAILED_CONTROL_PLANE_ENTRIES ): die("Device Manager failed artifact contract mismatch") @@ -10216,6 +10315,60 @@ def validate_device_plane_manager_reconciliation_evidence(payload_dir): } +def validate_device_plane_manager_activation_predecessor(payload_dir): + descriptor = validate_device_plane_manager_control_plane_payload( + payload_dir + ) + reconciliation_artifact = ( + APPLIED_DIR / DEVICE_PLANE_MANAGER_RECONCILIATION_ARTIFACT + ) + try: + artifact_stat = reconciliation_artifact.lstat() + except FileNotFoundError: + die("Device Manager reconciliation applied artifact is missing") + if ( + stat.S_ISLNK(artifact_stat.st_mode) + or not stat.S_ISREG(artifact_stat.st_mode) + or sha256_file(reconciliation_artifact) + != DEVICE_PLANE_MANAGER_RECONCILIATION_ARTIFACT_SHA256 + ): + die("Device Manager reconciliation applied artifact mismatch") + + records = [ + value + for value in load_state(STATE_FILE) + if value.get("id") + == DEVICE_PLANE_MANAGER_RECONCILIATION_PATCH_ID + ] + if len(records) != 1: + die("Device Manager reconciliation applied journal count mismatch") + record = records[0] + if ( + record.get("artifact") + != DEVICE_PLANE_MANAGER_RECONCILIATION_ARTIFACT + or record.get("backup_id") + != DEVICE_PLANE_MANAGER_RECONCILIATION_APPLY_BACKUP_ID + or record.get("component") != "device-plane" + or record.get("sha256") + != DEVICE_PLANE_MANAGER_RECONCILIATION_ARTIFACT_SHA256 + or record.get("status") != "ok" + ): + die("Device Manager reconciliation applied journal mismatch") + + backup_dir = validate_device_plane_manager_reconciliation_backup() + runtime = validate_device_plane_manager_reconciled_baseline( + backup_dir, + marker_installed=True, + ) + return { + "mode": "reconciled-manager-forward-activation", + "descriptor": descriptor, + "reconciliationArtifact": reconciliation_artifact, + "reconciliationRecord": record, + "runtime": runtime, + } + + def device_plane_service_container_ids(service): if service not in ( *DEVICE_PLANE_RUNTIME_SERVICES, @@ -15845,6 +15998,7 @@ def plan_artifact(artifact): device_plane_network_publication_preflight = None device_plane_b2_ingress_preflight = None device_plane_b2_recovery_preflight = None + device_plane_manager_activation_preflight = None device_plane_manager_reconciliation_preflight = None device_plane_backhaul_preflight = None device_plane_backhaul_vps_enrollment_preflight = None @@ -15873,7 +16027,11 @@ def plan_artifact(artifact): manifest, entries, payload_dir = load_artifact(artifact, Path(tmp)) reject_terminal_engine_l2_failed_artifact(manifest, sha) reject_terminal_device_plane_foundation_artifact(manifest, sha) - reject_terminal_device_plane_manager_artifact(manifest, sha) + reject_terminal_device_plane_manager_artifact( + manifest, + sha, + entries, + ) reject_terminal_device_plane_backhaul_artifact(manifest, sha) transition_descriptor = None transition_preflight = None @@ -16051,6 +16209,15 @@ def plan_artifact(artifact): payload_dir ) ) + if is_device_plane_manager_control_plane_slice( + manifest["component"], + entries, + ): + device_plane_manager_activation_preflight = ( + validate_device_plane_manager_activation_predecessor( + payload_dir + ) + ) if is_device_plane_manager_reconciliation_slice( manifest["component"], entries, @@ -17613,6 +17780,35 @@ def plan_artifact(artifact): ) print("device_gateway_tcp_9921=disabled:unpublished") print("device_plane_rollback=marker-only-runtime-unchanged") + if device_plane_manager_activation_preflight is not None: + print( + "device_plane_transition=" + f"{device_plane_manager_activation_preflight['mode']}" + ) + print( + "device_plane_predecessor_patch=" + f"{DEVICE_PLANE_MANAGER_RECONCILIATION_PATCH_ID}" + ) + print( + "device_plane_predecessor_artifact_sha256=" + f"{DEVICE_PLANE_MANAGER_RECONCILIATION_ARTIFACT_SHA256}" + ) + print( + "device_plane_runtime_mutation=" + "build+recreate:device-control-core,device-manager" + ) + print( + "device_plane_runtime_services=" + "preserved:device-gateway,device-postgres," + "device-backhaul-target" + ) + print("device_manager_health_gate=bounded-grace+contract") + print("device_manager_public_route=unchanged:absent") + print("device_gateway_tcp_9921=preserved:loopback-only") + print( + "device_plane_rollback=" + "source+reconciled-baseline-runtime" + ) if device_plane_manager_reconciliation_preflight is not None: print( "device_plane_transition=" @@ -20849,6 +21045,7 @@ def apply_artifact(artifact): reject_terminal_device_plane_manager_artifact( manifest, sha, + entries, ) reject_terminal_device_plane_backhaul_artifact( manifest, @@ -20901,6 +21098,13 @@ def apply_artifact(artifact): validate_device_plane_b2_discovery_rollback_recovery_evidence( payload_dir ) + if is_device_plane_manager_control_plane_slice( + component, + entries, + ): + validate_device_plane_manager_activation_predecessor( + payload_dir + ) if is_device_plane_manager_reconciliation_slice( component, entries, diff --git a/infra/deploy-runner/test_device_manager_control_plane_artifacts.py b/infra/deploy-runner/test_device_manager_control_plane_artifacts.py index 576f293..6da2a4c 100644 --- a/infra/deploy-runner/test_device_manager_control_plane_artifacts.py +++ b/infra/deploy-runner/test_device_manager_control_plane_artifacts.py @@ -235,6 +235,68 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): ) runtime_acceptance.assert_called_once_with() + def test_v2_activation_requires_exact_applied_reconciliation(self): + with tempfile.TemporaryDirectory( + prefix="nodedc-device-manager-v2-predecessor-", + ) as directory: + root = Path(directory) + artifact = root / RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_ARTIFACT + artifact_bytes = b"reviewed-reconciliation-artifact" + artifact.write_bytes(artifact_bytes) + state_file = root / "applied.jsonl" + state_file.write_text( + json.dumps({ + "id": RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_PATCH_ID, + "artifact": RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_ARTIFACT, + "backup_id": ( + RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_APPLY_BACKUP_ID + ), + "component": "device-plane", + "sha256": hashlib.sha256(artifact_bytes).hexdigest(), + "status": "ok", + }) + "\n", + encoding="utf-8", + ) + backup = root / "failed-backup" + with ( + mock.patch.object(RUNNER, "APPLIED_DIR", root), + mock.patch.object(RUNNER, "STATE_FILE", state_file), + mock.patch.object( + RUNNER, + "DEVICE_PLANE_MANAGER_RECONCILIATION_ARTIFACT_SHA256", + hashlib.sha256(artifact_bytes).hexdigest(), + ), + mock.patch.object( + RUNNER, + "validate_device_plane_manager_control_plane_payload", + return_value=( + RUNNER.expected_device_plane_manager_control_plane_descriptor() + ), + ) as payload, + mock.patch.object( + RUNNER, + "validate_device_plane_manager_reconciliation_backup", + return_value=backup, + ), + mock.patch.object( + RUNNER, + "validate_device_plane_manager_reconciled_baseline", + return_value={"accepted": True}, + ) as baseline, + ): + result = ( + RUNNER.validate_device_plane_manager_activation_predecessor( + root / "payload" + ) + ) + + self.assertEqual( + result["mode"], + "reconciled-manager-forward-activation", + ) + payload.assert_called_once_with(root / "payload") + baseline.assert_called_once_with(backup, marker_installed=True) + def test_initial_install_rollback_removes_manager_and_restores_core_only(self): entries = RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES missing = { diff --git a/infra/deploy-runner/test_device_manager_control_plane_reconciliation_artifact.py b/infra/deploy-runner/test_device_manager_control_plane_reconciliation_artifact.py index 52ee2c9..af47f6f 100644 --- a/infra/deploy-runner/test_device_manager_control_plane_reconciliation_artifact.py +++ b/infra/deploy-runner/test_device_manager_control_plane_reconciliation_artifact.py @@ -121,7 +121,7 @@ class DeviceManagerControlPlaneReconciliationArtifactTest(unittest.TestCase): def test_failed_control_plane_is_terminal(self): with self.assertRaisesRegex( RUNNER.DeployError, - "exact registered reconciliation successor", + "exact v2 activation successor", ): RUNNER.reject_terminal_device_plane_manager_artifact( {"id": RUNNER.DEVICE_PLANE_MANAGER_FAILED_PATCH_ID}, @@ -129,12 +129,21 @@ class DeviceManagerControlPlaneReconciliationArtifactTest(unittest.TestCase): ) with self.assertRaisesRegex( RUNNER.DeployError, - "exact registered reconciliation successor", + "exact v2 activation successor", ): RUNNER.reject_terminal_device_plane_manager_artifact( {"id": "different"}, RUNNER.DEVICE_PLANE_MANAGER_FAILED_ARTIFACT_SHA256, ) + with self.assertRaisesRegex( + RUNNER.DeployError, + "exact v2 activation successor", + ): + RUNNER.reject_terminal_device_plane_manager_artifact( + {"id": "different", "component": "device-plane"}, + "1" * 64, + RUNNER.DEVICE_PLANE_MANAGER_FAILED_CONTROL_PLANE_ENTRIES, + ) def test_health_grace_waits_through_unhealthy_and_exited(self): results = [ @@ -190,8 +199,13 @@ class DeviceManagerControlPlaneReconciliationArtifactTest(unittest.TestCase): }, ], } - existing = list(RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_EXISTING) - missing = list(RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_MISSING) + missing_set = { + RUNNER.DEVICE_PLANE_MANAGER_COMPOSE_REL, + "services/device-manager", + RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, + } + existing = [entry for entry in entries if entry not in missing_set] + missing = [entry for entry in entries if entry in missing_set] with ( mock.patch.object( RUNNER,