From 35c37de586a5f1b2f29921e79d9b03495f23cdad Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 11 Aug 2026 21:01:37 +0300 Subject: [PATCH] fix(deploy): track installed device manager release generation --- ...-device-manager-control-plane-artifact.mjs | 2 +- infra/deploy-runner/nodedc-deploy | 45 ++++++++++- ..._device_manager_control_plane_artifacts.py | 76 +++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) 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 e6d355f..34380e0 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-release-20260811-012", ...extra] = process.argv.slice(2); +const [patchId = "device-manager-release-20260811-013", ...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 = [ diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index 68a229f..71f9745 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -9455,6 +9455,46 @@ def validate_device_plane_manager_release_payload( ) +def installed_device_plane_manager_compose_sha256(): + root = DEVICE_PLANE_ROOT + v1 = root / DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL + v2 = root / DEVICE_PLANE_MANAGER_RELEASE_V2_REL + + # A successful v2 overlay intentionally leaves the immutable v1 release + # descriptor as predecessor evidence. Prefer the highest installed + # generation, but validate its exact schema/security boundary before using + # 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 v2.exists() or v2.is_symlink(): + descriptor = read_strict_json( + v2, + "installed Device Manager release v2 descriptor", + max_bytes=16 * 1024, + ) + validate_device_plane_manager_release_descriptor( + descriptor, + schema_version="nodedc.device-plane.device-manager-release.v2", + boundaries=expected_device_plane_manager_release_v2_boundaries(), + ) + return DEVICE_PLANE_MANAGER_RELEASE_V2_COMPOSE_SHA256 + + if v1.exists() or v1.is_symlink(): + descriptor = read_strict_json( + v1, + "installed Device Manager release v1 descriptor", + max_bytes=16 * 1024, + ) + validate_device_plane_manager_release_descriptor( + descriptor, + schema_version="nodedc.device-plane.device-manager-release.v1", + boundaries=expected_device_plane_manager_release_v1_boundaries(), + ) + return DEVICE_PLANE_MANAGER_RELEASE_V1_COMPOSE_SHA256 + + die("installed Device Manager release descriptor is missing") + + def validate_device_plane_manager_failed_control_plane_payload(payload_dir): descriptor = read_strict_json( payload_dir / DEVICE_PLANE_MANAGER_FAILED_CONTROL_PLANE_REL, @@ -14829,11 +14869,14 @@ def component_compose_files( if component == "device-plane": manager_overlay = DEVICE_PLANE_ROOT / DEVICE_PLANE_MANAGER_COMPOSE_REL if manager_overlay.exists() or manager_overlay.is_symlink(): + expected_manager_compose_sha256 = ( + installed_device_plane_manager_compose_sha256() + ) if ( manager_overlay.is_symlink() or not manager_overlay.is_file() or sha256_file(manager_overlay) - != DEVICE_PLANE_MANAGER_COMPOSE_SHA256 + != expected_manager_compose_sha256 ): die("installed Device Manager Compose drift detected") files = (*files, manager_overlay) 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 0ede2ec..f8975f4 100644 --- a/infra/deploy-runner/test_device_manager_control_plane_artifacts.py +++ b/infra/deploy-runner/test_device_manager_control_plane_artifacts.py @@ -278,6 +278,82 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): ), ) + def test_installed_manager_compose_follows_release_generation(self): + with tempfile.TemporaryDirectory( + prefix="nodedc-device-manager-compose-generation-", + ) as directory: + root = Path(directory) + deployment = root / "deployment" + deployment.mkdir() + compose = root / RUNNER.DEVICE_PLANE_MANAGER_COMPOSE_REL + compose.write_text("services: {}\n", encoding="utf-8") + v1 = root / RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL + v2 = root / RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V2_REL + v1.write_text( + json.dumps(device_manager_release_v1_descriptor()), + encoding="utf-8", + ) + + with ( + mock.patch.object(RUNNER, "DEVICE_PLANE_ROOT", root), + mock.patch.dict( + RUNNER.COMPONENTS["device-plane"], + {"compose_files": ()}, + ), + mock.patch.object( + RUNNER, + "sha256_file", + return_value=( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V1_COMPOSE_SHA256 + ), + ), + ): + self.assertEqual( + RUNNER.component_compose_files("device-plane"), + (compose,), + ) + + v2.write_text( + json.dumps(device_manager_release_descriptor()), + encoding="utf-8", + ) + with ( + mock.patch.object(RUNNER, "DEVICE_PLANE_ROOT", root), + mock.patch.dict( + RUNNER.COMPONENTS["device-plane"], + {"compose_files": ()}, + ), + mock.patch.object( + RUNNER, + "sha256_file", + return_value=( + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V2_COMPOSE_SHA256 + ), + ), + ): + self.assertEqual( + RUNNER.component_compose_files("device-plane"), + (compose,), + ) + + with ( + mock.patch.object(RUNNER, "DEVICE_PLANE_ROOT", root), + mock.patch.dict( + RUNNER.COMPONENTS["device-plane"], + {"compose_files": ()}, + ), + mock.patch.object( + RUNNER, + "sha256_file", + return_value="0" * 64, + ), + ): + with self.assertRaisesRegex( + RUNNER.DeployError, + "installed Device Manager Compose drift detected", + ): + RUNNER.component_compose_files("device-plane") + def test_public_route_artifact_is_last_and_proxy_only(self): manifest, entries, _names, result = self.assert_deterministic_artifact( "build-platform-device-manager-route-artifact.mjs",