diff --git a/device-plane/deployment/device-edge-core-channel-upgrade-v4.json b/device-plane/deployment/device-edge-core-channel-upgrade-v4.json new file mode 100644 index 0000000..888fd49 --- /dev/null +++ b/device-plane/deployment/device-edge-core-channel-upgrade-v4.json @@ -0,0 +1,39 @@ +{ + "schemaVersion": "nodedc.device-plane.device-edge-core-channel-upgrade.v4", + "transitionId": "__PATCH_ID__", + "action": "upgrade", + "upgradePredecessor": { + "patchId": "device-edge-core-channel-upgrade-v2-20260812-021", + "artifactSha256": "e40a6fd24edfecac09e42cd82635a77850541bcf047788db3e9c55d2b9e58867" + }, + "failedAttempt": { + "patchId": "device-edge-core-channel-upgrade-v3-20260812-022", + "artifactSha256": "9e2b409a4b2d19711db434e90d03ac8e3db77bd74949f83cace7949f33caf613", + "backupId": "device-plane-device-edge-core-channel-upgrade-v3-20260812-022-20260812-123620" + }, + "service": "device-control-core", + "composeActivation": "replace-core-network-membership-with-private-plus-egress", + "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", + "coreNetworks": [ + "device-plane-private", + "device-plane-egress" + ], + "removedCoreNetwork": "device-plane-control", + "composeCompatibility": "synology-compose-v2.20-no-gw-priority", + "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+exact-private-egress-network-boundary", + "rollback": "restore-upgrade-v2-021-source-and-preapply-core-runtime" +} diff --git a/device-plane/docker-compose.device-plane.yml b/device-plane/docker-compose.device-plane.yml index b8b859c..f36bd16 100644 --- a/device-plane/docker-compose.device-plane.yml +++ b/device-plane/docker-compose.device-plane.yml @@ -70,7 +70,6 @@ services: - "127.0.0.1:18120:18120" networks: - device-plane-private - - device-plane-control depends_on: device-postgres: condition: service_healthy 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 bbdea3c..815ea5b 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,19 +15,25 @@ 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 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" +const upgradeV4 = patchId.startsWith("device-edge-core-channel-upgrade-v4-"); +const upgradeV2 = !upgradeV4 && patchId.startsWith("device-edge-core-channel-upgrade-v2-"); +const upgradeV1 = !upgradeV4 && !upgradeV2 && patchId.startsWith("device-edge-core-channel-upgrade-"); +const upgrade = upgradeV1 || upgradeV2 || upgradeV4; +const descriptorPath = upgradeV4 + ? "deployment/device-edge-core-channel-upgrade-v4.json" + : 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 composePath = upgradeV4 + ? "docker-compose.device-plane.yml" + : "docker-compose.device-edge-core-channel.yml"; const entries = [ ".dockerignore", "package.json", "package-lock.json", - "docker-compose.device-edge-core-channel.yml", + composePath, "packages/device-protocol-contract", "packages/device-edge-channel-contract", "services/device-control-core", @@ -73,39 +79,82 @@ try { } } - const compose = await readFile(join(payload, "docker-compose.device-edge-core-channel.yml"), "utf8"); - for (const required of [ + const compose = await readFile(join(payload, composePath), "utf8"); + const commonComposeRequired = [ "device-control-core:", - "DEVICE_EDGE_CHANNEL_ENABLED: \"true\"", - "DEVICE_EDGE_CHANNEL_CORE_KEY_FILE: /run/nodedc-secrets/device-edge-channel/core-private-key.pem", - "DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE: /run/nodedc-secrets/device-edge-channel/core-certificate.pem", - "DEVICE_EDGE_CHANNEL_TRUST_ROOT: /run/nodedc-secrets/device-edge-channel/peers", - "source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-private-key.pem", - "source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-certificate.pem", - "source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/peers", - "name: nodedc-device-plane-egress", - ]) { + ]; + const composeRequired = upgradeV4 + ? [ + ...commonComposeRequired, + " - device-plane-private", + " - device-plane-control", + ] + : [ + ...commonComposeRequired, + "DEVICE_EDGE_CHANNEL_ENABLED: \"true\"", + "DEVICE_EDGE_CHANNEL_CORE_KEY_FILE: /run/nodedc-secrets/device-edge-channel/core-private-key.pem", + "DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE: /run/nodedc-secrets/device-edge-channel/core-certificate.pem", + "DEVICE_EDGE_CHANNEL_TRUST_ROOT: /run/nodedc-secrets/device-edge-channel/peers", + "source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-private-key.pem", + "source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-certificate.pem", + "source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/peers", + "name: nodedc-device-plane-egress", + ]; + for (const required of composeRequired) { if (!compose.includes(required)) { throw new Error(`device_edge_core_channel_compose_contract_missing:${required}`); } } - for (const forbidden of [ - "device-manager:", - "device-gateway:", - "device-postgres:", - "PRIVATE KEY", - "ports:", - "network_mode:", - "privileged:", - ]) { + if (upgradeV4) { + const coreBlock = compose.split("\n device-control-core:", 2)[1]?.split("\n device-gateway:", 1)[0] || ""; + const gatewayBlock = compose.split("\n device-gateway:", 2)[1]?.split("\nnetworks:", 1)[0] || ""; + if ( + !coreBlock.includes(" - device-plane-private") + || coreBlock.includes(" - device-plane-control") + || !gatewayBlock.includes(" - device-plane-private") + || !gatewayBlock.includes(" - device-plane-control") + ) { + throw new Error("device_edge_core_channel_v4_network_boundary_mismatch"); + } + } + const composeForbidden = upgradeV4 + ? ["gw_priority:", "network_mode:", "privileged:"] + : [ + "device-manager:", + "device-gateway:", + "device-postgres:", + "PRIVATE KEY", + "ports:", + "network_mode:", + "privileged:", + ]; + for (const forbidden of composeForbidden) { if (compose.includes(forbidden)) { throw new Error(`device_edge_core_channel_compose_boundary_violation:${forbidden}`); } } const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8")); - const descriptorContractMatches = upgradeV2 + const descriptorContractMatches = upgradeV4 ? ( + descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-upgrade.v4" + && descriptor.action === "upgrade" + && descriptor.composeActivation === "replace-core-network-membership-with-private-plus-egress" + && descriptor.identityRecovery === "forbidden-valid-existing-identity-required" + && descriptor.endpointPolicy === "public-ipv4-standard-https-tcp-443-only" + && descriptor.upgradePredecessor?.patchId === "device-edge-core-channel-upgrade-v2-20260812-021" + && descriptor.upgradePredecessor?.artifactSha256 === "e40a6fd24edfecac09e42cd82635a77850541bcf047788db3e9c55d2b9e58867" + && descriptor.failedAttempt?.patchId === "device-edge-core-channel-upgrade-v3-20260812-022" + && descriptor.failedAttempt?.artifactSha256 === "9e2b409a4b2d19711db434e90d03ac8e3db77bd74949f83cace7949f33caf613" + && descriptor.failedAttempt?.backupId === "device-plane-device-edge-core-channel-upgrade-v3-20260812-022-20260812-123620" + && JSON.stringify(descriptor.coreNetworks) === JSON.stringify(["device-plane-private", "device-plane-egress"]) + && descriptor.removedCoreNetwork === "device-plane-control" + && descriptor.composeCompatibility === "synology-compose-v2.20-no-gw-priority" + && descriptor.edgeRegistrations === "preserved" + && descriptor.rollback === "restore-upgrade-v2-021-source-and-preapply-core-runtime" + ) + : upgradeV2 + ? ( descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-upgrade.v2" && descriptor.action === "upgrade" && descriptor.composeActivation === "preserve-dedicated-additive-override" diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index 8124600..326968a 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -256,12 +256,21 @@ DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_REL = ( DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL = ( "deployment/device-edge-core-channel-upgrade-v2.json" ) +DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL = ( + "deployment/device-edge-core-channel-upgrade-v4.json" +) DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL = ( "docker-compose.device-edge-core-channel.yml" ) DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_SHA256 = ( "cac1e06b21202d8d96f5694b1adb1e67c0a3cdd31fcfca56cb7e19839c8516d8" ) +DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_PREDECESSOR_BASE_COMPOSE_SHA256 = ( + "eb1018cc0ffeaa0c019944d8810e2daa01eeca4c06289efff175785fb16457e7" +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_BASE_COMPOSE_SHA256 = ( + "6a4c08313cc97bbfd86c4e133c6b2e55ba18a499a3bb6f267c7059754f83e8cf" +) DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_ENTRIES = ( ".dockerignore", "package.json", @@ -292,6 +301,16 @@ DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES = ( "services/device-control-core", DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL, ) +DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_ENTRIES = ( + ".dockerignore", + "package.json", + "package-lock.json", + "docker-compose.device-plane.yml", + "packages/device-protocol-contract", + "packages/device-edge-channel-contract", + "services/device-control-core", + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL, +) DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_PREDECESSOR_PATCH_ID = ( "device-edge-core-channel-bootstrap-20260812-018" ) @@ -304,6 +323,26 @@ DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_PREDECESSOR_PATCH_ID = ( DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_PREDECESSOR_ARTIFACT_SHA256 = ( "8e9a220275959f378c1c4b00be5c7192e79afe2134eaab808a64e515870a8438" ) +DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_PREDECESSOR_PATCH_ID = ( + "device-edge-core-channel-upgrade-v2-20260812-021" +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_PREDECESSOR_ARTIFACT_SHA256 = ( + "e40a6fd24edfecac09e42cd82635a77850541bcf047788db3e9c55d2b9e58867" +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_PATCH_ID = ( + "device-edge-core-channel-upgrade-v3-20260812-022" +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_ARTIFACT_SHA256 = ( + "9e2b409a4b2d19711db434e90d03ac8e3db77bd74949f83cace7949f33caf613" +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_ARTIFACT = ( + "nodedc-device-plane-device-edge-core-channel-upgrade-v3-" + "20260812-022.tgz.20260812-123620" +) +DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_BACKUP_ID = ( + "device-plane-device-edge-core-channel-upgrade-v3-" + "20260812-022-20260812-123620" +) DEVICE_PLANE_EDGE_CORE_CHANNEL_MANAGER_PREDECESSOR_PATCH_ID = ( "device-manager-release-20260811-010" ) @@ -3835,6 +3874,7 @@ def allowed_payload_path(component, 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_EDGE_CORE_CHANNEL_UPGRADE_V4_REL, DEVICE_PLANE_MANAGER_RECONCILIATION_REL, DEVICE_PLANE_MANAGER_V2_RECONCILIATION_REL, "packages/device-protocol-contract", @@ -8891,7 +8931,15 @@ def load_artifact(artifact, work_dir): manifest["component"], entries, ): - if is_device_plane_edge_core_channel_upgrade_v2_slice( + if is_device_plane_edge_core_channel_upgrade_v4_slice( + manifest["component"], + entries, + ): + validate_device_plane_edge_core_channel_upgrade_v4_payload( + payload_dir, + expected_transition_id=manifest["id"], + ) + elif is_device_plane_edge_core_channel_upgrade_v2_slice( manifest["component"], entries, ): @@ -9404,6 +9452,7 @@ def is_device_plane_edge_core_channel_bootstrap_slice(component, entries): DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_ENTRIES, DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_ENTRIES, DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES, + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_ENTRIES, ) ) @@ -9415,6 +9464,7 @@ def is_device_plane_edge_core_channel_upgrade_slice(component, entries): and tuple(entries) in ( DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_ENTRIES, DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES, + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_ENTRIES, ) ) @@ -9427,6 +9477,14 @@ def is_device_plane_edge_core_channel_upgrade_v2_slice(component, entries): ) +def is_device_plane_edge_core_channel_upgrade_v4_slice(component, entries): + return ( + component == "device-plane" + and entries is not None + and tuple(entries) == DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_ENTRIES + ) + + def is_device_plane_manager_v2_control_plane_slice(component, entries): return ( component == "device-plane" @@ -9661,6 +9719,72 @@ def expected_device_plane_edge_core_channel_upgrade_v2_descriptor( } +def expected_device_plane_edge_core_channel_upgrade_v4_descriptor( + transition_id, +): + return { + "schemaVersion": ( + "nodedc.device-plane.device-edge-core-channel-upgrade.v4" + ), + "transitionId": transition_id, + "action": "upgrade", + "upgradePredecessor": { + "patchId": ( + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_PREDECESSOR_PATCH_ID + ), + "artifactSha256": ( + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_PREDECESSOR_ARTIFACT_SHA256 + ), + }, + "failedAttempt": { + "patchId": ( + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_PATCH_ID + ), + "artifactSha256": ( + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_ARTIFACT_SHA256 + ), + "backupId": ( + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_BACKUP_ID + ), + }, + "service": "device-control-core", + "composeActivation": ( + "replace-core-network-membership-with-private-plus-egress" + ), + "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", + "coreNetworks": [ + "device-plane-private", + "device-plane-egress", + ], + "removedCoreNetwork": "device-plane-control", + "composeCompatibility": "synology-compose-v2.20-no-gw-priority", + "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+" + "exact-private-egress-network-boundary" + ), + "rollback": ( + "restore-upgrade-v2-021-source-and-preapply-core-runtime" + ), + } + + def expected_device_plane_manager_failed_control_plane_descriptor(): return { "schemaVersion": ( @@ -10048,6 +10172,36 @@ def validate_device_plane_edge_core_channel_upgrade_v2_payload( return descriptor +def validate_device_plane_edge_core_channel_upgrade_v4_payload( + payload_dir, + *, + expected_transition_id=None, +): + descriptor = read_strict_json( + payload_dir / DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL, + "Device Edge Core channel upgrade v4 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_v4_descriptor( + transition_id + ) + ): + die("Device Edge Core channel upgrade v4 descriptor mismatch") + validate_device_plane_edge_core_channel_upgrade_v4_base_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: @@ -10087,6 +10241,38 @@ def validate_device_plane_edge_core_channel_compose(payload_dir): return compose +def validate_device_plane_edge_core_channel_upgrade_v4_base_compose( + payload_dir, +): + compose = payload_dir / "docker-compose.device-plane.yml" + if ( + sha256_file(compose) + != DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_BASE_COMPOSE_SHA256 + ): + die("Device Edge Core channel v4 base Compose mismatch") + compose_text = compose.read_text(encoding="utf-8") + try: + core_block = compose_text.split( + "\n device-control-core:", 1 + )[1].split("\n device-gateway:", 1)[0] + gateway_block = compose_text.split( + "\n device-gateway:", 1 + )[1].split("\nnetworks:", 1)[0] + except IndexError: + die("Device Edge Core channel v4 service topology is incomplete") + if ( + " - device-plane-private" not in core_block + or " - device-plane-control" in core_block + or " - device-plane-private" not in gateway_block + or " - device-plane-control" not in gateway_block + or "gw_priority:" in compose_text + or "network_mode:" in compose_text + or "privileged:" in compose_text + ): + die("Device Edge Core channel v4 network boundary mismatch") + return compose + + def installed_device_plane_manager_compose_sha256(): root = DEVICE_PLANE_ROOT v1 = root / DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL @@ -11777,6 +11963,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_V4_REL + ).exists(): + return validate_device_plane_edge_core_channel_upgrade_v4_predecessor( + payload_dir + ) if ( payload_dir / DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL ).exists(): @@ -12075,6 +12267,128 @@ def validate_device_plane_edge_core_channel_upgrade_v2_predecessor( } +def validate_device_plane_edge_core_channel_upgrade_v4_predecessor( + payload_dir, +): + descriptor = validate_device_plane_edge_core_channel_upgrade_v4_payload( + payload_dir + ) + installed_upgrade = ( + DEVICE_PLANE_ROOT / DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL + ) + if installed_upgrade.exists() or installed_upgrade.is_symlink(): + die("Device Edge Core channel upgrade v4 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 v4 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 v4 predecessor journal mismatch") + failed = descriptor["failedAttempt"] + failed_artifact = ( + FAILED_DIR / DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_ARTIFACT + ) + failed_backup = BACKUPS_DIR / failed["backupId"] + if ( + not failed_artifact.is_file() + or failed_artifact.is_symlink() + or sha256_file(failed_artifact) != failed["artifactSha256"] + or not failed_backup.is_dir() + or failed_backup.is_symlink() + ): + die("Device Edge Core channel upgrade v4 failed-attempt evidence mismatch") + failed_records = [ + row for row in load_state(FAILED_STATE_FILE) + if row.get("id") == failed["patchId"] + and row.get("sha256") == failed["artifactSha256"] + ] + if ( + len(failed_records) != 1 + or failed_records[0].get("status") != "failed" + or failed_records[0].get("component") != "device-plane" + or failed_records[0].get("backup_id") != failed["backupId"] + or failed_records[0].get("started_apply") is not True + or failed_records[0].get("rollback_status") + != "ok:device-plane-overlay:source+runtime-restored:8" + or failed_records[0].get("message") + != "Device Control Core Edge channel network boundary mismatch" + ): + die("Device Edge Core channel upgrade v4 failed-attempt journal mismatch") + with tempfile.TemporaryDirectory( + prefix="device-edge-core-channel-upgrade-v4-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_V2_ENTRIES + ): + die("Device Edge Core channel upgrade v4 predecessor type mismatch") + validate_device_plane_edge_core_channel_upgrade_v2_payload( + predecessor_payload, + expected_transition_id=predecessor["patchId"], + ) + expected_source = collect_exact_files( + predecessor_payload, + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES, + "Device Edge Core channel upgrade v4 predecessor source", + ) + actual_source = collect_exact_files( + DEVICE_PLANE_ROOT, + DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES, + "installed Device Edge Core channel upgrade v2 source", + ) + if actual_source != expected_source: + die("installed Device Edge Core channel upgrade v2 source drift detected") + installed_base_compose = DEVICE_PLANE_ROOT / "docker-compose.device-plane.yml" + if ( + installed_base_compose.is_symlink() + or not installed_base_compose.is_file() + or sha256_file(installed_base_compose) + != DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_PREDECESSOR_BASE_COMPOSE_SHA256 + ): + die("installed Device Edge Core channel v4 base 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 v4 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-private-plus-egress-upgrade-v4", + "descriptor": descriptor, + "identityState": identity_state, + "upgradeArtifact": artifact, + "failedArtifact": failed_artifact, + "failedBackup": failed_backup, + } + + def validate_device_plane_manager_v2_reconciliation_backup(): backup_dir = ( BACKUPS_DIR / DEVICE_PLANE_MANAGER_V2_RECONCILIATION_BACKUP_ID @@ -13698,7 +14012,11 @@ def validate_device_plane_runtime_secret_metadata(include_management=False): return "exact" -def validate_device_manager_control_plane_runtime(*, require_edge_channel=None): +def validate_device_manager_control_plane_runtime( + *, + require_edge_channel=None, + core_network_mode=None, +): core_ids = device_plane_service_container_ids("device-control-core") manager_ids = device_plane_service_container_ids("device-manager") if len(core_ids) != 1 or len(manager_ids) != 1: @@ -13824,12 +14142,22 @@ def validate_device_manager_control_plane_runtime(*, require_edge_channel=None): core_networks = set( ((core.get("NetworkSettings") or {}).get("Networks") or {}).keys() ) - expected_core_networks = { - DEVICE_PLANE_PRIVATE_NETWORK, - DEVICE_PLANE_CONTROL_NETWORK, - } - if require_edge_channel: - expected_core_networks.add(DEVICE_PLANE_EGRESS_NETWORK) + if core_network_mode not in (None, "private-egress"): + die("Device Control Core Edge channel network mode is invalid") + if core_network_mode == "private-egress" and not require_edge_channel: + die("Device Control Core private egress requires Edge channel") + if core_network_mode == "private-egress": + expected_core_networks = { + DEVICE_PLANE_PRIVATE_NETWORK, + DEVICE_PLANE_EGRESS_NETWORK, + } + else: + expected_core_networks = { + DEVICE_PLANE_PRIVATE_NETWORK, + DEVICE_PLANE_CONTROL_NETWORK, + } + if require_edge_channel: + expected_core_networks.add(DEVICE_PLANE_EGRESS_NETWORK) if core_networks != expected_core_networks: die("Device Control Core Edge channel network boundary mismatch") manager_mounts = { @@ -15883,6 +16211,9 @@ def component_compose_files( edge_upgrade_v2_descriptor = ( DEVICE_PLANE_ROOT / DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL ) + edge_upgrade_v4_descriptor = ( + DEVICE_PLANE_ROOT / DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL + ) edge_override = ( DEVICE_PLANE_ROOT / DEVICE_PLANE_EDGE_CORE_CHANNEL_COMPOSE_REL ) @@ -15897,6 +16228,10 @@ def component_compose_files( edge_upgrade_v2_descriptor.exists() or edge_upgrade_v2_descriptor.is_symlink() ) + edge_upgrade_v4_descriptor_exists = ( + edge_upgrade_v4_descriptor.exists() + or edge_upgrade_v4_descriptor.is_symlink() + ) edge_override_exists = edge_override.exists() or edge_override.is_symlink() if ( edge_override_exists @@ -15904,6 +16239,7 @@ def component_compose_files( edge_descriptor_exists or edge_upgrade_descriptor_exists or edge_upgrade_v2_descriptor_exists + or edge_upgrade_v4_descriptor_exists ) or edge_upgrade_descriptor_exists and not edge_descriptor_exists @@ -15911,6 +16247,12 @@ def component_compose_files( and not ( edge_descriptor_exists and edge_upgrade_descriptor_exists ) + or edge_upgrade_v4_descriptor_exists + and not ( + edge_descriptor_exists + and edge_upgrade_descriptor_exists + and edge_upgrade_v2_descriptor_exists + ) ): die("installed Device Edge Core channel source is incomplete") if edge_descriptor_exists: @@ -15988,6 +16330,35 @@ def component_compose_files( ) ): die("installed Device Edge Core channel upgrade v2 descriptor drift detected") + if edge_upgrade_v4_descriptor_exists: + if ( + upgrade_v2_id + != DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_PREDECESSOR_PATCH_ID + or edge_upgrade_v4_descriptor.is_symlink() + or not edge_upgrade_v4_descriptor.is_file() + or sha256_file( + DEVICE_PLANE_ROOT / "docker-compose.device-plane.yml" + ) + != DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_BASE_COMPOSE_SHA256 + ): + die("installed Device Edge Core channel upgrade v4 predecessor drift detected") + upgrade_v4 = read_strict_json( + edge_upgrade_v4_descriptor, + "installed Device Edge Core channel upgrade v4 descriptor", + max_bytes=16 * 1024, + ) + upgrade_v4_id = upgrade_v4.get("transitionId") + if ( + not isinstance(upgrade_v4_id, str) + or not re.fullmatch( + r"[A-Za-z0-9._-]{1,96}", upgrade_v4_id + ) + or upgrade_v4 + != expected_device_plane_edge_core_channel_upgrade_v4_descriptor( + upgrade_v4_id + ) + ): + die("installed Device Edge Core channel upgrade v4 descriptor drift detected") files = (*files, edge_override) overlay = DEVICE_PLANE_ROOT / DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL if overlay.exists() or overlay.is_symlink(): @@ -19983,6 +20354,19 @@ def plan_artifact(artifact): f"{predecessor['artifactSha256']}" ) print("device_edge_channel_endpoint_policy=public-ipv4-standard-https-tcp-443-only") + if "coreNetworks" in edge_descriptor: + print( + "device_edge_channel_core_networks=" + f"{','.join(edge_descriptor['coreNetworks'])}" + ) + print( + "device_edge_channel_removed_core_network=" + f"{edge_descriptor['removedCoreNetwork']}" + ) + print( + "device_edge_channel_compose_compatibility=" + f"{edge_descriptor['composeCompatibility']}" + ) else: print( "device_plane_predecessor_patch=" @@ -22600,9 +22984,18 @@ 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( - require_edge_channel=True - ) + if is_device_plane_edge_core_channel_upgrade_v4_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( + require_edge_channel=True, + ) return if component == "platform" and entries is not None and is_platform_provider_catalog_only(entries): return 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 2d8247c..145310d 100644 --- a/infra/deploy-runner/test_device_manager_control_plane_artifacts.py +++ b/infra/deploy-runner/test_device_manager_control_plane_artifacts.py @@ -364,6 +364,97 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): ) ) + def test_edge_core_channel_upgrade_v4_is_core_only_and_pins_upgrade_021(self): + patch_id = "device-edge-core-channel-upgrade-v4-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_V4_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-v4.json", + names, + ) + self.assertIn( + "payload/docker-compose.device-plane.yml", + names, + ) + descriptor = ( + RUNNER.expected_device_plane_edge_core_channel_upgrade_v4_descriptor( + patch_id + ) + ) + self.assertEqual( + descriptor["upgradePredecessor"]["patchId"], + "device-edge-core-channel-upgrade-v2-20260812-021", + ) + self.assertEqual( + descriptor["upgradePredecessor"]["artifactSha256"], + "e40a6fd24edfecac09e42cd82635a77850541bcf047788db3e9c55d2b9e58867", + ) + self.assertEqual( + descriptor["failedAttempt"]["patchId"], + "device-edge-core-channel-upgrade-v3-20260812-022", + ) + self.assertEqual( + descriptor["failedAttempt"]["artifactSha256"], + "9e2b409a4b2d19711db434e90d03ac8e3db77bd74949f83cace7949f33caf613", + ) + self.assertEqual( + descriptor["coreNetworks"], + ["device-plane-private", "device-plane-egress"], + ) + self.assertEqual( + descriptor["removedCoreNetwork"], + "device-plane-control", + ) + self.assertEqual( + descriptor["composeCompatibility"], + "synology-compose-v2.20-no-gw-priority", + ) + self.assertTrue( + RUNNER.is_device_plane_edge_core_channel_upgrade_v4_slice( + "device-plane", + entries, + ) + ) + + def test_edge_core_channel_upgrade_v4_rejects_installed_marker(self): + with tempfile.TemporaryDirectory( + prefix="nodedc-device-edge-upgrade-v4-installed-", + ) as directory: + root = Path(directory) + marker = root / RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL + marker.parent.mkdir(parents=True) + marker.write_text("{}\n", encoding="utf-8") + descriptor = ( + RUNNER.expected_device_plane_edge_core_channel_upgrade_v4_descriptor( + "device-edge-core-channel-upgrade-v4-unit-002" + ) + ) + with ( + mock.patch.object(RUNNER, "DEVICE_PLANE_ROOT", root), + mock.patch.object( + RUNNER, + "validate_device_plane_edge_core_channel_upgrade_v4_payload", + return_value=descriptor, + ), + ): + with self.assertRaisesRegex( + RUNNER.DeployError, + "upgrade v4 is already installed", + ): + RUNNER.validate_device_plane_edge_core_channel_upgrade_v4_predecessor( + root / "payload" + ) + def test_edge_core_channel_upgrade_v2_rejects_installed_marker(self): with tempfile.TemporaryDirectory( prefix="nodedc-device-edge-upgrade-v2-installed-", @@ -508,6 +599,162 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): ], ) + def test_edge_core_channel_upgrade_v4_accepts_exact_applied_021(self): + with tempfile.TemporaryDirectory( + prefix="nodedc-device-edge-upgrade-v4-predecessor-", + ) as directory: + root = Path(directory) + applied = root / "applied" + failed = root / "failed" + backups = root / "backups" + temporary = root / "tmp" + device_plane = root / "device-plane" + for path in ( + applied, + failed, + backups, + temporary, + device_plane / "deployment", + ): + path.mkdir(parents=True) + predecessor_patch = ( + RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_PREDECESSOR_PATCH_ID + ) + predecessor_sha = ( + RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_PREDECESSOR_ARTIFACT_SHA256 + ) + artifact_name = f"nodedc-device-plane-{predecessor_patch}.tgz" + artifact = applied / artifact_name + artifact.write_bytes(b"reviewed-upgrade-021") + installed_base = device_plane / "docker-compose.device-plane.yml" + installed_base.write_text("services: {}\n", encoding="utf-8") + 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", + ) + failed_artifact = ( + failed + / RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_ARTIFACT + ) + failed_artifact.write_bytes(b"reviewed-failed-upgrade-022") + failed_backup = ( + backups + / RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_BACKUP_ID + ) + failed_backup.mkdir() + failed_state_file = root / "failed.jsonl" + failed_state_file.write_text( + json.dumps({ + "id": RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_PATCH_ID, + "artifact": failed_artifact.name, + "backup_id": failed_backup.name, + "component": "device-plane", + "sha256": RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_ARTIFACT_SHA256, + "status": "failed", + "started_apply": True, + "rollback_status": "ok:device-plane-overlay:source+runtime-restored:8", + "message": "Device Control Core Edge channel network boundary mismatch", + }) + "\n", + encoding="utf-8", + ) + descriptor = ( + RUNNER.expected_device_plane_edge_core_channel_upgrade_v4_descriptor( + "device-edge-core-channel-upgrade-v4-unit-003" + ) + ) + expected_source = {"source": "upgrade-021"} + with ( + mock.patch.object(RUNNER, "APPLIED_DIR", applied), + mock.patch.object(RUNNER, "FAILED_DIR", failed), + mock.patch.object(RUNNER, "BACKUPS_DIR", backups), + mock.patch.object(RUNNER, "TMP_DIR", temporary), + mock.patch.object(RUNNER, "STATE_FILE", state_file), + mock.patch.object( + RUNNER, + "FAILED_STATE_FILE", + failed_state_file, + ), + mock.patch.object(RUNNER, "DEVICE_PLANE_ROOT", device_plane), + mock.patch.object( + RUNNER, + "validate_device_plane_edge_core_channel_upgrade_v4_payload", + return_value=descriptor, + ), + mock.patch.object( + RUNNER, + "sha256_file", + side_effect=lambda path: ( + RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_PREDECESSOR_BASE_COMPOSE_SHA256 + if Path(path) == installed_base + else RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_ARTIFACT_SHA256 + if Path(path) == failed_artifact + else 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_V2_ENTRIES, + root / "predecessor-payload", + ), + ), + mock.patch.object( + RUNNER, + "validate_device_plane_edge_core_channel_upgrade_v2_payload", + ) as validate_predecessor, + mock.patch.object( + RUNNER, + "collect_exact_files", + side_effect=(expected_source, expected_source), + ), + 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_v4_predecessor( + root / "payload" + ) + ) + + self.assertEqual( + result["mode"], + "edge-core-channel-private-plus-egress-upgrade-v4", + ) + 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", @@ -747,6 +994,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_V4_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") @@ -811,7 +1076,46 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): services, )[0] ) - runtime_acceptance.assert_called_once_with(require_edge_channel=True) + runtime_acceptance.assert_called_once_with( + require_edge_channel=True + ) + + def test_upgrade_v4_apply_gate_checks_preserved_runtime_and_edge_contract(self): + entries = RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_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, + core_network_mode="private-egress", + ) def test_activation_resolves_predecessor_from_descriptor_and_journal(self): with tempfile.TemporaryDirectory( @@ -1274,6 +1578,79 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): runtime_acceptance.assert_called_once_with(require_edge_channel=True) self.assertEqual(result, f"source+runtime-restored:{len(entries)}") + def test_edge_upgrade_v4_rollback_restores_upgrade_021_core_runtime(self): + entries = RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_ENTRIES + missing = { + RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL, + } + existing = [entry for entry in entries if entry not in missing] + with tempfile.TemporaryDirectory( + prefix="nodedc-device-edge-upgrade-v4-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( + "\n".join(sorted(missing)) + "\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)