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
|
||||
[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,
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
}] };
|
||||
|
||||
Reference in New Issue
Block a user