From 9fa74a1243f73942074aea148daffca9f89265c6 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 12 Aug 2026 09:48:51 +0300 Subject: [PATCH] feat(deploy): add versioned Core channel upgrade v2 --- .../device-edge-core-channel-upgrade-v2.json | 28 ++ ...e-edge-core-channel-bootstrap-artifact.mjs | 32 +- infra/deploy-runner/nodedc-deploy | 277 +++++++++++++++- ..._device_manager_control_plane_artifacts.py | 313 ++++++++++++++++++ 4 files changed, 633 insertions(+), 17 deletions(-) create mode 100644 device-plane/deployment/device-edge-core-channel-upgrade-v2.json diff --git a/device-plane/deployment/device-edge-core-channel-upgrade-v2.json b/device-plane/deployment/device-edge-core-channel-upgrade-v2.json new file mode 100644 index 0000000..f1d6752 --- /dev/null +++ b/device-plane/deployment/device-edge-core-channel-upgrade-v2.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": "nodedc.device-plane.device-edge-core-channel-upgrade.v2", + "transitionId": "__PATCH_ID__", + "action": "upgrade", + "upgradePredecessor": { + "patchId": "device-edge-core-channel-upgrade-20260812-019", + "artifactSha256": "8e9a220275959f378c1c4b00be5c7192e79afe2134eaab808a64e515870a8438" + }, + "service": "device-control-core", + "composeActivation": "preserve-dedicated-additive-override", + "identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export", + "identityRecovery": "forbidden-valid-existing-identity-required", + "tlsPurpose": "clientAuth", + "direction": "core-initiated", + "endpointPolicy": "public-ipv4-standard-https-tcp-443-only", + "publicIngress": "none-on-synology", + "edgeRegistrations": "preserved", + "commandTransport": "disabled", + "gelios": "untouched", + "preservedServices": [ + "device-manager", + "device-gateway", + "device-postgres", + "device-backhaul-target" + ], + "healthGate": "bounded-container-grace+core-edge-contract", + "rollback": "restore-upgrade-019-source-and-preapply-core-runtime" +} diff --git a/infra/deploy-runner/build-device-edge-core-channel-bootstrap-artifact.mjs b/infra/deploy-runner/build-device-edge-core-channel-bootstrap-artifact.mjs index 89a239a..bbdea3c 100644 --- a/infra/deploy-runner/build-device-edge-core-channel-bootstrap-artifact.mjs +++ b/infra/deploy-runner/build-device-edge-core-channel-bootstrap-artifact.mjs @@ -15,10 +15,14 @@ if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { throw new Error("usage: build-device-edge-core-channel-bootstrap-artifact.mjs [patch-id]"); } -const upgrade = patchId.startsWith("device-edge-core-channel-upgrade-"); -const descriptorPath = upgrade - ? "deployment/device-edge-core-channel-upgrade-v1.json" - : "deployment/device-edge-core-channel-bootstrap-v1.json"; +const upgradeV2 = patchId.startsWith("device-edge-core-channel-upgrade-v2-"); +const upgradeV1 = !upgradeV2 && patchId.startsWith("device-edge-core-channel-upgrade-"); +const upgrade = upgradeV1 || upgradeV2; +const descriptorPath = upgradeV2 + ? "deployment/device-edge-core-channel-upgrade-v2.json" + : upgradeV1 + ? "deployment/device-edge-core-channel-upgrade-v1.json" + : "deployment/device-edge-core-channel-bootstrap-v1.json"; const entries = [ ".dockerignore", "package.json", @@ -100,8 +104,20 @@ try { } const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8")); - const descriptorContractMatches = upgrade + const descriptorContractMatches = upgradeV2 ? ( + descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-upgrade.v2" + && descriptor.action === "upgrade" + && descriptor.composeActivation === "preserve-dedicated-additive-override" + && descriptor.identityRecovery === "forbidden-valid-existing-identity-required" + && descriptor.endpointPolicy === "public-ipv4-standard-https-tcp-443-only" + && descriptor.upgradePredecessor?.patchId === "device-edge-core-channel-upgrade-20260812-019" + && descriptor.upgradePredecessor?.artifactSha256 === "8e9a220275959f378c1c4b00be5c7192e79afe2134eaab808a64e515870a8438" + && descriptor.edgeRegistrations === "preserved" + && descriptor.rollback === "restore-upgrade-019-source-and-preapply-core-runtime" + ) + : upgradeV1 + ? ( descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-upgrade.v1" && descriptor.action === "upgrade" && descriptor.composeActivation === "preserve-dedicated-additive-override" @@ -109,13 +125,13 @@ try { && descriptor.endpointPolicy === "public-ipv4-standard-https-tcp-443-only" && descriptor.bootstrapPredecessor?.patchId === "device-edge-core-channel-bootstrap-20260812-018" && descriptor.bootstrapPredecessor?.artifactSha256 === "5598b7388b491fe524ab46038ce476482a93a6cf07d8ca5e00206c69ded02931" - ) - : ( + ) + : ( descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-bootstrap.v1" && descriptor.action === "activate" && descriptor.composeActivation === "dedicated-additive-override" && descriptor.identityRecovery === "exact-invalid-unexported-failed-predecessor-only" - ); + ); if ( !descriptorContractMatches || descriptor.transitionId !== patchId diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index 5d95a56..8124600 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -253,6 +253,9 @@ DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL = ( DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_REL = ( "deployment/device-edge-core-channel-upgrade-v1.json" ) +DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL = ( + "deployment/device-edge-core-channel-upgrade-v2.json" +) DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL = ( "docker-compose.device-edge-core-channel.yml" ) @@ -279,12 +282,28 @@ DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_ENTRIES = ( "services/device-control-core", DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_REL, ) +DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES = ( + ".dockerignore", + "package.json", + "package-lock.json", + DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL, + "packages/device-protocol-contract", + "packages/device-edge-channel-contract", + "services/device-control-core", + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL, +) DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_PREDECESSOR_PATCH_ID = ( "device-edge-core-channel-bootstrap-20260812-018" ) DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_PREDECESSOR_ARTIFACT_SHA256 = ( "5598b7388b491fe524ab46038ce476482a93a6cf07d8ca5e00206c69ded02931" ) +DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_PREDECESSOR_PATCH_ID = ( + "device-edge-core-channel-upgrade-20260812-019" +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_PREDECESSOR_ARTIFACT_SHA256 = ( + "8e9a220275959f378c1c4b00be5c7192e79afe2134eaab808a64e515870a8438" +) DEVICE_PLANE_EDGE_CORE_CHANNEL_MANAGER_PREDECESSOR_PATCH_ID = ( "device-manager-release-20260811-010" ) @@ -3815,6 +3834,7 @@ def allowed_payload_path(component, rel): DEVICE_PLANE_MANAGER_RELEASE_V2_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL, DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_REL, + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL, DEVICE_PLANE_MANAGER_RECONCILIATION_REL, DEVICE_PLANE_MANAGER_V2_RECONCILIATION_REL, "packages/device-protocol-contract", @@ -8871,7 +8891,15 @@ def load_artifact(artifact, work_dir): manifest["component"], entries, ): - if is_device_plane_edge_core_channel_upgrade_slice( + if is_device_plane_edge_core_channel_upgrade_v2_slice( + manifest["component"], + entries, + ): + validate_device_plane_edge_core_channel_upgrade_v2_payload( + payload_dir, + expected_transition_id=manifest["id"], + ) + elif is_device_plane_edge_core_channel_upgrade_slice( manifest["component"], entries, ): @@ -9375,6 +9403,7 @@ def is_device_plane_edge_core_channel_bootstrap_slice(component, entries): and tuple(entries) in ( DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_ENTRIES, DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_ENTRIES, + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES, ) ) @@ -9383,7 +9412,18 @@ def is_device_plane_edge_core_channel_upgrade_slice(component, entries): return ( component == "device-plane" and entries is not None - and tuple(entries) == DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_ENTRIES + and tuple(entries) in ( + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_ENTRIES, + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES, + ) + ) + + +def is_device_plane_edge_core_channel_upgrade_v2_slice(component, entries): + return ( + component == "device-plane" + and entries is not None + and tuple(entries) == DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES ) @@ -9577,6 +9617,50 @@ def expected_device_plane_edge_core_channel_upgrade_descriptor( } +def expected_device_plane_edge_core_channel_upgrade_v2_descriptor( + transition_id, +): + return { + "schemaVersion": ( + "nodedc.device-plane.device-edge-core-channel-upgrade.v2" + ), + "transitionId": transition_id, + "action": "upgrade", + "upgradePredecessor": { + "patchId": ( + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_PREDECESSOR_PATCH_ID + ), + "artifactSha256": ( + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_PREDECESSOR_ARTIFACT_SHA256 + ), + }, + "service": "device-control-core", + "composeActivation": "preserve-dedicated-additive-override", + "identity": ( + "reuse-existing-runner-managed-host-local-private-key-" + "public-certificate-export" + ), + "identityRecovery": "forbidden-valid-existing-identity-required", + "tlsPurpose": "clientAuth", + "direction": "core-initiated", + "endpointPolicy": "public-ipv4-standard-https-tcp-443-only", + "publicIngress": "none-on-synology", + "edgeRegistrations": "preserved", + "commandTransport": "disabled", + "gelios": "untouched", + "preservedServices": [ + "device-manager", + "device-gateway", + "device-postgres", + "device-backhaul-target", + ], + "healthGate": "bounded-container-grace+core-edge-contract", + "rollback": ( + "restore-upgrade-019-source-and-preapply-core-runtime" + ), + } + + def expected_device_plane_manager_failed_control_plane_descriptor(): return { "schemaVersion": ( @@ -9936,6 +10020,34 @@ def validate_device_plane_edge_core_channel_upgrade_payload( return descriptor +def validate_device_plane_edge_core_channel_upgrade_v2_payload( + payload_dir, + *, + expected_transition_id=None, +): + descriptor = read_strict_json( + payload_dir / DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL, + "Device Edge Core channel upgrade v2 descriptor", + max_bytes=16 * 1024, + ) + transition_id = descriptor.get("transitionId") + if ( + not isinstance(transition_id, str) + or not re.fullmatch(r"[A-Za-z0-9._-]{1,96}", transition_id) + or ( + expected_transition_id is not None + and transition_id != expected_transition_id + ) + or descriptor + != expected_device_plane_edge_core_channel_upgrade_v2_descriptor( + transition_id + ) + ): + die("Device Edge Core channel upgrade v2 descriptor mismatch") + validate_device_plane_edge_core_channel_compose(payload_dir) + return descriptor + + def validate_device_plane_edge_core_channel_compose(payload_dir): compose = payload_dir / DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL if sha256_file(compose) != DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_SHA256: @@ -11665,6 +11777,12 @@ def inspect_device_edge_channel_core_identity_state(): def validate_device_plane_edge_core_channel_bootstrap_predecessor( payload_dir, ): + if ( + payload_dir / DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL + ).exists(): + return validate_device_plane_edge_core_channel_upgrade_v2_predecessor( + payload_dir + ) if ( payload_dir / DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_REL ).exists(): @@ -11864,6 +11982,99 @@ def validate_device_plane_edge_core_channel_upgrade_predecessor( } +def validate_device_plane_edge_core_channel_upgrade_v2_predecessor( + payload_dir, +): + descriptor = validate_device_plane_edge_core_channel_upgrade_v2_payload( + payload_dir + ) + installed_upgrade = ( + DEVICE_PLANE_ROOT / DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL + ) + if installed_upgrade.exists() or installed_upgrade.is_symlink(): + die("Device Edge Core channel upgrade v2 is already installed") + predecessor = descriptor["upgradePredecessor"] + artifact_name = f"nodedc-device-plane-{predecessor['patchId']}.tgz" + artifact = APPLIED_DIR / artifact_name + if ( + not artifact.is_file() + or artifact.is_symlink() + or sha256_file(artifact) != predecessor["artifactSha256"] + ): + die("Device Edge Core channel upgrade v2 predecessor mismatch") + records = [ + row for row in load_state(STATE_FILE) + if row.get("id") == predecessor["patchId"] + and row.get("sha256") == predecessor["artifactSha256"] + ] + if ( + len(records) != 1 + or records[0].get("status") != "ok" + or records[0].get("component") != "device-plane" + or records[0].get("artifact") != artifact_name + ): + die("Device Edge Core channel upgrade v2 predecessor journal mismatch") + with tempfile.TemporaryDirectory( + prefix="device-edge-core-channel-upgrade-v2-predecessor-", + dir=TMP_DIR, + ) as directory: + manifest, entries, predecessor_payload = load_artifact( + artifact, + Path(directory), + ) + if ( + manifest.get("id") != predecessor["patchId"] + or manifest.get("component") != "device-plane" + or manifest.get("type") != "app-overlay" + or tuple(entries) + != DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_ENTRIES + ): + die("Device Edge Core channel upgrade v2 predecessor type mismatch") + validate_device_plane_edge_core_channel_upgrade_payload( + predecessor_payload, + expected_transition_id=predecessor["patchId"], + ) + expected_source = collect_exact_files( + predecessor_payload, + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_ENTRIES, + "Device Edge Core channel upgrade v2 predecessor source", + ) + actual_source = collect_exact_files( + DEVICE_PLANE_ROOT, + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_ENTRIES, + "installed Device Edge Core channel upgrade v1 source", + ) + if actual_source != expected_source: + die("installed Device Edge Core channel upgrade v1 source drift detected") + installed_bootstrap = read_strict_json( + DEVICE_PLANE_ROOT / DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_REL, + "installed Device Edge Core channel bootstrap predecessor", + max_bytes=16 * 1024, + ) + if installed_bootstrap != ( + expected_device_plane_edge_core_channel_bootstrap_descriptor( + DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_PREDECESSOR_PATCH_ID + ) + ): + die("installed Device Edge Core channel bootstrap predecessor drift detected") + identity_state = inspect_device_edge_channel_core_identity_state() + if identity_state != "valid-reuse-at-apply": + die("Device Edge Core channel upgrade v2 requires the valid active identity") + for service in ( + "device-control-core", + "device-manager", + "device-gateway", + "device-postgres", + ): + healthcheck_compose_service("device-plane", service) + return { + "mode": "edge-core-channel-forward-upgrade-v2", + "descriptor": descriptor, + "identityState": identity_state, + "upgradeArtifact": artifact, + } + + def validate_device_plane_manager_v2_reconciliation_backup(): backup_dir = ( BACKUPS_DIR / DEVICE_PLANE_MANAGER_V2_RECONCILIATION_BACKUP_ID @@ -15669,6 +15880,9 @@ def component_compose_files( edge_upgrade_descriptor = ( DEVICE_PLANE_ROOT / DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_REL ) + edge_upgrade_v2_descriptor = ( + DEVICE_PLANE_ROOT / DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL + ) edge_override = ( DEVICE_PLANE_ROOT / DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL ) @@ -15679,12 +15893,24 @@ def component_compose_files( edge_upgrade_descriptor.exists() or edge_upgrade_descriptor.is_symlink() ) + edge_upgrade_v2_descriptor_exists = ( + edge_upgrade_v2_descriptor.exists() + or edge_upgrade_v2_descriptor.is_symlink() + ) edge_override_exists = edge_override.exists() or edge_override.is_symlink() if ( edge_override_exists - != (edge_descriptor_exists or edge_upgrade_descriptor_exists) + != ( + edge_descriptor_exists + or edge_upgrade_descriptor_exists + or edge_upgrade_v2_descriptor_exists + ) or edge_upgrade_descriptor_exists and not edge_descriptor_exists + or edge_upgrade_v2_descriptor_exists + and not ( + edge_descriptor_exists and edge_upgrade_descriptor_exists + ) ): die("installed Device Edge Core channel source is incomplete") if edge_descriptor_exists: @@ -15713,6 +15939,7 @@ def component_compose_files( ) ): die("installed Device Edge Core channel descriptor drift detected") + upgrade_id = None if edge_upgrade_descriptor_exists: if ( transition_id @@ -15736,6 +15963,31 @@ def component_compose_files( ) ): die("installed Device Edge Core channel upgrade descriptor drift detected") + if edge_upgrade_v2_descriptor_exists: + if ( + upgrade_id + != DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_PREDECESSOR_PATCH_ID + or edge_upgrade_v2_descriptor.is_symlink() + or not edge_upgrade_v2_descriptor.is_file() + ): + die("installed Device Edge Core channel upgrade v2 predecessor drift detected") + upgrade_v2 = read_strict_json( + edge_upgrade_v2_descriptor, + "installed Device Edge Core channel upgrade v2 descriptor", + max_bytes=16 * 1024, + ) + upgrade_v2_id = upgrade_v2.get("transitionId") + if ( + not isinstance(upgrade_v2_id, str) + or not re.fullmatch( + r"[A-Za-z0-9._-]{1,96}", upgrade_v2_id + ) + or upgrade_v2 + != expected_device_plane_edge_core_channel_upgrade_v2_descriptor( + upgrade_v2_id + ) + ): + die("installed Device Edge Core channel upgrade v2 descriptor drift detected") files = (*files, edge_override) overlay = DEVICE_PLANE_ROOT / DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL if overlay.exists() or overlay.is_symlink(): @@ -19713,18 +19965,22 @@ def plan_artifact(artifact): "device_plane_transition=" f"{device_plane_edge_core_channel_preflight['mode']}" ) - upgrade = ( - device_plane_edge_core_channel_preflight["mode"] - == "edge-core-channel-standard-https-443-upgrade" + edge_descriptor = ( + device_plane_edge_core_channel_preflight["descriptor"] ) + upgrade = edge_descriptor["action"] == "upgrade" if upgrade: + predecessor = edge_descriptor.get( + "upgradePredecessor", + edge_descriptor.get("bootstrapPredecessor"), + ) print( "device_plane_predecessor_patch=" - f"{DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_PREDECESSOR_PATCH_ID}" + f"{predecessor['patchId']}" ) print( "device_plane_predecessor_artifact_sha256=" - f"{DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_PREDECESSOR_ARTIFACT_SHA256}" + f"{predecessor['artifactSha256']}" ) print("device_edge_channel_endpoint_policy=public-ipv4-standard-https-tcp-443-only") else: @@ -19763,7 +20019,10 @@ def plan_artifact(artifact): print("device_edge_channel_identity=host-local-private-key:public-certificate-export-only") if upgrade: print("device_edge_channel_identity_recovery=forbidden:reuse-valid-only") - print("device_edge_channel_registrations=preserved:explicit-443-reconciliation-required") + if edge_descriptor["edgeRegistrations"] == "preserved": + print("device_edge_channel_registrations=preserved") + else: + print("device_edge_channel_registrations=preserved:explicit-443-reconciliation-required") else: print("device_edge_channel_invalid_failed_016=recover-exact-unexported-only") print("device_edge_channel_registrations=preserved") 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 f692bfb..2d8247c 100644 --- a/infra/deploy-runner/test_device_manager_control_plane_artifacts.py +++ b/infra/deploy-runner/test_device_manager_control_plane_artifacts.py @@ -318,6 +318,196 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): "public-ipv4-standard-https-tcp-443-only", ) + def test_edge_core_channel_upgrade_v2_is_core_only_and_pins_upgrade_019(self): + patch_id = "device-edge-core-channel-upgrade-v2-unit-001" + manifest, entries, names, result = self.assert_deterministic_artifact( + "build-device-edge-core-channel-bootstrap-artifact.mjs", + patch_id, + RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES, + ) + self.assertEqual(manifest["component"], "device-plane") + self.assertEqual( + RUNNER.component_services("device-plane", entries), + ("device-control-core",), + ) + self.assertEqual(len(RUNNER.component_builds("device-plane", entries)), 1) + self.assertEqual(result["services"], ["device-control-core"]) + self.assertIn( + "payload/deployment/device-edge-core-channel-upgrade-v2.json", + names, + ) + descriptor = ( + RUNNER.expected_device_plane_edge_core_channel_upgrade_v2_descriptor( + patch_id + ) + ) + self.assertEqual(descriptor["action"], "upgrade") + self.assertEqual( + descriptor["upgradePredecessor"]["patchId"], + "device-edge-core-channel-upgrade-20260812-019", + ) + self.assertEqual( + descriptor["upgradePredecessor"]["artifactSha256"], + "8e9a220275959f378c1c4b00be5c7192e79afe2134eaab808a64e515870a8438", + ) + self.assertEqual(descriptor["edgeRegistrations"], "preserved") + self.assertTrue( + RUNNER.is_device_plane_edge_core_channel_upgrade_slice( + "device-plane", + entries, + ) + ) + self.assertTrue( + RUNNER.is_device_plane_edge_core_channel_upgrade_v2_slice( + "device-plane", + entries, + ) + ) + + def test_edge_core_channel_upgrade_v2_rejects_installed_marker(self): + with tempfile.TemporaryDirectory( + prefix="nodedc-device-edge-upgrade-v2-installed-", + ) as directory: + root = Path(directory) + marker = root / RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL + marker.parent.mkdir(parents=True) + marker.write_text("{}\n", encoding="utf-8") + descriptor = ( + RUNNER.expected_device_plane_edge_core_channel_upgrade_v2_descriptor( + "device-edge-core-channel-upgrade-v2-unit-002" + ) + ) + with ( + mock.patch.object(RUNNER, "DEVICE_PLANE_ROOT", root), + mock.patch.object( + RUNNER, + "validate_device_plane_edge_core_channel_upgrade_v2_payload", + return_value=descriptor, + ), + ): + with self.assertRaisesRegex( + RUNNER.DeployError, + "upgrade v2 is already installed", + ): + RUNNER.validate_device_plane_edge_core_channel_upgrade_v2_predecessor( + root / "payload" + ) + + def test_edge_core_channel_upgrade_v2_accepts_exact_applied_019(self): + with tempfile.TemporaryDirectory( + prefix="nodedc-device-edge-upgrade-v2-predecessor-", + ) as directory: + root = Path(directory) + applied = root / "applied" + temporary = root / "tmp" + device_plane = root / "device-plane" + for path in (applied, temporary, device_plane / "deployment"): + path.mkdir(parents=True) + predecessor_patch = ( + RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_PREDECESSOR_PATCH_ID + ) + predecessor_sha = ( + RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_PREDECESSOR_ARTIFACT_SHA256 + ) + artifact_name = f"nodedc-device-plane-{predecessor_patch}.tgz" + artifact = applied / artifact_name + artifact.write_bytes(b"reviewed-upgrade-019") + state_file = root / "applied.jsonl" + state_file.write_text( + json.dumps({ + "id": predecessor_patch, + "artifact": artifact_name, + "component": "device-plane", + "sha256": predecessor_sha, + "status": "ok", + }) + "\n", + encoding="utf-8", + ) + bootstrap = ( + RUNNER.expected_device_plane_edge_core_channel_bootstrap_descriptor( + RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_PREDECESSOR_PATCH_ID + ) + ) + descriptor = ( + RUNNER.expected_device_plane_edge_core_channel_upgrade_v2_descriptor( + "device-edge-core-channel-upgrade-v2-unit-003" + ) + ) + expected_source = {"source": "upgrade-019"} + with ( + mock.patch.object(RUNNER, "APPLIED_DIR", applied), + mock.patch.object(RUNNER, "TMP_DIR", temporary), + mock.patch.object(RUNNER, "STATE_FILE", state_file), + mock.patch.object(RUNNER, "DEVICE_PLANE_ROOT", device_plane), + mock.patch.object( + RUNNER, + "validate_device_plane_edge_core_channel_upgrade_v2_payload", + return_value=descriptor, + ), + mock.patch.object( + RUNNER, + "sha256_file", + return_value=predecessor_sha, + ), + mock.patch.object( + RUNNER, + "load_artifact", + return_value=( + { + "id": predecessor_patch, + "component": "device-plane", + "type": "app-overlay", + }, + RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_ENTRIES, + root / "predecessor-payload", + ), + ), + mock.patch.object( + RUNNER, + "validate_device_plane_edge_core_channel_upgrade_payload", + ) as validate_predecessor, + mock.patch.object( + RUNNER, + "collect_exact_files", + side_effect=(expected_source, expected_source), + ), + mock.patch.object( + RUNNER, + "read_strict_json", + return_value=bootstrap, + ), + mock.patch.object( + RUNNER, + "inspect_device_edge_channel_core_identity_state", + return_value="valid-reuse-at-apply", + ), + mock.patch.object( + RUNNER, + "healthcheck_compose_service", + ) as health, + ): + result = ( + RUNNER.validate_device_plane_edge_core_channel_upgrade_v2_predecessor( + root / "payload" + ) + ) + + self.assertEqual(result["mode"], "edge-core-channel-forward-upgrade-v2") + self.assertEqual(result["upgradeArtifact"], artifact) + validate_predecessor.assert_called_once_with( + root / "predecessor-payload", + expected_transition_id=predecessor_patch, + ) + self.assertEqual( + [call.args for call in health.call_args_list], + [ + ("device-plane", "device-control-core"), + ("device-plane", "device-manager"), + ("device-plane", "device-gateway"), + ("device-plane", "device-postgres"), + ], + ) + def test_release_v2_keeps_release_v1_predecessor_contract_immutable(self): predecessor = device_manager_release_v1_descriptor( release_id="device-manager-release-20260811-010", @@ -539,6 +729,24 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): allow_invalid_unexported_recovery=False ) + with ( + mock.patch.object( + RUNNER, + "ensure_platform_runtime_secret", + ), + mock.patch.object( + RUNNER, + "ensure_device_edge_channel_core_identity", + ) as ensure_edge_identity, + ): + RUNNER.prepare_component_runtime( + "device-plane", + RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES, + ) + ensure_edge_identity.assert_called_once_with( + allow_invalid_unexported_recovery=False + ) + def test_apply_gate_checks_exact_services_core_contract_and_runtime_boundary(self): entries = RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V1_SUCCESSOR_ENTRIES services = ("device-control-core", "device-manager") @@ -571,6 +779,40 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): ) runtime_acceptance.assert_called_once_with() + def test_upgrade_v2_apply_gate_checks_preserved_runtime_and_edge_contract(self): + entries = RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES + services = ("device-control-core",) + with ( + mock.patch.object( + RUNNER, + "healthcheck_compose_service_with_grace", + ) as service_health, + mock.patch.object(RUNNER, "healthcheck_url") as url_health, + mock.patch.object( + RUNNER, + "validate_device_manager_control_plane_runtime", + ) as runtime_acceptance, + ): + RUNNER.run_healthchecks("device-plane", entries, services) + + self.assertEqual( + [call.args for call in service_health.call_args_list], + [ + ("device-plane", "device-control-core"), + ("device-plane", "device-manager"), + ("device-plane", "device-gateway"), + ("device-plane", "device-postgres"), + ], + ) + url_health.assert_called_once_with( + RUNNER.component_healthchecks( + "device-plane", + entries, + services, + )[0] + ) + runtime_acceptance.assert_called_once_with(require_edge_channel=True) + def test_activation_resolves_predecessor_from_descriptor_and_journal(self): with tempfile.TemporaryDirectory( prefix="nodedc-device-manager-release-predecessor-", @@ -961,6 +1203,77 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): f"source+runtime-restored:{len(entries)}", ) + def test_edge_upgrade_v2_rollback_restores_upgrade_019_core_runtime(self): + entries = RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES + missing = {RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL} + existing = [entry for entry in entries if entry not in missing] + with tempfile.TemporaryDirectory( + prefix="nodedc-device-edge-upgrade-v2-rollback-", + ) as directory: + backup = Path(directory) / "backup" + backup.mkdir() + (backup / "existing-files.txt").write_text( + "\n".join(existing) + "\n", + encoding="utf-8", + ) + (backup / "missing-files.txt").write_text( + RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL + "\n", + encoding="utf-8", + ) + (backup / "runtime-before.json").write_text( + json.dumps(healthy_device_plane_inventory(include_manager=True)), + encoding="utf-8", + ) + with ( + mock.patch.object( + RUNNER, + "stop_and_remove_compose_services", + ) as stop, + mock.patch.object( + RUNNER, + "restore_platform_overlay", + return_value=len(entries), + ), + mock.patch.object( + RUNNER, + "run_component_runtime", + ) as restore_runtime, + mock.patch.object( + RUNNER, + "healthcheck_compose_service_with_grace", + ) as restore_health, + mock.patch.object( + RUNNER, + "validate_device_manager_control_plane_runtime", + ) as runtime_acceptance, + ): + result = RUNNER.rollback_device_plane_apply( + Path(directory) / "live", + backup, + entries, + "test-stamp", + True, + ("device-control-core",), + ) + + stop.assert_not_called() + restore_runtime.assert_called_once_with( + "device-plane", + existing, + ("device-control-core",), + ) + self.assertEqual( + [call.args for call in restore_health.call_args_list], + [ + ("device-plane", "device-control-core"), + ("device-plane", "device-manager"), + ("device-plane", "device-gateway"), + ("device-plane", "device-postgres"), + ], + ) + runtime_acceptance.assert_called_once_with(require_edge_channel=True) + self.assertEqual(result, f"source+runtime-restored:{len(entries)}") + if __name__ == "__main__": unittest.main(verbosity=2)