From 6ed4414a978442b38cb2f4db08907a0c7acf54ed Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 22 Aug 2026 13:08:04 +0300 Subject: [PATCH] feat(manager): expose safe VPS edge health --- README.md | 6 +- .../server/device-core-client.mjs | 12 ++ .../device-manager/src/DeviceControlViews.tsx | 58 ++++++++- apps/device-manager/src/DeviceManagerApp.tsx | 6 +- apps/device-manager/src/styles.css | 14 ++- apps/device-manager/src/types.ts | 6 + deployment/device-manager-release-v7.json | 45 +++++++ ..._INFRASTRUCTURE_HOST_ONTOLOGY_CANDIDATE.md | 115 ++++++++++++++++++ ...-device-manager-control-plane-artifact.mjs | 103 +++++++++++----- ..._device_manager_control_plane_artifacts.py | 67 ++++++++++ services/device-control-core/src/app.mjs | 3 + .../src/project-query-repository.mjs | 35 +++++- .../device-control-core/test/app.test.mjs | 14 ++- .../test/project-query-repository.test.mjs | 22 +++- 14 files changed, 464 insertions(+), 42 deletions(-) create mode 100644 deployment/device-manager-release-v7.json create mode 100644 docs/DEVICE_INFRASTRUCTURE_HOST_ONTOLOGY_CANDIDATE.md diff --git a/README.md b/README.md index 1701b8c..e6fb4fa 100644 --- a/README.md +++ b/README.md @@ -66,4 +66,8 @@ sudo /usr/local/sbin/nodedc-deploy apply /volume1/docker/nodedc-deploy/inbox/ ) : null} + {view === "hosts" ? ( + setDialog("edge")} + /> + ) : null} {view === "sessions" ? : null} {view === "bindings" ? ( void; +}) { + return ( + + Новый VPS Edge : null} + /> + + + {workspace.edges.map((edge) => { + const routes = workspace.routes.filter((route) => route.edgeRef === edge.edgeRef); + const runtimeState = edge.channel?.runtimeState ?? "unobserved"; + return ( + + ); + })} + + + +

+ Общий реестр произвольных VPS, deployments, services и управляемая консоль требуют отдельного канонического ontology package. Текущий экран намеренно отображает только уже существующую проверяемую Edge-инфраструктуру. +

+
+
+ ); +} + function SessionsView({ workspace }: { workspace: ProjectWorkspace }) { return ( @@ -931,9 +983,9 @@ function commaList(value: string) { } function statusTone(status: string): "neutral" | "success" | "warning" | "danger" { - if (["active", "online", "verified", "applied", "recorded", "immutable"].includes(status)) return "success"; - if (["failed", "rejected", "revoked", "retired"].includes(status)) return "danger"; - if (["draft", "provisioning", "pending", "pending_external_approval", "unknown", "disabled"].includes(status)) return "warning"; + if (["active", "accepted", "online", "verified", "applied", "recorded", "immutable"].includes(status)) return "success"; + if (["absent", "failed", "rejected", "revoked", "retired"].includes(status)) return "danger"; + if (["connecting", "draft", "provisioning", "pending", "pending_external_approval", "unknown", "unobserved", "disabled"].includes(status)) return "warning"; return "neutral"; } diff --git a/apps/device-manager/src/DeviceManagerApp.tsx b/apps/device-manager/src/DeviceManagerApp.tsx index 9ffbb67..18f2725 100644 --- a/apps/device-manager/src/DeviceManagerApp.tsx +++ b/apps/device-manager/src/DeviceManagerApp.tsx @@ -98,6 +98,7 @@ const sectionNavigation: Record = { { id: "collections", label: "Коллекции", icon: "folder", capability: "inventory.read" }, ], infrastructure: [ + { id: "hosts", label: "VPS и хосты", icon: "building", capability: "telemetry.observe" }, { id: "infrastructure", label: "Edges и маршруты", icon: "globe", capability: "telemetry.observe" }, { id: "catalog", label: "Модели и адаптеры", icon: "database", capability: "project.read" }, ], @@ -799,7 +800,7 @@ function ProjectView({ view, workspace, canManageCollections, canClaim, canConfi onInventoryDetailChange: (detail: DeviceInventoryDetailState | null) => void; }) { if (!workspace) return
Загружаем проект…
; - if (["catalog", "infrastructure", "sessions", "bindings", "commands", "audit", "access", "settings"].includes(view)) { + if (["catalog", "hosts", "infrastructure", "sessions", "bindings", "commands", "audit", "access", "settings"].includes(view)) { return .nodedc-select-anchor, +.device-control-command-form > .nodedc-button { + margin-top: 1.35rem; +} + .device-control-command-policy p { margin-top: 5px; color: var(--nodedc-text-secondary); @@ -145,6 +150,11 @@ grid-template-columns: 1fr; } + .device-control-command-form > .nodedc-select-anchor, + .device-control-command-form > .nodedc-button { + margin-top: 0; + } + .device-control-command-policy > .nodedc-status { grid-column: 2; justify-self: start; @@ -526,6 +536,8 @@ body { min-height: 220px; place-items: center; color: var(--nodedc-text-secondary); + font-size: 0.82rem; + line-height: 1.55; text-align: center; } diff --git a/apps/device-manager/src/types.ts b/apps/device-manager/src/types.ts index 942f30e..648c55e 100644 --- a/apps/device-manager/src/types.ts +++ b/apps/device-manager/src/types.ts @@ -194,6 +194,12 @@ export interface EdgeView { displayName: string; deploymentRef: string | null; lifecycleState: string; + channel: { + lifecycleState: string; + generationRef: string | null; + runtimeState: "accepted" | "connecting" | "absent" | "unobserved" | "disabled" | "revoked" | string; + lastErrorCode: string | null; + }; createdAt: string | null; updatedAt: string | null; } diff --git a/deployment/device-manager-release-v7.json b/deployment/device-manager-release-v7.json new file mode 100644 index 0000000..c8b2a38 --- /dev/null +++ b/deployment/device-manager-release-v7.json @@ -0,0 +1,45 @@ +{ + "schemaVersion": "nodedc.device-plane.device-manager-release.v7", + "releaseId": "__PATCH_ID__", + "action": "upgrade", + "predecessor": { + "kind": "release", + "patchId": "device-manager-release-v6-20260822-035", + "artifactSha256": "193faabe930e2b3f212f8eb45288e39f850ec714528b28881095be654baf9a80" + }, + "controlCorePredecessor": { + "patchId": "device-control-core-release-v2-20260822-036", + "artifactSha256": "8708cc4b59fa0cd5e9c6e6a7b2654ba01ea60271549167aca2631f94000d3da3" + }, + "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", + "overviewLayout": "mission-core-landing-stage-v1", + "faviconSet": "nodedc-adaptive-v1", + "commandFormLayout": "aligned-control-row-v1", + "secondaryEmptyTypography": "help-text-sm-v1", + "infrastructureHostProjection": "edge-registration-live-channel-v1", + "ontologyStatus": "generic-host-domain-candidate-not-canonical", + "gelios": "untouched-legacy-only", + "rollback": "restore-preapply-snapshot-preserve-manager-data" +} diff --git a/docs/DEVICE_INFRASTRUCTURE_HOST_ONTOLOGY_CANDIDATE.md b/docs/DEVICE_INFRASTRUCTURE_HOST_ONTOLOGY_CANDIDATE.md new file mode 100644 index 0000000..d201590 --- /dev/null +++ b/docs/DEVICE_INFRASTRUCTURE_HOST_ONTOLOGY_CANDIDATE.md @@ -0,0 +1,115 @@ +# Device Infrastructure Host ontology candidate + +Status: **candidate, not canonical** +Date: 2026-08-22 + +## Why this is a candidate + +The live read-only NODE.DC ontology catalog currently has no canonical generic +compute host, deployment, service instance or health-observation entities. Its +`integration.connection` entity models provider-account connections and must +not be reused for a VPS merely to unblock a screen. + +Device Core therefore does not mint local identifiers and present them as +official ontology. The first shipped projection is limited to the already +canonical product relationship that exists in Device Core today: + +```text +Device Project -> Route -> Edge registration -> pinned Core↔Edge channel + | + +-> opaque deploymentRef +``` + +The UI calls this projection `VPS / Edge host` and derives current reachability +from the live channel supervisor. It does not expose channel endpoints, trust +material, certificates or credentials. + +## Proposed canonical concepts + +The following names are discussion handles only. Final identifiers, scopes and +relation direction must be published by the official ontology owner before +Device Core persists them. + +| Candidate concept | Scope | Required meaning | +| --- | --- | --- | +| `infrastructure.host` | owner + project visibility | A physical or virtual compute host independent of provider and workload | +| `infrastructure.endpoint` | host | A bounded management or service endpoint without credentials | +| `infrastructure.deployment` | owner/project | An immutable desired deployment of an artifact or workload | +| `infrastructure.service_instance` | host + deployment | A runtime instance produced by a deployment | +| `infrastructure.health_observation` | observed entity | A time-bounded observation with source, timestamp and evidence class | +| `infrastructure.management_session` | actor + host | An expiring, audited brokered management session | + +Proposed relations: + +```text +device.project --uses--> infrastructure.host +infrastructure.host --exposes--> infrastructure.endpoint +infrastructure.deployment --targets--> infrastructure.host +infrastructure.service_instance --runs-on--> infrastructure.host +infrastructure.service_instance --realizes--> infrastructure.deployment +device.edge-registration --runs-on--> infrastructure.host +device.route --terminates-at--> device.edge-registration +infrastructure.health-observation --observes--> host|endpoint|service-instance +infrastructure.management-session --targets--> infrastructure.host +``` + +## State is faceted, not flattened + +A single `online` flag is insufficient and would hardcode the current VPS +case. Each host projection needs independent facets: + +- lifecycle: `provisioning | active | suspended | retired`; +- reachability: `reachable | degraded | unreachable | unobserved`; +- management access: `available | denied | expired | unconfigured`; +- workload health: per service instance, never inferred from host ping alone; +- observation freshness: `observedAt`, TTL and source; +- desired/actual configuration: immutable revision refs and reconciliation + state, with secrets represented only by opaque secret refs. + +This allows one host to be reachable while a service is unhealthy, or a +service to be healthy while interactive management access is intentionally +disabled. + +## Registration workflow after ontology publication + +1. Create or select an owner-scoped host identity. +2. Grant project visibility through a canonical project-host relation. +3. Attach a provider-neutral endpoint projection and an opaque credential ref. +4. Run a bounded reachability probe through a server-side worker. +5. Register deployments and discovered service instances as separate entities. +6. Link an Edge registration to the host when that role is actually deployed. +7. Emit health observations with TTL instead of mutating a permanent `online` + property. + +Provider fields such as Beget, Hetzner or AWS remain annotations or provider +relations. They never change the host identity or the UI information model. + +## Console boundary + +An unrestricted WebSSH terminal is not part of the first slice. If introduced, +it must be a server-side session broker with all of the following properties: + +- short-lived session and explicit target selection; +- authorization checked at session creation and command execution; +- no private key or password delivered to the browser; +- bounded command catalog by default; +- immutable actor/target/timing/exit-code audit; +- output size limits and secret redaction; +- explicit break-glass mode for arbitrary commands; +- automatic expiry, revocation and concurrent-session limits. + +The broker must reference `infrastructure.management_session` after that +concept becomes canonical. A browser shell iframe or direct browser-to-SSH +connection is outside the Device Core security boundary. + +## Delivery sequence + +1. **Shipped candidate UI projection:** VPS/Edge hosts, route relationships and + live pinned-channel state from existing Device Core records. +2. **Ontology gate:** publish host/deployment/service/health entities and + relations in the official ontology package. +3. **Inventory:** add provider-neutral host registration and project grants. +4. **Monitoring:** persist bounded health observations from a dedicated worker. +5. **Configuration:** immutable desired revisions and reconciliation receipts. +6. **Management sessions:** bounded command runner first; audited break-glass + console only after a separate threat-model review. 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 5b00307..7982720 100644 --- a/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs +++ b/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs @@ -11,11 +11,13 @@ const platformRoot = resolve(scriptDir, "../.."); const devicePlaneRoot = platformRoot; const managerRoot = resolve(platformRoot, "apps/device-manager"); const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); -const [patchId = "device-manager-release-v6-20260822-035", ...extra] = process.argv.slice(2); +const [patchId = "device-manager-release-v7-20260822-037", ...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-v6-") - ? "deployment/device-manager-release-v6.json" +const descriptorPath = patchId.startsWith("device-manager-release-v7-") + ? "deployment/device-manager-release-v7.json" + : patchId.startsWith("device-manager-release-v6-") + ? "deployment/device-manager-release-v6.json" : patchId.startsWith("device-manager-release-v5-") ? "deployment/device-manager-release-v5.json" : patchId.startsWith("device-manager-release-v4-") @@ -28,7 +30,8 @@ const isV3 = descriptorPath.endsWith("release-v3.json"); const isV4 = descriptorPath.endsWith("release-v4.json"); const isV5 = descriptorPath.endsWith("release-v5.json"); const isV6 = descriptorPath.endsWith("release-v6.json"); -const isPersistent = isV4 || isV5 || isV6; +const isV7 = descriptorPath.endsWith("release-v7.json"); +const isPersistent = isV4 || isV5 || isV6 || isV7; const isManagerOnly = isV3 || isPersistent; const composeSource = resolve(devicePlaneRoot, "docker-compose.device-manager.yml"); const composeSourceSha256 = createHash("sha256").update(await readFile(composeSource)).digest("hex"); @@ -167,7 +170,38 @@ try { : "restore-preapply-snapshot") ); if (commonContractInvalid) throw new Error("device_manager_activation_successor_contract_mismatch"); - if (descriptorPath.endsWith("release-v6.json")) { + if (descriptorPath.endsWith("release-v7.json")) { + if ( + descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v7" + || descriptor.predecessor?.kind !== "release" + || descriptor.predecessor?.patchId !== "device-manager-release-v6-20260822-035" + || descriptor.predecessor?.artifactSha256 !== "193faabe930e2b3f212f8eb45288e39f850ec714528b28881095be654baf9a80" + || descriptor.controlCorePredecessor?.patchId !== "device-control-core-release-v2-20260822-036" + || descriptor.controlCorePredecessor?.artifactSha256 !== "8708cc4b59fa0cd5e9c6e6a7b2654ba01ea60271549167aca2631f94000d3da3" + || descriptor.edgeChannelPredecessor?.patchId !== "device-edge-core-channel-upgrade-v4-20260812-023" + || descriptor.edgeChannelPredecessor?.artifactSha256 !== "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc" + || 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.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.overviewLayout !== "mission-core-landing-stage-v1" + || descriptor.faviconSet !== "nodedc-adaptive-v1" + || descriptor.commandFormLayout !== "aligned-control-row-v1" + || descriptor.secondaryEmptyTypography !== "help-text-sm-v1" + || descriptor.infrastructureHostProjection !== "edge-registration-live-channel-v1" + || descriptor.ontologyStatus !== "generic-host-domain-candidate-not-canonical" + || descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data" + || descriptor.gelios !== "untouched-legacy-only" + ) throw new Error("device_manager_v7_infrastructure_host_projection_contract_mismatch"); + await validateFaviconBundle(payload, "device_manager_v7"); + } else if (descriptorPath.endsWith("release-v6.json")) { if ( descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v6" || descriptor.predecessor?.kind !== "release" @@ -193,29 +227,7 @@ try { || descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data" || descriptor.gelios !== "untouched-legacy-only" ) throw new Error("device_manager_v6_favicon_contract_mismatch"); - const faviconHashes = { - "favicon.ico": "f8933114a85646335ea5c94944f56d3cd8c48a6032016719f7905ff244ec0aa2", - "favicon/favicon.ico": "f8933114a85646335ea5c94944f56d3cd8c48a6032016719f7905ff244ec0aa2", - "favicon/icon-adaptive.svg": "481984e83997d786bb0a72ad1ee80037db13aef3a0792ab3109df95c2199b38e", - "favicon/apple-touch-icon.png": "afdccc28152a566e264e533ca218362f05d5bcec936c647f9a54f414c0bd4763", - "favicon/icon-192.png": "5b10a24feb4754f15c69761cef42f91a01f885d04095156b1a12e254875fdd4d", - "favicon/icon-512.png": "f98bac3dba59b7eefbe89f8bb8abc25567226a54ab7ed3b6b1a4caffbdd9ee15", - "favicon/manifest.webmanifest.json": "2a8ecdc6e6c64833f812ae02bbc0c7bd9b435e0cfa75cc21d6edf054d41275fc", - }; - for (const [relativePath, expectedSha256] of Object.entries(faviconHashes)) { - const content = await readFile(join(payload, "services/device-manager/dist", relativePath)); - const actualSha256 = createHash("sha256").update(content).digest("hex"); - if (actualSha256 !== expectedSha256) throw new Error(`device_manager_v6_favicon_hash_mismatch:${relativePath}`); - } - const indexHtml = await readFile(join(payload, "services/device-manager/dist/index.html"), "utf8"); - for (const requiredLink of [ - 'href="/favicon/icon-adaptive.svg"', - 'href="/favicon/favicon.ico"', - 'href="/favicon/apple-touch-icon.png"', - 'href="/favicon/icon-192.png"', - 'href="/favicon/icon-512.png"', - 'href="/favicon/manifest.webmanifest.json"', - ]) if (!indexHtml.includes(requiredLink)) throw new Error(`device_manager_v6_favicon_link_missing:${requiredLink}`); + await validateFaviconBundle(payload, "device_manager_v6"); } else if (descriptorPath.endsWith("release-v5.json")) { if ( descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v5" @@ -303,6 +315,41 @@ try { await rm(stage, { recursive: true, force: true }); } +async function validateFaviconBundle(payloadRoot, errorPrefix) { + const faviconHashes = { + "favicon.ico": "f8933114a85646335ea5c94944f56d3cd8c48a6032016719f7905ff244ec0aa2", + "favicon/favicon.ico": "f8933114a85646335ea5c94944f56d3cd8c48a6032016719f7905ff244ec0aa2", + "favicon/icon-adaptive.svg": "481984e83997d786bb0a72ad1ee80037db13aef3a0792ab3109df95c2199b38e", + "favicon/apple-touch-icon.png": "afdccc28152a566e264e533ca218362f05d5bcec936c647f9a54f414c0bd4763", + "favicon/icon-192.png": "5b10a24feb4754f15c69761cef42f91a01f885d04095156b1a12e254875fdd4d", + "favicon/icon-512.png": "f98bac3dba59b7eefbe89f8bb8abc25567226a54ab7ed3b6b1a4caffbdd9ee15", + "favicon/manifest.webmanifest.json": "2a8ecdc6e6c64833f812ae02bbc0c7bd9b435e0cfa75cc21d6edf054d41275fc", + }; + for (const [relativePath, expectedSha256] of Object.entries(faviconHashes)) { + const content = await readFile(join(payloadRoot, "services/device-manager/dist", relativePath)); + const actualSha256 = createHash("sha256").update(content).digest("hex"); + if (actualSha256 !== expectedSha256) { + throw new Error(`${errorPrefix}_favicon_hash_mismatch:${relativePath}`); + } + } + const indexHtml = await readFile( + join(payloadRoot, "services/device-manager/dist/index.html"), + "utf8", + ); + for (const requiredLink of [ + 'href="/favicon/icon-adaptive.svg"', + 'href="/favicon/favicon.ico"', + 'href="/favicon/apple-touch-icon.png"', + 'href="/favicon/icon-192.png"', + 'href="/favicon/icon-512.png"', + 'href="/favicon/manifest.webmanifest.json"', + ]) { + if (!indexHtml.includes(requiredLink)) { + throw new Error(`${errorPrefix}_favicon_link_missing:${requiredLink}`); + } + } +} + async function copySafe(source, destination, sourceBoundary) { const sourceStat = await lstat(source); if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(sourceBoundary, source)}`); 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 5b0f0e9..698d0ee 100644 --- a/infra/deploy-runner/test_device_manager_control_plane_artifacts.py +++ b/infra/deploy-runner/test_device_manager_control_plane_artifacts.py @@ -570,6 +570,73 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): ) ) + def test_device_manager_release_v7_adds_live_edge_host_projection(self): + patch_id = "device-manager-release-v7-unit-001" + manifest, entries, names, result = self.assert_deterministic_artifact( + "build-device-manager-control-plane-artifact.mjs", + patch_id, + RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V7_ENTRIES, + ) + self.assertEqual(manifest["component"], "device-plane") + self.assertEqual(result["services"], ["device-manager"]) + 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", ".map")) for name in names)) + template = json.loads( + ( + DEVICE_CORE_ROOT + / "deployment/device-manager-release-v7.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.v7" + ), + boundaries=( + RUNNER.expected_device_plane_manager_release_v7_boundaries() + ), + expected_release_id=patch_id, + ), + descriptor, + ) + self.assertEqual( + descriptor["predecessor"], + { + "kind": "release", + "patchId": "device-manager-release-v6-20260822-035", + "artifactSha256": ( + "193faabe930e2b3f212f8eb45288e39f850ec714528b28881095be654baf9a80" + ), + }, + ) + self.assertEqual( + descriptor["controlCorePredecessor"], + { + "patchId": "device-control-core-release-v2-20260822-036", + "artifactSha256": ( + "8708cc4b59fa0cd5e9c6e6a7b2654ba01ea60271549167aca2631f94000d3da3" + ), + }, + ) + self.assertEqual( + descriptor["infrastructureHostProjection"], + "edge-registration-live-channel-v1", + ) + self.assertTrue( + RUNNER.is_device_plane_manager_release_v7_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") diff --git a/services/device-control-core/src/app.mjs b/services/device-control-core/src/app.mjs index a046354..8a1c047 100644 --- a/services/device-control-core/src/app.mjs +++ b/services/device-control-core/src/app.mjs @@ -287,6 +287,9 @@ export function createControlCoreApp({ commandTransport: typedCommandRuntime ? "typed-service-ping-v1" : "disabled", + edgeChannelStatus: edgeChannelStatusProvider + ? edgeChannelStatusProvider() + : null, }, ); return writeJson(response, 200, { ok: true, workspace }); diff --git a/services/device-control-core/src/project-query-repository.mjs b/services/device-control-core/src/project-query-repository.mjs index e600766..abe1ec8 100644 --- a/services/device-control-core/src/project-query-repository.mjs +++ b/services/device-control-core/src/project-query-repository.mjs @@ -50,7 +50,7 @@ export async function getDeviceProjectWorkspace( client, actor, projectId, - { commandTransport = "disabled" } = {}, + { commandTransport = "disabled", edgeChannelStatus = null } = {}, ) { const project = await findProjectWithCapability( client, @@ -204,7 +204,8 @@ export async function getDeviceProjectWorkspace( ); const edges = await client.query( `select de.id, de.edge_key, de.display_name, de.deployment_ref, - de.lifecycle_state, de.created_at, de.updated_at + de.lifecycle_state, de.channel_lifecycle_state, + de.channel_generation_ref, de.created_at, de.updated_at from device_edges de where $2::boolean or exists ( @@ -333,7 +334,10 @@ export async function getDeviceProjectWorkspace( adapterPackages: adapterPackages.rows.map(adapterPackageView), adapterVersions: adapterVersions.rows.map(adapterVersionView), modelProfiles: modelProfiles.rows.map(modelProfileView), - edges: edges.rows.map(edgeView), + edges: edges.rows.map((row) => edgeView( + row, + edgeChannelRuntime(edgeChannelStatus, `edge:${row.id}`), + )), routes: routes.rows.map(routeView), sessions: sessions.rows.map(sessionView), bindings: bindings.rows.map(bindingView), @@ -496,18 +500,41 @@ function modelProfileView(row) { }; } -function edgeView(row) { +function edgeView(row, runtime) { + const channelLifecycleState = row.channel_lifecycle_state ?? "disabled"; return { edgeRef: `edge:${row.id}`, edgeKey: row.edge_key, displayName: row.display_name, deploymentRef: row.deployment_ref ?? null, lifecycleState: row.lifecycle_state, + channel: { + lifecycleState: channelLifecycleState, + generationRef: row.channel_generation_ref ?? null, + runtimeState: channelLifecycleState === "active" + ? runtime?.channel ?? "unobserved" + : channelLifecycleState, + lastErrorCode: runtime?.lastErrorCode ?? null, + }, createdAt: toIso(row.created_at), updatedAt: toIso(row.updated_at), }; } +function edgeChannelRuntime(status, edgeRef) { + if (!status || !Array.isArray(status.edges)) return null; + const value = status.edges.find((item) => item?.edgeRegistrationId === edgeRef); + if (!value) return null; + return { + channel: ["accepted", "connecting", "absent"].includes(value.channel) + ? value.channel + : "unobserved", + lastErrorCode: typeof value.lastErrorCode === "string" + ? value.lastErrorCode.slice(0, 128) + : null, + }; +} + function routeView(row) { return { routeRef: `route:${row.id}`, diff --git a/services/device-control-core/test/app.test.mjs b/services/device-control-core/test/app.test.mjs index 9f7f6d0..496d818 100644 --- a/services/device-control-core/test/app.test.mjs +++ b/services/device-control-core/test/app.test.mjs @@ -347,14 +347,23 @@ test("project query is service-authenticated and forwards only the trusted actor test("project workspace query accepts only a canonical project path", async () => { const projectId = "11111111-1111-4111-8111-111111111111"; let queried; + const edgeChannels = { + enabled: true, + edges: [{ + edgeRegistrationId: "edge:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + channel: "accepted", + lastErrorCode: null, + }], + }; const runtime = await startTestServer({ managementApiEnabled: true, managementToken, + edgeChannelStatusProvider: () => edgeChannels, repository: { health: async () => "ready", executeManagementCommand: async () => ({ replayed: false, result: {} }), - getProjectWorkspace: async (actor, id) => { - queried = { actor, id }; + getProjectWorkspace: async (actor, id, options) => { + queried = { actor, id, options }; return { project: { projectRef: `project:${id}` }, devices: [] }; }, }, @@ -367,6 +376,7 @@ test("project workspace query accepts only a canonical project path", async () = assert.equal(response.status, 200); assert.equal((await response.json()).workspace.devices.length, 0); assert.equal(queried.id, projectId); + assert.equal(queried.options.edgeChannelStatus, edgeChannels); const invalid = await fetch( `${runtime.baseUrl}/internal/v1/query/projects/not-a-project/workspace`, diff --git a/services/device-control-core/test/project-query-repository.test.mjs b/services/device-control-core/test/project-query-repository.test.mjs index 7458eda..7e53936 100644 --- a/services/device-control-core/test/project-query-repository.test.mjs +++ b/services/device-control-core/test/project-query-repository.test.mjs @@ -53,7 +53,15 @@ test("project list applies direct-grant precedence and returns bounded summaries test("project workspace returns only masked identity projections", async () => { const client = workspaceClient(); - const workspace = await getDeviceProjectWorkspace(client, actor, projectId); + const workspace = await getDeviceProjectWorkspace(client, actor, projectId, { + edgeChannelStatus: { + edges: [{ + edgeRegistrationId: "edge:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + channel: "accepted", + lastErrorCode: null, + }], + }, + }); assert.equal(workspace.project.projectRef, `project:${projectId}`); assert.equal(workspace.devices[0].identifier.masked, "***********0001"); @@ -66,6 +74,12 @@ test("project workspace returns only masked identity projections", async () => { assert.equal(workspace.adapterPackages[0].packageKey, "generic-tracker"); assert.equal(workspace.modelProfiles[0].modelProfileRef, "vendor.model.v1"); assert.equal(workspace.routes[0].activeSessionCount, 1); + assert.deepEqual(workspace.edges[0].channel, { + lifecycleState: "active", + generationRef: "channel-generation:1", + runtimeState: "accepted", + lastErrorCode: null, + }); assert.equal(workspace.sessions[0].frameCount, 12); assert.equal(workspace.bindings[0].lifecycleState, "pending_external_approval"); assert.equal(workspace.configurationRevisions[0].revisionNumber, 1); @@ -103,6 +117,10 @@ test("project read source never selects identifier digests or credential refs", source, /\b(?:identifier_digest|expected_identifier_digest|credential_ref|parameters_digest|parameters_projection|transport_message_ref|external_approval_ref|external_approval_digest)\b/, ); + assert.doesNotMatch( + source, + /\b(?:channel_endpoint|channel_servername|channel_trust_bundle_ref|channel_certificate_identities)\b/, + ); assert.doesNotMatch(source, /\b(?:dae\.payload|dcr\.configuration)\b/); }); @@ -167,6 +185,8 @@ function workspaceClient({ queries = [] } = {}) { display_name: "Edge one", deployment_ref: "deployment:edge-one", lifecycle_state: "active", + channel_lifecycle_state: "active", + channel_generation_ref: "channel-generation:1", created_at: timestamp, updated_at: timestamp, }] };