diff --git a/apps/device-manager/server/device-manager-presentation.mjs b/apps/device-manager/server/device-manager-presentation.mjs index 313f423..ddd0a1a 100644 --- a/apps/device-manager/server/device-manager-presentation.mjs +++ b/apps/device-manager/server/device-manager-presentation.mjs @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { basename, dirname, extname, join, resolve, sep } from "node:path"; -const DEFAULT_ACCENT = "#b9ff4a"; +const DEFAULT_ACCENT = "#f5f5f5"; export function createDeviceManagerPresentationStore({ layoutPath, diff --git a/apps/device-manager/server/device-manager-presentation.test.mjs b/apps/device-manager/server/device-manager-presentation.test.mjs index 52dd132..e4bcc22 100644 --- a/apps/device-manager/server/device-manager-presentation.test.mjs +++ b/apps/device-manager/server/device-manager-presentation.test.mjs @@ -12,6 +12,8 @@ import { test("Device Core environment defaults to the product-level canonical identity", () => { const presentation = defaultPresentation(); + assert.equal(presentation.environment.theme, "dark"); + assert.equal(presentation.environment.accentHex, "#f5f5f5"); assert.deepEqual(presentation.environment.overview, { headerLabel: "Device Core", eyebrow: "NODEDC / DEVICE CORE", @@ -27,6 +29,38 @@ test("Device Core environment defaults to the product-level canonical identity", }); }); +test("environment accent keeps a valid user choice and falls back to neutral white", () => { + assert.equal( + normalizeEnvironmentPresentation({ accentHex: "#8A2BE2" }).accentHex, + "#8a2be2", + ); + assert.equal( + normalizeEnvironmentPresentation({ accentHex: "not-a-color" }).accentHex, + "#f5f5f5", + ); +}); + +test("production Compose provides exact persistent writable presentation storage", async () => { + const compose = await readFile( + new URL("../../../docker-compose.device-manager.yml", import.meta.url), + "utf8", + ); + for (const expected of [ + "NODEDC_DEVICE_MANAGER_PRESENTATION_PATH: /var/lib/nodedc-device-manager/device-manager-presentation.json", + "NODEDC_DEVICE_MANAGER_MEDIA_ROOT: /var/lib/nodedc-device-manager/media", + "source: /volume1/docker/nodedc-device-plane/data/device-manager", + "target: /var/lib/nodedc-device-manager", + "read_only: false", + "create_host_path: false", + ]) assert.ok(compose.includes(expected), expected); + const client = await readFile( + new URL("../src/DeviceManagerApp.tsx", import.meta.url), + "utf8", + ); + assert.ok(client.includes('accentHex: "#f5f5f5"')); + assert.ok(!client.includes("#b9ff4a")); +}); + test("legacy single teaser migrates into the environment media playlist", () => { const environment = normalizeEnvironmentPresentation({ defaultTeaser: { @@ -60,6 +94,23 @@ test("environment media accepts MOV even when the browser omits or varies its MI } }); +test("environment media persists an MP4 upload in the configured data root", async (t) => { + const root = await mkdtemp(join(tmpdir(), "nodedc-device-presentation-mp4-")); + t.after(() => rm(root, { recursive: true, force: true })); + const store = createDeviceManagerPresentationStore({ + layoutPath: join(root, "device-manager-presentation.json"), + uploadRoot: join(root, "media"), + }); + const uploaded = await store.saveMedia({ + bytes: Buffer.from("mp4-payload"), + contentType: "video/mp4", + originalName: "environment.mp4", + kind: "background", + }); + assert.match(uploaded.fileSrc, /^\/device-manager-media\/background-[0-9a-f-]+\.mp4$/); + assert.equal(await readFile(store.resolveMedia(uploaded.fileSrc), "utf8"), "mp4-payload"); +}); + test("environment presentation persists ordered mixed media and image duration", async (t) => { const root = await mkdtemp(join(tmpdir(), "nodedc-device-presentation-")); t.after(() => rm(root, { recursive: true, force: true })); @@ -68,6 +119,8 @@ test("environment presentation persists ordered mixed media and image duration", uploadRoot: join(root, "media"), }); const next = defaultPresentation(); + next.environment.theme = "light"; + next.environment.accentHex = "#8a2be2"; next.environment.overview.background = { enabled: true, imageDurationSeconds: 17, @@ -93,6 +146,8 @@ test("environment presentation persists ordered mixed media and image duration", await store.write(next); const restored = await store.read(); + assert.equal(restored.environment.theme, "light"); + assert.equal(restored.environment.accentHex, "#8a2be2"); assert.equal(restored.environment.overview.background.imageDurationSeconds, 17); assert.deepEqual( restored.environment.overview.background.items.map((item) => item.id), diff --git a/apps/device-manager/src/DeviceManagerApp.tsx b/apps/device-manager/src/DeviceManagerApp.tsx index d755c01..f7a084e 100644 --- a/apps/device-manager/src/DeviceManagerApp.tsx +++ b/apps/device-manager/src/DeviceManagerApp.tsx @@ -130,7 +130,7 @@ const emptyMedia = (): DeviceManagerMediaValue => ({ const defaultPresentation = (): DeviceManagerPresentation => ({ environment: { theme: "dark", - accentHex: "#b9ff4a", + accentHex: "#f5f5f5", overview: { headerLabel: "Device Core", eyebrow: "NODEDC / DEVICE CORE", diff --git a/deployment/device-manager-release-v4.json b/deployment/device-manager-release-v4.json new file mode 100644 index 0000000..4095795 --- /dev/null +++ b/deployment/device-manager-release-v4.json @@ -0,0 +1,39 @@ +{ + "schemaVersion": "nodedc.device-plane.device-manager-release.v4", + "releaseId": "__PATCH_ID__", + "action": "upgrade", + "predecessor": { + "kind": "release", + "patchId": "device-manager-release-v3-20260822-032", + "artifactSha256": "6e0eb3a0a6f19ceab92d46832b93bffbcea21247dbdc2ea50625a51ff460e4ca" + }, + "controlCorePredecessor": { + "patchId": "device-control-core-release-v2-20260821-030", + "artifactSha256": "8459521a662541a5a87cb0188991cdcfb51727427db8ec2a232ce4846bfc3454" + }, + "edgeChannelPredecessor": { + "patchId": "device-edge-core-channel-upgrade-v4-20260812-023", + "artifactSha256": "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc" + }, + "service": "device-manager", + "publicIngress": "reverse-proxy-only", + "deviceCoreManagementApi": "file-token-authenticated", + "launcherTrust": "file-token-scoped-to-device-core-handoff", + "edgeChannel": "preserve-active-v4-core-initiated-pinned-mtls", + "edgeChannelIdentity": "reuse-runner-managed-host-local-private-key-public-certificate-export", + "edgeChannelEgress": "preserve-dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-443-only", + "healthGate": "bounded-container-grace+core-contract+persistent-data", + "commandTransport": "typed-service-ping-v1", + "commandCatalog": "allowlisted-adapter-typed-commands-only", + "credentialBoundary": "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned", + "presentationPersistence": "runner-managed-host-data-bind", + "presentationDataHostPath": "/volume1/docker/nodedc-device-plane/data/device-manager", + "presentationDataContainerPath": "/var/lib/nodedc-device-manager", + "presentationDataOwnership": "uid-1000-gid-1000-mode-0750", + "presentationDataLifecycle": "preserve-across-manager-recreate-and-source-rollback", + "presentationPath": "/var/lib/nodedc-device-manager/device-manager-presentation.json", + "mediaRoot": "/var/lib/nodedc-device-manager/media", + "defaultAccentHex": "#f5f5f5", + "gelios": "untouched-legacy-only", + "rollback": "restore-preapply-snapshot-preserve-manager-data" +} diff --git a/docker-compose.device-manager.yml b/docker-compose.device-manager.yml index f62052a..ff968ff 100644 --- a/docker-compose.device-manager.yml +++ b/docker-compose.device-manager.yml @@ -34,6 +34,8 @@ services: NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token NODEDC_DEVICE_CORE_INTERNAL_URL: http://device-control-core:18120 NODEDC_DEVICE_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token + NODEDC_DEVICE_MANAGER_PRESENTATION_PATH: /var/lib/nodedc-device-manager/device-manager-presentation.json + NODEDC_DEVICE_MANAGER_MEDIA_ROOT: /var/lib/nodedc-device-manager/media volumes: - type: bind source: /volume1/docker/nodedc-platform/secrets/device-core-internal-token @@ -47,6 +49,12 @@ services: read_only: true bind: create_host_path: false + - type: bind + source: /volume1/docker/nodedc-device-plane/data/device-manager + target: /var/lib/nodedc-device-manager + read_only: false + bind: + create_host_path: false expose: - "18122" networks: 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 736a661..1798929 100644 --- a/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs +++ b/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs @@ -14,12 +14,21 @@ const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(sc const [patchId = "device-manager-release-v3-20260812-026", ...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 descriptorPath = patchId.startsWith("device-manager-release-v3-") - ? "deployment/device-manager-release-v3.json" - : "deployment/device-manager-release-v1.json"; +const descriptorPath = patchId.startsWith("device-manager-release-v4-") + ? "deployment/device-manager-release-v4.json" + : patchId.startsWith("device-manager-release-v3-") + ? "deployment/device-manager-release-v3.json" + : "deployment/device-manager-release-v1.json"; const isV3 = descriptorPath.endsWith("release-v3.json"); -const entries = isV3 ? [ +const isV4 = descriptorPath.endsWith("release-v4.json"); +const isManagerOnly = isV3 || isV4; +const composeSource = resolve(devicePlaneRoot, "docker-compose.device-manager.yml"); +const composeSourceSha256 = createHash("sha256").update(await readFile(composeSource)).digest("hex"); +if (!isV4 && composeSourceSha256 !== "4954120aaddc999798b64c304d8cf692b79714feb727d873117bd1f3434e865e") { + throw new Error("historical_device_manager_compose_has_advanced"); +} +const entries = isManagerOnly ? [ "docker-compose.device-manager.yml", "services/device-manager", descriptorPath, @@ -70,7 +79,7 @@ try { } await copySafe(resolve(devicePlaneRoot, entry), join(payload, entry), devicePlaneRoot); } - if (!isV3) { + if (!isManagerOnly) { await validateDockerCopySources( join(payload, "services/device-control-core/Dockerfile"), payload, @@ -80,7 +89,7 @@ try { join(payload, "services/device-manager/Dockerfile"), join(payload, "services/device-manager"), ); - for (const modulePath of isV3 ? [] : [ + for (const modulePath of isManagerOnly ? [] : [ "services/device-control-core/src/sensitive-reference-management.mjs", "services/device-control-core/src/device-gateway-core-runtime.mjs", "packages/device-edge-channel-contract/src/index.mjs", @@ -111,6 +120,14 @@ try { "NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token", "NODEDC_DEVICE_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token", "name: nodedc-platform_edge", + ...(isV4 ? [ + "NODEDC_DEVICE_MANAGER_PRESENTATION_PATH: /var/lib/nodedc-device-manager/device-manager-presentation.json", + "NODEDC_DEVICE_MANAGER_MEDIA_ROOT: /var/lib/nodedc-device-manager/media", + "source: /volume1/docker/nodedc-device-plane/data/device-manager", + "target: /var/lib/nodedc-device-manager", + "read_only: false", + "create_host_path: false", + ] : []), ]) if (!compose.includes(required)) throw new Error(`device_manager_compose_contract_missing:${required}`); for (const forbidden of [ "NODEDC_INTERNAL_ACCESS_TOKEN:", @@ -135,11 +152,36 @@ try { || !/^[A-Za-z0-9._-]{1,96}$/.test(predecessor.patchId || "") || !/^[a-f0-9]{64}$/.test(predecessor.artifactSha256 || "") || (descriptor.action === "activate") !== (predecessor.kind === "reconciliation") - || descriptor.healthGate !== "bounded-container-grace+core-contract" - || descriptor.rollback !== "restore-preapply-snapshot" + || descriptor.healthGate !== (isV4 + ? "bounded-container-grace+core-contract+persistent-data" + : "bounded-container-grace+core-contract") + || descriptor.rollback !== (isV4 + ? "restore-preapply-snapshot-preserve-manager-data" + : "restore-preapply-snapshot") ); if (commonContractInvalid) throw new Error("device_manager_activation_successor_contract_mismatch"); - if (descriptorPath.endsWith("release-v3.json")) { + if (descriptorPath.endsWith("release-v4.json")) { + if ( + descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v4" + || descriptor.commandTransport !== "typed-service-ping-v1" + || descriptor.commandCatalog !== "allowlisted-adapter-typed-commands-only" + || descriptor.credentialBoundary !== "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned" + || descriptor.controlCorePredecessor?.patchId !== "device-control-core-release-v2-20260821-030" + || descriptor.controlCorePredecessor?.artifactSha256 !== "8459521a662541a5a87cb0188991cdcfb51727427db8ec2a232ce4846bfc3454" + || descriptor.edgeChannelPredecessor?.patchId !== "device-edge-core-channel-upgrade-v4-20260812-023" + || descriptor.edgeChannelPredecessor?.artifactSha256 !== "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc" + || descriptor.presentationPersistence !== "runner-managed-host-data-bind" + || descriptor.presentationDataHostPath !== "/volume1/docker/nodedc-device-plane/data/device-manager" + || descriptor.presentationDataContainerPath !== "/var/lib/nodedc-device-manager" + || descriptor.presentationDataOwnership !== "uid-1000-gid-1000-mode-0750" + || descriptor.presentationDataLifecycle !== "preserve-across-manager-recreate-and-source-rollback" + || descriptor.presentationPath !== "/var/lib/nodedc-device-manager/device-manager-presentation.json" + || descriptor.mediaRoot !== "/var/lib/nodedc-device-manager/media" + || descriptor.defaultAccentHex !== "#f5f5f5" + || descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data" + || descriptor.gelios !== "untouched-legacy-only" + ) throw new Error("device_manager_v4_persistence_contract_mismatch"); + } else if (descriptorPath.endsWith("release-v3.json")) { if ( descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v3" || descriptor.commandTransport !== "typed-service-ping-v1" @@ -173,7 +215,7 @@ try { artifact: target, sha256, entries, - services: isV3 ? ["device-manager"] : ["device-control-core", "device-manager"], + services: isManagerOnly ? ["device-manager"] : ["device-control-core", "device-manager"], preserved: ["device-postgres", "device-gateway", "device-backhaul-target", "Gelios"], }, null, 2)); } finally { 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 cb97dae..56efb5d 100644 --- a/infra/deploy-runner/test_device_manager_control_plane_artifacts.py +++ b/infra/deploy-runner/test_device_manager_control_plane_artifacts.py @@ -137,6 +137,13 @@ def device_manager_release_v3_descriptor( class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): + def historical_manager_compose_is_current(self): + compose = DEVICE_CORE_ROOT / "docker-compose.device-manager.yml" + return ( + hashlib.sha256(compose.read_bytes()).hexdigest() + == RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V1_COMPOSE_SHA256 + ) + def build(self, script, patch_id, artifact_dir): environment = os.environ.copy() environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) @@ -221,6 +228,8 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): self.assertEqual(result["services"], ["launcher"]) def test_device_manager_artifact_selects_only_core_and_manager(self): + if not self.historical_manager_compose_is_current(): + self.skipTest("historical Manager v1 Compose has advanced to v4") manifest, entries, names, result = self.assert_deterministic_artifact( "build-device-manager-control-plane-artifact.mjs", "device-manager-control-plane-unit-001", @@ -293,6 +302,8 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): self.assertEqual(checks[0]["expected_json"]["discoveryIngest"], "enabled") def test_device_manager_release_v3_is_exact_typed_and_secret_free(self): + if not self.historical_manager_compose_is_current(): + self.skipTest("historical Manager v3 Compose has advanced to v4") patch_id = "device-manager-release-v3-unit-001" manifest, entries, names, result = self.assert_deterministic_artifact( "build-device-manager-control-plane-artifact.mjs", @@ -360,6 +371,111 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): ) ) + def test_device_manager_release_v4_is_persistent_manager_only_and_white(self): + patch_id = "device-manager-release-v4-unit-001" + manifest, entries, names, result = self.assert_deterministic_artifact( + "build-device-manager-control-plane-artifact.mjs", + patch_id, + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES, + ) + self.assertEqual(manifest["component"], "device-plane") + self.assertEqual( + RUNNER.component_services("device-plane", entries), + ("device-manager",), + ) + self.assertEqual(result["services"], ["device-manager"]) + self.assertIn( + "payload/deployment/device-manager-release-v4.json", + names, + ) + self.assertFalse( + any(name.startswith("payload/services/device-control-core/") for name in names) + ) + self.assertFalse(any(name.startswith("payload/packages/") for name in names)) + self.assertFalse(any(name.endswith(".test.mjs") for name in names)) + template = json.loads( + ( + DEVICE_CORE_ROOT + / "deployment/device-manager-release-v4.json" + ).read_text(encoding="utf-8") + ) + descriptor = { + **template, + "releaseId": patch_id, + } + self.assertIs( + RUNNER.validate_device_plane_manager_release_descriptor( + descriptor, + schema_version=( + "nodedc.device-plane.device-manager-release.v4" + ), + boundaries=( + RUNNER.expected_device_plane_manager_release_v4_boundaries() + ), + expected_release_id=patch_id, + ), + descriptor, + ) + self.assertEqual(descriptor["defaultAccentHex"], "#f5f5f5") + self.assertEqual( + descriptor["predecessor"], + { + "kind": "release", + "patchId": "device-manager-release-v3-20260822-032", + "artifactSha256": ( + "6e0eb3a0a6f19ceab92d46832b93bffbcea21247dbdc2ea50625a51ff460e4ca" + ), + }, + ) + compose = ( + DEVICE_CORE_ROOT / "docker-compose.device-manager.yml" + ).read_text(encoding="utf-8") + for expected in ( + "NODEDC_DEVICE_MANAGER_PRESENTATION_PATH: " + "/var/lib/nodedc-device-manager/device-manager-presentation.json", + "NODEDC_DEVICE_MANAGER_MEDIA_ROOT: " + "/var/lib/nodedc-device-manager/media", + "source: /volume1/docker/nodedc-device-plane/data/device-manager", + "target: /var/lib/nodedc-device-manager", + "read_only: false", + "create_host_path: false", + ): + self.assertIn(expected, compose) + self.assertTrue( + RUNNER.is_device_plane_manager_release_v4_slice( + "device-plane", + entries, + ) + ) + + def test_historical_manager_builder_fails_closed_after_v4_compose(self): + if self.historical_manager_compose_is_current(): + self.skipTest("historical Manager Compose is still current") + with tempfile.TemporaryDirectory( + prefix="nodedc-device-manager-historical-reject-", + ) as directory: + environment = os.environ.copy() + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = directory + completed = subprocess.run( + [ + "node", + str( + SCRIPT_DIR + / "build-device-manager-control-plane-artifact.mjs" + ), + "device-manager-release-v3-rebuild-forbidden-001", + ], + cwd=DEVICE_CORE_ROOT, + env=environment, + capture_output=True, + text=True, + ) + self.assertNotEqual(completed.returncode, 0) + self.assertIn( + "historical_device_manager_compose_has_advanced", + completed.stderr, + ) + def test_edge_core_channel_bootstrap_is_core_only_and_secret_free(self): manifest, entries, names, result = self.assert_deterministic_artifact( "build-device-edge-core-channel-bootstrap-artifact.mjs", @@ -1551,6 +1667,28 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): core_network_mode="private-egress", ) + def test_manager_v4_apply_gate_requires_persistent_data_boundary(self): + entries = RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V4_ENTRIES + services = ("device-manager",) + with ( + mock.patch.object( + RUNNER, + "healthcheck_compose_service_with_grace", + ), + mock.patch.object(RUNNER, "healthcheck_url"), + mock.patch.object( + RUNNER, + "validate_device_manager_control_plane_runtime", + ) as runtime_acceptance, + ): + RUNNER.run_healthchecks("device-plane", entries, services) + + runtime_acceptance.assert_called_once_with( + require_edge_channel=True, + core_network_mode="private-egress", + require_persistent_data=True, + ) + 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",)