feat(manager): expose safe VPS edge health
This commit is contained in:
@@ -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
|
See [Repository boundary](docs/REPOSITORY_BOUNDARY.md) and
|
||||||
[Implementation baseline](docs/IMPLEMENTATION_BASELINE.md) for the security,
|
[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,
|
displayName: input.displayName,
|
||||||
deploymentRef: input.deploymentRef ?? null,
|
deploymentRef: input.deploymentRef ?? null,
|
||||||
lifecycleState: input.lifecycleState ?? "provisioning",
|
lifecycleState: input.lifecycleState ?? "provisioning",
|
||||||
|
channel: existing?.channel ?? {
|
||||||
|
lifecycleState: "disabled",
|
||||||
|
generationRef: null,
|
||||||
|
runtimeState: "disabled",
|
||||||
|
lastErrorCode: null,
|
||||||
|
},
|
||||||
createdAt: existing?.createdAt || now(),
|
createdAt: existing?.createdAt || now(),
|
||||||
updatedAt: now(),
|
updatedAt: now(),
|
||||||
};
|
};
|
||||||
@@ -716,6 +722,12 @@ function seedArusnaviB2Preview({
|
|||||||
displayName: "Preview VPS edge",
|
displayName: "Preview VPS edge",
|
||||||
deploymentRef: "deployment:preview",
|
deploymentRef: "deployment:preview",
|
||||||
lifecycleState: "active",
|
lifecycleState: "active",
|
||||||
|
channel: {
|
||||||
|
lifecycleState: "active",
|
||||||
|
generationRef: "channel-generation:preview",
|
||||||
|
runtimeState: "accepted",
|
||||||
|
lastErrorCode: null,
|
||||||
|
},
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import type {
|
|||||||
export type ControlViewId =
|
export type ControlViewId =
|
||||||
| "catalog"
|
| "catalog"
|
||||||
| "infrastructure"
|
| "infrastructure"
|
||||||
|
| "hosts"
|
||||||
| "sessions"
|
| "sessions"
|
||||||
| "bindings"
|
| "bindings"
|
||||||
| "commands"
|
| "commands"
|
||||||
@@ -145,6 +146,13 @@ export function DeviceControlView({
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{view === "hosts" ? (
|
||||||
|
<HostsView
|
||||||
|
workspace={workspace}
|
||||||
|
canManage={platformOwner}
|
||||||
|
onCreateEdge={() => setDialog("edge")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{view === "sessions" ? <SessionsView workspace={workspace} /> : null}
|
{view === "sessions" ? <SessionsView workspace={workspace} /> : null}
|
||||||
{view === "bindings" ? (
|
{view === "bindings" ? (
|
||||||
<BindingsView
|
<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 }) {
|
function SessionsView({ workspace }: { workspace: ProjectWorkspace }) {
|
||||||
return (
|
return (
|
||||||
<ControlStack>
|
<ControlStack>
|
||||||
@@ -931,9 +983,9 @@ function commaList(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusTone(status: string): "neutral" | "success" | "warning" | "danger" {
|
function statusTone(status: string): "neutral" | "success" | "warning" | "danger" {
|
||||||
if (["active", "online", "verified", "applied", "recorded", "immutable"].includes(status)) return "success";
|
if (["active", "accepted", "online", "verified", "applied", "recorded", "immutable"].includes(status)) return "success";
|
||||||
if (["failed", "rejected", "revoked", "retired"].includes(status)) return "danger";
|
if (["absent", "failed", "rejected", "revoked", "retired"].includes(status)) return "danger";
|
||||||
if (["draft", "provisioning", "pending", "pending_external_approval", "unknown", "disabled"].includes(status)) return "warning";
|
if (["connecting", "draft", "provisioning", "pending", "pending_external_approval", "unknown", "unobserved", "disabled"].includes(status)) return "warning";
|
||||||
return "neutral";
|
return "neutral";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ const sectionNavigation: Record<PrimarySection, NavigationItem[]> = {
|
|||||||
{ id: "collections", label: "Коллекции", icon: "folder", capability: "inventory.read" },
|
{ id: "collections", label: "Коллекции", icon: "folder", capability: "inventory.read" },
|
||||||
],
|
],
|
||||||
infrastructure: [
|
infrastructure: [
|
||||||
|
{ id: "hosts", label: "VPS и хосты", icon: "building", capability: "telemetry.observe" },
|
||||||
{ id: "infrastructure", label: "Edges и маршруты", icon: "globe", capability: "telemetry.observe" },
|
{ id: "infrastructure", label: "Edges и маршруты", icon: "globe", capability: "telemetry.observe" },
|
||||||
{ id: "catalog", label: "Модели и адаптеры", icon: "database", capability: "project.read" },
|
{ id: "catalog", label: "Модели и адаптеры", icon: "database", capability: "project.read" },
|
||||||
],
|
],
|
||||||
@@ -799,7 +800,7 @@ function ProjectView({ view, workspace, canManageCollections, canClaim, canConfi
|
|||||||
onInventoryDetailChange: (detail: DeviceInventoryDetailState | null) => void;
|
onInventoryDetailChange: (detail: DeviceInventoryDetailState | null) => void;
|
||||||
}) {
|
}) {
|
||||||
if (!workspace) return <div className="device-manager-panel-empty">Загружаем проект…</div>;
|
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
|
return <DeviceControlView
|
||||||
view={view as ControlViewId}
|
view={view as ControlViewId}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
@@ -1455,6 +1456,7 @@ function viewTitle(view: ViewId) {
|
|||||||
inventory: "Устройства",
|
inventory: "Устройства",
|
||||||
collections: "Коллекции",
|
collections: "Коллекции",
|
||||||
catalog: "Модели и адаптеры",
|
catalog: "Модели и адаптеры",
|
||||||
|
hosts: "VPS и хосты",
|
||||||
infrastructure: "Edges и маршруты",
|
infrastructure: "Edges и маршруты",
|
||||||
sessions: "Gateway sessions",
|
sessions: "Gateway sessions",
|
||||||
bindings: "Data bindings",
|
bindings: "Data bindings",
|
||||||
@@ -1468,7 +1470,7 @@ function viewTitle(view: ViewId) {
|
|||||||
function sectionForView(view: ViewId): PrimarySection {
|
function sectionForView(view: ViewId): PrimarySection {
|
||||||
if (view === "overview") return "overview";
|
if (view === "overview") return "overview";
|
||||||
if (view === "inventory" || view === "collections" || view === "sessions") return "devices";
|
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";
|
if (view === "bindings" || view === "commands" || view === "settings") return "management";
|
||||||
return "administration";
|
return "administration";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,10 +86,15 @@
|
|||||||
.device-control-command-form {
|
.device-control-command-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto;
|
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto;
|
||||||
align-items: end;
|
align-items: start;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-control-command-form > .nodedc-select-anchor,
|
||||||
|
.device-control-command-form > .nodedc-button {
|
||||||
|
margin-top: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.device-control-command-policy p {
|
.device-control-command-policy p {
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
color: var(--nodedc-text-secondary);
|
color: var(--nodedc-text-secondary);
|
||||||
@@ -145,6 +150,11 @@
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.device-control-command-policy > .nodedc-status {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
@@ -526,6 +536,8 @@ body {
|
|||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
color: var(--nodedc-text-secondary);
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.55;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,12 @@ export interface EdgeView {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
deploymentRef: string | null;
|
deploymentRef: string | null;
|
||||||
lifecycleState: string;
|
lifecycleState: string;
|
||||||
|
channel: {
|
||||||
|
lifecycleState: string;
|
||||||
|
generationRef: string | null;
|
||||||
|
runtimeState: "accepted" | "connecting" | "absent" | "unobserved" | "disabled" | "revoked" | string;
|
||||||
|
lastErrorCode: string | null;
|
||||||
|
};
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 devicePlaneRoot = platformRoot;
|
||||||
const managerRoot = resolve(platformRoot, "apps/device-manager");
|
const managerRoot = resolve(platformRoot, "apps/device-manager");
|
||||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
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]");
|
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-")
|
const descriptorPath = patchId.startsWith("device-manager-release-v7-")
|
||||||
? "deployment/device-manager-release-v6.json"
|
? "deployment/device-manager-release-v7.json"
|
||||||
|
: patchId.startsWith("device-manager-release-v6-")
|
||||||
|
? "deployment/device-manager-release-v6.json"
|
||||||
: patchId.startsWith("device-manager-release-v5-")
|
: patchId.startsWith("device-manager-release-v5-")
|
||||||
? "deployment/device-manager-release-v5.json"
|
? "deployment/device-manager-release-v5.json"
|
||||||
: patchId.startsWith("device-manager-release-v4-")
|
: 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 isV4 = descriptorPath.endsWith("release-v4.json");
|
||||||
const isV5 = descriptorPath.endsWith("release-v5.json");
|
const isV5 = descriptorPath.endsWith("release-v5.json");
|
||||||
const isV6 = descriptorPath.endsWith("release-v6.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 isManagerOnly = isV3 || isPersistent;
|
||||||
const composeSource = resolve(devicePlaneRoot, "docker-compose.device-manager.yml");
|
const composeSource = resolve(devicePlaneRoot, "docker-compose.device-manager.yml");
|
||||||
const composeSourceSha256 = createHash("sha256").update(await readFile(composeSource)).digest("hex");
|
const composeSourceSha256 = createHash("sha256").update(await readFile(composeSource)).digest("hex");
|
||||||
@@ -167,7 +170,38 @@ try {
|
|||||||
: "restore-preapply-snapshot")
|
: "restore-preapply-snapshot")
|
||||||
);
|
);
|
||||||
if (commonContractInvalid) throw new Error("device_manager_activation_successor_contract_mismatch");
|
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 (
|
if (
|
||||||
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v6"
|
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v6"
|
||||||
|| descriptor.predecessor?.kind !== "release"
|
|| descriptor.predecessor?.kind !== "release"
|
||||||
@@ -193,29 +227,7 @@ try {
|
|||||||
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|
||||||
|| descriptor.gelios !== "untouched-legacy-only"
|
|| descriptor.gelios !== "untouched-legacy-only"
|
||||||
) throw new Error("device_manager_v6_favicon_contract_mismatch");
|
) throw new Error("device_manager_v6_favicon_contract_mismatch");
|
||||||
const faviconHashes = {
|
await validateFaviconBundle(payload, "device_manager_v6");
|
||||||
"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}`);
|
|
||||||
} else if (descriptorPath.endsWith("release-v5.json")) {
|
} else if (descriptorPath.endsWith("release-v5.json")) {
|
||||||
if (
|
if (
|
||||||
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v5"
|
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v5"
|
||||||
@@ -303,6 +315,41 @@ try {
|
|||||||
await rm(stage, { recursive: true, force: true });
|
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) {
|
async function copySafe(source, destination, sourceBoundary) {
|
||||||
const sourceStat = await lstat(source);
|
const sourceStat = await lstat(source);
|
||||||
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(sourceBoundary, 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):
|
def test_historical_manager_builder_fails_closed_after_v4_compose(self):
|
||||||
if self.historical_manager_compose_is_current():
|
if self.historical_manager_compose_is_current():
|
||||||
self.skipTest("historical Manager Compose is still current")
|
self.skipTest("historical Manager Compose is still current")
|
||||||
|
|||||||
@@ -287,6 +287,9 @@ export function createControlCoreApp({
|
|||||||
commandTransport: typedCommandRuntime
|
commandTransport: typedCommandRuntime
|
||||||
? "typed-service-ping-v1"
|
? "typed-service-ping-v1"
|
||||||
: "disabled",
|
: "disabled",
|
||||||
|
edgeChannelStatus: edgeChannelStatusProvider
|
||||||
|
? edgeChannelStatusProvider()
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return writeJson(response, 200, { ok: true, workspace });
|
return writeJson(response, 200, { ok: true, workspace });
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export async function getDeviceProjectWorkspace(
|
|||||||
client,
|
client,
|
||||||
actor,
|
actor,
|
||||||
projectId,
|
projectId,
|
||||||
{ commandTransport = "disabled" } = {},
|
{ commandTransport = "disabled", edgeChannelStatus = null } = {},
|
||||||
) {
|
) {
|
||||||
const project = await findProjectWithCapability(
|
const project = await findProjectWithCapability(
|
||||||
client,
|
client,
|
||||||
@@ -204,7 +204,8 @@ export async function getDeviceProjectWorkspace(
|
|||||||
);
|
);
|
||||||
const edges = await client.query(
|
const edges = await client.query(
|
||||||
`select de.id, de.edge_key, de.display_name, de.deployment_ref,
|
`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
|
from device_edges de
|
||||||
where $2::boolean
|
where $2::boolean
|
||||||
or exists (
|
or exists (
|
||||||
@@ -333,7 +334,10 @@ export async function getDeviceProjectWorkspace(
|
|||||||
adapterPackages: adapterPackages.rows.map(adapterPackageView),
|
adapterPackages: adapterPackages.rows.map(adapterPackageView),
|
||||||
adapterVersions: adapterVersions.rows.map(adapterVersionView),
|
adapterVersions: adapterVersions.rows.map(adapterVersionView),
|
||||||
modelProfiles: modelProfiles.rows.map(modelProfileView),
|
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),
|
routes: routes.rows.map(routeView),
|
||||||
sessions: sessions.rows.map(sessionView),
|
sessions: sessions.rows.map(sessionView),
|
||||||
bindings: bindings.rows.map(bindingView),
|
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 {
|
return {
|
||||||
edgeRef: `edge:${row.id}`,
|
edgeRef: `edge:${row.id}`,
|
||||||
edgeKey: row.edge_key,
|
edgeKey: row.edge_key,
|
||||||
displayName: row.display_name,
|
displayName: row.display_name,
|
||||||
deploymentRef: row.deployment_ref ?? null,
|
deploymentRef: row.deployment_ref ?? null,
|
||||||
lifecycleState: row.lifecycle_state,
|
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),
|
createdAt: toIso(row.created_at),
|
||||||
updatedAt: toIso(row.updated_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) {
|
function routeView(row) {
|
||||||
return {
|
return {
|
||||||
routeRef: `route:${row.id}`,
|
routeRef: `route:${row.id}`,
|
||||||
|
|||||||
@@ -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 () => {
|
test("project workspace query accepts only a canonical project path", async () => {
|
||||||
const projectId = "11111111-1111-4111-8111-111111111111";
|
const projectId = "11111111-1111-4111-8111-111111111111";
|
||||||
let queried;
|
let queried;
|
||||||
|
const edgeChannels = {
|
||||||
|
enabled: true,
|
||||||
|
edges: [{
|
||||||
|
edgeRegistrationId: "edge:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||||
|
channel: "accepted",
|
||||||
|
lastErrorCode: null,
|
||||||
|
}],
|
||||||
|
};
|
||||||
const runtime = await startTestServer({
|
const runtime = await startTestServer({
|
||||||
managementApiEnabled: true,
|
managementApiEnabled: true,
|
||||||
managementToken,
|
managementToken,
|
||||||
|
edgeChannelStatusProvider: () => edgeChannels,
|
||||||
repository: {
|
repository: {
|
||||||
health: async () => "ready",
|
health: async () => "ready",
|
||||||
executeManagementCommand: async () => ({ replayed: false, result: {} }),
|
executeManagementCommand: async () => ({ replayed: false, result: {} }),
|
||||||
getProjectWorkspace: async (actor, id) => {
|
getProjectWorkspace: async (actor, id, options) => {
|
||||||
queried = { actor, id };
|
queried = { actor, id, options };
|
||||||
return { project: { projectRef: `project:${id}` }, devices: [] };
|
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(response.status, 200);
|
||||||
assert.equal((await response.json()).workspace.devices.length, 0);
|
assert.equal((await response.json()).workspace.devices.length, 0);
|
||||||
assert.equal(queried.id, projectId);
|
assert.equal(queried.id, projectId);
|
||||||
|
assert.equal(queried.options.edgeChannelStatus, edgeChannels);
|
||||||
|
|
||||||
const invalid = await fetch(
|
const invalid = await fetch(
|
||||||
`${runtime.baseUrl}/internal/v1/query/projects/not-a-project/workspace`,
|
`${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 () => {
|
test("project workspace returns only masked identity projections", async () => {
|
||||||
const client = workspaceClient();
|
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.project.projectRef, `project:${projectId}`);
|
||||||
assert.equal(workspace.devices[0].identifier.masked, "***********0001");
|
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.adapterPackages[0].packageKey, "generic-tracker");
|
||||||
assert.equal(workspace.modelProfiles[0].modelProfileRef, "vendor.model.v1");
|
assert.equal(workspace.modelProfiles[0].modelProfileRef, "vendor.model.v1");
|
||||||
assert.equal(workspace.routes[0].activeSessionCount, 1);
|
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.sessions[0].frameCount, 12);
|
||||||
assert.equal(workspace.bindings[0].lifecycleState, "pending_external_approval");
|
assert.equal(workspace.bindings[0].lifecycleState, "pending_external_approval");
|
||||||
assert.equal(workspace.configurationRevisions[0].revisionNumber, 1);
|
assert.equal(workspace.configurationRevisions[0].revisionNumber, 1);
|
||||||
@@ -103,6 +117,10 @@ test("project read source never selects identifier digests or credential refs",
|
|||||||
source,
|
source,
|
||||||
/\b(?:identifier_digest|expected_identifier_digest|credential_ref|parameters_digest|parameters_projection|transport_message_ref|external_approval_ref|external_approval_digest)\b/,
|
/\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/);
|
assert.doesNotMatch(source, /\b(?:dae\.payload|dcr\.configuration)\b/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,6 +185,8 @@ function workspaceClient({ queries = [] } = {}) {
|
|||||||
display_name: "Edge one",
|
display_name: "Edge one",
|
||||||
deployment_ref: "deployment:edge-one",
|
deployment_ref: "deployment:edge-one",
|
||||||
lifecycle_state: "active",
|
lifecycle_state: "active",
|
||||||
|
channel_lifecycle_state: "active",
|
||||||
|
channel_generation_ref: "channel-generation:1",
|
||||||
created_at: timestamp,
|
created_at: timestamp,
|
||||||
updated_at: timestamp,
|
updated_at: timestamp,
|
||||||
}] };
|
}] };
|
||||||
|
|||||||
Reference in New Issue
Block a user