feat(manager): expose safe VPS edge health

This commit is contained in:
DCCONSTRUCTIONS
2026-08-22 13:08:04 +03:00
parent 07ffb22d38
commit 6ed4414a97
14 changed files with 464 additions and 42 deletions
+5 -1
View File
@@ -66,4 +66,8 @@ sudo /usr/local/sbin/nodedc-deploy apply /volume1/docker/nodedc-deploy/inbox/<ar
See [Repository boundary](docs/REPOSITORY_BOUNDARY.md) and
[Implementation baseline](docs/IMPLEMENTATION_BASELINE.md) for the security,
runtime and rollout constraints.
runtime and rollout constraints. The provider-neutral VPS/host expansion is
tracked as an explicit
[ontology candidate](docs/DEVICE_INFRASTRUCTURE_HOST_ONTOLOGY_CANDIDATE.md)
until the official ontology owner publishes canonical host, deployment,
service and health concepts.
@@ -439,6 +439,12 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
displayName: input.displayName,
deploymentRef: input.deploymentRef ?? null,
lifecycleState: input.lifecycleState ?? "provisioning",
channel: existing?.channel ?? {
lifecycleState: "disabled",
generationRef: null,
runtimeState: "disabled",
lastErrorCode: null,
},
createdAt: existing?.createdAt || now(),
updatedAt: now(),
};
@@ -716,6 +722,12 @@ function seedArusnaviB2Preview({
displayName: "Preview VPS edge",
deploymentRef: "deployment:preview",
lifecycleState: "active",
channel: {
lifecycleState: "active",
generationRef: "channel-generation:preview",
runtimeState: "accepted",
lastErrorCode: null,
},
createdAt: timestamp,
updatedAt: timestamp,
});
+55 -3
View File
@@ -38,6 +38,7 @@ import type {
export type ControlViewId =
| "catalog"
| "infrastructure"
| "hosts"
| "sessions"
| "bindings"
| "commands"
@@ -145,6 +146,13 @@ export function DeviceControlView({
}))}
/>
) : null}
{view === "hosts" ? (
<HostsView
workspace={workspace}
canManage={platformOwner}
onCreateEdge={() => setDialog("edge")}
/>
) : null}
{view === "sessions" ? <SessionsView workspace={workspace} /> : null}
{view === "bindings" ? (
<BindingsView
@@ -372,6 +380,50 @@ function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCr
);
}
function HostsView({ workspace, canManage, onCreateEdge }: {
workspace: ProjectWorkspace;
canManage: boolean;
onCreateEdge: () => void;
}) {
return (
<ControlStack>
<ControlToolbar
copy="VPS-хосты показаны через зарегистрированную роль Edge: состояние берётся из pinned mTLS Core↔Edge канала, а связи — из маршрутов проекта. Адреса, ключи и credentials в браузер не выдаются."
actions={canManage ? <Button size="compact" variant="primary" onClick={onCreateEdge}>Новый VPS Edge</Button> : null}
/>
<ControlSection title="VPS и Edge-хосты" count={workspace.edges.length}>
<ResourceGrid empty="VPS/Edge-хосты для проекта пока не зарегистрированы.">
{workspace.edges.map((edge) => {
const routes = workspace.routes.filter((route) => route.edgeRef === edge.edgeRef);
const runtimeState = edge.channel?.runtimeState ?? "unobserved";
return (
<ResourceCard
key={edge.edgeRef}
eyebrow="VPS / EDGE HOST"
title={edge.displayName}
description={edge.deploymentRef || edge.edgeKey}
status={runtimeState}
meta={[
`registration · ${edge.lifecycleState}`,
`channel · ${edge.channel?.lifecycleState ?? "disabled"}`,
`${routes.length} ${routes.length === 1 ? "маршрут" : "маршрутов"}`,
...(edge.channel?.generationRef ? [edge.channel.generationRef] : []),
...(edge.channel?.lastErrorCode ? [`error · ${edge.channel.lastErrorCode}`] : []),
]}
/>
);
})}
</ResourceGrid>
</ControlSection>
<GlassSurface padding="md" tone="soft">
<p className="device-manager-card-copy">
Общий реестр произвольных VPS, deployments, services и управляемая консоль требуют отдельного канонического ontology package. Текущий экран намеренно отображает только уже существующую проверяемую Edge-инфраструктуру.
</p>
</GlassSurface>
</ControlStack>
);
}
function SessionsView({ workspace }: { workspace: ProjectWorkspace }) {
return (
<ControlStack>
@@ -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";
}
+4 -2
View File
@@ -98,6 +98,7 @@ const sectionNavigation: Record<PrimarySection, NavigationItem[]> = {
{ 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 <div className="device-manager-panel-empty">Загружаем проект</div>;
if (["catalog", "infrastructure", "sessions", "bindings", "commands", "audit", "access", "settings"].includes(view)) {
if (["catalog", "hosts", "infrastructure", "sessions", "bindings", "commands", "audit", "access", "settings"].includes(view)) {
return <DeviceControlView
view={view as ControlViewId}
workspace={workspace}
@@ -1455,6 +1456,7 @@ function viewTitle(view: ViewId) {
inventory: "Устройства",
collections: "Коллекции",
catalog: "Модели и адаптеры",
hosts: "VPS и хосты",
infrastructure: "Edges и маршруты",
sessions: "Gateway sessions",
bindings: "Data bindings",
@@ -1468,7 +1470,7 @@ function viewTitle(view: ViewId) {
function sectionForView(view: ViewId): PrimarySection {
if (view === "overview") return "overview";
if (view === "inventory" || view === "collections" || view === "sessions") return "devices";
if (view === "catalog" || view === "infrastructure") return "infrastructure";
if (view === "catalog" || view === "hosts" || view === "infrastructure") return "infrastructure";
if (view === "bindings" || view === "commands" || view === "settings") return "management";
return "administration";
}
+13 -1
View File
@@ -86,10 +86,15 @@
.device-control-command-form {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto;
align-items: end;
align-items: start;
gap: 14px;
}
.device-control-command-form > .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;
}
+6
View File
@@ -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;
}
+45
View File
@@ -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"
}
@@ -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.
@@ -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)}`);
@@ -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")
+3
View File
@@ -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 });
@@ -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}`,
+12 -2
View File
@@ -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`,
@@ -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,
}] };