Compare commits

...
14 Commits
79 changed files with 9869 additions and 127 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.
+11 -2
View File
@@ -20,8 +20,17 @@ behavior; projects, inventory, collections and access remain shared Device Core
The project workspace covers inventory, discovery, collections, adapter/profile metadata,
Edges, routes, sessions, bindings, configuration state, the honest command ledger, immutable
audit metadata and project grants. Navigation and actions are derived from effective project
capabilities. Global adapter/profile/Edge mutation is additionally restricted to a Hub owner.
audit metadata and project grants. It also joins the official ontology projection for stable
Assets, temporal Device-to-Asset bindings, provider-neutral Hosts, endpoints, deployments,
service instances and freshness-bounded health observations. Navigation and actions are
derived from effective project capabilities. Global adapter/profile/Edge mutation is
additionally restricted to a Hub owner.
Host credentials are accepted only as opaque `secret-ref:*` values by the server-side Core
command. The browser projection receives only `managementCredentialConfigured`; it never
receives the reference or secret. Missing or expired health evidence is rendered as
`unobserved`, never inferred as `unreachable`. Arbitrary WebSSH remains disabled pending a
separate short-lived management-session and break-glass design.
Command planning and transport intentionally have no Device Manager mutation route yet.
The UI never presents `sent` as success: `acknowledged` and `verified` remain different
@@ -13,6 +13,14 @@ const commandRoutes = new Map([
["enrollment-intents:ensure", "/internal/v1/management/enrollment-intents:ensure"],
["devices:claim", "/internal/v1/management/devices:claim"],
["devices:update", "/internal/v1/management/devices:update"],
["assets:ensure", "/internal/v1/management/assets:ensure"],
["asset-bindings:ensure", "/internal/v1/management/asset-bindings:ensure"],
["asset-bindings:close", "/internal/v1/management/asset-bindings:close"],
["infrastructure-hosts:ensure", "/internal/v1/management/infrastructure-hosts:ensure"],
["infrastructure-endpoints:ensure", "/internal/v1/management/infrastructure-endpoints:ensure"],
["infrastructure-deployments:ensure", "/internal/v1/management/infrastructure-deployments:ensure"],
["infrastructure-service-instances:ensure", "/internal/v1/management/infrastructure-service-instances:ensure"],
["health-observations:record", "/internal/v1/management/health-observations:record"],
["device-bindings:ensure", "/internal/v1/management/device-bindings:ensure"],
["device-bindings:revoke", "/internal/v1/management/device-bindings:revoke"],
[
@@ -61,8 +69,13 @@ export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = {
},
async getWorkspace(actor, projectRef) {
const projectId = entityId(projectRef, "project");
return request(`/internal/v1/query/projects/${projectId}/workspace`, actor)
.then((body) => body.workspace);
const [workspace, ontology] = await Promise.all([
request(`/internal/v1/query/projects/${projectId}/workspace`, actor)
.then((body) => body.workspace),
request(`/internal/v1/query/projects/${projectId}/ontology`, actor)
.then((body) => body.projection),
]);
return { ...workspace, ontology };
},
async execute(command, actor, input, idempotencyKey) {
const pathname = commandRoutes.get(command);
@@ -100,6 +113,13 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
const configurationStates = new Map();
const auditEvents = [];
const commands = new Map();
const assets = new Map();
const assetBindings = new Map();
const hosts = new Map();
const endpoints = new Map();
const deployments = new Map();
const serviceInstances = new Map();
const healthObservations = new Map();
function now() {
return new Date().toISOString();
@@ -166,6 +186,56 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
identifierProjection: fixture === "arusnavi-b2" ? "authorized-full" : "masked-only",
auditPayloadProjection: "metadata-only",
},
ontology: ontologyProjection(projectRef),
};
}
function ontologyProjection(projectRef) {
const projectHosts = projectValues(hosts, projectRef);
const projectServices = projectValues(serviceInstances, projectRef);
const withHealth = (subjectKind, value, subjectRef) => ({
...value,
health: latestPreviewHealth(projectRef, subjectKind, subjectRef),
});
return {
ontology: {
catalogHash: "229c61c02a790906",
packages: ["asset", "device", "infrastructure", "observation"],
},
assets: projectValues(assets, projectRef),
assetBindings: projectValues(assetBindings, projectRef),
hosts: projectHosts.map((host) => ({
...withHealth("host", host, host.hostRef),
telemetry: host.telemetry ?? emptyPreviewHostTelemetry(),
})),
endpoints: projectValues(endpoints, projectRef),
deployments: projectValues(deployments, projectRef),
serviceInstances: projectServices.map((service) =>
withHealth("service-instance", service, service.serviceInstanceRef)),
policies: {
restrictedIdentifiers: "masked-only",
managementCredentials: "opaque-reference-only",
missingHealthEvidence: "unobserved-not-unhealthy",
arbitraryConsole: "disabled",
},
};
}
function latestPreviewHealth(projectRef, subjectKind, subjectRef) {
const values = projectValues(healthObservations, projectRef)
.filter((item) => item.subjectKind === subjectKind && item.subjectRef === subjectRef)
.sort((left, right) => right.observedAt.localeCompare(left.observedAt));
const latest = values[0];
if (!latest) return { state: "unobserved", freshness: "missing", observationRef: null };
const fresh = new Date(latest.expiresAt).valueOf() > Date.now();
return {
state: fresh ? latest.observedState : "unobserved",
freshness: fresh ? "fresh" : "stale",
lastObservedState: latest.observedState,
evidenceClass: latest.evidenceClass,
observedAt: latest.observedAt,
expiresAt: latest.expiresAt,
observationRef: latest.healthObservationRef,
};
}
@@ -178,6 +248,10 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
routes,
sessions,
configurationStates,
hosts,
deployments,
serviceInstances,
healthObservations,
});
else if (fixture != null && fixture !== "") {
throw serviceError("device_manager_preview_fixture_invalid", 400);
@@ -439,6 +513,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(),
};
@@ -602,6 +682,167 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
if (command === "devices:claim") {
throw serviceError("device_discovery_not_found", 404);
}
if (command === "assets:ensure") {
if (!projects.has(input.projectRef)) throw serviceError("device_project_not_found", 404);
const existing = projectValues(assets, input.projectRef)
.find((item) => item.assetKey === input.assetKey);
const assetRef = existing?.assetRef || `asset:${randomUUID()}`;
const asset = {
assetRef,
projectRef: input.projectRef,
assetKey: input.assetKey,
displayName: input.displayName,
assetTypeRef: input.assetTypeRef,
lifecycleState: input.lifecycleState ?? "active",
ontology: previewOntology("asset.asset"),
};
assets.set(assetRef, asset);
audit(actor, input.projectRef, createdEvent(existing, "asset"));
return { replayed: false, result: { created: !existing, asset } };
}
if (command === "asset-bindings:ensure") {
const device = devices.get(input.deviceRef);
const asset = assets.get(input.assetRef);
if (!device || device.projectRef !== input.projectRef) {
throw serviceError("device_not_found", 404);
}
if (!asset || asset.projectRef !== input.projectRef) {
throw serviceError("device_asset_not_found", 404);
}
const existing = projectValues(assetBindings, input.projectRef)
.find((item) => item.bindingKey === input.bindingKey);
const assetBindingRef = existing?.assetBindingRef || `asset-binding:${randomUUID()}`;
const assetBinding = {
assetBindingRef,
projectRef: input.projectRef,
bindingKey: input.bindingKey,
deviceRef: input.deviceRef,
deviceName: device.displayName,
assetRef: input.assetRef,
assetName: asset.displayName,
bindingKind: input.bindingKind ?? "tracking",
validFrom: input.validFrom,
validTo: null,
provenanceRef: input.provenanceRef,
ontology: previewOntology("device.asset_binding"),
};
assetBindings.set(assetBindingRef, assetBinding);
audit(actor, input.projectRef, createdEvent(existing, "asset_binding"));
return { replayed: false, result: { created: !existing, assetBinding } };
}
if (command === "asset-bindings:close") {
const existing = assetBindings.get(input.assetBindingRef);
if (!existing || existing.projectRef !== input.projectRef || existing.validTo) {
throw serviceError("device_asset_binding_not_closable", 409);
}
const assetBinding = { ...existing, validTo: input.validTo };
assetBindings.set(existing.assetBindingRef, assetBinding);
audit(actor, input.projectRef, "asset_binding.closed");
return { replayed: false, result: { closed: true, assetBinding } };
}
if (command === "infrastructure-hosts:ensure") {
if (!projects.has(input.projectRef)) throw serviceError("device_project_not_found", 404);
const existing = projectValues(hosts, input.projectRef)
.find((item) => item.hostKey === input.hostKey);
const hostRef = existing?.hostRef || `host:${randomUUID()}`;
const host = {
hostRef,
projectRef: input.projectRef,
hostKey: input.hostKey,
displayName: input.displayName,
providerRef: input.providerRef ?? null,
externalRef: input.externalRef ?? null,
managementCredentialConfigured: Boolean(input.managementCredentialRef),
lifecycleState: input.lifecycleState ?? "provisioning",
ontology: previewOntology("infrastructure.host"),
};
hosts.set(hostRef, host);
audit(actor, input.projectRef, createdEvent(existing, "infrastructure_host"));
return { replayed: false, result: { created: !existing, host } };
}
if (command === "infrastructure-endpoints:ensure") {
const host = hosts.get(input.hostRef);
if (!host || host.projectRef !== input.projectRef) throw serviceError("device_host_not_found", 404);
const existing = projectValues(endpoints, input.projectRef)
.find((item) => item.hostRef === input.hostRef && item.endpointKey === input.endpointKey);
const endpointRef = existing?.endpointRef || `endpoint:${randomUUID()}`;
const endpoint = {
endpointRef,
projectRef: input.projectRef,
hostRef: input.hostRef,
endpointKey: input.endpointKey,
purpose: input.purpose,
endpointUri: input.endpointUri,
lifecycleState: input.lifecycleState ?? "active",
ontology: previewOntology("infrastructure.endpoint"),
};
endpoints.set(endpointRef, endpoint);
audit(actor, input.projectRef, createdEvent(existing, "infrastructure_endpoint"));
return { replayed: false, result: { created: !existing, endpoint } };
}
if (command === "infrastructure-deployments:ensure") {
const host = hosts.get(input.hostRef);
if (!host || host.projectRef !== input.projectRef) throw serviceError("device_host_not_found", 404);
const existing = projectValues(deployments, input.projectRef)
.find((item) => item.deploymentKey === input.deploymentKey);
const deploymentRef = existing?.deploymentRef || `deployment:${randomUUID()}`;
const deployment = {
deploymentRef,
projectRef: input.projectRef,
hostRef: input.hostRef,
deploymentKey: input.deploymentKey,
displayName: input.displayName,
artifactRef: input.artifactRef,
artifactDigest: input.artifactDigest,
lifecycleState: input.lifecycleState ?? "desired",
ontology: previewOntology("infrastructure.deployment"),
};
deployments.set(deploymentRef, deployment);
audit(actor, input.projectRef, createdEvent(existing, "infrastructure_deployment"));
return { replayed: false, result: { created: !existing, deployment } };
}
if (command === "infrastructure-service-instances:ensure") {
const host = hosts.get(input.hostRef);
const deployment = deployments.get(input.deploymentRef);
if (!host || host.projectRef !== input.projectRef) throw serviceError("device_host_not_found", 404);
if (!deployment || deployment.projectRef !== input.projectRef) {
throw serviceError("device_deployment_not_found", 404);
}
const existing = projectValues(serviceInstances, input.projectRef)
.find((item) => item.hostRef === input.hostRef && item.serviceKey === input.serviceKey);
const serviceInstanceRef = existing?.serviceInstanceRef || `service-instance:${randomUUID()}`;
const serviceInstance = {
serviceInstanceRef,
projectRef: input.projectRef,
hostRef: input.hostRef,
deploymentRef: input.deploymentRef,
edgeRef: input.edgeRef ?? null,
serviceKey: input.serviceKey,
displayName: input.displayName,
serviceRole: input.serviceRole,
lifecycleState: input.lifecycleState ?? "provisioning",
ontology: previewOntology("infrastructure.service_instance"),
};
serviceInstances.set(serviceInstanceRef, serviceInstance);
audit(actor, input.projectRef, createdEvent(existing, "infrastructure_service_instance"));
return { replayed: false, result: { created: !existing, serviceInstance } };
}
if (command === "health-observations:record") {
const healthObservationRef = `health-observation:${randomUUID()}`;
const healthObservation = {
healthObservationRef,
projectRef: input.projectRef,
subjectKind: input.subjectKind,
subjectRef: input.subjectRef,
observedState: input.observedState,
evidenceClass: input.evidenceClass,
observedAt: input.observedAt,
expiresAt: input.expiresAt,
};
healthObservations.set(healthObservationRef, healthObservation);
audit(actor, input.projectRef, "health_observation.recorded");
return { replayed: false, result: { recorded: true, healthObservation } };
}
if (command === "devices:update") {
const device = devices.get(input.deviceRef);
if (!device || device.projectRef !== input.projectRef) {
@@ -652,6 +893,92 @@ function createdEvent(existing, resource) {
return `${resource}.${existing ? "updated" : "created"}`;
}
function previewOntology(entityId) {
return { entityId, catalogHash: "229c61c02a790906" };
}
function emptyPreviewHostTelemetry() {
return {
state: "unobserved",
freshness: "missing",
observedAt: null,
receivedAt: null,
expiresAt: null,
current: null,
history: [],
observation: null,
};
}
function previewHostTelemetry(hostRef, serviceInstanceRef, edgeRef) {
const now = Date.now();
const history = Array.from({ length: 60 }, (_, index) => {
const observedAt = new Date(now - (59 - index) * 2_000).toISOString();
const phase = index / 7;
return {
observedAt,
cpuUsagePercent: 18 + Math.sin(phase) * 8 + (index % 5),
memoryUsedPercent: 42 + Math.sin(phase / 2) * 3,
network: [{
interface: "eth0",
bytesReceived: 8_000_000 + index * (32_000 + (index % 4) * 4_000),
bytesSent: 3_000_000 + index * (14_000 + (index % 3) * 2_000),
}],
};
});
const observedAt = history.at(-1).observedAt;
const current = {
schemaVersion: "nodedc.infrastructure.host-telemetry.v1",
profile: "linux-host-telegraf-v1",
hostKey: "robot2b-b2-edge-vps",
observedAt,
source: {
agent: "telegraf",
agentVersion: "1.38.4",
collectorRef: "service:nodedc-host-telemetry-agent",
},
hardware: {
hostname: "koffyvngij",
architecture: "x64",
platform: "linux",
kernelRelease: "6.8.0-79-generic",
cpuModel: "AMD EPYC Processor (KVM)",
logicalProcessors: 1,
},
cpu: { usagePercent: history.at(-1).cpuUsagePercent, load1: 0.31, load5: 0.24, load15: 0.18 },
memory: { totalBytes: 1_007_681_536, availableBytes: 570_425_344, freeBytes: null, usedBytes: 437_256_192, usedPercent: history.at(-1).memoryUsedPercent },
swap: { totalBytes: 0, availableBytes: null, freeBytes: 0, usedBytes: 0, usedPercent: 0 },
system: { uptimeSeconds: 723_419, users: 1, processes: { total: 118, running: 2, sleeping: 115, blocked: 0, zombies: 1 } },
disks: [{ device: "/dev/vda1", mount: "/", filesystem: "ext4", totalBytes: 21_474_836_480, freeBytes: 14_495_514_624, usedBytes: 6_979_321_856, usedPercent: 32.5 }],
network: [{ interface: "eth0", bytesReceived: history.at(-1).network[0].bytesReceived, bytesSent: history.at(-1).network[0].bytesSent, packetsReceived: 91_482, packetsSent: 64_501, errorsReceived: 0, errorsSent: 0, droppedReceived: 0, droppedSent: 0 }],
services: [
{ name: "nodedc-device-edge-channel.service", loadState: "loaded", activeState: "active", subState: "running", memoryBytes: 71_303_168, restarts: 0, pid: 1482 },
{ name: "nodedc-host-telemetry-agent.service", loadState: "loaded", activeState: "active", subState: "running", memoryBytes: 35_651_584, restarts: 0, pid: 1510 },
{ name: "ssh.service", loadState: "loaded", activeState: "active", subState: "running", memoryBytes: 8_388_608, restarts: 0, pid: 712 },
],
};
return {
state: "online",
freshness: "fresh",
observedAt,
receivedAt: new Date(now).toISOString(),
expiresAt: new Date(now + 15_000).toISOString(),
current,
history,
observation: {
observationRef: "observation:preview-host-telemetry",
entityId: "observation.observation",
catalogHash: "229c61c02a790906",
targetRef: hostRef,
serviceInstanceRef,
edgeRef,
profileRef: "linux-host-telegraf-v1",
source: { ...current.source, provenanceRef: `${edgeRef}:${current.source.collectorRef}` },
observedProperties: ["host.cpu.utilization", "host.memory.utilization", "host.disk.utilization", "host.network.counters", "host.systemd.unit-state"],
},
};
}
function requirePlatformOwner(actor) {
if (actor?.hubRole !== "owner") {
throw serviceError("device_platform_catalog_access_denied", 403);
@@ -667,6 +994,10 @@ function seedArusnaviB2Preview({
routes,
sessions,
configurationStates,
hosts,
deployments,
serviceInstances,
healthObservations,
}) {
const timestamp = new Date().toISOString();
const ownerScopeRef = "owner-scope:78da71d5-f48f-4de0-8e47-729f6d644151";
@@ -675,6 +1006,9 @@ function seedArusnaviB2Preview({
const edgeRef = "edge:73da0c42-a641-4559-b8f7-23509b60bfe9";
const routeRef = "route:fef9b7a0-a462-4d68-9991-af026203368b";
const sessionRef = "session:57ead610-47de-45f7-a42d-fbe4fa0aba38";
const hostRef = "host:adf2a5b6-3c0b-4a39-998c-07dfb7818ad1";
const deploymentRef = "deployment:49b296f8-4cc8-470f-9dc1-2e3a543fd224";
const serviceInstanceRef = "service-instance:01f14736-f5c2-4867-9cbc-2d268996a871";
const scope = {
ownerScopeRef,
scopeKind: "personal",
@@ -716,9 +1050,61 @@ 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,
});
hosts.set(hostRef, {
hostRef,
projectRef,
hostKey: "robot2b-b2-edge-vps",
displayName: "Robot2B B2 Edge VPS",
providerRef: "provider:beget",
externalRef: "host:koffyvngij",
managementCredentialConfigured: true,
lifecycleState: "active",
telemetry: previewHostTelemetry(hostRef, serviceInstanceRef, edgeRef),
ontology: previewOntology("infrastructure.host"),
});
deployments.set(deploymentRef, {
deploymentRef,
projectRef,
hostRef,
deploymentKey: "device-edge-vps-command-transport",
displayName: "Device Edge VPS runtime",
artifactRef: "device-edge-vps-command-transport-20260812-013",
artifactDigest: "sha256:c7486ec879681ddd706f229b628c8556ca8c9ccc4f152a85debb409c302759ef",
lifecycleState: "active",
ontology: previewOntology("infrastructure.deployment"),
});
serviceInstances.set(serviceInstanceRef, {
serviceInstanceRef,
projectRef,
hostRef,
deploymentRef,
edgeRef,
serviceKey: "device-edge",
displayName: "Robot2B B2 Device Edge",
serviceRole: "device.edge",
lifecycleState: "active",
ontology: previewOntology("infrastructure.service_instance"),
});
const healthObservationRef = "health-observation:b912a45a-2b97-4562-8f9e-824cc24e0710";
healthObservations.set(healthObservationRef, {
healthObservationRef,
projectRef,
subjectKind: "host",
subjectRef: hostRef,
observedState: "reachable",
evidenceClass: "agent.telemetry",
observedAt: timestamp,
expiresAt: new Date(Date.now() + 60_000).toISOString(),
});
routes.set(routeRef, {
routeRef,
projectRef,
@@ -857,6 +1243,7 @@ const ownerCapabilities = Object.freeze([
"project.manage",
"access.manage",
"inventory.read",
"asset.manage",
"device.enroll",
"device.claim",
"device.transfer",
@@ -864,6 +1251,9 @@ const ownerCapabilities = Object.freeze([
"route.manage",
"binding.manage",
"telemetry.observe",
"observation.write",
"infrastructure.read",
"infrastructure.manage",
"configuration.read",
"configuration.manage",
"command.plan",
@@ -60,6 +60,36 @@ test("Device Core client accepts only canonical commands and entity refs", async
);
});
test("Device Core client merges workspace and ontology projections", async () => {
const calls = [];
const client = createDeviceCoreClient({
baseUrl: "http://127.0.0.1:3210",
token,
fetchImpl: async (url) => {
calls.push(String(url));
if (String(url).endsWith("/ontology")) {
return jsonResponse(200, {
ok: true,
projection: {
ontology: { catalogHash: "229c61c02a790906", packages: [] },
assets: [], assetBindings: [], hosts: [], endpoints: [],
deployments: [], serviceInstances: [], policies: {},
},
});
}
return jsonResponse(200, { ok: true, workspace: { project: { projectRef: "project:test" } } });
},
});
const workspace = await client.getWorkspace(
actor,
"project:11111111-1111-4111-8111-111111111111",
);
assert.equal(workspace.ontology.ontology.catalogHash, "229c61c02a790906");
assert.equal(calls.length, 2);
assert.ok(calls.some((value) => value.endsWith("/workspace")));
assert.ok(calls.some((value) => value.endsWith("/ontology")));
});
test("local preview is empty and creates resources only through canonical commands", async () => {
const client = createLocalPreviewDeviceCore();
assert.deepEqual(await client.listProjects(actor), []);
@@ -81,6 +111,47 @@ test("local preview is empty and creates resources only through canonical comman
assert.equal(created.result.created, true);
const projectRef = created.result.project.projectRef;
const host = await client.execute("infrastructure-hosts:ensure", actor, {
projectRef,
hostKey: "preview-vps",
displayName: "Preview VPS",
providerRef: "provider:preview",
externalRef: "provider-resource:preview-vps",
managementCredentialRef: "secret-ref:device-core/preview-vps",
lifecycleState: "active",
});
const deployment = await client.execute("infrastructure-deployments:ensure", actor, {
projectRef,
hostRef: host.result.host.hostRef,
deploymentKey: "preview-edge",
displayName: "Preview Edge deployment",
artifactRef: "artifact:device-edge/1.0.0",
artifactDigest: `sha256:${"c".repeat(64)}`,
lifecycleState: "active",
});
await client.execute("infrastructure-service-instances:ensure", actor, {
projectRef,
hostRef: host.result.host.hostRef,
deploymentRef: deployment.result.deployment.deploymentRef,
edgeRef: null,
serviceKey: "device-edge",
displayName: "Device Edge",
serviceRole: "device.edge",
lifecycleState: "active",
});
await client.execute("health-observations:record", actor, {
projectRef,
subjectKind: "host",
subjectRef: host.result.host.hostRef,
observedState: "reachable",
evidenceClass: "manual",
sourceRef: "test:preview",
schemaRef: "nodedc.health.test.v1",
evidence: {},
observedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
});
await client.execute("collections:ensure", actor, {
projectRef,
collectionKey: "field-devices",
@@ -217,6 +288,10 @@ test("local preview is empty and creates resources only through canonical comman
assert.equal(workspace.grants.length, 2);
assert.ok(workspace.auditEvents.some((event) => event.eventType === "device_binding.created"));
assert.equal(workspace.policies.commandTransport, "disabled");
assert.equal(workspace.ontology.hosts[0].health.state, "reachable");
assert.equal(workspace.ontology.serviceInstances[0].serviceRole, "device.edge");
assert.equal(workspace.ontology.hosts[0].managementCredentialConfigured, true);
assert.equal(JSON.stringify(workspace).includes("secret-ref:device-core/preview-vps"), false);
assert.deepEqual(workspace.devices, []);
});
@@ -84,6 +84,74 @@ test("Device Core overview follows the Mission Core landing-stage geometry", asy
assert.ok(!client.includes('<GlassSurface className="device-manager-home"'));
});
test("VPS inventory and monitoring follow the Mission Core system workspace geometry", async () => {
const client = await readFile(
new URL("../src/DeviceControlViews.tsx", import.meta.url),
"utf8",
);
const styles = await readFile(
new URL("../src/styles.css", import.meta.url),
"utf8",
);
const infrastructureSource = client.slice(
client.indexOf("function HostsView"),
client.indexOf("function SessionsView"),
);
for (const expected of [
'className="infrastructure-system-workspace"',
'className="infrastructure-system-workspace host-monitoring-workspace"',
'className="infrastructure-overview-block"',
'className="infrastructure-section infrastructure-hosts-block"',
'className="infrastructure-host-list"',
'className="infrastructure-host-card__summary"',
'className="infrastructure-host-card__freshness"',
'className="infrastructure-host-card__toggle"',
'className="infrastructure-host-card__details"',
'className="infrastructure-host-relations"',
'aria-expanded={expanded}',
'const [expandedHostRefs, setExpandedHostRefs] = useState<Set<string>>',
'topology.endpoints.filter((item) => item.hostRef === host.hostRef)',
'topology.deployments.filter((item) => item.hostRef === host.hostRef)',
'topology.serviceInstances.filter((item) => item.hostRef === host.hostRef)',
'className="host-monitoring-series-grid"',
'className="host-monitoring-hardware"',
'className="host-monitoring-hardware-facts"',
'className="infrastructure-runtime-grid"',
'percentageTelemetryWindow(cpuHistory, 5)',
'percentageTelemetryWindow(memoryHistory, 4)',
'className="host-monitoring-series__range"',
'resource={networkRate.received == null ? "нет данных" : "входящий трафик"}',
'if (!counters.length) return null;',
]) assert.ok(infrastructureSource.includes(expected), expected);
for (const expected of [
".infrastructure-system-workspace {",
".infrastructure-overview-block,",
".infrastructure-hosts-block,",
".infrastructure-host-card__summary {",
"align-items: center;",
".infrastructure-host-card__freshness[data-freshness=\"fresh\"] {",
".infrastructure-host-card[data-expanded=\"true\"] .infrastructure-host-card__toggle svg {",
".infrastructure-host-relations {",
"grid-template-columns: repeat(3, minmax(0, 1fr));",
"grid-template-columns: repeat(4, minmax(0, 1fr));",
"background: var(--infrastructure-panel-soft);",
".host-monitoring-series polyline {",
"stroke: var(--nodedc-text-primary);",
".host-monitoring-hardware-facts {",
"grid-template-columns: repeat(2, minmax(0, 1fr));",
]) assert.ok(styles.includes(expected), expected);
assert.ok(!infrastructureSource.includes("<ResourceCard"));
assert.ok(!infrastructureSource.includes('title="Сервисы хостов"'));
assert.ok(!infrastructureSource.includes('title="Deployments и endpoints"'));
assert.ok(!infrastructureSource.includes("ceiling={100}"));
assert.ok(!infrastructureSource.includes("Канонический ontology catalog"));
assert.ok(!styles.includes(".host-telemetry-metric,"));
assert.ok(!styles.includes(".host-telemetry-facts"));
});
test("legacy single teaser migrates into the environment media playlist", () => {
const environment = normalizeEnvironmentPresentation({
defaultTeaser: {
@@ -29,6 +29,14 @@ const mutationRoutes = new Map([
["/api/device-manager/enrollment-intents:ensure", "enrollment-intents:ensure"],
["/api/device-manager/devices:claim", "devices:claim"],
["/api/device-manager/devices:update", "devices:update"],
["/api/device-manager/assets:ensure", "assets:ensure"],
["/api/device-manager/asset-bindings:ensure", "asset-bindings:ensure"],
["/api/device-manager/asset-bindings:close", "asset-bindings:close"],
["/api/device-manager/infrastructure-hosts:ensure", "infrastructure-hosts:ensure"],
["/api/device-manager/infrastructure-endpoints:ensure", "infrastructure-endpoints:ensure"],
["/api/device-manager/infrastructure-deployments:ensure", "infrastructure-deployments:ensure"],
["/api/device-manager/infrastructure-service-instances:ensure", "infrastructure-service-instances:ensure"],
["/api/device-manager/health-observations:record", "health-observations:record"],
["/api/device-manager/device-bindings:ensure", "device-bindings:ensure"],
["/api/device-manager/device-bindings:revoke", "device-bindings:revoke"],
[
@@ -123,6 +123,34 @@ test("Device Manager BFF exposes an empty, mutation-driven project workspace", a
deploymentRef: "deployment:preview-edge",
lifecycleState: "provisioning",
});
const host = await postJson(`${baseUrl}/api/device-manager/infrastructure-hosts:ensure`, {
projectRef,
hostKey: "preview-vps",
displayName: "Preview VPS",
providerRef: "provider:preview",
externalRef: "provider-resource:preview-vps",
managementCredentialRef: "secret-ref:device-core/preview-vps",
lifecycleState: "active",
});
const deployment = await postJson(`${baseUrl}/api/device-manager/infrastructure-deployments:ensure`, {
projectRef,
hostRef: host.result.host.hostRef,
deploymentKey: "preview-edge",
displayName: "Preview Edge deployment",
artifactRef: "artifact:device-edge/1.0.0",
artifactDigest: `sha256:${"c".repeat(64)}`,
lifecycleState: "active",
});
await postJson(`${baseUrl}/api/device-manager/infrastructure-service-instances:ensure`, {
projectRef,
hostRef: host.result.host.hostRef,
deploymentRef: deployment.result.deployment.deploymentRef,
edgeRef: null,
serviceKey: "device-edge",
displayName: "Device Edge",
serviceRole: "device.edge",
lifecycleState: "active",
});
await postJson(`${baseUrl}/api/device-manager/project-grants:upsert`, {
projectRef,
principalKind: "group",
@@ -141,6 +169,9 @@ test("Device Manager BFF exposes an empty, mutation-driven project workspace", a
assert.equal(workspace.workspace.edges[0].edgeKey, "preview-edge");
assert.equal(workspace.workspace.grants.length, 2);
assert.equal(workspace.workspace.policies.commandTransport, "disabled");
assert.equal(workspace.workspace.ontology.hosts[0].hostKey, "preview-vps");
assert.equal(workspace.workspace.ontology.serviceInstances[0].serviceKey, "device-edge");
assert.equal(JSON.stringify(workspace).includes("secret-ref:device-core/preview-vps"), false);
assert.deepEqual(workspace.workspace.devices, []);
const missingKey = await fetch(`${baseUrl}/api/device-manager/projects:ensure`, {
+907 -4
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from "react";
import {
Button,
GlassSurface,
Icon,
IconButton,
Select,
SettingsCard,
StatusBadge,
@@ -14,12 +15,20 @@ import {
import {
createConfigurationRevision,
closeAssetBinding,
ensureAsset,
ensureAssetBinding,
ensureAdapterPackage,
ensureDeviceBinding,
ensureEdge,
ensureInfrastructureDeployment,
ensureInfrastructureEndpoint,
ensureInfrastructureHost,
ensureInfrastructureServiceInstance,
ensureRoute,
registerAdapterVersion,
registerModelProfile,
recordHealthObservation,
revokeDeviceBinding,
sendServicePing,
setDesiredConfiguration,
@@ -28,9 +37,12 @@ import {
import type {
AdapterPackageView,
AdapterVersionView,
AssetBindingView,
BindingView,
DeviceManagerSession,
EdgeView,
InfrastructureHostView,
InfrastructureServiceInstanceView,
ModelProfileView,
ProjectWorkspace,
} from "./types";
@@ -38,6 +50,7 @@ import type {
export type ControlViewId =
| "catalog"
| "infrastructure"
| "hosts"
| "sessions"
| "bindings"
| "commands"
@@ -54,6 +67,13 @@ type DialogId =
| "binding"
| "grant"
| "configuration"
| "asset"
| "asset-binding"
| "host"
| "endpoint"
| "deployment"
| "service-instance"
| "health-observation"
| null;
export function DeviceControlView({
@@ -61,12 +81,14 @@ export function DeviceControlView({
workspace,
session,
onRefresh,
onPoll,
onError,
}: {
view: ControlViewId;
workspace: ProjectWorkspace;
session: DeviceManagerSession;
onRefresh: () => Promise<void>;
onPoll: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const [dialog, setDialog] = useState<DialogId>(null);
@@ -145,6 +167,28 @@ export function DeviceControlView({
}))}
/>
) : null}
{view === "hosts" ? (
<HostsView
workspace={workspace}
canManageInfrastructure={capabilities.has("infrastructure.manage")}
canManageAssets={capabilities.has("asset.manage")}
canManageBindings={capabilities.has("binding.manage")}
onCreateHost={() => setDialog("host")}
onCreateEndpoint={() => setDialog("endpoint")}
onCreateDeployment={() => setDialog("deployment")}
onCreateService={() => setDialog("service-instance")}
onRecordHealth={() => setDialog("health-observation")}
onCreateAsset={() => setDialog("asset")}
onCreateAssetBinding={() => setDialog("asset-binding")}
onCloseAssetBinding={(binding) => mutateAndRefresh(() => closeAssetBinding({
projectRef: workspace.project.projectRef,
assetBindingRef: binding.assetBindingRef,
validTo: new Date().toISOString(),
}))}
onPoll={onPoll}
onError={onError}
/>
) : null}
{view === "sessions" ? <SessionsView workspace={workspace} /> : null}
{view === "bindings" ? (
<BindingsView
@@ -236,6 +280,55 @@ export function DeviceControlView({
onCreated={completed}
onError={onError}
/>
<AssetDialog
open={dialog === "asset"}
projectRef={workspace.project.projectRef}
onClose={close}
onCreated={completed}
onError={onError}
/>
<AssetBindingDialog
open={dialog === "asset-binding"}
workspace={workspace}
onClose={close}
onCreated={completed}
onError={onError}
/>
<HostDialog
open={dialog === "host"}
projectRef={workspace.project.projectRef}
onClose={close}
onCreated={completed}
onError={onError}
/>
<EndpointDialog
open={dialog === "endpoint"}
workspace={workspace}
onClose={close}
onCreated={completed}
onError={onError}
/>
<DeploymentDialog
open={dialog === "deployment"}
workspace={workspace}
onClose={close}
onCreated={completed}
onError={onError}
/>
<ServiceInstanceDialog
open={dialog === "service-instance"}
workspace={workspace}
onClose={close}
onCreated={completed}
onError={onError}
/>
<HealthObservationDialog
open={dialog === "health-observation"}
workspace={workspace}
onClose={close}
onCreated={completed}
onError={onError}
/>
</>
);
}
@@ -372,6 +465,596 @@ function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCr
);
}
function HostsView({
workspace,
canManageInfrastructure,
canManageAssets,
canManageBindings,
onCreateHost,
onCreateEndpoint,
onCreateDeployment,
onCreateService,
onRecordHealth,
onCreateAsset,
onCreateAssetBinding,
onCloseAssetBinding,
onPoll,
onError,
}: {
workspace: ProjectWorkspace;
canManageInfrastructure: boolean;
canManageAssets: boolean;
canManageBindings: boolean;
onCreateHost: () => void;
onCreateEndpoint: () => void;
onCreateDeployment: () => void;
onCreateService: () => void;
onRecordHealth: () => void;
onCreateAsset: () => void;
onCreateAssetBinding: () => void;
onCloseAssetBinding: (binding: AssetBindingView) => void;
onPoll: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const [selectedHostRef, setSelectedHostRef] = useState<string | null>(null);
const [expandedHostRefs, setExpandedHostRefs] = useState<Set<string>>(() => new Set());
const inventoryRef = useRef<HTMLDivElement>(null);
const topology = workspace.ontology;
const selectedHost = selectedHostRef
? topology.hosts.find((host) => host.hostRef === selectedHostRef) ?? null
: null;
useEffect(() => {
if (selectedHostRef) return;
resetApplicationPanelScroll(inventoryRef.current);
}, [selectedHostRef]);
const toggleHost = (hostRef: string) => {
setExpandedHostRefs((current) => {
const next = new Set(current);
if (next.has(hostRef)) next.delete(hostRef);
else next.add(hostRef);
return next;
});
};
if (selectedHost) {
return (
<HostTelemetryWorkspace
host={selectedHost}
services={topology.serviceInstances.filter((service) => service.hostRef === selectedHost.hostRef)}
onBack={() => setSelectedHostRef(null)}
onPoll={onPoll}
onError={onError}
/>
);
}
return (
<div className="infrastructure-system-workspace" ref={inventoryRef}>
<section className="infrastructure-overview-block">
<div className="infrastructure-workspace-lead">
<div>
<span className="infrastructure-eyebrow">ИНФРАСТРУКТУРА / VPS И ХОСТЫ</span>
<h2>VPS и хосты</h2>
<p>Вычислительные узлы проекта, их подключения и запущенные сервисы.</p>
</div>
{canManageInfrastructure ? (
<div className="infrastructure-workspace-actions">
<Button size="compact" variant="primary" onClick={onCreateHost}>Новый VPS</Button>
</div>
) : null}
</div>
<div className="infrastructure-overview-grid" aria-label="Сводка инфраструктуры">
<InfrastructureCount label="ХОСТЫ" value={topology.hosts.length} detail={`${topology.hosts.filter((host) => host.telemetry.freshness === "fresh").length} со свежими данными`} />
<InfrastructureCount label="ENDPOINTS" value={topology.endpoints.length} detail="точки подключения" />
<InfrastructureCount label="DEPLOYMENTS" value={topology.deployments.length} detail="развёрнутые контуры" />
<InfrastructureCount label="SERVICES" value={topology.serviceInstances.length} detail="экземпляры сервисов" />
</div>
</section>
<section className="infrastructure-section infrastructure-hosts-block">
<InfrastructureSectionHeading
eyebrow="ВЫЧИСЛИТЕЛЬНЫЕ УЗЛЫ"
title="Зарегистрированные хосты"
description="Компактный список VPS. Раскройте только тот хост, связи которого нужно посмотреть."
status={russianCount(topology.hosts.length, "хост", "хоста", "хостов")}
actions={canManageInfrastructure ? <>
<Button size="compact" onClick={onRecordHealth}>Наблюдение</Button>
<Button size="compact" onClick={onCreateEndpoint} disabled={!topology.hosts.length}>Endpoint</Button>
<Button size="compact" onClick={onCreateDeployment} disabled={!topology.hosts.length}>Deployment</Button>
<Button size="compact" variant="primary" onClick={onCreateService} disabled={!topology.deployments.length}>Service</Button>
</> : null}
/>
{topology.hosts.length ? (
<div className="infrastructure-host-list">
{topology.hosts.map((host) => {
const hostEndpoints = topology.endpoints.filter((item) => item.hostRef === host.hostRef);
const hostDeployments = topology.deployments.filter((item) => item.hostRef === host.hostRef);
const hostServices = topology.serviceInstances.filter((item) => item.hostRef === host.hostRef);
const expanded = expandedHostRefs.has(host.hostRef);
const detailsId = `infrastructure-host-details-${host.hostRef.replace(/[^A-Za-z0-9_-]/g, "-")}`;
return (
<article className="infrastructure-host-card" data-expanded={expanded ? "true" : undefined} key={host.hostRef}>
<header className="infrastructure-host-card__summary">
<div className="infrastructure-host-card__identity">
<span className="infrastructure-eyebrow">COMPUTE HOST</span>
<h4>{host.displayName}</h4>
<code>{host.externalRef || host.hostKey}</code>
</div>
<div className="infrastructure-host-card__actions">
<Button size="compact" variant="primary" onClick={() => setSelectedHostRef(host.hostRef)}>Мониторинг</Button>
<span
className="infrastructure-host-card__freshness"
data-freshness={host.telemetry.freshness}
role="status"
aria-label={freshnessLabel(host.telemetry.freshness)}
title={freshnessLabel(host.telemetry.freshness)}
/>
<IconButton
className="infrastructure-host-card__toggle"
label={expanded ? `Свернуть ${host.displayName}` : `Развернуть ${host.displayName}`}
aria-expanded={expanded}
aria-controls={detailsId}
onClick={() => toggleHost(host.hostRef)}
>
<Icon name="chevron-down" size={18} />
</IconButton>
</div>
</header>
{expanded ? (
<div className="infrastructure-host-card__details" id={detailsId}>
<dl className="infrastructure-host-card__facts">
<div><dt>Состояние</dt><dd>{host.lifecycleState}</dd></div>
<div><dt>Провайдер</dt><dd>{host.providerRef || "Не указан"}</dd></div>
<div><dt>Доступ управления</dt><dd>{host.managementCredentialConfigured ? "Настроен" : "Не настроен"}</dd></div>
<div><dt>Последнее наблюдение</dt><dd>{formatDate(host.telemetry.observedAt)}</dd></div>
</dl>
<div className="infrastructure-host-relations">
<section>
<header><span className="infrastructure-eyebrow">ENDPOINTS</span><strong>{hostEndpoints.length}</strong></header>
{hostEndpoints.length ? <div className="infrastructure-registry-list">{hostEndpoints.map((endpoint) => (
<InfrastructureRegistryRow key={endpoint.endpointRef} label={endpoint.purpose} title={endpoint.endpointKey} description={endpoint.endpointUri} status={endpoint.lifecycleState} />
))}</div> : <p>Точки подключения не зарегистрированы.</p>}
</section>
<section>
<header><span className="infrastructure-eyebrow">DEPLOYMENTS</span><strong>{hostDeployments.length}</strong></header>
{hostDeployments.length ? <div className="infrastructure-registry-list">{hostDeployments.map((deployment) => (
<InfrastructureRegistryRow key={deployment.deploymentRef} label="DEPLOYMENT" title={deployment.displayName} description={`${deployment.artifactRef} · ${shortDigest(deployment.artifactDigest)}`} status={deployment.lifecycleState} />
))}</div> : <p>Развёртывания не зарегистрированы.</p>}
</section>
<section>
<header><span className="infrastructure-eyebrow">SERVICES</span><strong>{hostServices.length}</strong></header>
{hostServices.length ? <div className="infrastructure-registry-list">{hostServices.map((service) => {
const edge = service.edgeRef ? workspace.edges.find((item) => item.edgeRef === service.edgeRef) : null;
const runtimeState = edge?.channel.runtimeState || service.health.state;
return <InfrastructureRegistryRow key={service.serviceInstanceRef} label={service.serviceRole} title={service.displayName} description={`${service.serviceKey} · ${edge?.displayName || "Edge не связан"}`} status={runtimeState} />;
})}</div> : <p>Сервисы не зарегистрированы.</p>}
</section>
</div>
</div>
) : null}
</article>
);
})}
</div>
) : <div className="infrastructure-empty">VPS и хосты для проекта пока не зарегистрированы.</div>}
</section>
<section className="infrastructure-section infrastructure-assets-block">
<InfrastructureSectionHeading
eyebrow="DEVICE ASSETS"
title="Объекты и трекеры"
description="Стабильные объекты проекта и история привязанных к ним устройств."
status={russianCount(topology.assets.length, "объект", "объекта", "объектов")}
actions={<>
{canManageAssets ? <Button size="compact" onClick={onCreateAsset}>Новый Asset</Button> : null}
{canManageBindings ? <Button size="compact" variant="primary" onClick={onCreateAssetBinding} disabled={!topology.assets.length || !workspace.devices.length}>Привязать tracker</Button> : null}
</>}
/>
{topology.assets.length ? (
<div className="infrastructure-asset-grid">
{topology.assets.map((asset) => {
const activeBindings = topology.assetBindings.filter((binding) => binding.assetRef === asset.assetRef && !binding.validTo);
return (
<GlassSurface className="infrastructure-asset-card" padding="md" tone="soft" key={asset.assetRef}>
<div><span className="infrastructure-eyebrow">{asset.assetTypeRef}</span><strong>{asset.displayName}</strong><small>{asset.assetKey}</small></div>
<div><StatusBadge tone={statusTone(asset.lifecycleState)}>{asset.lifecycleState}</StatusBadge><small>{activeBindings.length} активных привязок</small></div>
</GlassSurface>
);
})}
</div>
) : <div className="infrastructure-empty">Объекты проекта пока не созданы.</div>}
{topology.assetBindings.length ? (
<div className="infrastructure-registry-list">
{topology.assetBindings.map((binding) => (
<div className="infrastructure-registry-row" key={binding.assetBindingRef}>
<div><span className="infrastructure-eyebrow">DEVICE ↔ ASSET</span><strong>{binding.deviceName} → {binding.assetName}</strong><small>{binding.bindingKind} · {formatDate(binding.validFrom)}</small></div>
<div><StatusBadge tone={binding.validTo ? "neutral" : "success"}>{binding.validTo ? "Закрыта" : "Активна"}</StatusBadge>{!binding.validTo && canManageBindings ? <Button size="compact" onClick={() => onCloseAssetBinding(binding)}>Закрыть</Button> : null}</div>
</div>
))}
</div>
) : null}
</section>
</div>
);
}
function HostTelemetryWorkspace({
host,
services,
onBack,
onPoll,
onError,
}: {
host: InfrastructureHostView;
services: InfrastructureServiceInstanceView[];
onBack: () => void;
onPoll: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const workspaceRef = useRef<HTMLDivElement>(null);
useEffect(() => {
resetApplicationPanelScroll(workspaceRef.current);
}, [host.hostRef]);
useEffect(() => {
let active = true;
const timer = window.setInterval(() => {
onPoll().catch((reason) => active && onError(reason));
}, 3_000);
return () => {
active = false;
window.clearInterval(timer);
};
}, [onError, onPoll]);
const telemetry = host.telemetry;
const current = telemetry.current;
const networkRate = calculateNetworkRate(telemetry.history);
const cpuHistory = telemetry.history.map((sample) => sample.cpuUsagePercent);
const memoryHistory = telemetry.history.map((sample) => sample.memoryUsedPercent);
const receiveHistory = calculateNetworkRateHistory(telemetry.history, "received");
const sendHistory = calculateNetworkRateHistory(telemetry.history, "sent");
const cpuDomain = percentageTelemetryWindow(cpuHistory, 5);
const memoryDomain = percentageTelemetryWindow(memoryHistory, 4);
const runtimeServices = current?.services ?? [];
const networkInterfaces = current?.network.filter((item) => item.interface !== "lo") ?? [];
return (
<div className="infrastructure-system-workspace host-monitoring-workspace" ref={workspaceRef}>
<section className="infrastructure-workspace-lead">
<div>
<span className="infrastructure-eyebrow">СИСТЕМА / ВЫЧИСЛИТЕЛЬНЫЕ МОДУЛИ</span>
<h2>{host.displayName}</h2>
<p>Аппаратный и процессинговый срез выбранного VPS. Последнее обновление: {formatDate(telemetry.observedAt)}.</p>
</div>
<div className="infrastructure-workspace-actions">
<StatusBadge tone={freshnessTone(telemetry.freshness)}>{freshnessLabel(telemetry.freshness)}</StatusBadge>
<IconButton label="Обновить телеметрию" onClick={() => onPoll().catch(onError)}>
<Icon name="refresh" size={17} />
</IconButton>
<IconButton label="Вернуться к VPS и хостам" onClick={onBack}>
<Icon name="chevron-left" size={18} />
</IconButton>
</div>
</section>
<section className="host-monitoring-series-grid" aria-label="Аппаратная телеметрия VPS">
<HostTelemetrySeries label="CPU" value={formatPercent(current?.cpu.usagePercent)} resource={formatLoad(current?.cpu)} values={cpuHistory} domain={cpuDomain} />
<HostTelemetrySeries label="RAM" value={formatPercent(current?.memory.usedPercent)} resource={formatUsedTotal(current?.memory.usedBytes, current?.memory.totalBytes)} values={memoryHistory} domain={memoryDomain} />
<HostTelemetrySeries label="NETWORK RX" value={formatRate(networkRate.received)} resource={networkRate.received == null ? "нет данных" : "входящий трафик"} values={receiveHistory} />
<HostTelemetrySeries label="NETWORK TX" value={formatRate(networkRate.sent)} resource={networkRate.sent == null ? "нет данных" : "исходящий трафик"} values={sendHistory} />
</section>
<GlassSurface className="host-monitoring-hardware" padding="lg">
<InfrastructureSectionHeading
eyebrow="HARDWARE"
title={current?.hardware.hostname ?? host.hostKey}
description={[current?.hardware.platform, current?.hardware.architecture].filter(Boolean).join(" / ") || "Аппаратный профиль недоступен"}
status={telemetry.state}
statusTone={freshnessTone(telemetry.freshness)}
/>
<div className="host-monitoring-hardware-facts">
<dl>
<TelemetryFact label="Процессор" value={current?.hardware.cpuModel} />
<TelemetryFact label="Логические ядра" value={formatNullable(current?.hardware.logicalProcessors)} />
<TelemetryFact label="Память занята" value={formatUsedTotal(current?.memory.usedBytes, current?.memory.totalBytes)} />
<TelemetryFact label="Uptime" value={formatDuration(current?.system.uptimeSeconds)} />
</dl>
<dl>
<TelemetryFact label="Платформа" value={[current?.hardware.platform, current?.hardware.architecture].filter(Boolean).join(" / ") || null} />
<TelemetryFact label="Kernel" value={current?.hardware.kernelRelease} />
<TelemetryFact label="Процессы" value={formatNullable(current?.system.processes.total)} />
<TelemetryFact label="Load average" value={formatLoad(current?.cpu)} />
</dl>
</div>
<div className="host-monitoring-disk-list">
{(current?.disks ?? []).map((disk, index) => (
<div key={`${disk.device}:${disk.mount}:${index}`}>
<span>Диск {disk.mount ?? disk.device ?? "—"}</span>
<strong>{formatUsedTotal(disk.usedBytes, disk.totalBytes)}</strong>
</div>
))}
{!current?.disks.length ? <div className="infrastructure-empty">Данные о дисках ещё не поступили.</div> : null}
</div>
</GlassSurface>
<section className="infrastructure-section">
<InfrastructureSectionHeading
eyebrow="NETWORK"
title="Сетевые интерфейсы"
description="Счётчики трафика и ошибок по активным интерфейсам VPS."
status={russianCount(networkInterfaces.length, "интерфейс", "интерфейса", "интерфейсов")}
/>
{networkInterfaces.length ? (
<div className="host-monitoring-network-grid">
{networkInterfaces.map((item, index) => (
<GlassSurface className="host-monitoring-network-card" padding="md" tone="soft" key={`${item.interface}:${index}`}>
<header><strong>{item.interface ?? "Интерфейс"}</strong><small>{formatPackets(item.packetsReceived, item.packetsSent)}</small></header>
<dl>
<div><dt>Получено</dt><dd>{formatMetricBytes(item.bytesReceived)}</dd></div>
<div><dt>Отправлено</dt><dd>{formatMetricBytes(item.bytesSent)}</dd></div>
<div><dt>Ошибки RX / TX</dt><dd>{formatNullable(item.errorsReceived)} / {formatNullable(item.errorsSent)}</dd></div>
<div><dt>Потери RX / TX</dt><dd>{formatNullable(item.droppedReceived)} / {formatNullable(item.droppedSent)}</dd></div>
</dl>
</GlassSurface>
))}
</div>
) : <div className="infrastructure-empty">Сетевые счётчики ещё не поступили.</div>}
</section>
<section className="infrastructure-section">
<InfrastructureSectionHeading
eyebrow="PROCESSING RUNTIME"
title="Сервисы VPS"
description={`Состояние systemd-юнитов; с хостом связано ${russianCount(services.length, "сервис", "сервиса", "сервисов")} Device Core.`}
status={russianCount(runtimeServices.length, "юнит", "юнита", "юнитов")}
statusTone={runtimeServices.some((service) => service.activeState === "failed") ? "danger" : runtimeServices.length ? "success" : "warning"}
/>
{runtimeServices.length ? (
<div className="infrastructure-runtime-grid">
{runtimeServices.map((service, index) => (
<GlassSurface className="host-monitoring-runtime-card" padding="md" tone="soft" key={`${service.name}:${index}`}>
<header>
<div><span className="infrastructure-eyebrow">SYSTEMD UNIT</span><h3>{service.name ?? "systemd unit"}</h3><code>{service.subState ?? "—"}</code></div>
<StatusBadge tone={service.activeState === "active" ? "success" : service.activeState === "failed" ? "danger" : "warning"}>{service.activeState ?? "unknown"}</StatusBadge>
</header>
<dl>
<div><dt>Load</dt><dd>{service.loadState ?? "—"}</dd></div>
<div><dt>Память</dt><dd>{formatMetricBytes(service.memoryBytes)}</dd></div>
<div><dt>Перезапуски</dt><dd>{formatNullable(service.restarts)}</dd></div>
<div><dt>PID</dt><dd>{formatNullable(service.pid)}</dd></div>
</dl>
</GlassSurface>
))}
</div>
) : <div className="infrastructure-empty">Состояние сервисов ещё не поступило.</div>}
</section>
</div>
);
}
function InfrastructureCount({ label, value, detail }: { label: string; value: number; detail: string }) {
return (
<div className="infrastructure-count-card">
<span>{label}</span><strong>{value}</strong><small>{detail}</small>
</div>
);
}
function InfrastructureSectionHeading({ eyebrow, title, description, status, statusTone: tone = "neutral", actions = null }: {
eyebrow: string;
title: string;
description: string;
status: string;
statusTone?: "neutral" | "success" | "warning" | "danger";
actions?: ReactNode;
}) {
return (
<header className="infrastructure-section-heading">
<div><span className="infrastructure-eyebrow">{eyebrow}</span><h3>{title}</h3><p>{description}</p></div>
<div className="infrastructure-section-actions"><StatusBadge tone={tone}>{status}</StatusBadge>{actions}</div>
</header>
);
}
function InfrastructureRegistryRow({ label, title, description, status }: { label: string; title: string; description: string; status: string }) {
return (
<div className="infrastructure-registry-row">
<div><span className="infrastructure-eyebrow">{label}</span><strong>{title}</strong><small>{description}</small></div>
<StatusBadge tone={statusTone(status)}>{status}</StatusBadge>
</div>
);
}
type TelemetryLineDomain = {
minimum: number;
maximum: number;
label?: string;
};
function HostTelemetrySeries({ label, values, value, resource, domain }: { label: string; values: Array<number | null>; value: string; resource?: string | null; domain?: TelemetryLineDomain | null }) {
const points = telemetryLinePoints(values, domain);
return (
<div className="host-monitoring-series">
<div>
<span className="host-monitoring-series__label"><span>{label}</span>{resource ? <small>{resource}</small> : null}</span>
<strong>{value}</strong>
</div>
<svg viewBox="0 0 100 38" preserveAspectRatio="none" role="img" aria-label={`${label}: ${value}`}>
<path d="M0 37 H100" />
{points ? <polyline points={points} /> : null}
</svg>
{domain?.label ? <small className="host-monitoring-series__range">{domain.label}</small> : null}
</div>
);
}
function telemetryLinePoints(values: Array<number | null>, domain?: TelemetryLineDomain | null) {
const finite = values.filter((value): value is number => value !== null && Number.isFinite(value));
if (!finite.length) return "";
const minimum = domain?.minimum ?? 0;
const maximum = Math.max(domain?.maximum ?? Math.max(...finite, 1), minimum + Number.EPSILON);
const range = maximum - minimum;
const denominator = Math.max(1, values.length - 1);
return values.flatMap((value, index) => {
if (value === null || !Number.isFinite(value)) return [];
const x = index / denominator * 100;
const normalized = Math.max(minimum, Math.min(maximum, value));
const y = 36 - (normalized - minimum) / range * 34;
return [`${x.toFixed(2)},${y.toFixed(2)}`];
}).join(" ");
}
function percentageTelemetryWindow(values: Array<number | null>, minimumSpan: number): TelemetryLineDomain | null {
const finite = values
.filter((value): value is number => value !== null && Number.isFinite(value))
.map((value) => Math.max(0, Math.min(100, value)));
if (!finite.length) return null;
const observedMinimum = Math.min(...finite);
const observedMaximum = Math.max(...finite);
const padding = Math.max(0.5, (observedMaximum - observedMinimum) * 0.15);
let minimum = observedMinimum - padding;
let maximum = observedMaximum + padding;
if (maximum - minimum < minimumSpan) {
const center = (observedMinimum + observedMaximum) / 2;
minimum = center - minimumSpan / 2;
maximum = center + minimumSpan / 2;
}
if (minimum < 0) {
maximum = Math.min(100, maximum - minimum);
minimum = 0;
}
if (maximum > 100) {
minimum = Math.max(0, minimum - (maximum - 100));
maximum = 100;
}
minimum = Math.floor(minimum * 10) / 10;
maximum = Math.ceil(maximum * 10) / 10;
return {
minimum,
maximum,
label: `шкала ${formatScalePercent(minimum)}–${formatScalePercent(maximum)}`,
};
}
function formatScalePercent(value: number) {
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}%`;
}
function TelemetryFact({ label, value }: { label: string; value: string | null | undefined }) {
return <div><dt>{label}</dt><dd>{value || "—"}</dd></div>;
}
function freshnessTone(freshness: "fresh" | "stale" | "missing"): "success" | "warning" | "danger" {
if (freshness === "fresh") return "success";
if (freshness === "stale") return "warning";
return "danger";
}
function freshnessLabel(freshness: "fresh" | "stale" | "missing") {
if (freshness === "fresh") return "Свежие данные";
if (freshness === "stale") return "Данные устарели";
return "Нет данных";
}
function russianCount(value: number, one: string, few: string, many: string) {
const absolute = Math.abs(value) % 100;
const last = absolute % 10;
const form = absolute > 10 && absolute < 20 ? many : last === 1 ? one : last > 1 && last < 5 ? few : many;
return `${new Intl.NumberFormat("ru-RU").format(value)} ${form}`;
}
function resetApplicationPanelScroll(element: HTMLElement | null) {
const scroller = element?.closest<HTMLElement>(".nodedc-application-panel__body");
if (scroller) scroller.scrollTop = 0;
}
function calculateNetworkRate(history: InfrastructureHostView["telemetry"]["history"]) {
if (history.length < 2) return { received: null, sent: null };
const previous = history[history.length - 2];
const latest = history[history.length - 1];
const seconds = (new Date(latest.observedAt).valueOf() - new Date(previous.observedAt).valueOf()) / 1000;
if (!Number.isFinite(seconds) || seconds <= 0) return { received: null, sent: null };
const previousTotals = networkTotals(previous.network);
const latestTotals = networkTotals(latest.network);
if (!previousTotals || !latestTotals) return { received: null, sent: null };
return {
received: latestTotals.received == null || previousTotals.received == null ? null : nonNegativeRate(latestTotals.received - previousTotals.received, seconds),
sent: latestTotals.sent == null || previousTotals.sent == null ? null : nonNegativeRate(latestTotals.sent - previousTotals.sent, seconds),
};
}
function calculateNetworkRateHistory(history: InfrastructureHostView["telemetry"]["history"], direction: "received" | "sent") {
return history.slice(1).map((sample, index) => {
const previous = history[index];
const seconds = (new Date(sample.observedAt).valueOf() - new Date(previous.observedAt).valueOf()) / 1000;
if (seconds <= 0) return null;
const currentTotals = networkTotals(sample.network);
const previousTotals = networkTotals(previous.network);
if (!currentTotals || !previousTotals || currentTotals[direction] == null || previousTotals[direction] == null) return null;
return nonNegativeRate(currentTotals[direction] - previousTotals[direction], seconds);
});
}
function networkTotals(network: InfrastructureHostView["telemetry"]["history"][number]["network"]) {
const counters = network.filter((item) => item.interface !== "lo" && (item.bytesReceived != null || item.bytesSent != null));
if (!counters.length) return null;
const receivedCounters = counters.map((item) => item.bytesReceived).filter((value): value is number => value !== null && Number.isFinite(value));
const sentCounters = counters.map((item) => item.bytesSent).filter((value): value is number => value !== null && Number.isFinite(value));
return {
received: receivedCounters.length ? receivedCounters.reduce((total, value) => total + value, 0) : null,
sent: sentCounters.length ? sentCounters.reduce((total, value) => total + value, 0) : null,
};
}
function nonNegativeRate(bytes: number, seconds: number) {
const value = bytes / seconds;
return Number.isFinite(value) && value >= 0 ? value : null;
}
function formatPercent(value: number | null | undefined) {
return value == null ? "—" : `${value.toFixed(value >= 10 ? 0 : 1)}%`;
}
function formatRate(value: number | null) {
return value == null ? "—" : `${formatMetricBytes(value)}/s`;
}
function formatMetricBytes(value: number | null | undefined) {
if (value == null || !Number.isFinite(value)) return "—";
if (value < 1024) return `${Math.round(value)} B`;
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KiB`;
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MiB`;
return `${(value / 1024 ** 3).toFixed(1)} GiB`;
}
function formatUsedTotal(used: number | null | undefined, total: number | null | undefined) {
return used == null || total == null ? "—" : `${formatMetricBytes(used)} / ${formatMetricBytes(total)}`;
}
function formatNullable(value: number | null | undefined) {
return value == null ? "—" : new Intl.NumberFormat("ru-RU").format(value);
}
function formatDuration(seconds: number | null | undefined) {
if (seconds == null) return "—";
const days = Math.floor(seconds / 86_400);
const hours = Math.floor((seconds % 86_400) / 3_600);
const minutes = Math.floor((seconds % 3_600) / 60);
return [days ? `${days} д` : null, hours ? `${hours} ч` : null, `${minutes} мин`].filter(Boolean).join(" ");
}
function formatLoad(cpu: { load1: number | null; load5: number | null; load15: number | null } | null | undefined) {
if (!cpu || cpu.load1 == null) return "load average —";
return `load ${[cpu.load1, cpu.load5, cpu.load15].map((value) => value?.toFixed(2) ?? "—").join(" / ")}`;
}
function formatPackets(received: number | null | undefined, sent: number | null | undefined) {
return `↓ ${formatNullable(received)} пакетов · ↑ ${formatNullable(sent)} пакетов`;
}
function SessionsView({ workspace }: { workspace: ProjectWorkspace }) {
return (
<ControlStack>
@@ -701,6 +1384,226 @@ function ModelProfileDialog({ versions, ...props }: DialogBaseProps & { versions
</FormWindow>;
}
function AssetDialog({ projectRef, ...props }: DialogBaseProps & { projectRef: string }) {
const [assetKey, setAssetKey] = useState("");
const [displayName, setDisplayName] = useState("");
const [assetTypeRef, setAssetTypeRef] = useState("asset-type:delivery-trike");
return <FormWindow {...props} id="asset-form" title="Новый Asset" submit={async () => {
await ensureAsset({
projectRef,
assetKey,
displayName,
assetTypeRef,
lifecycleState: "active",
});
}}>
<KeyField label="Asset key" value={assetKey} onChange={setAssetKey} />
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
<TextField label="Asset type ref" value={assetTypeRef} onChange={(event) => setAssetTypeRef(event.target.value)} required description="Канонический тип или стабильная ссылка на тип, не модель трекера." />
</FormWindow>;
}
function AssetBindingDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
const [deviceRef, setDeviceRef] = useState(workspace.devices[0]?.deviceRef ?? "");
const [assetRef, setAssetRef] = useState(workspace.ontology.assets[0]?.assetRef ?? "");
const [bindingKey, setBindingKey] = useState("");
const [bindingKind, setBindingKind] = useState<"tracking" | "installed" | "assigned">("tracking");
const [provenanceRef, setProvenanceRef] = useState("onboarding:device-manager");
useEffect(() => {
if (!workspace.devices.some((item) => item.deviceRef === deviceRef)) {
setDeviceRef(workspace.devices[0]?.deviceRef ?? "");
}
if (!workspace.ontology.assets.some((item) => item.assetRef === assetRef)) {
setAssetRef(workspace.ontology.assets[0]?.assetRef ?? "");
}
}, [assetRef, deviceRef, workspace]);
return <FormWindow {...props} id="asset-binding-form" title="Привязать Device к Asset" disabled={!deviceRef || !assetRef} submit={async () => {
await ensureAssetBinding({
projectRef: workspace.project.projectRef,
bindingKey,
deviceRef,
assetRef,
bindingKind,
validFrom: new Date().toISOString(),
provenanceRef,
});
}}>
<Select label="Device" value={deviceRef} onChange={setDeviceRef} options={workspace.devices.map((item) => ({ value: item.deviceRef, label: item.displayName, description: item.modelProfileRef }))} />
<Select label="Asset" value={assetRef} onChange={setAssetRef} options={workspace.ontology.assets.map((item) => ({ value: item.assetRef, label: item.displayName, description: item.assetTypeRef }))} />
<KeyField label="Binding key" value={bindingKey} onChange={setBindingKey} />
<Select label="Relation" value={bindingKind} onChange={setBindingKind} options={[
{ value: "tracking", label: "Tracking" },
{ value: "installed", label: "Installed" },
{ value: "assigned", label: "Assigned" },
]} />
<TextField label="Provenance ref" value={provenanceRef} onChange={(event) => setProvenanceRef(event.target.value)} required />
</FormWindow>;
}
function HostDialog({ projectRef, ...props }: DialogBaseProps & { projectRef: string }) {
const [hostKey, setHostKey] = useState("");
const [displayName, setDisplayName] = useState("");
const [providerRef, setProviderRef] = useState("");
const [externalRef, setExternalRef] = useState("");
const [credentialRef, setCredentialRef] = useState("");
return <FormWindow {...props} id="host-form" title="Новый VPS / Host" submit={async () => {
await ensureInfrastructureHost({
projectRef,
hostKey,
displayName,
providerRef: providerRef || null,
externalRef: externalRef || null,
managementCredentialRef: credentialRef || null,
lifecycleState: "active",
});
}}>
<KeyField label="Host key" value={hostKey} onChange={setHostKey} />
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
<TextField label="Provider ref" value={providerRef} onChange={(event) => setProviderRef(event.target.value)} placeholder="provider:beget" />
<TextField label="External resource ref" value={externalRef} onChange={(event) => setExternalRef(event.target.value)} placeholder="provider-resource:vps-123" />
<TextField label="Management credential ref" value={credentialRef} onChange={(event) => setCredentialRef(event.target.value)} placeholder="secret-ref:device-core/host-key" description="Только server-side secret reference. Пароль или приватный ключ сюда вводить нельзя." />
</FormWindow>;
}
function EndpointDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
const hosts = workspace.ontology.hosts;
const [hostRef, setHostRef] = useState(hosts[0]?.hostRef ?? "");
const [endpointKey, setEndpointKey] = useState("");
const [purpose, setPurpose] = useState<"management" | "service" | "monitoring">("management");
const [endpointUri, setEndpointUri] = useState("");
useEffect(() => {
if (!hosts.some((item) => item.hostRef === hostRef)) setHostRef(hosts[0]?.hostRef ?? "");
}, [hostRef, hosts]);
return <FormWindow {...props} id="endpoint-form" title="Host endpoint" disabled={!hostRef} submit={async () => {
await ensureInfrastructureEndpoint({
projectRef: workspace.project.projectRef,
hostRef,
endpointKey,
purpose,
endpointUri,
lifecycleState: "active",
});
}}>
<Select label="Host" value={hostRef} onChange={setHostRef} options={hosts.map((item) => ({ value: item.hostRef, label: item.displayName }))} />
<KeyField label="Endpoint key" value={endpointKey} onChange={setEndpointKey} />
<Select label="Purpose" value={purpose} onChange={setPurpose} options={[
{ value: "management", label: "Management" },
{ value: "monitoring", label: "Monitoring" },
{ value: "service", label: "Service" },
]} />
<TextField label="Endpoint URI" value={endpointUri} onChange={(event) => setEndpointUri(event.target.value)} required placeholder="ssh://203.0.113.10:22/" description="HTTPS, SSH или TCP. URI с userinfo, query или fragment будет отклонён." />
</FormWindow>;
}
function DeploymentDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
const hosts = workspace.ontology.hosts;
const [hostRef, setHostRef] = useState(hosts[0]?.hostRef ?? "");
const [deploymentKey, setDeploymentKey] = useState("");
const [displayName, setDisplayName] = useState("");
const [artifactRef, setArtifactRef] = useState("");
const [artifactDigest, setArtifactDigest] = useState("");
useEffect(() => {
if (!hosts.some((item) => item.hostRef === hostRef)) setHostRef(hosts[0]?.hostRef ?? "");
}, [hostRef, hosts]);
return <FormWindow {...props} id="deployment-form" title="Infrastructure deployment" disabled={!hostRef} submit={async () => {
await ensureInfrastructureDeployment({
projectRef: workspace.project.projectRef,
hostRef,
deploymentKey,
displayName,
artifactRef,
artifactDigest,
lifecycleState: "active",
});
}}>
<Select label="Host" value={hostRef} onChange={setHostRef} options={hosts.map((item) => ({ value: item.hostRef, label: item.displayName }))} />
<KeyField label="Deployment key" value={deploymentKey} onChange={setDeploymentKey} />
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
<TextField label="Artifact ref" value={artifactRef} onChange={(event) => setArtifactRef(event.target.value)} required placeholder="artifact:device-edge/1.0.0" />
<TextField label="Artifact digest" value={artifactDigest} onChange={(event) => setArtifactDigest(event.target.value)} required placeholder="sha256:…" />
</FormWindow>;
}
function ServiceInstanceDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
const hosts = workspace.ontology.hosts;
const [hostRef, setHostRef] = useState(hosts[0]?.hostRef ?? "");
const matchingDeployments = workspace.ontology.deployments.filter((item) => item.hostRef === hostRef);
const [deploymentRef, setDeploymentRef] = useState(matchingDeployments[0]?.deploymentRef ?? "");
const [edgeRef, setEdgeRef] = useState("");
const [serviceKey, setServiceKey] = useState("");
const [displayName, setDisplayName] = useState("");
const [serviceRole, setServiceRole] = useState("device.edge");
useEffect(() => {
if (!hosts.some((item) => item.hostRef === hostRef)) setHostRef(hosts[0]?.hostRef ?? "");
if (!matchingDeployments.some((item) => item.deploymentRef === deploymentRef)) {
setDeploymentRef(matchingDeployments[0]?.deploymentRef ?? "");
}
}, [deploymentRef, hostRef, hosts, matchingDeployments]);
return <FormWindow {...props} id="service-instance-form" title="Service instance" disabled={!hostRef || !deploymentRef} submit={async () => {
await ensureInfrastructureServiceInstance({
projectRef: workspace.project.projectRef,
hostRef,
deploymentRef,
edgeRef: edgeRef || null,
serviceKey,
displayName,
serviceRole,
lifecycleState: "active",
});
}}>
<Select label="Host" value={hostRef} onChange={setHostRef} options={hosts.map((item) => ({ value: item.hostRef, label: item.displayName }))} />
<Select label="Deployment" value={deploymentRef} onChange={setDeploymentRef} options={matchingDeployments.map((item) => ({ value: item.deploymentRef, label: item.displayName }))} />
<Select label="Edge role" value={edgeRef} onChange={setEdgeRef} options={[
{ value: "", label: "Без Edge registration" },
...workspace.edges.map((item) => ({ value: item.edgeRef, label: item.displayName, description: item.channel.runtimeState })),
]} />
<KeyField label="Service key" value={serviceKey} onChange={setServiceKey} />
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
<TextField label="Service role" value={serviceRole} onChange={(event) => setServiceRole(event.target.value.toLowerCase())} required placeholder="device.edge" />
</FormWindow>;
}
function HealthObservationDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
const targets = [
...workspace.ontology.hosts.map((item) => ({ value: `host|${item.hostRef}`, label: item.displayName, description: "Host" })),
...workspace.ontology.serviceInstances.map((item) => ({ value: `service-instance|${item.serviceInstanceRef}`, label: item.displayName, description: "Service instance" })),
];
const [target, setTarget] = useState(targets[0]?.value ?? "");
const [observedState, setObservedState] = useState<"reachable" | "degraded" | "unreachable">("reachable");
const [ttlMinutes, setTtlMinutes] = useState("5");
const [evidence, setEvidence] = useState("{}");
useEffect(() => {
if (!targets.some((item) => item.value === target)) setTarget(targets[0]?.value ?? "");
}, [target, targets]);
return <FormWindow {...props} id="health-observation-form" title="Health evidence" disabled={!target} submit={async () => {
const [subjectKind, subjectRef] = target.split("|");
const observedAt = new Date();
const ttl = Number(ttlMinutes);
await recordHealthObservation({
projectRef: workspace.project.projectRef,
subjectKind: subjectKind as "host" | "service-instance",
subjectRef,
observedState,
evidenceClass: "manual",
sourceRef: "device-manager:manual-observation",
schemaRef: "nodedc.health.manual.v1",
evidence: JSON.parse(evidence) as Record<string, unknown>,
observedAt: observedAt.toISOString(),
expiresAt: new Date(observedAt.valueOf() + ttl * 60_000).toISOString(),
});
}}>
<Select label="Subject" value={target} onChange={setTarget} options={targets} />
<Select label="Observed state" value={observedState} onChange={setObservedState} options={[
{ value: "reachable", label: "Reachable" },
{ value: "degraded", label: "Degraded" },
{ value: "unreachable", label: "Unreachable" },
]} />
<TextField label="TTL, минут" value={ttlMinutes} onChange={(event) => setTtlMinutes(event.target.value)} required inputMode="numeric" />
<TextAreaField label="Bounded evidence JSON" value={evidence} onChange={(event) => setEvidence(event.target.value)} required />
<p className="device-manager-card-copy">Это ручное наблюдение с TTL. Автоматический probe должен писать тот же canonical contract от собственного source ref.</p>
</FormWindow>;
}
function EdgeDialog(props: DialogBaseProps) {
const [edgeKey, setEdgeKey] = useState("");
const [displayName, setDisplayName] = useState("");
@@ -931,9 +1834,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";
}
+5 -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,12 +800,13 @@ 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}
session={session}
onRefresh={onRefresh}
onPoll={onPoll}
onError={onError}
/>;
}
@@ -1455,6 +1457,7 @@ function viewTitle(view: ViewId) {
inventory: "Устройства",
collections: "Коллекции",
catalog: "Модели и адаптеры",
hosts: "VPS и хосты",
infrastructure: "Edges и маршруты",
sessions: "Gateway sessions",
bindings: "Data bindings",
@@ -1468,7 +1471,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";
}
+120
View File
@@ -1,10 +1,16 @@
import type {
AdapterPackageView,
AdapterVersionView,
AssetBindingView,
AssetView,
BindingView,
ConfigurationRevisionView,
EdgeView,
ModelProfileView,
InfrastructureDeploymentView,
InfrastructureEndpointView,
InfrastructureHostView,
InfrastructureServiceInstanceView,
ProjectGrantView,
DeviceManagerSession,
DeviceManagerPresentation,
@@ -144,6 +150,120 @@ export async function ensureEnrollmentIntent(input: {
return mutate("/api/device-manager/enrollment-intents:ensure", input);
}
export async function ensureAsset(input: {
projectRef: string;
assetKey: string;
displayName: string;
assetTypeRef: string;
lifecycleState?: "active" | "retired";
}) {
return mutate<{ created: boolean; asset: AssetView }>(
"/api/device-manager/assets:ensure",
input,
);
}
export async function ensureAssetBinding(input: {
projectRef: string;
bindingKey: string;
deviceRef: string;
assetRef: string;
bindingKind: "tracking" | "installed" | "assigned";
validFrom: string;
provenanceRef: string;
}) {
return mutate<{ created: boolean; assetBinding: AssetBindingView }>(
"/api/device-manager/asset-bindings:ensure",
input,
);
}
export async function closeAssetBinding(input: {
projectRef: string;
assetBindingRef: string;
validTo: string;
}) {
return mutate<{ closed: boolean; assetBinding: AssetBindingView }>(
"/api/device-manager/asset-bindings:close",
input,
);
}
export async function ensureInfrastructureHost(input: {
projectRef: string;
hostKey: string;
displayName: string;
providerRef: string | null;
externalRef: string | null;
managementCredentialRef: string | null;
lifecycleState: "provisioning" | "active" | "suspended" | "retired";
}) {
return mutate<{ created: boolean; host: InfrastructureHostView }>(
"/api/device-manager/infrastructure-hosts:ensure",
input,
);
}
export async function ensureInfrastructureEndpoint(input: {
projectRef: string;
hostRef: string;
endpointKey: string;
purpose: "management" | "service" | "monitoring";
endpointUri: string;
lifecycleState: "active" | "disabled" | "retired";
}) {
return mutate<{ created: boolean; endpoint: InfrastructureEndpointView }>(
"/api/device-manager/infrastructure-endpoints:ensure",
input,
);
}
export async function ensureInfrastructureDeployment(input: {
projectRef: string;
hostRef: string;
deploymentKey: string;
displayName: string;
artifactRef: string;
artifactDigest: string;
lifecycleState: "desired" | "applying" | "active" | "failed" | "retired";
}) {
return mutate<{ created: boolean; deployment: InfrastructureDeploymentView }>(
"/api/device-manager/infrastructure-deployments:ensure",
input,
);
}
export async function ensureInfrastructureServiceInstance(input: {
projectRef: string;
hostRef: string;
deploymentRef: string;
edgeRef: string | null;
serviceKey: string;
displayName: string;
serviceRole: string;
lifecycleState: "provisioning" | "active" | "degraded" | "stopped" | "retired";
}) {
return mutate<{ created: boolean; serviceInstance: InfrastructureServiceInstanceView }>(
"/api/device-manager/infrastructure-service-instances:ensure",
input,
);
}
export async function recordHealthObservation(input: {
projectRef: string;
subjectKind: "host" | "service-instance";
subjectRef: string;
observedState: "reachable" | "degraded" | "unreachable";
evidenceClass: "agent_probe" | "channel" | "management_probe" | "manual";
sourceRef: string;
schemaRef: string;
evidence: Record<string, unknown>;
observedAt: string;
expiresAt: string;
}) {
return mutate("/api/device-manager/health-observations:record", input);
}
export async function upsertProjectGrant(input: {
projectRef: string;
principalKind: "user" | "group";
+704 -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);
@@ -122,6 +127,655 @@
padding-right: 3px;
}
.infrastructure-system-workspace {
--infrastructure-panel-soft: var(--nodedc-canvas-soft);
--infrastructure-accent-soft: color-mix(in srgb, var(--nodedc-text-primary) 4.5%, transparent);
--infrastructure-hairline: color-mix(in srgb, var(--nodedc-text-primary) 8%, transparent);
display: grid;
min-width: 0;
gap: 1rem;
padding-bottom: 1rem;
}
.infrastructure-overview-block,
.infrastructure-hosts-block,
.infrastructure-assets-block {
min-width: 0;
border-radius: var(--nodedc-radius-card);
background: var(--infrastructure-panel-soft);
padding: 1rem;
}
.infrastructure-system-workspace .nodedc-status {
min-height: auto;
justify-content: flex-start;
gap: 0.42rem;
border-radius: 0;
background: transparent;
color: var(--nodedc-text-primary);
padding: 0;
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-medium);
}
.infrastructure-system-workspace .nodedc-status::before {
width: 0.42rem;
height: 0.42rem;
flex: 0 0 0.42rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.infrastructure-system-workspace .nodedc-status[data-tone="success"],
.infrastructure-system-workspace .nodedc-status[data-tone="warning"],
.infrastructure-system-workspace .nodedc-status[data-tone="danger"],
.infrastructure-system-workspace .nodedc-status[data-tone="accent"] {
background: transparent;
color: var(--nodedc-text-primary);
}
.infrastructure-system-workspace .nodedc-status[data-tone="success"]::before {
background: rgb(var(--nodedc-success-rgb));
}
.infrastructure-system-workspace .nodedc-status[data-tone="warning"]::before {
background: rgb(var(--nodedc-warning-rgb));
}
.infrastructure-system-workspace .nodedc-status[data-tone="danger"]::before {
background: rgb(var(--nodedc-danger-rgb));
}
.infrastructure-system-workspace .nodedc-status[data-tone="accent"]::before {
background: var(--nodedc-text-primary);
}
.infrastructure-eyebrow {
display: block;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
font-weight: 820;
letter-spacing: 0.12em;
line-height: 1.2;
text-transform: uppercase;
}
.infrastructure-workspace-lead,
.infrastructure-section-heading {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 1.2rem;
}
.infrastructure-workspace-lead {
padding: 0;
}
.infrastructure-workspace-lead h2,
.infrastructure-section-heading h3 {
margin: 0.4rem 0 0;
color: var(--nodedc-text-primary);
letter-spacing: -0.035em;
}
.infrastructure-workspace-lead h2 {
font-size: 1.42rem;
}
.infrastructure-section-heading h3 {
font-size: 1.02rem;
}
.infrastructure-workspace-lead p,
.infrastructure-section-heading p {
max-width: 48rem;
margin: 0.42rem 0 0;
color: var(--nodedc-text-muted);
font-size: 0.68rem;
line-height: 1.48;
}
.infrastructure-workspace-actions,
.infrastructure-section-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.55rem;
}
.infrastructure-overview-grid,
.host-monitoring-series-grid {
display: grid;
min-width: 0;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
}
.infrastructure-overview-block .infrastructure-overview-grid {
margin-top: 1rem;
}
.infrastructure-count-card,
.host-monitoring-series {
min-width: 0;
border-radius: var(--nodedc-radius-card);
background: var(--infrastructure-panel-soft);
}
.infrastructure-count-card {
display: grid;
align-content: center;
gap: 0.3rem;
min-height: 5.4rem;
padding: 0.82rem;
background: var(--infrastructure-accent-soft);
}
.infrastructure-count-card span,
.infrastructure-count-card small,
.host-monitoring-series span {
color: var(--nodedc-text-muted);
font-size: 0.62rem;
}
.infrastructure-count-card strong,
.host-monitoring-series strong {
color: var(--nodedc-text-primary);
font-size: 1.14rem;
font-weight: 720;
letter-spacing: -0.035em;
white-space: nowrap;
}
.infrastructure-count-card small {
overflow: hidden;
font-size: 0.55rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.infrastructure-section {
display: grid;
min-width: 0;
gap: 0.75rem;
}
.infrastructure-section--separated {
padding-top: 0.25rem;
border-top: 1px solid var(--infrastructure-hairline);
}
.infrastructure-runtime-grid,
.infrastructure-asset-grid,
.host-monitoring-network-grid {
display: grid;
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
}
.infrastructure-host-list {
display: grid;
min-width: 0;
gap: 0.5rem;
}
.infrastructure-host-card,
.infrastructure-runtime-card,
.host-monitoring-runtime-card,
.host-monitoring-network-card {
display: grid;
min-width: 0;
gap: 0.9rem;
}
.infrastructure-host-card {
overflow: hidden;
border-radius: var(--nodedc-radius-control);
background: var(--infrastructure-accent-soft);
}
.infrastructure-host-card__summary,
.infrastructure-runtime-card > header,
.host-monitoring-runtime-card > header {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.infrastructure-host-card__summary {
min-height: 4.35rem;
padding: 0.62rem 0.72rem;
}
.infrastructure-host-card__identity {
min-width: 0;
}
.infrastructure-host-card__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.6rem;
}
.infrastructure-host-card__freshness {
width: 0.48rem;
height: 0.48rem;
flex: 0 0 0.48rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
.infrastructure-host-card__freshness[data-freshness="fresh"] {
background: rgb(var(--nodedc-success-rgb));
}
.infrastructure-host-card__freshness[data-freshness="stale"] {
background: rgb(var(--nodedc-warning-rgb));
}
.infrastructure-host-card__toggle svg {
transition: transform 160ms ease;
}
.infrastructure-host-card[data-expanded="true"] .infrastructure-host-card__toggle svg {
transform: rotate(180deg);
}
.infrastructure-host-card h4,
.infrastructure-runtime-card h3,
.host-monitoring-runtime-card h3 {
margin: 0.38rem 0 0.18rem;
color: var(--nodedc-text-primary);
font-size: 0.9rem;
letter-spacing: -0.025em;
}
.infrastructure-host-card code,
.infrastructure-runtime-card code,
.host-monitoring-runtime-card code {
display: block;
max-width: 23rem;
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.56rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.infrastructure-host-card__details {
display: grid;
min-width: 0;
gap: 0.8rem;
padding: 0 0.72rem 0.72rem;
}
.infrastructure-host-card__facts {
display: grid;
min-width: 0;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0;
padding: 0.72rem;
border-radius: var(--nodedc-radius-control);
background: var(--infrastructure-panel-soft);
}
.infrastructure-host-card__facts > div {
display: grid;
min-width: 0;
gap: 0.22rem;
padding-right: 0.65rem;
}
.infrastructure-host-card__facts dt {
color: var(--nodedc-text-muted);
font-size: 0.58rem;
}
.infrastructure-host-card__facts dd {
overflow: hidden;
margin: 0;
color: var(--nodedc-text-primary);
font-size: 0.67rem;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.infrastructure-host-relations {
display: grid;
min-width: 0;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
}
.infrastructure-host-relations > section {
display: grid;
min-width: 0;
align-content: start;
gap: 0.55rem;
padding: 0.72rem;
border-radius: var(--nodedc-radius-control);
background: var(--infrastructure-panel-soft);
}
.infrastructure-host-relations > section > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
}
.infrastructure-host-relations > section > header strong {
color: var(--nodedc-text-primary);
font-size: 0.68rem;
}
.infrastructure-host-relations > section > p {
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.58rem;
line-height: 1.45;
}
.infrastructure-host-relations .infrastructure-registry-row {
padding: 0.58rem 0.62rem;
}
.infrastructure-host-relations .infrastructure-registry-row .nodedc-status {
font-size: 0;
}
.infrastructure-host-relations .infrastructure-registry-row .nodedc-status::before {
margin: 0;
}
.infrastructure-host-card dl,
.infrastructure-runtime-card dl,
.host-monitoring-runtime-card dl,
.host-monitoring-network-card dl {
display: grid;
min-width: 0;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0;
gap: 0;
}
.infrastructure-host-card dl > div,
.infrastructure-runtime-card dl > div,
.host-monitoring-runtime-card dl > div,
.host-monitoring-network-card dl > div {
display: grid;
min-width: 0;
gap: 0.22rem;
padding-right: 0.65rem;
}
.infrastructure-host-card dt,
.infrastructure-runtime-card dt,
.host-monitoring-runtime-card dt,
.host-monitoring-network-card dt,
.host-monitoring-hardware-facts dt {
color: var(--nodedc-text-muted);
font-size: 0.58rem;
}
.infrastructure-host-card dd,
.infrastructure-runtime-card dd,
.host-monitoring-runtime-card dd,
.host-monitoring-network-card dd,
.host-monitoring-hardware-facts dd {
min-width: 0;
overflow: hidden;
margin: 0;
color: var(--nodedc-text-primary);
font-size: 0.67rem;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.infrastructure-host-card footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.infrastructure-host-card footer > span {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.58rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.infrastructure-registry-list {
display: grid;
min-width: 0;
gap: 0.48rem;
}
.infrastructure-registry-row,
.infrastructure-asset-card {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.9rem;
padding: 0.72rem 0.8rem;
border-radius: var(--nodedc-radius-control);
background: var(--infrastructure-panel-soft);
}
.infrastructure-registry-row > div,
.infrastructure-asset-card > div {
display: grid;
min-width: 0;
gap: 0.2rem;
}
.infrastructure-registry-row > div:last-child,
.infrastructure-asset-card > div:last-child {
flex: 0 0 auto;
justify-items: end;
}
.infrastructure-registry-row strong,
.infrastructure-asset-card strong {
overflow: hidden;
color: var(--nodedc-text-primary);
font-size: 0.68rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.infrastructure-registry-row small,
.infrastructure-asset-card small {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.55rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.infrastructure-empty {
padding: 0.8rem;
border-radius: var(--nodedc-radius-control);
background: var(--infrastructure-panel-soft);
color: var(--nodedc-text-muted);
font-size: 0.65rem;
line-height: 1.5;
}
.host-monitoring-series {
overflow: hidden;
padding: 0.82rem 0.82rem 0.32rem;
}
.host-monitoring-series > div {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.host-monitoring-series__label {
display: grid;
min-width: 0;
gap: 0.16rem;
}
.host-monitoring-series__label small {
overflow: hidden;
max-width: 11rem;
color: var(--nodedc-text-secondary);
font-size: 0.54rem;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.host-monitoring-series svg {
display: block;
width: 100%;
height: 2.45rem;
margin-top: 0.45rem;
}
.host-monitoring-series path {
fill: none;
stroke: var(--infrastructure-hairline);
stroke-width: 0.8;
vector-effect: non-scaling-stroke;
}
.host-monitoring-series polyline {
fill: none;
stroke: var(--nodedc-text-primary);
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.15;
vector-effect: non-scaling-stroke;
}
.host-monitoring-series__range {
display: block;
margin-top: -0.1rem;
color: var(--nodedc-text-secondary);
font-size: 0.5rem;
line-height: 1.2;
text-align: right;
}
.host-monitoring-hardware {
display: grid;
min-width: 0;
gap: 1rem;
}
.host-monitoring-hardware-facts {
display: grid;
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
}
.host-monitoring-hardware-facts dl {
display: grid;
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
gap: 0;
border-radius: var(--nodedc-radius-control);
background: var(--infrastructure-panel-soft);
}
.host-monitoring-hardware-facts dl > div {
display: grid;
min-width: 0;
gap: 0.22rem;
padding: 0.75rem;
}
.host-monitoring-disk-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.55rem;
}
.host-monitoring-disk-list > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
padding: 0.62rem 0.72rem;
border-radius: var(--nodedc-radius-control);
background: var(--infrastructure-accent-soft);
}
.host-monitoring-disk-list span {
color: var(--nodedc-text-muted);
font-size: 0.61rem;
}
.host-monitoring-disk-list strong {
color: var(--nodedc-text-primary);
font-size: 0.64rem;
}
.host-monitoring-network-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
}
.host-monitoring-network-card header strong,
.host-monitoring-network-card header small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.host-monitoring-network-card header strong {
color: var(--nodedc-text-primary);
font-size: 0.71rem;
}
.host-monitoring-network-card header small {
margin-top: 0.25rem;
color: var(--nodedc-text-muted);
font-size: 0.55rem;
}
.host-monitoring-network-card dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
@media (max-width: 1100px) {
.infrastructure-overview-grid,
.host-monitoring-series-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.host-monitoring-network-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.infrastructure-host-relations {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.device-control-resource-grid,
.device-control-policy-grid {
@@ -145,6 +799,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;
@@ -158,6 +817,48 @@
grid-column: 2;
justify-content: flex-start;
}
.infrastructure-workspace-lead,
.infrastructure-section-heading {
display: grid;
}
.infrastructure-workspace-actions,
.infrastructure-section-actions {
justify-content: space-between;
}
.infrastructure-overview-grid,
.host-monitoring-series-grid,
.infrastructure-host-list,
.infrastructure-runtime-grid,
.infrastructure-asset-grid,
.host-monitoring-hardware-facts,
.host-monitoring-network-grid {
grid-template-columns: 1fr;
}
.infrastructure-host-card__facts,
.infrastructure-runtime-card dl,
.host-monitoring-runtime-card dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.infrastructure-host-card__summary,
.infrastructure-registry-row,
.infrastructure-asset-card {
align-items: flex-start;
}
.infrastructure-host-card__summary {
display: grid;
}
.infrastructure-host-card__actions {
width: 100%;
justify-content: flex-end;
}
}
html,
@@ -526,6 +1227,8 @@ body {
min-height: 220px;
place-items: center;
color: var(--nodedc-text-secondary);
font-size: 0.82rem;
line-height: 1.55;
text-align: center;
}
+161
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;
}
@@ -305,6 +311,160 @@ export interface ProjectGrantView {
lifecycleState: string;
}
export interface OntologyRefView {
entityId: string;
catalogHash: string;
}
export interface AssetView {
assetRef: string;
assetKey: string;
displayName: string;
assetTypeRef: string;
lifecycleState: string;
ontology: OntologyRefView;
}
export interface AssetBindingView {
assetBindingRef: string;
bindingKey: string;
deviceRef: string;
deviceName: string;
assetRef: string;
assetName: string;
bindingKind: string;
validFrom: string;
validTo: string | null;
provenanceRef: string;
ontology: OntologyRefView;
}
export interface HealthProjectionView {
state: string;
freshness: "fresh" | "stale" | "missing";
lastObservedState?: string;
evidenceClass?: string;
observedAt?: string | null;
expiresAt?: string | null;
observationRef: string | null;
}
export interface InfrastructureHostView {
hostRef: string;
hostKey: string;
displayName: string;
providerRef: string | null;
externalRef: string | null;
managementCredentialConfigured: boolean;
lifecycleState: string;
health: HealthProjectionView;
telemetry: InfrastructureHostTelemetryView;
ontology: OntologyRefView;
}
export interface InfrastructureHostTelemetrySnapshot {
schemaVersion: "nodedc.infrastructure.host-telemetry.v1";
profile: "linux-host-telegraf-v1";
hostKey: string;
observedAt: string;
source: { agent: string; agentVersion: string; collectorRef: string };
hardware: {
hostname: string | null;
architecture: string | null;
platform: string | null;
kernelRelease: string | null;
cpuModel: string | null;
logicalProcessors: number | null;
};
cpu: { usagePercent: number | null; load1: number | null; load5: number | null; load15: number | null };
memory: { totalBytes: number | null; availableBytes: number | null; freeBytes: number | null; usedBytes: number | null; usedPercent: number | null };
swap: { totalBytes: number | null; availableBytes: number | null; freeBytes: number | null; usedBytes: number | null; usedPercent: number | null };
system: {
uptimeSeconds: number | null;
users: number | null;
processes: { total: number | null; running: number | null; sleeping: number | null; blocked: number | null; zombies: number | null };
};
disks: Array<{ device: string | null; mount: string | null; filesystem: string | null; totalBytes: number | null; freeBytes: number | null; usedBytes: number | null; usedPercent: number | null }>;
network: Array<{ interface: string | null; bytesReceived: number | null; bytesSent: number | null; packetsReceived: number | null; packetsSent: number | null; errorsReceived: number | null; errorsSent: number | null; droppedReceived: number | null; droppedSent: number | null }>;
services: Array<{ name: string | null; loadState: string | null; activeState: string | null; subState: string | null; memoryBytes: number | null; restarts: number | null; pid: number | null }>;
}
export interface InfrastructureHostTelemetryView {
state: string;
freshness: "fresh" | "stale" | "missing";
observedAt: string | null;
receivedAt: string | null;
expiresAt: string | null;
current: InfrastructureHostTelemetrySnapshot | null;
history: Array<{
observedAt: string;
cpuUsagePercent: number | null;
memoryUsedPercent: number | null;
network: Array<{ interface: string | null; bytesReceived: number | null; bytesSent: number | null }>;
}>;
observation: null | {
observationRef: string;
entityId: string;
catalogHash: string;
targetRef: string;
serviceInstanceRef: string;
edgeRef: string;
profileRef: string;
source: { agent: string; agentVersion: string; collectorRef: string; provenanceRef: string };
observedProperties: string[];
};
}
export interface InfrastructureEndpointView {
endpointRef: string;
hostRef: string;
endpointKey: string;
purpose: "management" | "service" | "monitoring";
endpointUri: string;
lifecycleState: string;
ontology: OntologyRefView;
}
export interface InfrastructureDeploymentView {
deploymentRef: string;
hostRef: string;
deploymentKey: string;
displayName: string;
artifactRef: string;
artifactDigest: string;
lifecycleState: string;
ontology: OntologyRefView;
}
export interface InfrastructureServiceInstanceView {
serviceInstanceRef: string;
hostRef: string;
deploymentRef: string;
edgeRef: string | null;
serviceKey: string;
displayName: string;
serviceRole: string;
lifecycleState: string;
health: HealthProjectionView;
ontology: OntologyRefView;
}
export interface ProjectOntologyProjection {
ontology: { catalogHash: string; packages: string[] };
assets: AssetView[];
assetBindings: AssetBindingView[];
hosts: InfrastructureHostView[];
endpoints: InfrastructureEndpointView[];
deployments: InfrastructureDeploymentView[];
serviceInstances: InfrastructureServiceInstanceView[];
policies: {
restrictedIdentifiers: string;
managementCredentials: string;
missingHealthEvidence: string;
arbitraryConsole: string;
};
}
export interface ProjectWorkspace {
project: ProjectSummary;
devices: DeviceView[];
@@ -323,6 +483,7 @@ export interface ProjectWorkspace {
commands: CommandView[];
auditEvents: AuditEventView[];
grants: ProjectGrantView[];
ontology: ProjectOntologyProjection;
policies: {
commandTransport: "disabled" | "typed-service-ping-v1";
commandPlanningApi: "disabled" | "enabled";
@@ -0,0 +1,36 @@
{
"schemaVersion": "nodedc.device-plane.device-control-core-incident-audit.v1",
"mode": "double-rollback-failed-read-only-audit",
"allowedOperation": "canonical-plan-only",
"applyAllowed": false,
"failedAttempts": [
{
"patchId": "device-control-core-release-v3-20260822-040",
"artifactSha256": "08448a56cdf391076f92c5242e368fd0033b420167874645eebfa1f22184ee92",
"backupId": "device-plane-device-control-core-release-v3-20260822-040-20260822-184245"
},
{
"patchId": "device-control-core-release-v3-reconciliation-20260822-042",
"artifactSha256": "54ab243439bce724fa0a0872b76cc32e0052ea5127153214d92872f02ae831cf",
"backupId": "device-plane-device-control-core-release-v3-reconciliation-20260822-042-20260822-195448"
}
],
"readOnlyEvidence": [
"device-control-core-runtime-inventory",
"device-control-core-bounded-container-logs",
"device-postgres-schema-presence",
"device-postgres-wait-activity"
],
"runtimeMutation": "none",
"sourceMutation": "none",
"databaseMutation": "none",
"networkMutation": "none",
"secretRead": "none",
"preservedServices": [
"device-control-core",
"device-manager",
"device-gateway",
"device-postgres",
"device-backhaul-target"
]
}
@@ -0,0 +1,29 @@
{
"schemaVersion": "nodedc.device-plane.device-control-core-migration-replay-audit.v1",
"mode": "rejected-recovery-044-live-invariants-read-only-audit",
"allowedOperation": "canonical-plan-only",
"applyAllowed": false,
"rejectedRecovery": {
"patchId": "device-control-core-migration-replay-recovery-20260822-044",
"artifactSha256": "b893d8c90f98943797d32f486d6477d58a3be69eb1291e28c4a4bbd2e96774b7"
},
"readOnlyEvidence": [
"invalid-command-kind-count",
"triggering-receipt-count",
"constraint-validated",
"constraint-covers-final-command-kinds",
"host-telemetry-table-absent"
],
"runtimeMutation": "none",
"sourceMutation": "none",
"databaseMutation": "none",
"networkMutation": "none",
"secretRead": "none",
"preservedServices": [
"device-control-core",
"device-manager",
"device-gateway",
"device-postgres",
"device-backhaul-target"
]
}
@@ -0,0 +1,36 @@
{
"schemaVersion": "nodedc.device-plane.device-control-core-migration-replay-checkpoint-recovery.v2",
"mode": "terminal-044-replay-checkpoint-forward-repair",
"failedIncidentAudit": "device-control-core-incident-audit-20260822-043",
"failedRecovery": {
"patchId": "device-control-core-migration-replay-recovery-20260822-044",
"artifactSha256": "b893d8c90f98943797d32f486d6477d58a3be69eb1291e28c4a4bbd2e96774b7",
"failure": "preflight-replay-checkpoint-race",
"startedApply": false
},
"sourcePredecessor": {
"path": "services/device-control-core/migrations/014_device_registry_profile_commands.sql",
"sha256": "751accf346b34d2774cc7b9572640d2c25fdb0b1db793ac32183b56f48e26508"
},
"sourceTarget": {
"path": "services/device-control-core/migrations/014_device_registry_profile_commands.sql",
"sha256": "38bd86b42828d44c7101d5433ddc36018e92eedeee37b9de296432ad676edd46"
},
"rootCause": "restarting-core-cycles-exact-committed-migration-checkpoints",
"repair": "migration-014-add-constraint-not-valid",
"databasePreflight": "exact-replay-checkpoint-005-007-009-011-and-final-compatible-rows",
"databaseRowMutation": "none",
"databaseSchemaOutcome": "exact-final-migration-016-validated-command-kind-check",
"runtimeAction": "build+recreate-device-control-core-only",
"runtimePredecessor": "proven-degraded-restarting-exact-preapply-image",
"preservedServices": [
"device-manager",
"device-gateway",
"device-postgres",
"device-backhaul-target"
],
"databaseVolume": "nodedc-device-plane-postgres-data",
"publicIngress": "disabled",
"edgeChannel": "core-initiated-pinned-mtls-registered-edges-only",
"rollback": "source+exact-degraded-predecessor-image-runtime"
}
@@ -0,0 +1,30 @@
{
"schemaVersion": "nodedc.device-plane.device-control-core-migration-replay-recovery.v1",
"mode": "double-rollback-failed-migration-014-forward-repair",
"failedIncidentAudit": "device-control-core-incident-audit-20260822-043",
"sourcePredecessor": {
"path": "services/device-control-core/migrations/014_device_registry_profile_commands.sql",
"sha256": "751accf346b34d2774cc7b9572640d2c25fdb0b1db793ac32183b56f48e26508"
},
"sourceTarget": {
"path": "services/device-control-core/migrations/014_device_registry_profile_commands.sql",
"sha256": "38bd86b42828d44c7101d5433ddc36018e92eedeee37b9de296432ad676edd46"
},
"rootCause": "intermediate-command-kind-check-revalidated-historical-receipts",
"repair": "migration-014-add-constraint-not-valid",
"databasePreflight": "all-live-command-kinds-covered-by-final-migration-016",
"databaseRowMutation": "none",
"databaseSchemaOutcome": "final-migration-016-validated-command-kind-check",
"runtimeAction": "build+recreate-device-control-core-only",
"runtimePredecessor": "proven-degraded-double-rollback-state",
"preservedServices": [
"device-manager",
"device-gateway",
"device-postgres",
"device-backhaul-target"
],
"databaseVolume": "nodedc-device-plane-postgres-data",
"publicIngress": "disabled",
"edgeChannel": "core-initiated-pinned-mtls-registered-edges-only",
"rollback": "source+exact-degraded-predecessor-image-runtime"
}
@@ -0,0 +1,25 @@
{
"schemaVersion": "nodedc.device-plane.device-control-core-release-v3-reconciliation.v1",
"mode": "failed-release-v3-exact-preapply-image-restore",
"failedPatchId": "device-control-core-release-v3-20260822-040",
"failedArtifactSha256": "08448a56cdf391076f92c5242e368fd0033b420167874645eebfa1f22184ee92",
"failedArtifact": "nodedc-device-plane-device-control-core-release-v3-20260822-040.tgz.20260822-184245",
"backupId": "device-plane-device-control-core-release-v3-20260822-040-20260822-184245",
"predecessorPatchId": "device-control-core-release-v2-20260822-038",
"predecessorArtifactSha256": "e2d062b82b022dba662522b5d6e192026ac964d78950d903295ca3cbbc95ab28",
"preapplyImageId": "sha256:31d35733ee46225b487c0f02a7b52d4ba2d13f5b99f6a717b7f5e6f5460b412a",
"sourceAction": "accept-byte-exact-restored-preapply-source",
"runtimeAction": "retag-exact-preapply-image+recreate-device-control-core-only",
"preservedServices": [
"device-manager",
"device-gateway",
"device-postgres",
"device-backhaul-target"
],
"databaseVolume": "nodedc-device-plane-postgres-data",
"publicIngress": "disabled",
"edgeChannel": "core-initiated-pinned-mtls-registered-edges-only",
"commandTransport": "typed-service-ping-v1",
"gelios": "untouched-legacy-only",
"rollback": "marker+exact-preapply-image-runtime"
}
@@ -0,0 +1,40 @@
{
"schemaVersion": "nodedc.device-plane.device-control-core-release.v3",
"releaseId": "__PATCH_ID__",
"action": "upgrade",
"predecessor": {
"kind": "release",
"patchId": "device-control-core-release-v2-20260822-038",
"artifactSha256": "e2d062b82b022dba662522b5d6e192026ac964d78950d903295ca3cbbc95ab28"
},
"service": "device-control-core",
"composeActivation": "preserve-active-v4-topology",
"identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export",
"identityRecovery": "forbidden-valid-existing-identity-required",
"tlsPurpose": "clientAuth",
"direction": "core-initiated",
"endpointPolicy": "public-ipv4-standard-https-tcp-443-only",
"coreNetworks": [
"device-plane-private",
"device-plane-egress"
],
"publicIngress": "none-on-synology",
"edgeRegistrations": "preserved",
"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",
"telemetryTransport": "edge-channel-host-telemetry-observed-v1",
"telemetryContract": "nodedc.infrastructure.host-telemetry.v1",
"telemetryStorage": "device-control-core-postgres-seven-day-retention",
"ontologyProjection": "observation-observed-property-provenance-freshness-v1",
"telemetryFreshness": "fifteen-seconds-missing-stale-not-unhealthy",
"gelios": "untouched-legacy-only",
"preservedServices": [
"device-manager",
"device-gateway",
"device-postgres",
"device-backhaul-target"
],
"healthGate": "bounded-container-grace+core-edge-contract+exact-private-egress-network-boundary",
"rollback": "restore-preapply-source-and-core-runtime"
}
@@ -0,0 +1,45 @@
{
"schemaVersion": "nodedc.device-plane.device-control-core-release.v4",
"releaseId": "__PATCH_ID__",
"action": "upgrade",
"predecessor": {
"kind": "migration-replay-checkpoint-recovery",
"patchId": "device-control-core-migration-replay-checkpoint-recovery-20260822-046",
"artifactSha256": "46000c76977fb583fc7c9cf74ecf624efd8b404f7b8d0322e0270e7b8ac6e450"
},
"service": "device-control-core",
"composeActivation": "preserve-active-v4-topology",
"identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export",
"identityRecovery": "forbidden-valid-existing-identity-required",
"tlsPurpose": "clientAuth",
"direction": "core-initiated",
"endpointPolicy": "public-ipv4-standard-https-tcp-443-only",
"coreNetworks": [
"device-plane-private",
"device-plane-egress"
],
"publicIngress": "none-on-synology",
"edgeRegistrations": "preserved",
"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",
"telemetryTransport": "edge-channel-host-telemetry-observed-v1",
"telemetryContract": "nodedc.infrastructure.host-telemetry.v1",
"telemetryStorage": "device-control-core-postgres-seven-day-retention",
"ontologyProjection": "observation-observed-property-provenance-freshness-v1",
"telemetryFreshness": "fifteen-seconds-missing-stale-not-unhealthy",
"recoveryPredecessor": "terminal-applied-046-exact-source-runtime-database",
"databasePreflight": "final-migration-016-validated-and-host-telemetry-table-absent",
"databaseRowMutation": "none-before-core-startup-migrations",
"databaseSchemaOutcome": "migration-017-host-telemetry-table-present",
"runtimePredecessor": "healthy-recovery-046-core-generation",
"gelios": "untouched-legacy-only",
"preservedServices": [
"device-manager",
"device-gateway",
"device-postgres",
"device-backhaul-target"
],
"healthGate": "bounded-container-grace+core-edge-contract+exact-private-egress-network-boundary",
"rollback": "restore-preapply-source-and-core-runtime"
}
@@ -0,0 +1,77 @@
{
"schemaVersion": "nodedc.device-edge-vps.host-telemetry.v1",
"mode": "provider-neutral-host-observation-over-accepted-core-channel",
"status": "active-host-telemetry",
"authority": "DCPLATFORM-21/DCPLATFORM-76/DCPLATFORM-77/ADR-0001",
"component": "device-edge-vps",
"phase": "host-telemetry",
"runtimeHost": "koffyvngij",
"predecessorPatch": "device-edge-vps-command-transport-20260812-013",
"predecessorArtifactSha256": "c7486ec879681ddd706f229b628c8556ca8c9ccc4f152a85debb409c302759ef",
"agent": "telegraf",
"agentVersion": "1.38.4",
"agentRuntimeUser": "nodedc-telemetry",
"agentService": "nodedc-host-telemetry-agent.service",
"collector": "127.0.0.1:18223/internal/v1/host-telemetry",
"collectorExposure": "loopback-only",
"transport": "existing-core-initiated-pinned-mtls-channel",
"messageKind": "host.telemetry.observed",
"observationEntity": "observation.observation",
"observationTarget": "infrastructure.host",
"observedProperties": [
"host.cpu.utilization",
"host.memory.utilization",
"host.swap.utilization",
"host.disk.utilization",
"host.network.counters",
"host.process.counts",
"host.systemd.unit-state"
],
"commandTransport": "typed-service-ping-v1",
"publicIngress": "preserved:tcp/443-mtls-core-channel+tcp/9921-bidirectional-tracker-session",
"mqtt": "disabled-no-public-broker-no-wan-plaintext",
"database": "none-on-vps",
"credentials": "none-added",
"gelios": "untouched-legacy-only",
"resourceCeilings": {
"agentMemory": "96M",
"agentSwap": "0",
"agentCpu": "15%",
"agentTasks": 64,
"agentOpenFiles": 512,
"collectorBodyBytes": 524288,
"channelEnvelopeBytes": 1048576
},
"preserved": [
"management-ssh-key",
"accepted-node-runtime",
"accepted-core-channel-trust-and-registration",
"accepted-tracker-ingress",
"accepted-typed-command-transport",
"retired-tailnet-boundary",
"gelios-production-path"
],
"forbidden": [
"public-mqtt",
"plaintext-wan-telemetry",
"vps-initiated-synology-connection",
"public-health",
"vps-database",
"browser-secrets",
"generic-shell",
"ontology-core-telemetry-storage"
],
"acceptance": [
"exact-command-transport-013-predecessor",
"telegraf-1.38.4-exact-archive-and-binary",
"dedicated-non-root-agent",
"loopback-only-http-output",
"core-channel-remains-accepted",
"host-observation-reaches-device-control-core",
"public-port-set-unchanged",
"typed-command-transport-preserved",
"tailscale-remains-absent",
"gelios-untouched"
],
"rollback": "restore-exact-command-transport-013-source-runtime-and-remove-agent-runtime"
}
@@ -0,0 +1,56 @@
{
"schemaVersion": "nodedc.device-plane.device-manager-release.v10",
"releaseId": "__PATCH_ID__",
"action": "upgrade",
"predecessor": {
"kind": "release",
"patchId": "device-manager-release-v8-20260822-039",
"artifactSha256": "30a83d4c6b5c029c96c4af19fa558e0ae75e4b60bc897881457304bd17e0e465"
},
"controlCorePredecessor": {
"patchId": "device-control-core-release-v4-20260823-047",
"artifactSha256": "4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
},
"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": "ontology-backed-host-runtime-v1",
"ontologyFoundation": "ontology-core-device-foundation-20260822-001",
"ontologyCatalogHash": "229c61c02a790906",
"assetBinding": "temporal-device-asset-binding-v1",
"infrastructureRuntime": "host-endpoint-deployment-service-instance-v1",
"healthEvidence": "ttl-observation-missing-not-unhealthy-v1",
"telemetryWorkspace": "mission-core-compute-module-parity-v1",
"telemetryNavigation": "full-workspace-back-navigation-v1",
"telemetryPollInterval": "three-seconds",
"telemetryFreshness": "fifteen-seconds-missing-stale-not-unhealthy",
"telemetryOntologyProjection": "observation-observed-property-provenance-freshness-v1",
"telemetryAgent": "telegraf-host-observer-v1",
"interactiveShell": "disabled-pending-managed-session-boundary",
"gelios": "untouched-legacy-only",
"rollback": "restore-preapply-snapshot-preserve-manager-data"
}
@@ -0,0 +1,63 @@
{
"schemaVersion": "nodedc.device-plane.device-manager-release.v11",
"releaseId": "__PATCH_ID__",
"action": "upgrade",
"predecessor": {
"kind": "release",
"patchId": "device-manager-release-v10-20260823-048",
"artifactSha256": "e6b983a314db4f8c27d89062dfedf5ed0523cc30421170799d181a19e2d85d4c"
},
"controlCorePredecessor": {
"patchId": "device-control-core-release-v4-20260823-047",
"artifactSha256": "4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
},
"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": "ontology-backed-host-runtime-v1",
"ontologyFoundation": "ontology-core-device-foundation-20260822-001",
"ontologyCatalogHash": "229c61c02a790906",
"assetBinding": "temporal-device-asset-binding-v1",
"infrastructureRuntime": "host-endpoint-deployment-service-instance-v1",
"healthEvidence": "ttl-observation-missing-not-unhealthy-v1",
"designSystem": "nodedc-canonical-components-and-tokens-v1",
"missionCoreReference": "compute-modules-workspace-71c8b04",
"infrastructureWorkspaceLayout": "mission-core-system-workspace-v1",
"hostInventoryComposition": "mission-core-compute-host-list-v1",
"telemetryWorkspace": "mission-core-compute-module-visual-parity-v2",
"telemetrySurface": "borderless-soft-surface-v1",
"telemetryStatus": "mission-core-dot-status-v1",
"telemetryNavigation": "full-workspace-back-navigation-v1",
"telemetryScroll": "reset-on-workspace-transition-v1",
"telemetryPollInterval": "three-seconds",
"telemetryFreshness": "fifteen-seconds-missing-stale-not-unhealthy",
"telemetryOntologyProjection": "observation-observed-property-provenance-freshness-v1",
"telemetryAgent": "telegraf-host-observer-v1",
"interactiveShell": "disabled-pending-managed-session-boundary",
"gelios": "untouched-legacy-only",
"rollback": "restore-preapply-snapshot-preserve-manager-data"
}
@@ -0,0 +1,67 @@
{
"schemaVersion": "nodedc.device-plane.device-manager-release.v12",
"releaseId": "__PATCH_ID__",
"action": "upgrade",
"predecessor": {
"kind": "release",
"patchId": "device-manager-release-v11-20260823-049",
"artifactSha256": "c1e2056b50bfbb0d03d077461d0c27cc56cc52967c3f5620be14871c8a6d5cf0"
},
"controlCorePredecessor": {
"patchId": "device-control-core-release-v4-20260823-047",
"artifactSha256": "4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
},
"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": "ontology-backed-host-runtime-v1",
"ontologyFoundation": "ontology-core-device-foundation-20260822-001",
"ontologyCatalogHash": "229c61c02a790906",
"assetBinding": "temporal-device-asset-binding-v1",
"infrastructureRuntime": "host-endpoint-deployment-service-instance-v1",
"healthEvidence": "ttl-observation-missing-not-unhealthy-v1",
"designSystem": "nodedc-canonical-components-and-tokens-v1",
"missionCoreReference": "compute-modules-workspace-71c8b04",
"infrastructureWorkspaceLayout": "mission-core-system-workspace-v1",
"hostInventoryComposition": "mission-core-compute-host-list-v1",
"telemetryWorkspace": "mission-core-compute-module-adaptive-window-v3",
"telemetrySurface": "borderless-soft-surface-v1",
"telemetryStatus": "mission-core-dot-status-v1",
"telemetryNavigation": "full-workspace-back-navigation-v1",
"telemetryScroll": "reset-on-workspace-transition-v1",
"telemetryPollInterval": "three-seconds",
"telemetryFreshness": "fifteen-seconds-missing-stale-not-unhealthy",
"telemetryOntologyProjection": "observation-observed-property-provenance-freshness-v1",
"telemetryAgent": "telegraf-host-observer-v1",
"telemetryGraphScale": "adaptive-observed-window-explicit-domain-v1",
"telemetryCpuMinimumSpan": "five-percentage-points",
"telemetryMemoryMinimumSpan": "four-percentage-points",
"telemetryNetworkMissingSemantics": "missing-counters-never-zero-v1",
"interactiveShell": "disabled-pending-managed-session-boundary",
"gelios": "untouched-legacy-only",
"rollback": "restore-preapply-snapshot-preserve-manager-data"
}
@@ -0,0 +1,74 @@
{
"schemaVersion": "nodedc.device-plane.device-manager-release.v13",
"releaseId": "__PATCH_ID__",
"action": "upgrade",
"predecessor": {
"kind": "release",
"patchId": "device-manager-release-v12-20260823-050",
"artifactSha256": "1a49839140e5f2e49763d78f24ee47d946e244bcfde15a9c38266e8bd14c0d49"
},
"controlCorePredecessor": {
"patchId": "device-control-core-release-v4-20260823-047",
"artifactSha256": "4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
},
"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": "ontology-backed-host-runtime-v1",
"ontologyFoundation": "ontology-core-device-foundation-20260822-001",
"ontologyCatalogHash": "229c61c02a790906",
"assetBinding": "temporal-device-asset-binding-v1",
"infrastructureRuntime": "host-endpoint-deployment-service-instance-v1",
"healthEvidence": "ttl-observation-missing-not-unhealthy-v1",
"designSystem": "nodedc-canonical-components-and-tokens-v1",
"missionCoreReference": "compute-modules-workspace-71c8b04",
"infrastructureWorkspaceLayout": "mission-core-system-workspace-v2",
"hostInventoryComposition": "mission-core-compute-host-accordion-v2",
"hostInventoryOverviewSurface": "separate-summary-soft-surface-v1",
"hostInventoryCollectionSurface": "separate-host-collection-soft-surface-v1",
"hostInventoryRow": "compact-centered-accordion-v1",
"hostInventoryFreshness": "dot-only-v1",
"hostInventoryRelations": "host-scoped-endpoint-deployment-service-v1",
"hostInventoryDefaultExpansion": "collapsed",
"hostInventoryScaleTarget": "five-hundred-collapsed-rows-v1",
"telemetryWorkspace": "mission-core-compute-module-adaptive-window-v3",
"telemetrySurface": "borderless-soft-surface-v1",
"telemetryStatus": "mission-core-dot-status-v1",
"telemetryNavigation": "full-workspace-back-navigation-v1",
"telemetryScroll": "reset-on-workspace-transition-v1",
"telemetryPollInterval": "three-seconds",
"telemetryFreshness": "fifteen-seconds-missing-stale-not-unhealthy",
"telemetryOntologyProjection": "observation-observed-property-provenance-freshness-v1",
"telemetryAgent": "telegraf-host-observer-v1",
"telemetryGraphScale": "adaptive-observed-window-explicit-domain-v1",
"telemetryCpuMinimumSpan": "five-percentage-points",
"telemetryMemoryMinimumSpan": "four-percentage-points",
"telemetryNetworkMissingSemantics": "missing-counters-never-zero-v1",
"interactiveShell": "disabled-pending-managed-session-boundary",
"gelios": "untouched-legacy-only",
"rollback": "restore-preapply-snapshot-preserve-manager-data"
}
+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"
}
+50
View File
@@ -0,0 +1,50 @@
{
"schemaVersion": "nodedc.device-plane.device-manager-release.v8",
"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-038",
"artifactSha256": "e2d062b82b022dba662522b5d6e192026ac964d78950d903295ca3cbbc95ab28"
},
"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": "ontology-backed-host-runtime-v1",
"ontologyFoundation": "ontology-core-device-foundation-20260822-001",
"ontologyCatalogHash": "229c61c02a790906",
"assetBinding": "temporal-device-asset-binding-v1",
"infrastructureRuntime": "host-endpoint-deployment-service-instance-v1",
"healthEvidence": "ttl-observation-missing-not-unhealthy-v1",
"interactiveShell": "disabled-pending-managed-session-boundary",
"gelios": "untouched-legacy-only",
"rollback": "restore-preapply-snapshot-preserve-manager-data"
}
+56
View File
@@ -0,0 +1,56 @@
{
"schemaVersion": "nodedc.device-plane.device-manager-release.v9",
"releaseId": "__PATCH_ID__",
"action": "upgrade",
"predecessor": {
"kind": "release",
"patchId": "device-manager-release-v8-20260822-039",
"artifactSha256": "30a83d4c6b5c029c96c4af19fa558e0ae75e4b60bc897881457304bd17e0e465"
},
"controlCorePredecessor": {
"patchId": "device-control-core-release-v3-20260822-040",
"artifactSha256": "08448a56cdf391076f92c5242e368fd0033b420167874645eebfa1f22184ee92"
},
"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": "ontology-backed-host-runtime-v1",
"ontologyFoundation": "ontology-core-device-foundation-20260822-001",
"ontologyCatalogHash": "229c61c02a790906",
"assetBinding": "temporal-device-asset-binding-v1",
"infrastructureRuntime": "host-endpoint-deployment-service-instance-v1",
"healthEvidence": "ttl-observation-missing-not-unhealthy-v1",
"telemetryWorkspace": "mission-core-compute-module-parity-v1",
"telemetryNavigation": "full-workspace-back-navigation-v1",
"telemetryPollInterval": "three-seconds",
"telemetryFreshness": "fifteen-seconds-missing-stale-not-unhealthy",
"telemetryOntologyProjection": "observation-observed-property-provenance-freshness-v1",
"telemetryAgent": "telegraf-host-observer-v1",
"interactiveShell": "disabled-pending-managed-session-boundary",
"gelios": "untouched-legacy-only",
"rollback": "restore-preapply-snapshot-preserve-manager-data"
}
@@ -0,0 +1,132 @@
# Device Core asset and infrastructure ontology contract
Status: **canonical foundation implemented**
Date: 2026-08-22
Production ontology catalog hash: `229c61c02a790906`
## Authority
Device Core consumes the official read-only NODE.DC Ontology Core catalog. It
does not mint local ontology identifiers. The production catalog now publishes
the `asset`, `device`, `infrastructure` and `observation` packages required by
this runtime foundation.
Canonical entities used by Device Core:
| Entity | Runtime meaning |
| --- | --- |
| `asset.asset` | Stable business or physical object whose identity survives tracker replacement |
| `device.tracking_device` | A tracker registered through Device Core enrollment and claim |
| `device.asset_binding` | Temporal, evidenced Device-to-Asset association |
| `infrastructure.host` | Provider-neutral physical or virtual compute host |
| `infrastructure.endpoint` | Credential-free management, monitoring or service endpoint |
| `infrastructure.deployment` | Desired immutable artifact deployment on a host |
| `infrastructure.service_instance` | Runtime service realizing a deployment on a host |
| `observation.health_observation` | Time-bounded health evidence with source and freshness |
| `observation.position_observation` | Canonical position evidence for Foundry/map projections |
`infrastructure.host` is deliberately distinct from
`integration.connection`, `infrastructure.endpoint`,
`infrastructure.deployment` and Device Edge registration.
## Direct B2 onboarding
Direct B2 registration reuses the existing secure Device Core lifecycle:
```text
adapter + model profile + route
|
v
enrollment intent (IMEI -> HMAC digest + masked projection)
|
v
quarantine discovery -> claim -> device.tracking_device
|
v
asset.asset <- temporal device.asset_binding
```
No raw IMEI, provider credential or device secret is stored in the ontology or
returned to the browser. The current Gelios flow remains a legacy integration;
new B2 trackers can be enrolled directly and attached to the same stable Asset
model without making Gelios the identity authority.
Closing a binding records `valid_to` and actor evidence. Tracker replacement
therefore changes the active binding, not the Asset identity or its history.
## VPS and Edge topology
```text
device.project -> infrastructure.host -> infrastructure.endpoint
|
v
infrastructure.deployment
|
v
infrastructure.service_instance
|
+---- optional link ----> device.edge-registration
```
Provider names and provider resource IDs are annotations. They never define
the host identity. A management credential is an opaque `secret-ref:*`; the
secret value is not accepted by the API, stored in these tables or projected
to the Manager.
An Edge link is allowed only when the Edge is already used by a route in the
same Device project. This prevents a generic host screen from silently taking
ownership of a platform-wide Edge registration.
## Health and monitoring
Health is append-only evidence, not a mutable `online` property. Each
observation has:
- subject: Host or Service Instance;
- state: `reachable`, `degraded` or `unreachable`;
- evidence class and schema reference;
- source reference;
- `observed_at` and mandatory `expires_at`;
- bounded, secret-free evidence projection.
The read projection returns `unobserved` when evidence is missing or stale.
Missing evidence is never converted into `unreachable`.
## API surface
Idempotent management commands:
- `assets:ensure`;
- `asset-bindings:ensure` and `asset-bindings:close`;
- `infrastructure-hosts:ensure`;
- `infrastructure-endpoints:ensure`;
- `infrastructure-deployments:ensure`;
- `infrastructure-service-instances:ensure`;
- `health-observations:record`.
The safe read projection is:
```text
GET /internal/v1/query/projects/{project-id}/ontology
```
It returns the catalog hash, assets and temporal bindings, provider-neutral
infrastructure inventory, and freshness-aware health projections. It never
returns credential references themselves.
## Console boundary
An unrestricted WebSSH terminal remains disabled. A future console requires a
separate `infrastructure.management_session` broker with short-lived sessions,
server-side credentials, command authorization, immutable audit, output limits,
redaction and explicit break-glass controls. A direct browser-to-SSH connection
is outside the Device Core security boundary.
## Next projection
Foundry consumes canonical Asset, Device, temporal binding and Observation
relations. Spatial layers are produced only from current
`observation.position_observation` evidence; Hosts without spatial evidence do
not appear on a map merely because they exist in inventory.
@@ -0,0 +1,92 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const sourceRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-control-core-incident-audit-20260822-043",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("usage: build-device-control-core-incident-audit-artifact.mjs [patch-id]");
}
const entry = "deployment/device-control-core-incident-audit-v1.json";
const stage = await mkdtemp(join(tmpdir(), "nodedc-control-core-incident-audit-"));
const payload = join(stage, "payload");
const target = resolve(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
try {
const descriptor = JSON.parse(await readFile(resolve(sourceRoot, entry), "utf8"));
if (
descriptor.schemaVersion
!== "nodedc.device-plane.device-control-core-incident-audit.v1"
|| descriptor.mode !== "double-rollback-failed-read-only-audit"
|| descriptor.allowedOperation !== "canonical-plan-only"
|| descriptor.applyAllowed !== false
|| descriptor.runtimeMutation !== "none"
|| descriptor.sourceMutation !== "none"
|| descriptor.databaseMutation !== "none"
|| descriptor.networkMutation !== "none"
|| descriptor.secretRead !== "none"
) {
throw new Error("device_control_core_incident_audit_descriptor_mismatch");
}
await mkdir(dirname(join(payload, entry)), { recursive: true });
await cp(resolve(sourceRoot, entry), join(payload, entry), { force: true });
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${entry}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{ encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
);
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const sha256 = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
component: "device-plane",
artifact: target,
sha256,
entries: [entry],
build: [],
services: [],
allowedOperation: descriptor.allowedOperation,
runtimeMutation: descriptor.runtimeMutation,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
@@ -0,0 +1,95 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const sourceRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-control-core-migration-replay-audit-20260822-045",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-device-control-core-migration-replay-audit-artifact.mjs [patch-id]",
);
}
const entry = "deployment/device-control-core-migration-replay-audit-v1.json";
const stage = await mkdtemp(join(tmpdir(), "nodedc-control-core-migration-audit-"));
const payload = join(stage, "payload");
const target = resolve(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
try {
const descriptor = JSON.parse(await readFile(resolve(sourceRoot, entry), "utf8"));
if (
descriptor.schemaVersion
!== "nodedc.device-plane.device-control-core-migration-replay-audit.v1"
|| descriptor.mode
!== "rejected-recovery-044-live-invariants-read-only-audit"
|| descriptor.allowedOperation !== "canonical-plan-only"
|| descriptor.applyAllowed !== false
|| descriptor.runtimeMutation !== "none"
|| descriptor.sourceMutation !== "none"
|| descriptor.databaseMutation !== "none"
|| descriptor.networkMutation !== "none"
|| descriptor.secretRead !== "none"
) {
throw new Error("device_control_core_migration_replay_audit_descriptor_mismatch");
}
await mkdir(dirname(join(payload, entry)), { recursive: true });
await cp(resolve(sourceRoot, entry), join(payload, entry), { force: true });
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${entry}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{ encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
);
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const sha256 = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
component: "device-plane",
artifact: target,
sha256,
entries: [entry],
build: [],
services: [],
allowedOperation: descriptor.allowedOperation,
runtimeMutation: descriptor.runtimeMutation,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
@@ -0,0 +1,106 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const sourceRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-control-core-migration-replay-checkpoint-recovery-20260822-046",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-device-control-core-migration-replay-checkpoint-recovery-artifact.mjs [patch-id]",
);
}
const entries = [
"services/device-control-core/migrations/014_device_registry_profile_commands.sql",
"deployment/device-control-core-migration-replay-checkpoint-recovery-v2.json",
];
const stage = await mkdtemp(join(tmpdir(), "nodedc-control-core-replay-checkpoint-"));
const payload = join(stage, "payload");
const target = resolve(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
try {
const descriptor = JSON.parse(await readFile(resolve(sourceRoot, entries[1]), "utf8"));
const targetBytes = await readFile(resolve(sourceRoot, entries[0]));
const targetSha256 = createHash("sha256").update(targetBytes).digest("hex");
if (
descriptor.schemaVersion
!== "nodedc.device-plane.device-control-core-migration-replay-checkpoint-recovery.v2"
|| descriptor.mode !== "terminal-044-replay-checkpoint-forward-repair"
|| descriptor.failedRecovery?.patchId
!== "device-control-core-migration-replay-recovery-20260822-044"
|| descriptor.failedRecovery?.artifactSha256
!== "b893d8c90f98943797d32f486d6477d58a3be69eb1291e28c4a4bbd2e96774b7"
|| descriptor.failedRecovery?.startedApply !== false
|| descriptor.sourceTarget.path !== entries[0]
|| descriptor.sourceTarget.sha256 !== targetSha256
|| descriptor.databaseRowMutation !== "none"
|| descriptor.runtimeAction !== "build+recreate-device-control-core-only"
) {
throw new Error(
"device_control_core_migration_replay_checkpoint_recovery_descriptor_mismatch",
);
}
for (const entry of entries) {
await mkdir(dirname(join(payload, entry)), { recursive: true });
await cp(resolve(sourceRoot, entry), join(payload, entry), { force: true });
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{ encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
);
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const sha256 = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
component: "device-plane",
artifact: target,
sha256,
entries,
build: ["nodedc/device-control-core:local"],
services: ["device-control-core"],
transition: descriptor.mode,
databaseRowMutation: descriptor.databaseRowMutation,
runtimeAction: descriptor.runtimeAction,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
@@ -0,0 +1,99 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const sourceRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-control-core-migration-replay-recovery-20260822-044",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-device-control-core-migration-replay-recovery-artifact.mjs [patch-id]",
);
}
const entries = [
"services/device-control-core/migrations/014_device_registry_profile_commands.sql",
"deployment/device-control-core-migration-replay-recovery-v1.json",
];
const stage = await mkdtemp(join(tmpdir(), "nodedc-control-core-migration-replay-"));
const payload = join(stage, "payload");
const target = resolve(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
try {
const descriptor = JSON.parse(await readFile(resolve(sourceRoot, entries[1]), "utf8"));
const targetBytes = await readFile(resolve(sourceRoot, entries[0]));
const targetSha256 = createHash("sha256").update(targetBytes).digest("hex");
if (
descriptor.schemaVersion
!== "nodedc.device-plane.device-control-core-migration-replay-recovery.v1"
|| descriptor.mode !== "double-rollback-failed-migration-014-forward-repair"
|| descriptor.sourceTarget.path !== entries[0]
|| descriptor.sourceTarget.sha256 !== targetSha256
|| descriptor.databaseRowMutation !== "none"
|| descriptor.runtimeAction !== "build+recreate-device-control-core-only"
) {
throw new Error("device_control_core_migration_replay_recovery_descriptor_mismatch");
}
for (const entry of entries) {
await mkdir(dirname(join(payload, entry)), { recursive: true });
await cp(resolve(sourceRoot, entry), join(payload, entry), { force: true });
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{ encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
);
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const sha256 = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
component: "device-plane",
artifact: target,
sha256,
entries,
build: ["nodedc/device-control-core:local"],
services: ["device-control-core"],
transition: descriptor.mode,
databaseRowMutation: descriptor.databaseRowMutation,
runtimeAction: descriptor.runtimeAction,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
@@ -16,14 +16,16 @@ const [
predecessorSha256,
...extra
] = process.argv.slice(2);
const isV4 = patchId.startsWith("device-control-core-release-v4-");
if (
extra.length
|| !/^device-control-core-release-[A-Za-z0-9._-]{1,67}$/.test(patchId)
|| !/^device-control-core-release(?:-v[234])?-[A-Za-z0-9._-]{1,67}$/.test(patchId)
|| (isV4 && patchId !== "device-control-core-release-v4-20260823-047")
|| ((predecessorPatchId === undefined) !== (predecessorSha256 === undefined))
|| (
predecessorPatchId !== undefined
&& (
!/^device-control-core-release-[A-Za-z0-9._-]{1,67}$/.test(predecessorPatchId)
!/^(?:device-control-core-release(?:-v[234])?-[A-Za-z0-9._-]{1,67}|device-control-core-migration-replay-checkpoint-recovery-20260822-046)$/.test(predecessorPatchId)
|| predecessorPatchId === patchId
|| !/^[0-9a-f]{64}$/.test(predecessorSha256)
)
@@ -35,12 +37,44 @@ if (
);
}
const isV3 = patchId.startsWith("device-control-core-release-v3-");
const isV2 = patchId.startsWith("device-control-core-release-v2-");
const includesTelemetry = isV3 || isV4;
const coreDockerfile = await readFile(
resolve(devicePlaneRoot, "services/device-control-core/Dockerfile"),
"utf8",
);
if (
!includesTelemetry
&& coreDockerfile.includes(
"COPY packages/infrastructure-telemetry-contract ./packages/infrastructure-telemetry-contract",
)
) {
throw new Error("historical_device_control_core_builder_has_advanced");
}
const expectedV2Predecessor = Object.freeze({
patchId: predecessorPatchId ?? "device-control-core-release-20260812-024",
artifactSha256: predecessorSha256 ?? "a289e909283109642e6bba3d9822a31f63423cfe0bbcd52705979681bd2bc793",
});
const descriptorPath = isV2
const expectedV4Predecessor = Object.freeze({
patchId: "device-control-core-migration-replay-checkpoint-recovery-20260822-046",
artifactSha256: "46000c76977fb583fc7c9cf74ecf624efd8b404f7b8d0322e0270e7b8ac6e450",
});
if (
isV4
&& predecessorPatchId !== undefined
&& (
predecessorPatchId !== expectedV4Predecessor.patchId
|| predecessorSha256 !== expectedV4Predecessor.artifactSha256
)
) {
throw new Error("device_control_core_release_v4_predecessor_mismatch");
}
const descriptorPath = isV4
? "deployment/device-control-core-release-v4.json"
: isV3
? "deployment/device-control-core-release-v3.json"
: isV2
? "deployment/device-control-core-release-v2.json"
: "deployment/device-control-core-release-v1.json";
const entries = [
@@ -49,6 +83,7 @@ const entries = [
"package-lock.json",
"packages/device-protocol-contract",
"packages/device-edge-channel-contract",
...(includesTelemetry ? ["packages/infrastructure-telemetry-contract"] : []),
"services/device-control-core",
descriptorPath,
];
@@ -67,7 +102,9 @@ try {
descriptor.releaseId = patchId;
if (predecessorPatchId !== undefined) {
descriptor.predecessor = {
kind: "release",
kind: isV4
? "migration-replay-checkpoint-recovery"
: "release",
patchId: predecessorPatchId,
artifactSha256: predecessorSha256,
};
@@ -88,6 +125,7 @@ try {
"services/device-control-core/src/device-gateway-core-runtime.mjs",
"packages/device-protocol-contract/src/index.mjs",
"packages/device-edge-channel-contract/src/index.mjs",
...(includesTelemetry ? ["packages/infrastructure-telemetry-contract/src/index.mjs"] : []),
]) {
const imported = spawnSync(
process.execPath,
@@ -101,7 +139,7 @@ try {
const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8"));
if (
descriptor.schemaVersion !== `nodedc.device-plane.device-control-core-release.${isV2 ? "v2" : "v1"}`
descriptor.schemaVersion !== `nodedc.device-plane.device-control-core-release.${isV4 ? "v4" : isV3 ? "v3" : isV2 ? "v2" : "v1"}`
|| descriptor.releaseId !== patchId
|| descriptor.action !== "upgrade"
|| descriptor.service !== "device-control-core"
@@ -113,16 +151,37 @@ try {
|| JSON.stringify(descriptor.coreNetworks) !== JSON.stringify(["device-plane-private", "device-plane-egress"])
|| descriptor.publicIngress !== "none-on-synology"
|| descriptor.edgeRegistrations !== "preserved"
|| descriptor.commandTransport !== (isV2 ? "typed-service-ping-v1" : "disabled")
|| descriptor.gelios !== (isV2 ? "untouched-legacy-only" : "untouched")
|| descriptor.commandTransport !== ((isV2 || isV3 || isV4) ? "typed-service-ping-v1" : "disabled")
|| descriptor.gelios !== ((isV2 || isV3 || isV4) ? "untouched-legacy-only" : "untouched")
|| descriptor.rollback !== "restore-preapply-source-and-core-runtime"
|| (
isV2
(isV2 || isV3 || isV4)
&& (
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.predecessor?.patchId !== expectedV2Predecessor.patchId
|| descriptor.predecessor?.artifactSha256 !== expectedV2Predecessor.artifactSha256
|| descriptor.predecessor?.patchId !== (isV4 ? expectedV4Predecessor : expectedV2Predecessor).patchId
|| descriptor.predecessor?.artifactSha256 !== (isV4 ? expectedV4Predecessor : expectedV2Predecessor).artifactSha256
)
)
|| (
includesTelemetry
&& (
descriptor.telemetryTransport !== "edge-channel-host-telemetry-observed-v1"
|| descriptor.telemetryContract !== "nodedc.infrastructure.host-telemetry.v1"
|| descriptor.telemetryStorage !== "device-control-core-postgres-seven-day-retention"
|| descriptor.ontologyProjection !== "observation-observed-property-provenance-freshness-v1"
|| descriptor.telemetryFreshness !== "fifteen-seconds-missing-stale-not-unhealthy"
)
)
|| (
isV4
&& (
descriptor.predecessor?.kind !== "migration-replay-checkpoint-recovery"
|| descriptor.recoveryPredecessor !== "terminal-applied-046-exact-source-runtime-database"
|| descriptor.databasePreflight !== "final-migration-016-validated-and-host-telemetry-table-absent"
|| descriptor.databaseRowMutation !== "none-before-core-startup-migrations"
|| descriptor.databaseSchemaOutcome !== "migration-017-host-telemetry-table-present"
|| descriptor.runtimePredecessor !== "healthy-recovery-046-core-generation"
)
)
) {
@@ -0,0 +1,114 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const sourceRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-control-core-release-v3-reconciliation-20260822-042",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-device-control-core-release-v3-reconciliation-artifact.mjs [patch-id]",
);
}
const entry = "deployment/device-control-core-release-v3-reconciliation-v1.json";
const stage = await mkdtemp(join(tmpdir(), "nodedc-control-core-v3-reconciliation-"));
const payload = join(stage, "payload");
const target = resolve(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
try {
const descriptor = JSON.parse(await readFile(resolve(sourceRoot, entry), "utf8"));
const expected = {
schemaVersion: "nodedc.device-plane.device-control-core-release-v3-reconciliation.v1",
mode: "failed-release-v3-exact-preapply-image-restore",
failedPatchId: "device-control-core-release-v3-20260822-040",
failedArtifactSha256:
"08448a56cdf391076f92c5242e368fd0033b420167874645eebfa1f22184ee92",
failedArtifact:
"nodedc-device-plane-device-control-core-release-v3-20260822-040.tgz.20260822-184245",
backupId:
"device-plane-device-control-core-release-v3-20260822-040-20260822-184245",
predecessorPatchId: "device-control-core-release-v2-20260822-038",
predecessorArtifactSha256:
"e2d062b82b022dba662522b5d6e192026ac964d78950d903295ca3cbbc95ab28",
preapplyImageId:
"sha256:31d35733ee46225b487c0f02a7b52d4ba2d13f5b99f6a717b7f5e6f5460b412a",
sourceAction: "accept-byte-exact-restored-preapply-source",
runtimeAction: "retag-exact-preapply-image+recreate-device-control-core-only",
preservedServices: [
"device-manager",
"device-gateway",
"device-postgres",
"device-backhaul-target",
],
databaseVolume: "nodedc-device-plane-postgres-data",
publicIngress: "disabled",
edgeChannel: "core-initiated-pinned-mtls-registered-edges-only",
commandTransport: "typed-service-ping-v1",
gelios: "untouched-legacy-only",
rollback: "marker+exact-preapply-image-runtime",
};
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error("device_control_core_v3_reconciliation_descriptor_mismatch");
}
await mkdir(dirname(join(payload, entry)), { recursive: true });
await cp(resolve(sourceRoot, entry), join(payload, entry), { force: true });
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${entry}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{ encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
);
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const sha256 = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
component: "device-plane",
artifact: target,
sha256,
entries: [entry],
build: [],
services: ["device-control-core"],
transition: descriptor.mode,
runtimeAction: descriptor.runtimeAction,
preservedServices: descriptor.preservedServices,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
@@ -19,6 +19,17 @@ const upgradeV4 = patchId.startsWith("device-edge-core-channel-upgrade-v4-");
const upgradeV2 = !upgradeV4 && patchId.startsWith("device-edge-core-channel-upgrade-v2-");
const upgradeV1 = !upgradeV4 && !upgradeV2 && patchId.startsWith("device-edge-core-channel-upgrade-");
const upgrade = upgradeV1 || upgradeV2 || upgradeV4;
const coreDockerfile = await readFile(
resolve(devicePlaneRoot, "services/device-control-core/Dockerfile"),
"utf8",
);
if (
coreDockerfile.includes(
"COPY packages/infrastructure-telemetry-contract ./packages/infrastructure-telemetry-contract",
)
) {
throw new Error("historical_device_edge_core_channel_builder_has_advanced");
}
const descriptorPath = upgradeV4
? "deployment/device-edge-core-channel-upgrade-v4.json"
: upgradeV2
@@ -38,11 +38,12 @@ if (
"tailscale-retirement",
"tracker-ingress",
"command-transport",
"host-telemetry",
].includes(phase)
|| !/^[A-Za-z0-9._-]{1,96}$/.test(patchId || "")
) {
throw new Error(
"usage: build-device-edge-vps-artifact.mjs <foundation|runtime-reconciliation|backhaul|relay|core-channel|tailscale-retirement|tracker-ingress|command-transport> <patch-id>",
"usage: build-device-edge-vps-artifact.mjs <foundation|runtime-reconciliation|backhaul|relay|core-channel|tailscale-retirement|tracker-ingress|command-transport|host-telemetry> <patch-id>",
);
}
@@ -53,16 +54,22 @@ if (
) {
throw new Error("vps_initiated_transport_frozen:ADR-0001");
}
const acceptedSharedSourcePhases = new Set(["core-channel", "tracker-ingress"]);
const acceptedSharedSourcePhases = new Set([
"core-channel",
"tracker-ingress",
"command-transport",
]);
if (acceptedSharedSourcePhases.has(phase)) {
throw new Error(`accepted_vps_phase_rebuild_frozen:${phase}:ADR-0001`);
}
const nodeArchive = "node-v22.23.2-linux-x64.tar.xz";
const tailscaleArchive = "tailscale_1.102.2_amd64.tgz";
const telegrafArchive = "telegraf-1.38.4_linux_amd64.tar.gz";
const runtimeDigests = new Map([
[nodeArchive, "d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307"],
[tailscaleArchive, "ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8"],
[telegrafArchive, "81857e9745ebf26e058b6fdc27b9b2c210fd1fe61e57d7fad3d4bb9131f60041"],
]);
const entriesByPhase = {
@@ -130,6 +137,21 @@ const entriesByPhase = {
"vps/edge-process/device-edge-runtime.mjs",
"deployment/device-edge-vps-command-transport-v1.json",
],
"host-telemetry": [
"packages/device-edge-channel-contract/package.json",
"packages/device-edge-channel-contract/src",
"packages/infrastructure-telemetry-contract/package.json",
"packages/infrastructure-telemetry-contract/src",
"services/device-edge-channel/package.json",
"services/device-edge-channel/src",
"vps/edge-process/device-edge-runtime.mjs",
"vps/edge-process/host-telemetry-runtime.mjs",
"vps/config/nodedc-host-telemetry-telegraf.conf",
"vps/systemd/nodedc-device-edge-runtime.service",
"vps/systemd/nodedc-host-telemetry-agent.service",
"deployment/device-edge-vps-host-telemetry-v1.json",
`vendor/${telegrafArchive}`,
],
};
const entries = entriesByPhase[phase];
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
@@ -191,10 +213,10 @@ try {
? "tcp/9921"
: ["core-channel", "tailscale-retirement"].includes(phase)
? "tcp/443-mtls-only"
: ["tracker-ingress", "command-transport"].includes(phase)
: ["tracker-ingress", "command-transport", "host-telemetry"].includes(phase)
? "tcp/443-mtls+tcp/9921-telemetry"
: "disabled",
commandTransport: phase === "command-transport"
commandTransport: ["command-transport", "host-telemetry"].includes(phase)
? "typed-service-ping-v1"
: "disabled",
gelios: "untouched",
@@ -212,7 +234,7 @@ async function assertBoundary() {
if (
descriptor.component !== "device-edge-vps"
|| descriptor.runtimeHost !== "koffyvngij"
|| descriptor.commandTransport !== (phase === "command-transport"
|| descriptor.commandTransport !== (["command-transport", "host-telemetry"].includes(phase)
? "typed-service-ping-v1"
: "disabled")
|| !String(descriptor.gelios || "").startsWith("untouched")
@@ -419,6 +441,43 @@ async function assertBoundary() {
}
}
}
if (phase === "host-telemetry") {
if (!/^[0-9a-f]{64}$/.test(descriptor.predecessorArtifactSha256 || "")) {
throw new Error("host_telemetry_predecessor_sha256_invalid");
}
for (const required of [
'"predecessorPatch": "device-edge-vps-command-transport-20260812-013"',
'"agent": "telegraf"',
'"agentVersion": "1.38.4"',
'"transport": "existing-core-initiated-pinned-mtls-channel"',
'"mqtt": "disabled-no-public-broker-no-wan-plaintext"',
"User=nodedc-telemetry",
"IPAddressDeny=any",
"IPAddressAllow=localhost",
"MemoryMax=96M",
"CPUQuota=15%",
'url = "http://127.0.0.1:18223/internal/v1/host-telemetry"',
'data_format = "json"',
"submitHostTelemetry",
"createHostTelemetryCollector",
]) {
if (!combined.includes(required)) {
throw new Error(`host_telemetry_boundary_missing:${required}`);
}
}
for (const forbidden of [
"mqtt://",
"tcp://",
"outputs.mqtt",
"PRIVATE KEY",
"TS_AUTHKEY",
"device.dc.ru",
]) {
if (combined.includes(forbidden)) {
throw new Error(`host_telemetry_boundary_violation:${forbidden}`);
}
}
}
}
function canonicalTarScript() {
@@ -11,10 +11,24 @@ 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-v13-20260823-051", ...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-")
const descriptorPath = patchId.startsWith("device-manager-release-v13-")
? "deployment/device-manager-release-v13.json"
: patchId.startsWith("device-manager-release-v12-")
? "deployment/device-manager-release-v12.json"
: patchId.startsWith("device-manager-release-v11-")
? "deployment/device-manager-release-v11.json"
: patchId.startsWith("device-manager-release-v10-")
? "deployment/device-manager-release-v10.json"
: patchId.startsWith("device-manager-release-v9-")
? "deployment/device-manager-release-v9.json"
: patchId.startsWith("device-manager-release-v8-")
? "deployment/device-manager-release-v8.json"
: 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"
@@ -28,7 +42,14 @@ 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 isV8 = descriptorPath.endsWith("release-v8.json");
const isV9 = descriptorPath.endsWith("release-v9.json");
const isV10 = descriptorPath.endsWith("release-v10.json");
const isV11 = descriptorPath.endsWith("release-v11.json");
const isV12 = descriptorPath.endsWith("release-v12.json");
const isV13 = descriptorPath.endsWith("release-v13.json");
const isPersistent = isV4 || isV5 || isV6 || isV7 || isV8 || isV9 || isV10 || isV11 || isV12 || isV13;
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 +188,215 @@ 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-v13.json")) {
if (
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v13"
|| descriptor.predecessor?.kind !== "release"
|| descriptor.predecessor?.patchId !== "device-manager-release-v12-20260823-050"
|| descriptor.predecessor?.artifactSha256 !== "1a49839140e5f2e49763d78f24ee47d946e244bcfde15a9c38266e8bd14c0d49"
|| descriptor.controlCorePredecessor?.patchId !== "device-control-core-release-v4-20260823-047"
|| descriptor.controlCorePredecessor?.artifactSha256 !== "4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
|| descriptor.edgeChannelPredecessor?.patchId !== "device-edge-core-channel-upgrade-v4-20260812-023"
|| descriptor.edgeChannelPredecessor?.artifactSha256 !== "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|| descriptor.designSystem !== "nodedc-canonical-components-and-tokens-v1"
|| descriptor.missionCoreReference !== "compute-modules-workspace-71c8b04"
|| descriptor.infrastructureWorkspaceLayout !== "mission-core-system-workspace-v2"
|| descriptor.hostInventoryComposition !== "mission-core-compute-host-accordion-v2"
|| descriptor.hostInventoryOverviewSurface !== "separate-summary-soft-surface-v1"
|| descriptor.hostInventoryCollectionSurface !== "separate-host-collection-soft-surface-v1"
|| descriptor.hostInventoryRow !== "compact-centered-accordion-v1"
|| descriptor.hostInventoryFreshness !== "dot-only-v1"
|| descriptor.hostInventoryRelations !== "host-scoped-endpoint-deployment-service-v1"
|| descriptor.hostInventoryDefaultExpansion !== "collapsed"
|| descriptor.hostInventoryScaleTarget !== "five-hundred-collapsed-rows-v1"
|| descriptor.telemetryWorkspace !== "mission-core-compute-module-adaptive-window-v3"
|| descriptor.telemetrySurface !== "borderless-soft-surface-v1"
|| descriptor.telemetryStatus !== "mission-core-dot-status-v1"
|| descriptor.telemetryNavigation !== "full-workspace-back-navigation-v1"
|| descriptor.telemetryScroll !== "reset-on-workspace-transition-v1"
|| descriptor.telemetryPollInterval !== "three-seconds"
|| descriptor.telemetryFreshness !== "fifteen-seconds-missing-stale-not-unhealthy"
|| descriptor.telemetryOntologyProjection !== "observation-observed-property-provenance-freshness-v1"
|| descriptor.telemetryAgent !== "telegraf-host-observer-v1"
|| descriptor.telemetryGraphScale !== "adaptive-observed-window-explicit-domain-v1"
|| descriptor.telemetryCpuMinimumSpan !== "five-percentage-points"
|| descriptor.telemetryMemoryMinimumSpan !== "four-percentage-points"
|| descriptor.telemetryNetworkMissingSemantics !== "missing-counters-never-zero-v1"
|| descriptor.interactiveShell !== "disabled-pending-managed-session-boundary"
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|| descriptor.gelios !== "untouched-legacy-only"
) throw new Error("device_manager_v13_host_inventory_accordion_contract_mismatch");
await validateFaviconBundle(payload, "device_manager_v13");
} else if (descriptorPath.endsWith("release-v12.json")) {
if (
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v12"
|| descriptor.predecessor?.kind !== "release"
|| descriptor.predecessor?.patchId !== "device-manager-release-v11-20260823-049"
|| descriptor.predecessor?.artifactSha256 !== "c1e2056b50bfbb0d03d077461d0c27cc56cc52967c3f5620be14871c8a6d5cf0"
|| descriptor.controlCorePredecessor?.patchId !== "device-control-core-release-v4-20260823-047"
|| descriptor.controlCorePredecessor?.artifactSha256 !== "4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
|| descriptor.edgeChannelPredecessor?.patchId !== "device-edge-core-channel-upgrade-v4-20260812-023"
|| descriptor.edgeChannelPredecessor?.artifactSha256 !== "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|| descriptor.designSystem !== "nodedc-canonical-components-and-tokens-v1"
|| descriptor.missionCoreReference !== "compute-modules-workspace-71c8b04"
|| descriptor.infrastructureWorkspaceLayout !== "mission-core-system-workspace-v1"
|| descriptor.hostInventoryComposition !== "mission-core-compute-host-list-v1"
|| descriptor.telemetryWorkspace !== "mission-core-compute-module-adaptive-window-v3"
|| descriptor.telemetrySurface !== "borderless-soft-surface-v1"
|| descriptor.telemetryStatus !== "mission-core-dot-status-v1"
|| descriptor.telemetryNavigation !== "full-workspace-back-navigation-v1"
|| descriptor.telemetryScroll !== "reset-on-workspace-transition-v1"
|| descriptor.telemetryPollInterval !== "three-seconds"
|| descriptor.telemetryFreshness !== "fifteen-seconds-missing-stale-not-unhealthy"
|| descriptor.telemetryOntologyProjection !== "observation-observed-property-provenance-freshness-v1"
|| descriptor.telemetryAgent !== "telegraf-host-observer-v1"
|| descriptor.telemetryGraphScale !== "adaptive-observed-window-explicit-domain-v1"
|| descriptor.telemetryCpuMinimumSpan !== "five-percentage-points"
|| descriptor.telemetryMemoryMinimumSpan !== "four-percentage-points"
|| descriptor.telemetryNetworkMissingSemantics !== "missing-counters-never-zero-v1"
|| descriptor.interactiveShell !== "disabled-pending-managed-session-boundary"
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|| descriptor.gelios !== "untouched-legacy-only"
) throw new Error("device_manager_v12_adaptive_telemetry_graph_contract_mismatch");
await validateFaviconBundle(payload, "device_manager_v12");
} else if (descriptorPath.endsWith("release-v11.json")) {
if (
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v11"
|| descriptor.predecessor?.kind !== "release"
|| descriptor.predecessor?.patchId !== "device-manager-release-v10-20260823-048"
|| descriptor.predecessor?.artifactSha256 !== "e6b983a314db4f8c27d89062dfedf5ed0523cc30421170799d181a19e2d85d4c"
|| descriptor.controlCorePredecessor?.patchId !== "device-control-core-release-v4-20260823-047"
|| descriptor.controlCorePredecessor?.artifactSha256 !== "4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
|| descriptor.edgeChannelPredecessor?.patchId !== "device-edge-core-channel-upgrade-v4-20260812-023"
|| descriptor.edgeChannelPredecessor?.artifactSha256 !== "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|| descriptor.designSystem !== "nodedc-canonical-components-and-tokens-v1"
|| descriptor.missionCoreReference !== "compute-modules-workspace-71c8b04"
|| descriptor.infrastructureWorkspaceLayout !== "mission-core-system-workspace-v1"
|| descriptor.hostInventoryComposition !== "mission-core-compute-host-list-v1"
|| descriptor.telemetryWorkspace !== "mission-core-compute-module-visual-parity-v2"
|| descriptor.telemetrySurface !== "borderless-soft-surface-v1"
|| descriptor.telemetryStatus !== "mission-core-dot-status-v1"
|| descriptor.telemetryNavigation !== "full-workspace-back-navigation-v1"
|| descriptor.telemetryScroll !== "reset-on-workspace-transition-v1"
|| descriptor.telemetryPollInterval !== "three-seconds"
|| descriptor.telemetryFreshness !== "fifteen-seconds-missing-stale-not-unhealthy"
|| descriptor.telemetryOntologyProjection !== "observation-observed-property-provenance-freshness-v1"
|| descriptor.telemetryAgent !== "telegraf-host-observer-v1"
|| descriptor.interactiveShell !== "disabled-pending-managed-session-boundary"
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|| descriptor.gelios !== "untouched-legacy-only"
) throw new Error("device_manager_v11_mission_core_visual_parity_contract_mismatch");
await validateFaviconBundle(payload, "device_manager_v11");
} else if (descriptorPath.endsWith("release-v10.json")) {
if (
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v10"
|| descriptor.predecessor?.kind !== "release"
|| descriptor.predecessor?.patchId !== "device-manager-release-v8-20260822-039"
|| descriptor.predecessor?.artifactSha256 !== "30a83d4c6b5c029c96c4af19fa558e0ae75e4b60bc897881457304bd17e0e465"
|| descriptor.controlCorePredecessor?.patchId !== "device-control-core-release-v4-20260823-047"
|| descriptor.controlCorePredecessor?.artifactSha256 !== "4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
|| descriptor.edgeChannelPredecessor?.patchId !== "device-edge-core-channel-upgrade-v4-20260812-023"
|| descriptor.edgeChannelPredecessor?.artifactSha256 !== "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|| descriptor.telemetryWorkspace !== "mission-core-compute-module-parity-v1"
|| descriptor.telemetryNavigation !== "full-workspace-back-navigation-v1"
|| descriptor.telemetryPollInterval !== "three-seconds"
|| descriptor.telemetryFreshness !== "fifteen-seconds-missing-stale-not-unhealthy"
|| descriptor.telemetryOntologyProjection !== "observation-observed-property-provenance-freshness-v1"
|| descriptor.telemetryAgent !== "telegraf-host-observer-v1"
|| descriptor.interactiveShell !== "disabled-pending-managed-session-boundary"
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|| descriptor.gelios !== "untouched-legacy-only"
) throw new Error("device_manager_v10_host_telemetry_workspace_contract_mismatch");
await validateFaviconBundle(payload, "device_manager_v10");
} else if (descriptorPath.endsWith("release-v9.json")) {
if (
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v9"
|| descriptor.predecessor?.kind !== "release"
|| descriptor.predecessor?.patchId !== "device-manager-release-v8-20260822-039"
|| descriptor.predecessor?.artifactSha256 !== "30a83d4c6b5c029c96c4af19fa558e0ae75e4b60bc897881457304bd17e0e465"
|| descriptor.controlCorePredecessor?.patchId !== "device-control-core-release-v3-20260822-040"
|| descriptor.controlCorePredecessor?.artifactSha256 !== "08448a56cdf391076f92c5242e368fd0033b420167874645eebfa1f22184ee92"
|| descriptor.edgeChannelPredecessor?.patchId !== "device-edge-core-channel-upgrade-v4-20260812-023"
|| descriptor.edgeChannelPredecessor?.artifactSha256 !== "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|| descriptor.telemetryWorkspace !== "mission-core-compute-module-parity-v1"
|| descriptor.telemetryNavigation !== "full-workspace-back-navigation-v1"
|| descriptor.telemetryPollInterval !== "three-seconds"
|| descriptor.telemetryFreshness !== "fifteen-seconds-missing-stale-not-unhealthy"
|| descriptor.telemetryOntologyProjection !== "observation-observed-property-provenance-freshness-v1"
|| descriptor.telemetryAgent !== "telegraf-host-observer-v1"
|| descriptor.interactiveShell !== "disabled-pending-managed-session-boundary"
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|| descriptor.gelios !== "untouched-legacy-only"
) throw new Error("device_manager_v9_host_telemetry_workspace_contract_mismatch");
await validateFaviconBundle(payload, "device_manager_v9");
} else if (descriptorPath.endsWith("release-v8.json")) {
if (
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v8"
|| 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-038"
|| descriptor.controlCorePredecessor?.artifactSha256 !== "e2d062b82b022dba662522b5d6e192026ac964d78950d903295ca3cbbc95ab28"
|| 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 !== "ontology-backed-host-runtime-v1"
|| descriptor.ontologyFoundation !== "ontology-core-device-foundation-20260822-001"
|| descriptor.ontologyCatalogHash !== "229c61c02a790906"
|| descriptor.assetBinding !== "temporal-device-asset-binding-v1"
|| descriptor.infrastructureRuntime !== "host-endpoint-deployment-service-instance-v1"
|| descriptor.healthEvidence !== "ttl-observation-missing-not-unhealthy-v1"
|| descriptor.interactiveShell !== "disabled-pending-managed-session-boundary"
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|| descriptor.gelios !== "untouched-legacy-only"
) throw new Error("device_manager_v8_canonical_ontology_runtime_contract_mismatch");
await validateFaviconBundle(payload, "device_manager_v8");
} else 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 +422,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 +510,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)}`);
+282 -10
View File
@@ -46,6 +46,8 @@ RELAY_USER = "nodedc-relay"
RELAY_GROUP = "nodedc-relay"
CHANNEL_USER = "nodedc-channel"
CHANNEL_GROUP = "nodedc-channel"
TELEMETRY_USER = "nodedc-telemetry"
TELEMETRY_GROUP = "nodedc-telemetry"
TAILSCALE_REQUIRED_TAG = "tag:device-edge-vps"
MANAGEMENT_KEY_FINGERPRINT = (
"SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw"
@@ -67,10 +69,19 @@ TAILSCALE_ARCHIVE_SHA256 = (
NODE_BIN_SHA256 = "3517c2df0b2f8cd7f422b4b8450ef81c6889f08eb03e281d6de9079b15e6a327"
TAILSCALE_BIN_SHA256 = "58b0fa0907677ea6afe0d3022cc3e99b1a03f39a7ed60144843ed38252e00c80"
TAILSCALED_BIN_SHA256 = "5f17b092bac92326325f6c4ffd9991fad3c073975abe412d02ee68721a500394"
TELEGRAF_VERSION = "1.38.4"
TELEGRAF_ARCHIVE = "telegraf-1.38.4_linux_amd64.tar.gz"
TELEGRAF_ARCHIVE_SHA256 = (
"81857e9745ebf26e058b6fdc27b9b2c210fd1fe61e57d7fad3d4bb9131f60041"
)
TELEGRAF_BIN_SHA256 = (
"0643b582546eb9c70d99a9646b3e49e25ccdea4f78ab06fd9096ed71dba1babb"
)
NODE_BIN = LIVE_ROOT / "runtime/node/bin/node"
TAILSCALE_BIN = LIVE_ROOT / "runtime/tailscale/tailscale"
TAILSCALED_BIN = LIVE_ROOT / "runtime/tailscale/tailscaled"
TELEGRAF_BIN = LIVE_ROOT / "runtime/telegraf/usr/bin/telegraf"
TAILSCALE_SOCKET = Path("/run/nodedc-b2-vps/tailscaled.sock")
TAILSCALE_STATE = Path("/var/lib/nodedc-b2-vps/tailscale/tailscaled.state")
TRUST_ROOT = Path("/var/lib/nodedc-b2-vps/trust")
@@ -102,6 +113,10 @@ CHANNEL_CORE_CERTIFICATE = CHANNEL_TRUST_ROOT / "core-certificate.pem"
CHANNEL_RUNTIME_CONFIG = CHANNEL_TRUST_ROOT / "runtime.json"
CHANNEL_HEALTH_PORT = 18222
CHANNEL_PUBLIC_PORT = 443
HOST_TELEMETRY_PORT = 18223
HOST_TELEMETRY_UNIT = Path(
"/etc/systemd/system/nodedc-host-telemetry-agent.service"
)
CORE_CHANNEL_ACCEPTED_PATCH = "device-edge-vps-core-channel-20260812-010"
CORE_CHANNEL_ACCEPTED_SHA256 = (
"c8ef3c4bb45850cad32e881eba081bc4c891c2886e5500d02cb94616d82353f3"
@@ -116,6 +131,12 @@ TRACKER_INGRESS_ACCEPTED_PATCH = "device-edge-vps-tracker-ingress-20260812-012"
TRACKER_INGRESS_ACCEPTED_SHA256 = (
"290acef118839c6b0c31aac864c47da1832a289537366af9322d4624a1dd81ec"
)
COMMAND_TRANSPORT_ACCEPTED_PATCH = (
"device-edge-vps-command-transport-20260812-013"
)
COMMAND_TRANSPORT_ACCEPTED_SHA256 = (
"c7486ec879681ddd706f229b628c8556ca8c9ccc4f152a85debb409c302759ef"
)
FOUNDATION_ENTRIES = (
"vps/config/00-nodedc-b2-vps.conf",
@@ -181,6 +202,21 @@ COMMAND_TRANSPORT_ENTRIES = (
"vps/edge-process/device-edge-runtime.mjs",
"deployment/device-edge-vps-command-transport-v1.json",
)
HOST_TELEMETRY_ENTRIES = (
"packages/device-edge-channel-contract/package.json",
"packages/device-edge-channel-contract/src",
"packages/infrastructure-telemetry-contract/package.json",
"packages/infrastructure-telemetry-contract/src",
"services/device-edge-channel/package.json",
"services/device-edge-channel/src",
"vps/edge-process/device-edge-runtime.mjs",
"vps/edge-process/host-telemetry-runtime.mjs",
"vps/config/nodedc-host-telemetry-telegraf.conf",
"vps/systemd/nodedc-device-edge-runtime.service",
"vps/systemd/nodedc-host-telemetry-agent.service",
"deployment/device-edge-vps-host-telemetry-v1.json",
f"vendor/{TELEGRAF_ARCHIVE}",
)
PHASE_ENTRIES = {
"foundation": FOUNDATION_ENTRIES,
@@ -191,6 +227,7 @@ PHASE_ENTRIES = {
"tailscale-retirement": TAILSCALE_RETIREMENT_ENTRIES,
"tracker-ingress": TRACKER_INGRESS_ENTRIES,
"command-transport": COMMAND_TRANSPORT_ENTRIES,
"host-telemetry": HOST_TELEMETRY_ENTRIES,
}
SUPERSEDED_TRANSPORT_PHASES = frozenset({"backhaul", "relay"})
@@ -312,6 +349,35 @@ PHASE_FILE_SHA256 = {
"deployment/device-edge-vps-command-transport-v1.json":
"971166143fe954b9c5043cce9a464d17efbc87933da1405b2517a4693a7bed09",
},
"host-telemetry": {
"deployment/device-edge-vps-host-telemetry-v1.json":
"c4dc60e68549c3c3844779091d5b767a2cb4152cd923433b488cb56e37717391",
"packages/device-edge-channel-contract/package.json":
"57d5349b5dcef2cacd4f3e4fad010359a65d59f5f903eff07d89f67c497f97c0",
"packages/device-edge-channel-contract/src/index.mjs":
"58a53836495dc891de191c6022cf7661a2198deb9fff7055a5cc23a36ddf49d2",
"packages/infrastructure-telemetry-contract/package.json":
"5ef70204acc9a2bee68be959347487dc2e8fb7fbe8bd88731033e7ab204acf34",
"packages/infrastructure-telemetry-contract/src/index.mjs":
"6d4b60b79e131380fcec403cf8842a3612614b5540c7052020e84d4fc8a9360f",
"services/device-edge-channel/package.json":
"bdf502be43b62bdd6db05b022a532d93ba954277ac5143d6058d2f27f6a2e9d2",
"services/device-edge-channel/src/runtime.mjs":
"4c0e874b2f1161910d3abde9a07f4a7744ffeece325303cc9985521a0eafb47b",
"services/device-edge-channel/src/server.mjs":
"a82057218bb368ab926404f90a19cc17c0359b57dc890f38a1324ab8c497c17b",
"vps/config/nodedc-host-telemetry-telegraf.conf":
"596e386d1e37b8178ccc660760567f5a8914ae7bef43b3603b31846c73154972",
"vps/edge-process/device-edge-runtime.mjs":
"0fc32e8c71a028777b0945ea6a0dbab22b0244caab1d7f616702c8a4a143c597",
"vps/edge-process/host-telemetry-runtime.mjs":
"ce1c6f368199d6c91e8a7496e0e8388e3c390018f2695107bc2877eced5e566d",
"vps/systemd/nodedc-device-edge-runtime.service":
"88cd8d34df254f8daa1d82f6bcd175fb061376b65491ee6ac90b296fce675dfb",
"vps/systemd/nodedc-host-telemetry-agent.service":
"0d3aa1644af528ebd4cca7a95fb1bb89fe14544a64fb9729fd469296c4343506",
f"vendor/{TELEGRAF_ARCHIVE}": TELEGRAF_ARCHIVE_SHA256,
},
}
# Exact immutable baselines from terminally accepted predecessor artifacts.
@@ -472,7 +538,11 @@ def validate_payload(payload: Path, phase: str):
descriptor.get("component") != COMPONENT
or descriptor.get("runtimeHost") != RUNTIME_HOST
or descriptor.get("commandTransport")
!= ("typed-service-ping-v1" if phase == "command-transport" else "disabled")
!= (
"typed-service-ping-v1"
if phase in {"command-transport", "host-telemetry"}
else "disabled"
)
or not str(descriptor.get("gelios", "")).startswith("untouched")
or not descriptor.get("rollback")
):
@@ -737,6 +807,25 @@ def current_phase_preflight(phase: str):
if (LIVE_ROOT / COMMAND_TRANSPORT_ENTRIES[-1]).exists():
die("VPS command transport target path already exists")
return {"predecessor": "accepted-tracker-ingress-012"}
if phase == "host-telemetry":
command_record = applied_phase_record("command-transport")
if (
command_record.get("patch") != COMMAND_TRANSPORT_ACCEPTED_PATCH
or command_record.get("sha256")
!= COMMAND_TRANSPORT_ACCEPTED_SHA256
):
die("VPS host telemetry command transport predecessor mismatch")
source_file_state("command-transport")
validate_command_transport_runtime()
if (
HOST_TELEMETRY_UNIT.exists()
or TELEGRAF_BIN.exists()
or user_exists(TELEMETRY_USER)
or (LIVE_ROOT / HOST_TELEMETRY_ENTRIES[-2]).exists()
):
die("VPS host telemetry target boundary already exists")
assert_port_closed(HOST_TELEMETRY_PORT)
return {"predecessor": "accepted-command-transport-013"}
validate_foundation_runtime(
require_running_tailnet=phase in {"backhaul", "relay"},
expected_key_user=BACKHAUL_USER if phase == "relay" else SERVICE_USER,
@@ -848,6 +937,12 @@ def backup_targets_for_phase(phase: str):
return common + [CHANNEL_UNIT, NFTABLES_CONFIG]
if phase == "command-transport":
return common + [CHANNEL_UNIT, NFTABLES_CONFIG]
if phase == "host-telemetry":
return common + [
CHANNEL_UNIT,
HOST_TELEMETRY_UNIT,
TELEGRAF_BIN.parent,
]
return common + [RELAY_UNIT, NFTABLES_CONFIG]
@@ -887,7 +982,13 @@ def create_backup(patch_id: str, phase: str):
"serviceUserExisted": user_exists(),
"serviceUsersExisted": {
name: user_exists(name)
for name in (SERVICE_USER, BACKHAUL_USER, RELAY_USER, CHANNEL_USER)
for name in (
SERVICE_USER,
BACKHAUL_USER,
RELAY_USER,
CHANNEL_USER,
TELEMETRY_USER,
)
},
"services": {
name: {
@@ -902,6 +1003,7 @@ def create_backup(patch_id: str, phase: str):
"nodedc-b2-backhaul.service",
"nodedc-b2-relay.service",
"nodedc-device-edge-channel.service",
"nodedc-host-telemetry-agent.service",
)
},
}
@@ -1002,10 +1104,17 @@ def ensure_service_user(name=SERVICE_USER, home_dir="/var/lib/nodedc-b2-vps"):
def extract_vendor_binary(archive: Path, member_name: str, target: Path, mode=0o755):
with tarfile.open(archive, "r:*") as package:
try:
member = package.getmember(member_name)
except KeyError:
accepted_names = {member_name, f"./{member_name}"}
matches = [
candidate
for candidate in package.getmembers()
if candidate.name in accepted_names
]
if not matches:
die(f"vendor binary member missing: {member_name}")
if len(matches) != 1:
die(f"vendor binary member ambiguous: {member_name}")
member = matches[0]
if not member.isfile() or member.issym() or member.islnk():
die(f"vendor binary member unsafe: {member_name}")
source = package.extractfile(member)
@@ -1336,6 +1445,32 @@ def apply_command_transport(_payload: Path):
validate_command_transport_runtime()
def apply_host_telemetry(_payload: Path):
ensure_service_user(
TELEMETRY_USER,
"/var/lib/nodedc-b2-vps/telemetry",
)
extract_vendor_binary(
LIVE_ROOT / f"vendor/{TELEGRAF_ARCHIVE}",
f"telegraf-{TELEGRAF_VERSION}/usr/bin/telegraf",
TELEGRAF_BIN,
)
install_file(
LIVE_ROOT / "vps/systemd/nodedc-device-edge-runtime.service",
CHANNEL_UNIT,
0o644,
)
install_file(
LIVE_ROOT / "vps/systemd/nodedc-host-telemetry-agent.service",
HOST_TELEMETRY_UNIT,
0o644,
)
systemctl("daemon-reload")
systemctl("restart", "nodedc-device-edge-channel.service")
systemctl("enable", "--now", "nodedc-host-telemetry-agent.service")
validate_host_telemetry_runtime()
def sshd_effective():
return run(["/usr/sbin/sshd", "-T"]).stdout.lower()
@@ -1777,6 +1912,126 @@ def validate_command_transport_runtime():
return health
def validate_host_telemetry_runtime():
source_file_state("foundation")
source_file_state("tailscale-retirement")
source_file_state("host-telemetry")
command_record = applied_phase_record("command-transport")
if (
command_record.get("patch") != COMMAND_TRANSPORT_ACCEPTED_PATCH
or command_record.get("sha256") != COMMAND_TRANSPORT_ACCEPTED_SHA256
):
die("host telemetry command transport identity mismatch")
binary = assert_regular_nonsymlink(
TELEGRAF_BIN,
"VPS Telegraf runtime",
)
if (
binary.st_uid != 0
or binary.st_gid != 0
or (binary.st_mode & 0o777) != 0o755
or sha256_file(TELEGRAF_BIN) != TELEGRAF_BIN_SHA256
):
die("VPS Telegraf runtime identity mismatch")
version = run([str(TELEGRAF_BIN), "version"]).stdout.strip()
if not version.startswith(f"Telegraf {TELEGRAF_VERSION}"):
die("VPS Telegraf version mismatch")
telemetry_account = pwd.getpwnam(TELEMETRY_USER)
channel_account = pwd.getpwnam(CHANNEL_USER)
if (
telemetry_account.pw_shell != "/usr/sbin/nologin"
or telemetry_account.pw_uid == channel_account.pw_uid
):
die("VPS telemetry runtime identity is not isolated")
if not service_active("nodedc-device-edge-channel.service"):
die("VPS Device Edge runtime is not active")
if not service_active("nodedc-host-telemetry-agent.service"):
die("VPS host telemetry agent is not active")
if (
systemctl(
"is-enabled",
"nodedc-host-telemetry-agent.service",
check=False,
).returncode != 0
):
die("VPS host telemetry agent is not enabled")
health = None
last_error = "no host telemetry acceptance"
for _attempt in range(60):
candidate = core_channel_health(require_accepted=True)
host_telemetry = candidate.get("hostTelemetry") or {}
if (
host_telemetry.get("listening") is True
and host_telemetry.get("host") == "127.0.0.1"
and host_telemetry.get("port") == HOST_TELEMETRY_PORT
and host_telemetry.get("profile") == "linux-host-telegraf-v1"
and int(host_telemetry.get("accepted") or 0) >= 1
and host_telemetry.get("lastErrorCode") is None
):
health = candidate
break
last_error = json.dumps(host_telemetry, sort_keys=True)
time.sleep(2)
if health is None:
die(f"VPS host telemetry acceptance timeout: {last_error}")
expected = {
"ok": True,
"service": "nodedc-device-edge-runtime",
"channel": "accepted",
"trackerIngress": "telemetry-ingest",
"commandTransport": "typed-service-ping-v1",
}
for key, value in expected.items():
if health.get(key) != value:
die(f"VPS host telemetry preserved contract mismatch: {key}")
if not port_is_open("127.0.0.1", HOST_TELEMETRY_PORT, timeout=5):
die("VPS host telemetry collector is unavailable")
if port_is_open(PUBLIC_IPV4, HOST_TELEMETRY_PORT, timeout=2):
die("VPS host telemetry collector became public")
for port in (1883, 8883):
if port_is_open("127.0.0.1", port) or port_is_open(PUBLIC_IPV4, port):
die(f"VPS forbidden MQTT listener became available: {port}")
for port in (22, CHANNEL_PUBLIC_PORT, 9921):
if not port_is_open(PUBLIC_IPV4, port, timeout=5):
die(f"VPS host telemetry preserved listener unavailable: {port}")
unit = run([
"/usr/bin/systemctl",
"show",
"nodedc-host-telemetry-agent.service",
"--property=User,Group,NoNewPrivileges,CapabilityBoundingSet,MemoryMax,MemorySwapMax,TasksMax,LimitNOFILE",
]).stdout
for required in (
"User=nodedc-telemetry",
"Group=nodedc-telemetry",
"NoNewPrivileges=yes",
"CapabilityBoundingSet=",
"MemoryMax=100663296",
"MemorySwapMax=0",
"TasksMax=64",
"LimitNOFILE=512",
):
if required not in unit:
die(f"VPS host telemetry resource boundary mismatch: {required}")
nft = run([
"/usr/sbin/nft",
"list",
"table",
"inet",
"nodedc_b2_vps",
]).stdout
for required in (
"policy drop",
"tcp dport 22",
"tcp dport 443",
"tcp dport 9921",
):
if required not in nft:
die(f"VPS host telemetry firewall contract mismatch: {required}")
if f"tcp dport {HOST_TELEMETRY_PORT}" in nft:
die("VPS host telemetry firewall exposed the collector")
return health
def validate_relay_runtime():
validate_backhaul_runtime()
source_file_state("relay")
@@ -1835,6 +2090,7 @@ def restore_service_enablement(metadata):
def rollback(backup: Path, phase: str):
for service in (
"nodedc-host-telemetry-agent.service",
"nodedc-b2-relay.service",
"nodedc-b2-backhaul.service",
"nodedc-device-edge-channel.service",
@@ -1866,6 +2122,9 @@ def rollback(backup: Path, phase: str):
if phase == "core-channel":
if not users_before.get(CHANNEL_USER, False) and user_exists(CHANNEL_USER):
run(["/usr/sbin/userdel", CHANNEL_USER], check=False)
if phase == "host-telemetry":
if not users_before.get(TELEMETRY_USER, False) and user_exists(TELEMETRY_USER):
run(["/usr/sbin/userdel", TELEMETRY_USER], check=False)
if phase == "foundation" and not metadata.get("serviceUserExisted"):
runtime_state_root = Path("/var/lib/nodedc-b2-vps")
if LIVE_ROOT.exists() and not LIVE_ROOT.is_symlink():
@@ -1955,7 +2214,7 @@ def plan_artifact(artifact_argument: str):
print("runtime_composition=single-non-root-process:core-channel+universal-gateway")
print("tailscale=preserved:absent")
print("services=recreate:nodedc-device-edge-channel")
else:
elif phase == "command-transport":
print("public_core_channel=preserved:155.212.211.15:443/tcp:tls13-mtls-h2")
print(f"health=127.0.0.1:{CHANNEL_HEALTH_PORT}:combined-edge-runtime")
print("public_b2_ingress=155.212.211.15:9921/tcp:bidirectional-session")
@@ -1964,16 +2223,27 @@ def plan_artifact(artifact_argument: str):
print("runtime_composition=single-non-root-process:core-channel+universal-gateway")
print("tailscale=preserved:absent")
print("services=recreate:nodedc-device-edge-channel")
else:
print("public_core_channel=preserved:155.212.211.15:443/tcp:tls13-mtls-h2")
print(f"health=127.0.0.1:{CHANNEL_HEALTH_PORT}:combined-edge-runtime")
print(f"host_telemetry_collector=127.0.0.1:{HOST_TELEMETRY_PORT}:loopback-only")
print(f"host_telemetry_agent=telegraf:{TELEGRAF_VERSION}:sha256:{TELEGRAF_ARCHIVE_SHA256}")
print("host_telemetry_interval=2s")
print("host_telemetry_transport=existing-pinned-mtls-core-channel")
print("host_telemetry_ontology=observation.observation=>infrastructure.host")
print("mqtt=disabled")
print("public_port_set=preserved:22,443,9921")
print("services=recreate:nodedc-device-edge-channel+create:nodedc-host-telemetry-agent")
print(
"command_transport=typed-service-ping-v1:allowlisted-adapter-only"
if phase == "command-transport"
if phase in {"command-transport", "host-telemetry"}
else "command_transport=disabled"
)
if phase == "command-transport":
if phase in {"command-transport", "host-telemetry"}:
print("command_catalog=allowlisted-adapter-typed-commands-only")
print(
"gelios=untouched-legacy-only"
if phase == "command-transport"
if phase in {"command-transport", "host-telemetry"}
else "gelios=untouched"
)
print("dns=unchanged")
@@ -2013,8 +2283,10 @@ def apply_artifact(artifact_argument: str):
apply_tailscale_retirement(loaded["payload"])
elif loaded["phase"] == "tracker-ingress":
apply_tracker_ingress(loaded["payload"])
else:
elif loaded["phase"] == "command-transport":
apply_command_transport(loaded["payload"])
else:
apply_host_telemetry(loaded["payload"])
archived = archive_artifact(loaded["artifact"], APPLIED_ROOT)
record = {
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
import hashlib
import importlib.machinery
import importlib.util
import inspect
import json
import os
import subprocess
import tarfile
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RUNNER_PATH = Path(
os.environ.get(
"NODEDC_DEPLOY_RUNNER",
SCRIPT_DIR.parents[2] / "platform/infra/deploy-runner/nodedc-deploy",
)
)
BUILDER = SCRIPT_DIR / "build-device-control-core-incident-audit-artifact.mjs"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_control_core_incident_audit_runner_under_test",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class DeviceControlCoreIncidentAuditArtifactTest(unittest.TestCase):
def build(self, artifact_dir, patch_id):
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
result = subprocess.run(
["node", str(BUILDER), patch_id],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(result.stdout)
def test_artifact_is_deterministic_plan_only_audit(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-control-core-incident-audit-artifact-",
) as directory:
artifact_dir = Path(directory)
patch_id = "device-control-core-incident-audit-unit-043"
first = self.build(artifact_dir, patch_id)
first_bytes = Path(first["artifact"]).read_bytes()
second = self.build(artifact_dir, patch_id)
second_bytes = Path(second["artifact"]).read_bytes()
self.assertEqual(first_bytes, second_bytes)
self.assertEqual(
first["sha256"],
hashlib.sha256(first_bytes).hexdigest(),
)
self.assertEqual(first["build"], [])
self.assertEqual(first["services"], [])
self.assertEqual(first["allowedOperation"], "canonical-plan-only")
self.assertEqual(first["runtimeMutation"], "none")
with tarfile.open(first["artifact"], "r:gz") as archive:
descriptor = json.loads(
archive.extractfile(
"payload/"
+ RUNNER.DEVICE_PLANE_CONTROL_CORE_INCIDENT_AUDIT_REL
).read().decode("utf-8")
)
self.assertEqual(
descriptor,
RUNNER.expected_device_plane_control_core_incident_audit_descriptor(),
)
entries = RUNNER.DEVICE_PLANE_CONTROL_CORE_INCIDENT_AUDIT_ENTRIES
self.assertEqual(RUNNER.component_services("device-plane", entries), ())
self.assertEqual(RUNNER.component_builds("device-plane", entries), ())
def test_descriptor_pins_both_rollback_failures(self):
descriptor = (
RUNNER.expected_device_plane_control_core_incident_audit_descriptor()
)
self.assertFalse(descriptor["applyAllowed"])
self.assertEqual(descriptor["runtimeMutation"], "none")
self.assertEqual(
[item["artifactSha256"] for item in descriptor["failedAttempts"]],
[
"08448a56cdf391076f92c5242e368fd0033b420167874645eebfa1f22184ee92",
"54ab243439bce724fa0a0872b76cc32e0052ea5127153214d92872f02ae831cf",
],
)
def test_runtime_audit_reads_logs_and_database_without_mutation(self):
log_result = mock.Mock(
returncode=0,
stdout="Error: device_database_migration_blocked\n",
stderr="at PostgresDeviceRepository.migrate (postgres-repository.mjs:110)\n",
)
database_result = mock.Mock(
returncode=0,
stdout=(
"activity\t42,active,Lock,relation,schema-ddl,181\n"
"schema\thost-telemetry-table-present\n"
),
stderr="",
)
with mock.patch.object(
RUNNER,
"device_plane_service_container_ids",
side_effect=[("c" * 64,), ("p" * 64,)],
), mock.patch.object(
RUNNER.subprocess,
"run",
side_effect=[log_result, database_result],
) as run:
evidence = RUNNER.collect_device_plane_control_core_incident_audit()
self.assertEqual(evidence["coreContainerId"], "c" * 64)
self.assertIn(
"Error: device_database_migration_blocked",
evidence["logErrors"],
)
self.assertEqual(
evidence["database"],
[
"activity\t42,active,Lock,relation,schema-ddl,181",
"schema\thost-telemetry-table-present",
],
)
self.assertEqual(run.call_count, 2)
log_command = run.call_args_list[0].args[0]
self.assertEqual(log_command[1:4], ["logs", "--tail", "240"])
database_command = run.call_args_list[1].args[0]
self.assertEqual(database_command[1], "exec")
query = database_command[database_command.index("-c") + 1]
self.assertTrue(query.lstrip().startswith("select 'schema'"))
self.assertNotIn(";", query)
def test_apply_path_rejects_plan_only_audit_before_state_mutation(self):
source = inspect.getsource(RUNNER.apply_artifact)
self.assertIn("canonical-plan-only", source)
self.assertIn("apply is forbidden", source)
self.assertLess(
source.index("canonical-plan-only"),
source.index("state_has_sha"),
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,333 @@
#!/usr/bin/env python3
import contextlib
import hashlib
import importlib.machinery
import importlib.util
import inspect
import io
import json
import os
import re
import subprocess
import tarfile
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RUNNER_PATH = Path(
os.environ.get(
"NODEDC_DEPLOY_RUNNER",
SCRIPT_DIR.parents[2] / "platform/infra/deploy-runner/nodedc-deploy",
)
)
BUILDER = (
SCRIPT_DIR
/ "build-device-control-core-migration-replay-audit-artifact.mjs"
)
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_control_core_migration_replay_audit_runner_under_test",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class DeviceControlCoreMigrationReplayAuditArtifactTest(unittest.TestCase):
def build(self, artifact_dir, patch_id):
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
result = subprocess.run(
["node", str(BUILDER), patch_id],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(result.stdout)
def test_artifact_is_deterministic_plan_only_audit(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-control-core-migration-replay-audit-",
) as directory:
artifact_dir = Path(directory)
patch_id = "device-control-core-migration-replay-audit-unit-045"
first = self.build(artifact_dir, patch_id)
first_bytes = Path(first["artifact"]).read_bytes()
second = self.build(artifact_dir, patch_id)
second_bytes = Path(second["artifact"]).read_bytes()
self.assertEqual(first_bytes, second_bytes)
self.assertEqual(
first["sha256"],
hashlib.sha256(first_bytes).hexdigest(),
)
self.assertEqual(first["build"], [])
self.assertEqual(first["services"], [])
self.assertEqual(first["allowedOperation"], "canonical-plan-only")
self.assertEqual(first["runtimeMutation"], "none")
with tarfile.open(first["artifact"], "r:gz") as archive:
descriptor = json.loads(
archive.extractfile(
"payload/"
+ RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_AUDIT_REL
).read().decode("utf-8")
)
self.assertEqual(
descriptor,
RUNNER.expected_device_plane_control_core_migration_replay_audit_descriptor(),
)
entries = RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_AUDIT_ENTRIES
self.assertEqual(RUNNER.component_services("device-plane", entries), ())
self.assertEqual(RUNNER.component_builds("device-plane", entries), ())
def test_database_audit_returns_all_mismatches_without_mutation(self):
database_result = mock.Mock(
returncode=0,
stdout=(
"2\t0\tfalse\tfalse\tfalse\t"
+ json.dumps(
sorted(
RUNNER.DEVICE_PLANE_CONTROL_CORE_REPLAY_011_COMMAND_KINDS
),
separators=(",", ":"),
)
+ "\n"
),
stderr="",
)
with mock.patch.object(
RUNNER,
"device_plane_service_container_ids",
return_value=("p" * 64,),
), mock.patch.object(
RUNNER.subprocess,
"run",
return_value=database_result,
) as run:
evidence = (
RUNNER.collect_device_plane_control_core_migration_replay_database_evidence(
enforce_recovery_invariants=False,
)
)
self.assertEqual(evidence["invalidCommandKindCount"], 2)
self.assertEqual(evidence["triggeringReceiptCount"], 0)
self.assertFalse(evidence["constraintValidated"])
self.assertFalse(evidence["constraintCoversFinalKinds"])
self.assertFalse(evidence["hostTelemetryTableAbsent"])
self.assertTrue(evidence["constraintMatchesReplay011Kinds"])
self.assertEqual(evidence["constraintPhase"], "replay-011")
self.assertTrue(evidence["constraintMatchesKnownReplayCheckpoint"])
self.assertFalse(evidence["recovery044Ready"])
self.assertFalse(evidence["checkpointRecoveryReady"])
self.assertFalse(evidence["finalStateReady"])
command = run.call_args.args[0]
query = command[command.index("-c") + 1]
self.assertTrue(query.lstrip().lower().startswith("with constraint_state"))
self.assertNotIn(";", query)
self.assertIsNone(
re.search(
r"(?im)^\s*(?:delete|update|insert|truncate|alter|drop|create)\b",
query,
)
)
def test_recovery_rejection_prints_bounded_database_evidence(self):
database_result = mock.Mock(
returncode=0,
stdout=(
"0\t0\ttrue\ttrue\ttrue\t"
+ json.dumps(
sorted(RUNNER.DEVICE_PLANE_CONTROL_CORE_FINAL_COMMAND_KINDS),
separators=(",", ":"),
)
+ "\n"
),
stderr="",
)
output = io.StringIO()
with mock.patch.object(
RUNNER,
"device_plane_service_container_ids",
return_value=("p" * 64,),
), mock.patch.object(
RUNNER.subprocess,
"run",
return_value=database_result,
), contextlib.redirect_stdout(output), self.assertRaises(
RUNNER.DeployError
):
RUNNER.collect_device_plane_control_core_migration_replay_database_evidence()
rendered = output.getvalue()
for marker in (
"invalid_command_kind_count=0",
"triggering_receipt_count=0",
"constraint_validated=true",
"constraint_covers_final_kinds=true",
"host_telemetry_table_absent=true",
"constraint_matches_replay_011_kinds=false",
"constraint_phase=final-016",
"constraint_matches_final_016_kinds=true",
"recovery_final_state_ready=false",
):
self.assertIn(marker, rendered)
def test_audit_preflight_resolves_recovery_from_canonical_inbox(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-control-core-migration-replay-preflight-",
) as directory:
root = Path(directory)
inbox = root / "inbox"
runner_tmp = root / "runner-tmp"
source = root / "device-plane"
inbox.mkdir()
runner_tmp.mkdir()
live_migration = (
source / RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_014_REL
)
live_migration.parent.mkdir(parents=True)
live_migration.write_bytes(b"migration-014-predecessor")
recovery_artifact = (
inbox
/ RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_RECOVERY_ARTIFACT
)
recovery_artifact.write_bytes(b"reviewed-recovery-044")
core = {
"containerId": "c" * 64,
"imageId": RUNNER.DEVICE_PLANE_CONTROL_CORE_V3_PREAPPLY_IMAGE_ID,
"status": "running",
"health": "unhealthy",
"restartCount": 3,
}
database = {
"invalidCommandKindCount": 0,
"triggeringReceiptCount": 0,
"constraintValidated": True,
"constraintCoversFinalKinds": True,
"hostTelemetryTableAbsent": True,
"constraintMatchesReplay011Kinds": True,
"constraintPhase": "replay-011",
"constraintMatchesKnownReplayCheckpoint": True,
"constraintMatchesFinalKinds": False,
"recovery044Ready": False,
"checkpointRecoveryReady": False,
"finalStateReady": False,
}
failure_evidence = {
"firstBackup": root / "first-backup",
"secondBackup": root / "second-backup",
}
recovery_manifest = {
"id": RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_RECOVERY_PATCH_ID,
"component": "device-plane",
"type": "app-overlay",
}
with (
mock.patch.object(RUNNER, "INBOX", inbox),
mock.patch.object(RUNNER, "TMP_DIR", runner_tmp),
mock.patch.object(
RUNNER,
"DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_RECOVERY_ARTIFACT_SHA256",
hashlib.sha256(recovery_artifact.read_bytes()).hexdigest(),
),
mock.patch.object(
RUNNER,
"DEVICE_PLANE_CONTROL_CORE_MIGRATION_014_PREDECESSOR_SHA256",
hashlib.sha256(live_migration.read_bytes()).hexdigest(),
),
mock.patch.object(
RUNNER,
"validate_device_plane_control_core_migration_replay_audit_payload",
return_value=(
RUNNER.expected_device_plane_control_core_migration_replay_audit_descriptor()
),
),
mock.patch.object(
RUNNER,
"validate_device_plane_control_core_double_failure_evidence",
return_value=failure_evidence,
),
mock.patch.object(
RUNNER,
"load_artifact",
return_value=(
recovery_manifest,
RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_RECOVERY_ENTRIES,
root / "recovery-payload",
),
),
mock.patch.object(
RUNNER,
"validate_device_plane_control_core_migration_replay_recovery_payload",
),
mock.patch.object(
RUNNER,
"load_state",
return_value=[],
) as load_state,
mock.patch.object(RUNNER, "component_root", return_value=source),
mock.patch.object(
RUNNER,
"validate_device_plane_control_core_migration_replay_preserved_runtime",
return_value={"current": {"services": []}, "core": core},
),
mock.patch.object(
RUNNER,
"collect_device_plane_control_core_migration_replay_database_evidence",
return_value=database,
) as collect_database,
):
evidence = (
RUNNER.validate_device_plane_control_core_migration_replay_audit_evidence(
root / "audit-payload"
)
)
self.assertEqual(evidence["recoveryArtifact"], recovery_artifact)
self.assertEqual(evidence["database"], database)
self.assertEqual(
[call.args for call in load_state.call_args_list],
[(RUNNER.STATE_FILE,), (RUNNER.FAILED_STATE_FILE,)],
)
collect_database.assert_called_once_with(
enforce_recovery_invariants=False,
)
def test_plan_and_apply_paths_preserve_plan_only_boundary(self):
plan_source = inspect.getsource(RUNNER.plan_artifact)
apply_source = inspect.getsource(RUNNER.apply_artifact)
self.assertIn(
"device_plane_control_core_migration_replay_audit_preflight",
plan_source,
)
self.assertIn(
"emit_device_plane_control_core_migration_replay_database_evidence",
plan_source,
)
self.assertIn(
"is_device_plane_control_core_migration_replay_audit_slice",
apply_source,
)
self.assertLess(
apply_source.index(
"is_device_plane_control_core_migration_replay_audit_slice"
),
apply_source.index("state_has_sha"),
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,442 @@
#!/usr/bin/env python3
import hashlib
import importlib.machinery
import importlib.util
import inspect
import json
import os
import re
import subprocess
import tarfile
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RUNNER_PATH = Path(
os.environ.get(
"NODEDC_DEPLOY_RUNNER",
SCRIPT_DIR.parents[2] / "platform/infra/deploy-runner/nodedc-deploy",
)
)
BUILDER = (
SCRIPT_DIR
/ "build-device-control-core-migration-replay-checkpoint-recovery-artifact.mjs"
)
LEGACY_BUILDER = (
SCRIPT_DIR
/ "build-device-control-core-migration-replay-recovery-artifact.mjs"
)
MIGRATION_ROOT = SCRIPT_DIR.parents[1] / "services/device-control-core/migrations"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_control_core_migration_replay_checkpoint_runner_under_test",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
def database_stdout(
kinds,
*,
invalid=0,
triggering=8,
validated="false",
covers_final="false",
telemetry_absent="true",
):
return "\t".join(
(
str(invalid),
str(triggering),
validated,
covers_final,
telemetry_absent,
json.dumps(sorted(kinds), separators=(",", ":")),
)
) + "\n"
class DeviceControlCoreMigrationReplayCheckpointRecoveryArtifactTest(
unittest.TestCase
):
def build(self, artifact_dir):
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
result = subprocess.run(
[
"node",
str(BUILDER),
RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_CHECKPOINT_RECOVERY_PATCH_ID,
],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(result.stdout)
def test_artifact_is_deterministic_exact_new_identity(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-control-core-checkpoint-recovery-",
) as directory:
artifact_dir = Path(directory)
first = self.build(artifact_dir)
first_bytes = Path(first["artifact"]).read_bytes()
second = self.build(artifact_dir)
second_bytes = Path(second["artifact"]).read_bytes()
self.assertEqual(first_bytes, second_bytes)
self.assertEqual(
first["sha256"],
RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_CHECKPOINT_RECOVERY_ARTIFACT_SHA256,
)
self.assertEqual(first["services"], ["device-control-core"])
self.assertEqual(first["databaseRowMutation"], "none")
self.assertEqual(
first["entries"],
list(
RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_CHECKPOINT_RECOVERY_ENTRIES
),
)
with tarfile.open(first["artifact"], "r:gz") as archive:
descriptor = json.loads(
archive.extractfile(
"payload/"
+ RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_CHECKPOINT_RECOVERY_REL
).read().decode("utf-8")
)
migration = archive.extractfile(
"payload/" + RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_014_REL
).read()
self.assertEqual(
descriptor,
RUNNER.expected_device_plane_control_core_migration_replay_checkpoint_recovery_descriptor(),
)
self.assertEqual(
hashlib.sha256(migration).hexdigest(),
RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_014_TARGET_SHA256,
)
self.assertIn(b")) not valid;", migration.lower())
self.assertIsNone(
re.search(
rb"(?im)^\s*(?:delete|update|insert|truncate)\b",
migration,
)
)
def test_runner_selects_only_core_and_exact_build(self):
entries = (
RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_CHECKPOINT_RECOVERY_ENTRIES
)
self.assertEqual(
RUNNER.component_services("device-plane", entries),
("device-control-core",),
)
builds = RUNNER.component_builds("device-plane", entries)
self.assertEqual(
builds,
((
RUNNER.DEVICE_PLANE_ROOT,
(
"build",
"--no-cache",
"--network=host",
"-f",
"services/device-control-core/Dockerfile",
"-t",
RUNNER.DEVICE_PLANE_CONTROL_CORE_IMAGE,
".",
),
),),
)
def test_checkpoint_sets_match_every_committed_replay_migration(self):
for phase, expected in RUNNER.DEVICE_PLANE_CONTROL_CORE_REPLAY_CHECKPOINTS:
migration_number = phase.split("-", 1)[1]
migration = next(MIGRATION_ROOT.glob(f"{migration_number}_*.sql"))
command_kinds = tuple(re.findall(r"'([^']+)'", migration.read_text()))
with self.subTest(phase=phase):
self.assertEqual(command_kinds, expected)
self.assertIn("not valid", migration.read_text().lower())
def test_every_exact_replay_checkpoint_is_accepted(self):
for phase, kinds in RUNNER.DEVICE_PLANE_CONTROL_CORE_REPLAY_CHECKPOINTS:
database_result = mock.Mock(
returncode=0,
stdout=database_stdout(kinds),
stderr="",
)
with self.subTest(phase=phase), mock.patch.object(
RUNNER,
"device_plane_service_container_ids",
return_value=("p" * 64,),
), mock.patch.object(
RUNNER.subprocess,
"run",
return_value=database_result,
):
evidence = (
RUNNER.collect_device_plane_control_core_migration_replay_database_evidence(
expected_state="replay-checkpoint-predecessor",
)
)
self.assertEqual(evidence["constraintPhase"], phase)
self.assertTrue(
evidence["constraintMatchesKnownReplayCheckpoint"]
)
self.assertTrue(evidence["checkpointRecoveryReady"])
self.assertFalse(evidence["finalStateReady"])
def test_unknown_missing_and_validated_checkpoint_states_are_rejected(self):
cases = (
(
"unknown",
database_stdout(("owner_scope.ensure", "unknown.ensure")),
),
(
"missing",
database_stdout((), validated="missing"),
),
(
"validated-checkpoint",
database_stdout(
RUNNER.DEVICE_PLANE_CONTROL_CORE_REPLAY_005_COMMAND_KINDS,
validated="true",
),
),
)
for name, stdout in cases:
database_result = mock.Mock(
returncode=0,
stdout=stdout,
stderr="",
)
with self.subTest(name=name), mock.patch.object(
RUNNER,
"device_plane_service_container_ids",
return_value=("p" * 64,),
), mock.patch.object(
RUNNER.subprocess,
"run",
return_value=database_result,
), self.assertRaises(RUNNER.DeployError):
RUNNER.collect_device_plane_control_core_migration_replay_database_evidence(
expected_state="replay-checkpoint-predecessor",
)
def test_final_acceptance_requires_exact_migration_016_set(self):
database_result = mock.Mock(
returncode=0,
stdout=database_stdout(
RUNNER.DEVICE_PLANE_CONTROL_CORE_FINAL_COMMAND_KINDS,
validated="true",
covers_final="true",
),
stderr="",
)
with mock.patch.object(
RUNNER,
"device_plane_service_container_ids",
return_value=("p" * 64,),
), mock.patch.object(
RUNNER.subprocess,
"run",
return_value=database_result,
):
evidence = (
RUNNER.collect_device_plane_control_core_migration_replay_database_evidence(
expected_state="final",
)
)
self.assertEqual(evidence["constraintPhase"], "final-016")
self.assertTrue(evidence["constraintMatchesFinalKinds"])
self.assertTrue(evidence["finalStateReady"])
self.assertFalse(evidence["checkpointRecoveryReady"])
def test_terminal_044_failure_evidence_is_exact_and_preapply(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-control-core-recovery-044-failure-",
) as directory:
root = Path(directory)
artifact_dir = root / "artifact"
failed_dir = root / "failed"
runner_tmp = root / "tmp"
state_file = root / "applied.jsonl"
failed_state_file = root / "failed.jsonl"
artifact_dir.mkdir()
failed_dir.mkdir()
runner_tmp.mkdir()
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
subprocess.run(
[
"node",
str(LEGACY_BUILDER),
RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_RECOVERY_PATCH_ID,
],
check=True,
capture_output=True,
text=True,
env=environment,
)
source = (
artifact_dir
/ RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_RECOVERY_ARTIFACT
)
failed_artifact = (
failed_dir
/ RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_RECOVERY_FAILED_ARTIFACT
)
source.replace(failed_artifact)
state_file.write_text("", encoding="utf-8")
record = {
"artifact": failed_artifact.name,
"backup_id": None,
"component": "device-plane",
"failed_at": (
RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_RECOVERY_FAILED_AT
),
"id": RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_RECOVERY_PATCH_ID,
"message": (
"Device Control Core migration recovery database invariant mismatch"
),
"rollback_status": "not-required",
"sha256": (
RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_RECOVERY_ARTIFACT_SHA256
),
"started_apply": False,
"status": "failed",
}
failed_state_file.write_text(
json.dumps(record) + "\n",
encoding="utf-8",
)
with mock.patch.object(RUNNER, "FAILED_DIR", failed_dir), mock.patch.object(
RUNNER,
"STATE_FILE",
state_file,
), mock.patch.object(
RUNNER,
"FAILED_STATE_FILE",
failed_state_file,
), mock.patch.object(RUNNER, "TMP_DIR", runner_tmp):
evidence = (
RUNNER.validate_device_plane_control_core_migration_replay_recovery_failure()
)
self.assertEqual(evidence["record"], record)
def test_checkpoint_recovery_preflight_reaches_database_collector(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-control-core-checkpoint-preflight-",
) as directory:
root = Path(directory)
migration = (
root / RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_014_REL
)
migration.parent.mkdir(parents=True)
migration.write_bytes(b"migration-014-predecessor")
core = {
"containerId": "c" * 64,
"imageId": RUNNER.DEVICE_PLANE_CONTROL_CORE_V3_PREAPPLY_IMAGE_ID,
"status": "restarting",
"health": "unhealthy",
"restartCount": 280,
}
runtime = {"core": core, "current": {"services": [core]}}
database = {
"constraintPhase": "replay-007",
"checkpointRecoveryReady": True,
}
descriptor = (
RUNNER.expected_device_plane_control_core_migration_replay_checkpoint_recovery_descriptor()
)
with mock.patch.object(
RUNNER,
"validate_device_plane_control_core_migration_replay_checkpoint_recovery_payload",
return_value=descriptor,
), mock.patch.object(
RUNNER,
"validate_device_plane_control_core_double_failure_evidence",
return_value={
"firstBackup": root / "first",
"secondBackup": root / "second",
},
), mock.patch.object(
RUNNER,
"validate_device_plane_control_core_migration_replay_recovery_failure",
return_value={"record": {"started_apply": False}},
), mock.patch.object(
RUNNER,
"component_root",
return_value=root,
), mock.patch.object(
RUNNER,
"DEVICE_PLANE_CONTROL_CORE_MIGRATION_014_PREDECESSOR_SHA256",
hashlib.sha256(migration.read_bytes()).hexdigest(),
), mock.patch.object(
RUNNER,
"validate_device_plane_control_core_migration_replay_preserved_runtime",
return_value=runtime,
), mock.patch.object(
RUNNER,
"inspect_optional_local_image",
return_value=RUNNER.DEVICE_PLANE_CONTROL_CORE_V3_PREAPPLY_IMAGE_ID,
), mock.patch.object(
RUNNER,
"collect_device_plane_control_core_migration_replay_database_evidence",
return_value=database,
) as collector:
evidence = (
RUNNER.validate_device_plane_control_core_migration_replay_checkpoint_recovery_evidence(
root
)
)
self.assertEqual(evidence["database"], database)
collector.assert_called_once_with(
expected_state="replay-checkpoint-predecessor",
)
def test_v2_rollback_and_acceptance_are_registered(self):
rollback_source = inspect.getsource(RUNNER.rollback_device_plane_apply)
health_source = inspect.getsource(RUNNER.run_healthchecks)
preflight_source = inspect.getsource(
RUNNER.validate_device_plane_control_core_migration_replay_checkpoint_recovery_evidence
)
acceptance_source = inspect.getsource(
RUNNER.accept_device_plane_control_core_migration_replay_checkpoint_recovery
)
self.assertIn(
"is_device_plane_control_core_migration_replay_checkpoint_recovery_slice",
rollback_source,
)
self.assertIn(
"accept_device_plane_control_core_migration_replay_checkpoint_recovery",
health_source,
)
self.assertIn(
'expected_state="replay-checkpoint-predecessor"',
preflight_source,
)
self.assertIn(
"validate_device_plane_control_core_migration_replay_recovery_failure",
preflight_source,
)
self.assertIn('expected_state="final"', acceptance_source)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
import hashlib
import importlib.machinery
import importlib.util
import inspect
import json
import os
import re
import subprocess
import tarfile
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RUNNER_PATH = Path(
os.environ.get(
"NODEDC_DEPLOY_RUNNER",
SCRIPT_DIR.parents[2] / "platform/infra/deploy-runner/nodedc-deploy",
)
)
BUILDER = (
SCRIPT_DIR
/ "build-device-control-core-migration-replay-recovery-artifact.mjs"
)
MIGRATION_011 = (
SCRIPT_DIR.parents[1]
/ "services/device-control-core/migrations/011_device_control_resource_commands.sql"
)
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_control_core_migration_replay_runner_under_test",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class DeviceControlCoreMigrationReplayRecoveryArtifactTest(unittest.TestCase):
def build(self, artifact_dir):
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
result = subprocess.run(
[
"node",
str(BUILDER),
RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_RECOVERY_PATCH_ID,
],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(result.stdout)
def test_artifact_is_deterministic_exact_minimal_repair(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-control-core-migration-replay-recovery-",
) as directory:
artifact_dir = Path(directory)
first = self.build(artifact_dir)
first_bytes = Path(first["artifact"]).read_bytes()
second = self.build(artifact_dir)
second_bytes = Path(second["artifact"]).read_bytes()
self.assertEqual(first_bytes, second_bytes)
self.assertEqual(
first["sha256"],
hashlib.sha256(first_bytes).hexdigest(),
)
self.assertEqual(first["services"], ["device-control-core"])
self.assertEqual(first["databaseRowMutation"], "none")
self.assertEqual(
first["entries"],
list(
RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_RECOVERY_ENTRIES
),
)
with tarfile.open(first["artifact"], "r:gz") as archive:
descriptor = json.loads(
archive.extractfile(
"payload/"
+ RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_RECOVERY_REL
).read().decode("utf-8")
)
migration = archive.extractfile(
"payload/" + RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_014_REL
).read()
self.assertEqual(
descriptor,
RUNNER.expected_device_plane_control_core_migration_replay_recovery_descriptor(),
)
self.assertEqual(
hashlib.sha256(migration).hexdigest(),
RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_014_TARGET_SHA256,
)
self.assertIn(b")) not valid;", migration.lower())
self.assertIsNone(
re.search(
rb"(?im)^\s*(?:delete|update|insert|truncate)\b",
migration,
)
)
def test_runner_selects_only_core_and_exact_build(self):
entries = (
RUNNER.DEVICE_PLANE_CONTROL_CORE_MIGRATION_REPLAY_RECOVERY_ENTRIES
)
self.assertEqual(
RUNNER.component_services("device-plane", entries),
("device-control-core",),
)
builds = RUNNER.component_builds("device-plane", entries)
self.assertEqual(len(builds), 1)
self.assertEqual(builds[0][0], RUNNER.DEVICE_PLANE_ROOT)
self.assertEqual(
builds[0][1],
(
"build",
"--no-cache",
"--network=host",
"-f",
"services/device-control-core/Dockerfile",
"-t",
RUNNER.DEVICE_PLANE_CONTROL_CORE_IMAGE,
".",
),
)
def test_database_preflight_is_read_only_and_covers_live_receipts(self):
database_result = mock.Mock(
returncode=0,
stdout=(
"0\t8\tfalse\tfalse\ttrue\t"
+ json.dumps(
sorted(
RUNNER.DEVICE_PLANE_CONTROL_CORE_REPLAY_011_COMMAND_KINDS
),
separators=(",", ":"),
)
+ "\n"
),
stderr="",
)
with mock.patch.object(
RUNNER,
"device_plane_service_container_ids",
return_value=("p" * 64,),
), mock.patch.object(
RUNNER.subprocess,
"run",
return_value=database_result,
) as run:
evidence = (
RUNNER.collect_device_plane_control_core_migration_replay_database_evidence(
expected_state="replay-011-predecessor",
)
)
self.assertEqual(evidence["invalidCommandKindCount"], 0)
self.assertEqual(evidence["triggeringReceiptCount"], 8)
self.assertFalse(evidence["constraintValidated"])
self.assertFalse(evidence["constraintCoversFinalKinds"])
self.assertTrue(evidence["constraintMatchesReplay011Kinds"])
self.assertEqual(evidence["constraintPhase"], "replay-011")
self.assertTrue(evidence["recovery044Ready"])
self.assertTrue(evidence["checkpointRecoveryReady"])
self.assertFalse(evidence["finalStateReady"])
command = run.call_args.args[0]
query = command[command.index("-c") + 1]
self.assertTrue(query.lstrip().lower().startswith("with constraint_state"))
self.assertNotIn(";", query)
self.assertIsNone(
re.search(
r"(?im)^\s*(?:delete|update|insert|truncate|alter|drop|create)\b",
query,
)
)
self.assertIn("regexp_matches", query)
self.assertIn("array_to_json", query)
def test_runner_predecessor_kind_set_matches_migration_011(self):
migration = MIGRATION_011.read_text(encoding="utf-8")
command_kinds = tuple(re.findall(r"'([^']+)'", migration))
self.assertEqual(
command_kinds,
RUNNER.DEVICE_PLANE_CONTROL_CORE_REPLAY_011_COMMAND_KINDS,
)
def test_database_acceptance_requires_final_validated_constraint(self):
database_result = mock.Mock(
returncode=0,
stdout=(
"0\t8\ttrue\ttrue\ttrue\t"
+ json.dumps(
sorted(RUNNER.DEVICE_PLANE_CONTROL_CORE_FINAL_COMMAND_KINDS),
separators=(",", ":"),
)
+ "\n"
),
stderr="",
)
with mock.patch.object(
RUNNER,
"device_plane_service_container_ids",
return_value=("p" * 64,),
), mock.patch.object(
RUNNER.subprocess,
"run",
return_value=database_result,
):
evidence = (
RUNNER.collect_device_plane_control_core_migration_replay_database_evidence(
expected_state="final",
)
)
self.assertTrue(evidence["constraintValidated"])
self.assertTrue(evidence["constraintCoversFinalKinds"])
self.assertFalse(evidence["constraintMatchesReplay011Kinds"])
self.assertEqual(evidence["constraintPhase"], "final-016")
self.assertTrue(evidence["constraintMatchesFinalKinds"])
self.assertFalse(evidence["recovery044Ready"])
self.assertFalse(evidence["checkpointRecoveryReady"])
self.assertTrue(evidence["finalStateReady"])
def test_database_preflight_rejects_ambiguous_constraint_shape(self):
database_result = mock.Mock(
returncode=0,
stdout=(
"0\t8\tfalse\tfalse\ttrue\t"
+ json.dumps(["owner_scope.ensure", "unknown.ensure"])
+ "\n"
),
stderr="",
)
with mock.patch.object(
RUNNER,
"device_plane_service_container_ids",
return_value=("p" * 64,),
), mock.patch.object(
RUNNER.subprocess,
"run",
return_value=database_result,
), self.assertRaises(RUNNER.DeployError):
RUNNER.collect_device_plane_control_core_migration_replay_database_evidence(
expected_state="replay-011-predecessor",
)
def test_rollback_accepts_exact_degraded_predecessor_boundary(self):
rollback_source = inspect.getsource(RUNNER.rollback_device_plane_apply)
acceptance_source = inspect.getsource(
RUNNER.accept_device_plane_control_core_rollback_runtime
)
preflight_source = inspect.getsource(
RUNNER.validate_device_plane_control_core_migration_replay_recovery_evidence
)
recovery_acceptance_source = inspect.getsource(
RUNNER.accept_device_plane_control_core_migration_replay_recovery
)
self.assertIn(
"is_device_plane_control_core_migration_replay_recovery_slice",
rollback_source,
)
self.assertIn("retag_device_plane_control_core_image", rollback_source)
self.assertIn("not predecessor_was_healthy", acceptance_source)
self.assertIn("changed preserved service", acceptance_source)
self.assertIn(
'expected_state="replay-011-predecessor"',
preflight_source,
)
self.assertIn(
'expected_state="final"',
recovery_acceptance_source,
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
import hashlib
import importlib.machinery
import importlib.util
import json
import os
import subprocess
import tarfile
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RUNNER_PATH = Path(
os.environ.get(
"NODEDC_DEPLOY_RUNNER",
SCRIPT_DIR.parents[2] / "platform/infra/deploy-runner/nodedc-deploy",
)
)
BUILDER = (
SCRIPT_DIR
/ "build-device-control-core-release-v3-reconciliation-artifact.mjs"
)
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_control_core_v3_reconciliation_runner_under_test",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class DeviceControlCoreV3ReconciliationArtifactTest(unittest.TestCase):
def build(self, artifact_dir, patch_id):
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
result = subprocess.run(
["node", str(BUILDER), patch_id],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(result.stdout)
def test_artifact_is_exact_deterministic_runtime_reconciliation(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-control-core-v3-reconciliation-artifact-",
) as directory:
artifact_dir = Path(directory)
patch_id = "device-control-core-v3-reconciliation-unit-042"
first = self.build(artifact_dir, patch_id)
first_bytes = Path(first["artifact"]).read_bytes()
second = self.build(artifact_dir, patch_id)
second_bytes = Path(second["artifact"]).read_bytes()
self.assertEqual(first_bytes, second_bytes)
self.assertEqual(
first["sha256"],
hashlib.sha256(first_bytes).hexdigest(),
)
self.assertEqual(first["component"], "device-plane")
self.assertEqual(
first["entries"],
list(RUNNER.DEVICE_PLANE_CONTROL_CORE_V3_RECONCILIATION_ENTRIES),
)
self.assertEqual(first["build"], [])
self.assertEqual(first["services"], ["device-control-core"])
with tarfile.open(first["artifact"], "r:gz") as archive:
names = [member.name for member in archive.getmembers()]
descriptor = json.loads(
archive.extractfile(
"payload/"
+ RUNNER.DEVICE_PLANE_CONTROL_CORE_V3_RECONCILIATION_REL
).read().decode("utf-8")
)
self.assertEqual(
descriptor,
RUNNER.expected_device_plane_control_core_v3_reconciliation_descriptor(),
)
self.assertEqual(
names,
[
"manifest.env",
"files.txt",
"payload",
"payload/deployment",
"payload/"
+ RUNNER.DEVICE_PLANE_CONTROL_CORE_V3_RECONCILIATION_REL,
],
)
self.assertEqual(
RUNNER.component_services(
"device-plane",
RUNNER.DEVICE_PLANE_CONTROL_CORE_V3_RECONCILIATION_ENTRIES,
),
("device-control-core",),
)
self.assertEqual(
RUNNER.component_builds(
"device-plane",
RUNNER.DEVICE_PLANE_CONTROL_CORE_V3_RECONCILIATION_ENTRIES,
),
(),
)
def test_descriptor_pins_exact_failed_evidence_and_preapply_image(self):
descriptor = (
RUNNER.expected_device_plane_control_core_v3_reconciliation_descriptor()
)
self.assertEqual(
descriptor["failedArtifactSha256"],
"08448a56cdf391076f92c5242e368fd0033b420167874645eebfa1f22184ee92",
)
self.assertEqual(
descriptor["preapplyImageId"],
"sha256:31d35733ee46225b487c0f02a7b52d4ba2d13f5b99f6a717b7f5e6f5460b412a",
)
self.assertEqual(
descriptor["runtimeAction"],
"retag-exact-preapply-image+recreate-device-control-core-only",
)
def test_runtime_recovery_retags_exact_image_without_build(self):
completed = mock.Mock(returncode=0, stdout="", stderr="")
with mock.patch.object(
RUNNER,
"inspect_optional_local_image",
side_effect=[
RUNNER.DEVICE_PLANE_CONTROL_CORE_V3_PREAPPLY_IMAGE_ID,
RUNNER.DEVICE_PLANE_CONTROL_CORE_V3_PREAPPLY_IMAGE_ID,
],
), mock.patch.object(
RUNNER.subprocess,
"run",
return_value=completed,
) as run:
RUNNER.restore_device_plane_control_core_v3_preapply_image()
run.assert_called_once_with(
[
str(RUNNER.DOCKER),
"image",
"tag",
RUNNER.DEVICE_PLANE_CONTROL_CORE_V3_PREAPPLY_IMAGE_ID,
RUNNER.DEVICE_PLANE_CONTROL_CORE_IMAGE,
],
check=False,
capture_output=True,
text=True,
)
def test_reconciliation_runtime_selects_only_core_and_no_build(self):
mark_runtime_started = mock.Mock()
with mock.patch.object(
RUNNER,
"prepare_component_runtime",
) as prepare, mock.patch.object(
RUNNER,
"restore_device_plane_control_core_v3_preapply_image",
) as restore, mock.patch.object(
RUNNER,
"run_compose",
) as compose, mock.patch.object(
RUNNER,
"run_build",
) as build:
RUNNER.run_device_plane_runtime_for_apply(
RUNNER.DEVICE_PLANE_CONTROL_CORE_V3_RECONCILIATION_ENTRIES,
("device-control-core",),
mark_runtime_started,
)
prepare.assert_called_once_with(
"device-plane",
RUNNER.DEVICE_PLANE_CONTROL_CORE_V3_RECONCILIATION_ENTRIES,
)
mark_runtime_started.assert_called_once_with()
restore.assert_called_once_with()
compose.assert_called_once_with(
"device-plane",
("device-control-core",),
RUNNER.DEVICE_PLANE_CONTROL_CORE_V3_RECONCILIATION_ENTRIES,
)
build.assert_not_called()
def test_release_rollback_reuses_exact_preapply_image_without_rebuild(self):
runtime_inventory = {
"schemaVersion": "nodedc.device-plane.runtime-inventory.v1",
"composeProject": "nodedc-device-plane",
"services": [{
"service": "device-control-core",
"containerId": "1" * 64,
"imageId": RUNNER.DEVICE_PLANE_CONTROL_CORE_V3_PREAPPLY_IMAGE_ID,
"status": "running",
"running": True,
"health": "healthy",
"restartCount": 0,
}],
}
entries = RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_V3_ENTRIES
with tempfile.TemporaryDirectory(
prefix="nodedc-control-core-v3-rollback-unit-",
) as directory, mock.patch.object(
RUNNER,
"read_backup_path_list",
side_effect=[list(entries), []],
), mock.patch.object(
RUNNER,
"validate_backup_partition",
return_value=(set(entries), set()),
), mock.patch.object(
RUNNER,
"read_strict_json",
return_value=runtime_inventory,
), mock.patch.object(
RUNNER,
"restore_platform_overlay",
return_value=len(entries),
), mock.patch.object(
RUNNER,
"retag_device_plane_control_core_image",
) as retag, mock.patch.object(
RUNNER,
"prepare_component_runtime",
), mock.patch.object(
RUNNER,
"run_compose",
) as compose, mock.patch.object(
RUNNER,
"run_component_runtime",
) as build_runtime, mock.patch.object(
RUNNER,
"accept_device_plane_control_core_rollback_runtime",
), mock.patch.object(
RUNNER,
"validate_device_manager_control_plane_runtime",
):
result = RUNNER.rollback_device_plane_apply(
Path(directory),
Path(directory),
entries,
"unit-stamp",
True,
("device-control-core",),
)
self.assertEqual(
result,
f"source+runtime-restored:{len(entries)}",
)
retag.assert_called_once_with(
RUNNER.DEVICE_PLANE_CONTROL_CORE_V3_PREAPPLY_IMAGE_ID,
"Device Control Core exact pre-apply rollback image",
)
compose.assert_called_once_with(
"device-plane",
("device-control-core",),
list(entries),
)
build_runtime.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import hashlib
import io
import importlib.machinery
import importlib.util
import json
@@ -82,7 +83,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
RUNNER.preflight({"phase": phase})
def test_accepted_shared_source_phases_cannot_be_rebuilt(self):
for phase in ("core-channel", "tracker-ingress"):
for phase in ("core-channel", "tracker-ingress", "command-transport"):
with self.subTest(phase=phase), tempfile.TemporaryDirectory(
prefix=f"nodedc-vps-frozen-{phase}-"
) as directory:
@@ -102,6 +103,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
for name, digest in (
(RUNNER.NODE_ARCHIVE, RUNNER.NODE_ARCHIVE_SHA256),
(RUNNER.TAILSCALE_ARCHIVE, RUNNER.TAILSCALE_ARCHIVE_SHA256),
(RUNNER.TELEGRAF_ARCHIVE, RUNNER.TELEGRAF_ARCHIVE_SHA256),
):
path = DEFAULT_RUNTIME_CACHE / name
if not path.is_file():
@@ -114,10 +116,19 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
+ ", ".join(missing)
)
def require_telegraf_cache(self):
archive = DEFAULT_RUNTIME_CACHE / RUNNER.TELEGRAF_ARCHIVE
if not archive.is_file():
self.skipTest(f"immutable Telegraf runtime is not available: {archive}")
self.assertEqual(
hashlib.sha256(archive.read_bytes()).hexdigest(),
RUNNER.TELEGRAF_ARCHIVE_SHA256,
)
def test_builders_are_deterministic_narrow_and_secret_free(self):
self.require_runtime_cache()
self.require_telegraf_cache()
for phase in (
"command-transport",
"host-telemetry",
):
with self.subTest(phase=phase), tempfile.TemporaryDirectory(
prefix=f"nodedc-vps-{phase}-"
@@ -183,7 +194,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
self.assertIn("runtime_digest_mismatch", result.stderr)
def test_runner_loads_each_exact_phase(self):
self.require_runtime_cache()
self.require_telegraf_cache()
with tempfile.TemporaryDirectory(prefix="nodedc-vps-load-") as directory:
inbox = Path(directory) / "inbox"
inbox.mkdir()
@@ -191,7 +202,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
RUNNER.INBOX_ROOT = inbox
try:
for phase in (
"command-transport",
"host-telemetry",
):
result = self.build(
inbox,
@@ -416,14 +427,15 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
self.assertIn("command_transport=disabled", rendered)
self.assertIn("gelios=untouched", rendered)
def test_command_transport_plan_is_typed_single_process_and_bounded(self):
with tempfile.TemporaryDirectory(prefix="nodedc-vps-command-plan-") as directory:
def test_host_telemetry_plan_is_loopback_agent_over_existing_mtls(self):
self.require_telegraf_cache()
with tempfile.TemporaryDirectory(prefix="nodedc-vps-host-telemetry-plan-") as directory:
inbox = Path(directory) / "inbox"
inbox.mkdir()
result = self.build(
inbox,
"command-transport",
"device-edge-vps-command-transport-plan-001",
"host-telemetry",
"device-edge-vps-host-telemetry-plan-001",
)
self.assertEqual(result.returncode, 0, result.stderr)
artifact = Path(json.loads(result.stdout)["artifact"])
@@ -433,7 +445,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
with patch.object(RUNNER, "assert_root"), patch.object(
RUNNER,
"preflight",
return_value={"predecessor": "accepted-tracker-ingress-012"},
return_value={"predecessor": "accepted-command-transport-013"},
), patch("builtins.print") as output:
RUNNER.plan_artifact(str(artifact))
finally:
@@ -442,14 +454,78 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
" ".join(str(arg) for arg in call.args)
for call in output.call_args_list
)
self.assertIn("phase=command-transport", rendered)
self.assertIn("predecessor=accepted-tracker-ingress-012", rendered)
self.assertIn("phase=host-telemetry", rendered)
self.assertIn("predecessor=accepted-command-transport-013", rendered)
self.assertIn("host_telemetry_agent=telegraf:1.38.4", rendered)
self.assertIn("host_telemetry_collector=127.0.0.1:18223:loopback-only", rendered)
self.assertIn("host_telemetry_transport=existing-pinned-mtls-core-channel", rendered)
self.assertIn("mqtt=disabled", rendered)
self.assertIn("command_transport=typed-service-ping-v1", rendered)
self.assertIn("command_catalog=allowlisted-adapter-typed-commands-only", rendered)
self.assertIn("runtime_composition=single-non-root-process", rendered)
self.assertIn("public_b2_ingress=155.212.211.15:9921/tcp:bidirectional-session", rendered)
self.assertIn("gelios=untouched-legacy-only", rendered)
def test_host_telemetry_predecessor_is_exact_command_transport_artifact(self):
source_root = SCRIPT_DIR.parents[1]
descriptor = json.loads(
(
source_root
/ "deployment/device-edge-vps-host-telemetry-v1.json"
).read_text(encoding="utf-8")
)
predecessor_sha = descriptor["predecessorArtifactSha256"]
self.assertRegex(predecessor_sha, r"^[0-9a-f]{64}$")
self.assertEqual(predecessor_sha, RUNNER.COMMAND_TRANSPORT_ACCEPTED_SHA256)
self.assertEqual(
predecessor_sha,
"c7486ec879681ddd706f229b628c8556ca8c9ccc4f152a85debb409c302759ef",
)
def test_vendor_binary_extractor_accepts_official_leading_dot_member(self):
member_name = "telegraf-1.38.4/usr/bin/telegraf"
binary = b"pinned-telegraf-binary"
with tempfile.TemporaryDirectory(
prefix="nodedc-vps-vendor-leading-dot-",
) as directory:
root = Path(directory)
archive = root / "telegraf.tgz"
target = root / "runtime/telegraf"
with tarfile.open(archive, "w:gz") as package:
member = tarfile.TarInfo(f"./{member_name}")
member.size = len(binary)
package.addfile(member, io.BytesIO(binary))
with patch.object(RUNNER.os, "chown"):
RUNNER.extract_vendor_binary(
archive,
member_name,
target,
)
self.assertEqual(target.read_bytes(), binary)
self.assertEqual(target.stat().st_mode & 0o777, 0o755)
def test_vendor_binary_extractor_rejects_ambiguous_spelling(self):
member_name = "telegraf-1.38.4/usr/bin/telegraf"
with tempfile.TemporaryDirectory(
prefix="nodedc-vps-vendor-ambiguous-",
) as directory:
root = Path(directory)
archive = root / "telegraf.tgz"
target = root / "runtime/telegraf"
with tarfile.open(archive, "w:gz") as package:
for name in (member_name, f"./{member_name}"):
member = tarfile.TarInfo(name)
member.size = 1
package.addfile(member, io.BytesIO(b"x"))
with self.assertRaisesRegex(
RUNNER.DeployError,
"vendor binary member ambiguous",
):
RUNNER.extract_vendor_binary(
archive,
member_name,
target,
)
self.assertFalse(target.exists())
def test_publish_payload_preserves_unselected_executable_modes(self):
with tempfile.TemporaryDirectory(prefix="nodedc-vps-publish-scope-") as directory:
root = Path(directory)
@@ -144,6 +144,16 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
== RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V1_COMPOSE_SHA256
)
def historical_control_core_builders_are_current(self):
dockerfile = (
DEVICE_CORE_ROOT / "services/device-control-core/Dockerfile"
).read_text(encoding="utf-8")
return (
"COPY packages/infrastructure-telemetry-contract "
"./packages/infrastructure-telemetry-contract"
not in dockerfile
)
def build(self, script, patch_id, artifact_dir):
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
@@ -570,6 +580,402 @@ 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_device_manager_release_v8_adds_canonical_ontology_runtime(self):
patch_id = "device-manager-release-v8-unit-001"
manifest, entries, names, result = self.assert_deterministic_artifact(
"build-device-manager-control-plane-artifact.mjs",
patch_id,
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V8_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-v8.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.v8"
),
boundaries=(
RUNNER.expected_device_plane_manager_release_v8_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-038",
"artifactSha256": (
"e2d062b82b022dba662522b5d6e192026ac964d78950d903295ca3cbbc95ab28"
),
},
)
self.assertEqual(
descriptor["infrastructureHostProjection"],
"ontology-backed-host-runtime-v1",
)
self.assertEqual(
descriptor["ontologyFoundation"],
"ontology-core-device-foundation-20260822-001",
)
self.assertEqual(descriptor["ontologyCatalogHash"], "229c61c02a790906")
self.assertEqual(
descriptor["healthEvidence"],
"ttl-observation-missing-not-unhealthy-v1",
)
self.assertTrue(
RUNNER.is_device_plane_manager_release_v8_slice(
"device-plane",
entries,
)
)
def test_device_manager_release_v10_pins_successful_telemetry_core(self):
patch_id = "device-manager-release-v10-unit-001"
manifest, entries, names, result = self.assert_deterministic_artifact(
"build-device-manager-control-plane-artifact.mjs",
patch_id,
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V10_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))
template = json.loads(
(
DEVICE_CORE_ROOT
/ "deployment/device-manager-release-v10.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.v10"
),
boundaries=(
RUNNER.expected_device_plane_manager_release_v10_boundaries()
),
expected_release_id=patch_id,
),
descriptor,
)
self.assertEqual(
descriptor["predecessor"],
{
"kind": "release",
"patchId": "device-manager-release-v8-20260822-039",
"artifactSha256": (
"30a83d4c6b5c029c96c4af19fa558e0ae75e4b60bc897881457304bd17e0e465"
),
},
)
self.assertEqual(
descriptor["controlCorePredecessor"],
{
"patchId": "device-control-core-release-v4-20260823-047",
"artifactSha256": (
"4aecceeb8d400fdd3dbec8fc86b151691f389d165e72677f396f8994823b6b5c"
),
},
)
self.assertEqual(
descriptor["telemetryWorkspace"],
"mission-core-compute-module-parity-v1",
)
self.assertTrue(
RUNNER.is_device_plane_manager_release_v10_slice(
"device-plane",
entries,
)
)
def test_device_manager_release_v11_pins_v10_and_visual_parity(self):
patch_id = "device-manager-release-v11-unit-001"
manifest, entries, names, result = self.assert_deterministic_artifact(
"build-device-manager-control-plane-artifact.mjs",
patch_id,
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V11_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))
template = json.loads(
(
DEVICE_CORE_ROOT
/ "deployment/device-manager-release-v11.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.v11"
),
boundaries=(
RUNNER.expected_device_plane_manager_release_v11_boundaries()
),
expected_release_id=patch_id,
),
descriptor,
)
self.assertEqual(
descriptor["predecessor"],
{
"kind": "release",
"patchId": "device-manager-release-v10-20260823-048",
"artifactSha256": (
"e6b983a314db4f8c27d89062dfedf5ed0523cc30421170799d181a19e2d85d4c"
),
},
)
self.assertEqual(
descriptor["telemetryWorkspace"],
"mission-core-compute-module-visual-parity-v2",
)
self.assertEqual(
descriptor["telemetrySurface"],
"borderless-soft-surface-v1",
)
self.assertTrue(
RUNNER.is_device_plane_manager_release_v11_slice(
"device-plane",
entries,
)
)
def test_device_manager_release_v12_pins_v11_and_adaptive_graphs(self):
patch_id = "device-manager-release-v12-unit-001"
manifest, entries, names, result = self.assert_deterministic_artifact(
"build-device-manager-control-plane-artifact.mjs",
patch_id,
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V12_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))
template = json.loads(
(
DEVICE_CORE_ROOT
/ "deployment/device-manager-release-v12.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.v12"
),
boundaries=(
RUNNER.expected_device_plane_manager_release_v12_boundaries()
),
expected_release_id=patch_id,
),
descriptor,
)
self.assertEqual(
descriptor["predecessor"],
{
"kind": "release",
"patchId": "device-manager-release-v11-20260823-049",
"artifactSha256": (
"c1e2056b50bfbb0d03d077461d0c27cc56cc52967c3f5620be14871c8a6d5cf0"
),
},
)
self.assertEqual(
descriptor["telemetryGraphScale"],
"adaptive-observed-window-explicit-domain-v1",
)
self.assertEqual(
descriptor["telemetryNetworkMissingSemantics"],
"missing-counters-never-zero-v1",
)
self.assertTrue(
RUNNER.is_device_plane_manager_release_v12_slice(
"device-plane",
entries,
)
)
def test_device_manager_release_v13_pins_v12_and_host_accordion(self):
patch_id = "device-manager-release-v13-unit-001"
manifest, entries, names, result = self.assert_deterministic_artifact(
"build-device-manager-control-plane-artifact.mjs",
patch_id,
RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V13_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))
template = json.loads(
(
DEVICE_CORE_ROOT
/ "deployment/device-manager-release-v13.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.v13"
),
boundaries=(
RUNNER.expected_device_plane_manager_release_v13_boundaries()
),
expected_release_id=patch_id,
),
descriptor,
)
self.assertEqual(
descriptor["predecessor"],
{
"kind": "release",
"patchId": "device-manager-release-v12-20260823-050",
"artifactSha256": (
"1a49839140e5f2e49763d78f24ee47d946e244bcfde15a9c38266e8bd14c0d49"
),
},
)
self.assertEqual(
descriptor["hostInventoryRow"],
"compact-centered-accordion-v1",
)
self.assertEqual(
descriptor["hostInventoryRelations"],
"host-scoped-endpoint-deployment-service-v1",
)
self.assertEqual(
descriptor["telemetryWorkspace"],
"mission-core-compute-module-adaptive-window-v3",
)
self.assertTrue(
RUNNER.is_device_plane_manager_release_v13_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")
@@ -598,7 +1004,40 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
completed.stderr,
)
def test_historical_core_builders_fail_closed_after_telemetry_dockerfile(self):
if self.historical_control_core_builders_are_current():
self.skipTest("historical Core Dockerfile is still current")
cases = (
(
"build-device-control-core-release-artifact.mjs",
"device-control-core-release-rebuild-forbidden-001",
"historical_device_control_core_builder_has_advanced",
),
(
"build-device-edge-core-channel-bootstrap-artifact.mjs",
"device-edge-core-channel-bootstrap-rebuild-forbidden-001",
"historical_device_edge_core_channel_builder_has_advanced",
),
)
for script, patch_id, expected_error in cases:
with self.subTest(script=script), tempfile.TemporaryDirectory(
prefix="nodedc-device-core-historical-reject-",
) as directory:
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = directory
completed = subprocess.run(
["node", str(SCRIPT_DIR / script), patch_id],
cwd=DEVICE_CORE_ROOT,
env=environment,
capture_output=True,
text=True,
)
self.assertNotEqual(completed.returncode, 0)
self.assertIn(expected_error, completed.stderr)
def test_edge_core_channel_bootstrap_is_core_only_and_secret_free(self):
if not self.historical_control_core_builders_are_current():
self.skipTest("historical Core channel builder generation is frozen")
manifest, entries, names, result = self.assert_deterministic_artifact(
"build-device-edge-core-channel-bootstrap-artifact.mjs",
"device-edge-core-channel-bootstrap-unit-001",
@@ -635,6 +1074,8 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
self.assertEqual(checks[0]["expected_json"]["commandTransport"], "disabled")
def test_edge_core_channel_upgrade_is_core_only_and_pins_bootstrap_018(self):
if not self.historical_control_core_builders_are_current():
self.skipTest("historical Core channel builder generation is frozen")
manifest, entries, names, result = self.assert_deterministic_artifact(
"build-device-edge-core-channel-bootstrap-artifact.mjs",
"device-edge-core-channel-upgrade-unit-001",
@@ -664,6 +1105,8 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
)
def test_edge_core_channel_upgrade_v2_is_core_only_and_pins_upgrade_019(self):
if not self.historical_control_core_builders_are_current():
self.skipTest("historical Core channel builder generation is frozen")
patch_id = "device-edge-core-channel-upgrade-v2-unit-001"
manifest, entries, names, result = self.assert_deterministic_artifact(
"build-device-edge-core-channel-bootstrap-artifact.mjs",
@@ -710,6 +1153,8 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
)
def test_edge_core_channel_upgrade_v4_is_core_only_and_pins_upgrade_021(self):
if not self.historical_control_core_builders_are_current():
self.skipTest("historical Core channel builder generation is frozen")
patch_id = "device-edge-core-channel-upgrade-v4-unit-001"
manifest, entries, names, result = self.assert_deterministic_artifact(
"build-device-edge-core-channel-bootstrap-artifact.mjs",
@@ -801,6 +1246,8 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
)
def test_control_core_release_is_repeatable_core_only_and_compose_free(self):
if not self.historical_control_core_builders_are_current():
self.skipTest("historical Core release v1 builder generation is frozen")
patch_id = "device-control-core-release-unit-001"
manifest, entries, names, result = self.assert_deterministic_artifact(
"build-device-control-core-release-artifact.mjs",
@@ -876,6 +1323,8 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
)
def test_control_core_release_v2_is_typed_core_only(self):
if not self.historical_control_core_builders_are_current():
self.skipTest("historical Core release v2 builder generation is frozen")
patch_id = "device-control-core-release-v2-unit-001"
manifest, entries, names, result = self.assert_deterministic_artifact(
"build-device-control-core-release-artifact.mjs",
@@ -911,7 +1360,96 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
)
self.assertEqual(descriptor["gelios"], "untouched-legacy-only")
def test_control_core_release_v4_is_recovery_pinned_telemetry_core_only(self):
patch_id = "device-control-core-release-v4-20260823-047"
manifest, entries, names, result = self.assert_deterministic_artifact(
"build-device-control-core-release-artifact.mjs",
patch_id,
RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_V4_ENTRIES,
)
self.assertEqual(manifest["component"], "device-plane")
self.assertEqual(result["services"], ["device-control-core"])
self.assertEqual(
RUNNER.component_services("device-plane", entries),
("device-control-core",),
)
self.assertIn(
"payload/deployment/device-control-core-release-v4.json",
names,
)
self.assertIn(
"payload/packages/infrastructure-telemetry-contract/src/index.mjs",
names,
)
self.assertIn(
"payload/services/device-control-core/migrations/"
"017_infrastructure_host_telemetry.sql",
names,
)
self.assertFalse(any("docker-compose" in name for name in names))
with tempfile.TemporaryDirectory(
prefix="nodedc-control-core-v4-read-",
) as directory:
result = self.build(
"build-device-control-core-release-artifact.mjs",
patch_id,
Path(directory),
)
extracted = Path(directory) / "extracted"
extracted.mkdir()
_manifest, _entries, payload = RUNNER.load_artifact(
Path(result["artifact"]),
extracted,
)
descriptor = json.loads(
(
payload / RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_V4_REL
).read_text(encoding="utf-8")
)
self.assertEqual(
descriptor["predecessor"],
{
"kind": "migration-replay-checkpoint-recovery",
"patchId": (
"device-control-core-migration-replay-checkpoint-"
"recovery-20260822-046"
),
"artifactSha256": (
"46000c76977fb583fc7c9cf74ecf624efd8b404f7b8d0322e0270e7b8ac6e450"
),
},
)
self.assertEqual(
descriptor["databaseSchemaOutcome"],
"migration-017-host-telemetry-table-present",
)
def test_control_core_release_v4_rejects_any_other_identity(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-control-core-v4-wrong-id-",
) as directory:
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = directory
completed = subprocess.run(
[
"node",
str(
SCRIPT_DIR
/ "build-device-control-core-release-artifact.mjs"
),
"device-control-core-release-v4-20260823-999",
],
cwd=DEVICE_CORE_ROOT,
env=environment,
capture_output=True,
text=True,
)
self.assertNotEqual(completed.returncode, 0)
self.assertIn("usage:", completed.stderr)
def test_control_core_release_builder_supports_release_predecessor(self):
if not self.historical_control_core_builders_are_current():
self.skipTest("historical Core release v1 builder generation is frozen")
with tempfile.TemporaryDirectory(
prefix="nodedc-control-core-successor-",
) as directory:
@@ -968,6 +1506,8 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
)
def test_control_core_release_v2_builder_supports_v2_release_predecessor(self):
if not self.historical_control_core_builders_are_current():
self.skipTest("historical Core release v2 builder generation is frozen")
with tempfile.TemporaryDirectory(
prefix="nodedc-control-core-v2-successor-",
) as directory:
@@ -2440,7 +2980,6 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
)),
encoding="utf-8",
)
restored_core_id = "f" * 64
with (
mock.patch.object(
RUNNER,
@@ -2453,29 +2992,20 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
),
mock.patch.object(
RUNNER,
"run_component_runtime",
"retag_device_plane_control_core_image",
) as retag_core_image,
mock.patch.object(
RUNNER,
"prepare_component_runtime",
) as prepare_runtime,
mock.patch.object(
RUNNER,
"run_compose",
) as restore_runtime,
mock.patch.object(
RUNNER,
"healthcheck_compose_service_with_grace",
) as restore_health,
mock.patch.object(
RUNNER,
"compose_service_container_id",
return_value=restored_core_id,
),
mock.patch.object(
RUNNER,
"inspect_device_plane_container",
return_value={
"Id": restored_core_id,
"State": {
"Status": "running",
"Running": True,
"Health": {"Status": "healthy"},
},
},
),
"accept_device_plane_control_core_rollback_runtime",
) as rollback_acceptance,
mock.patch.object(
RUNNER,
"validate_device_manager_control_plane_runtime",
@@ -2491,20 +3021,20 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
)
stop.assert_not_called()
restore_runtime.assert_called_once_with(
retag_core_image.assert_called_once_with(
"sha256:" + "a" * 64,
"Device Control Core exact pre-apply rollback image",
)
prepare_runtime.assert_called_once_with(
"device-plane",
existing,
)
restore_runtime.assert_called_once_with(
"device-plane",
("device-control-core",),
existing,
)
self.assertEqual(
[call.args for call in restore_health.call_args_list],
[
("device-plane", "device-manager"),
("device-plane", "device-gateway"),
("device-plane", "device-postgres"),
("device-plane", "device-backhaul-target"),
],
)
rollback_acceptance.assert_called_once()
runtime_acceptance.assert_called_once_with(
require_edge_channel=True,
core_network_mode="private-egress",
@@ -22,6 +22,7 @@ export const EDGE_TO_CORE_MESSAGE_KINDS = Object.freeze([
"adapter.message",
"delivery.acknowledged",
"command.status",
"host.telemetry.observed",
"channel.counters",
]);
@@ -0,0 +1,10 @@
{
"name": "@nodedc/infrastructure-telemetry-contract",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": "./src/index.mjs",
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,380 @@
export const HOST_TELEMETRY_SCHEMA =
"nodedc.infrastructure.host-telemetry.v1";
export const HOST_TELEMETRY_PROFILE = "linux-host-telegraf-v1";
const REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
export function telegrafBatchToHostTelemetry(input, context = {}) {
const metrics = telegrafMetrics(input);
const observedAt = latestMetricTimestamp(metrics) ?? normalizeTimestamp(
context.observedAt ?? new Date().toISOString(),
"observed_at",
);
const byName = new Map();
for (const metric of metrics) {
const values = byName.get(metric.name) ?? [];
values.push(metric);
byName.set(metric.name, values);
}
const cpu = metricWithTag(byName.get("cpu"), "cpu", "cpu-total")
?? firstMetric(byName.get("cpu"));
const memory = firstMetric(byName.get("mem"));
const swap = firstMetric(byName.get("swap"));
const system = firstMetric(byName.get("system"));
const processes = firstMetric(byName.get("processes"));
const systemCpu = firstMetric(byName.get("system_cpu"));
return normalizeHostTelemetrySnapshot({
schemaVersion: HOST_TELEMETRY_SCHEMA,
profile: HOST_TELEMETRY_PROFILE,
hostKey: context.hostKey,
observedAt,
source: {
agent: "telegraf",
agentVersion: context.agentVersion ?? "1.38.4",
collectorRef: context.collectorRef ?? "service:nodedc-host-telemetry-agent",
},
hardware: {
hostname: context.hostname ?? metricHost(metrics),
architecture: context.architecture ?? null,
platform: context.platform ?? "linux",
kernelRelease: context.kernelRelease ?? null,
cpuModel: context.cpuModel ?? null,
logicalProcessors: finiteInteger(
context.logicalProcessors ?? systemCpu?.fields.cpu_count,
),
},
cpu: {
usagePercent: finiteNumber(cpu?.fields.usage_active)
?? percentFromIdle(cpu?.fields.usage_idle),
load1: finiteNumber(system?.fields.load1),
load5: finiteNumber(system?.fields.load5),
load15: finiteNumber(system?.fields.load15),
},
memory: {
totalBytes: finiteInteger(memory?.fields.total),
availableBytes: finiteInteger(memory?.fields.available),
usedBytes: finiteInteger(memory?.fields.used),
usedPercent: finiteNumber(memory?.fields.used_percent),
},
swap: {
totalBytes: finiteInteger(swap?.fields.total),
freeBytes: finiteInteger(swap?.fields.free),
usedBytes: finiteInteger(swap?.fields.used),
usedPercent: finiteNumber(swap?.fields.used_percent),
},
system: {
uptimeSeconds: finiteInteger(system?.fields.uptime),
users: finiteInteger(system?.fields.n_users),
processes: {
total: finiteInteger(processes?.fields.total),
running: finiteInteger(processes?.fields.running),
sleeping: finiteInteger(processes?.fields.sleeping),
blocked: finiteInteger(processes?.fields.blocked),
zombies: finiteInteger(processes?.fields.zombies),
},
},
disks: (byName.get("disk") ?? []).map((metric) => ({
device: textOrNull(metric.tags.device),
mount: textOrNull(metric.tags.path),
filesystem: textOrNull(metric.tags.fstype),
totalBytes: finiteInteger(metric.fields.total),
freeBytes: finiteInteger(metric.fields.free),
usedBytes: finiteInteger(metric.fields.used),
usedPercent: finiteNumber(metric.fields.used_percent),
})),
network: (byName.get("net") ?? []).map((metric) => ({
interface: textOrNull(metric.tags.interface),
bytesReceived: finiteInteger(metric.fields.bytes_recv),
bytesSent: finiteInteger(metric.fields.bytes_sent),
packetsReceived: finiteInteger(metric.fields.packets_recv),
packetsSent: finiteInteger(metric.fields.packets_sent),
errorsReceived: finiteInteger(metric.fields.err_in),
errorsSent: finiteInteger(metric.fields.err_out),
droppedReceived: finiteInteger(metric.fields.drop_in),
droppedSent: finiteInteger(metric.fields.drop_out),
})),
services: (byName.get("systemd_units") ?? []).map((metric) => ({
name: textOrNull(metric.tags.name),
loadState: textOrNull(metric.tags.load),
activeState: textOrNull(metric.tags.active),
subState: textOrNull(metric.tags.sub),
memoryBytes: finiteInteger(metric.fields.mem_current),
restarts: finiteInteger(metric.fields.restarts),
pid: finiteInteger(metric.fields.pid),
})),
});
}
export function normalizeHostTelemetrySnapshot(input) {
assertPlainObject(input, "host_telemetry");
if (input.schemaVersion !== HOST_TELEMETRY_SCHEMA) {
throw new TypeError("host_telemetry_schema_invalid");
}
if (input.profile !== HOST_TELEMETRY_PROFILE) {
throw new TypeError("host_telemetry_profile_invalid");
}
const snapshot = {
schemaVersion: HOST_TELEMETRY_SCHEMA,
profile: HOST_TELEMETRY_PROFILE,
hostKey: normalizeRef(input.hostKey, "host_key"),
observedAt: normalizeTimestamp(input.observedAt, "observed_at"),
source: normalizeSource(input.source),
hardware: normalizeHardware(input.hardware),
cpu: normalizeCpu(input.cpu),
memory: normalizeMemory(input.memory, "memory"),
swap: normalizeMemory(input.swap, "swap"),
system: normalizeSystem(input.system),
disks: normalizeArray(input.disks, normalizeDisk, 32),
network: normalizeArray(input.network, normalizeNetwork, 64),
services: normalizeArray(input.services, normalizeService, 64),
};
return deepFreeze(snapshot);
}
function telegrafMetrics(input) {
const candidate = Array.isArray(input)
? input
: input && typeof input === "object" && Array.isArray(input.metrics)
? input.metrics
: input && typeof input === "object"
? [input]
: null;
if (!candidate || candidate.length < 1 || candidate.length > 512) {
throw new TypeError("host_telemetry_telegraf_batch_invalid");
}
return candidate.map((metric) => {
assertPlainObject(metric, "host_telemetry_telegraf_metric");
assertPlainObject(metric.fields, "host_telemetry_telegraf_fields");
const name = text(metric.name, 1, 80, "host_telemetry_telegraf_name_invalid");
const tags = metric.tags == null ? {} : metric.tags;
assertPlainObject(tags, "host_telemetry_telegraf_tags");
return { name, fields: { ...metric.fields }, tags: { ...tags }, timestamp: metric.timestamp };
});
}
function latestMetricTimestamp(metrics) {
let latest = null;
for (const metric of metrics) {
const raw = Number(metric.timestamp);
if (!Number.isFinite(raw) || raw <= 0) continue;
const milliseconds = raw > 10_000_000_000 ? raw / 1_000_000 : raw * 1000;
if (!Number.isFinite(milliseconds)) continue;
const value = new Date(milliseconds).toISOString();
if (!latest || value > latest) latest = value;
}
return latest;
}
function metricWithTag(metrics = [], key, value) {
return metrics.find((metric) => metric.tags[key] === value) ?? null;
}
function firstMetric(metrics = []) {
return metrics[0] ?? null;
}
function metricHost(metrics) {
for (const metric of metrics) {
if (typeof metric.tags.host === "string" && metric.tags.host.trim()) {
return metric.tags.host.trim().slice(0, 160);
}
}
return null;
}
function normalizeSource(input) {
assertPlainObject(input, "host_telemetry_source");
return {
agent: text(input.agent, 1, 64, "host_telemetry_agent_invalid"),
agentVersion: text(input.agentVersion, 1, 64, "host_telemetry_agent_version_invalid"),
collectorRef: normalizeRef(input.collectorRef, "collector_ref"),
};
}
function normalizeHardware(input) {
assertPlainObject(input, "host_telemetry_hardware");
return {
hostname: optionalText(input.hostname, 160),
architecture: optionalText(input.architecture, 64),
platform: optionalText(input.platform, 64),
kernelRelease: optionalText(input.kernelRelease, 160),
cpuModel: optionalText(input.cpuModel, 256),
logicalProcessors: optionalInteger(input.logicalProcessors, 1_024),
};
}
function normalizeCpu(input) {
assertPlainObject(input, "host_telemetry_cpu");
return {
usagePercent: optionalPercent(input.usagePercent),
load1: optionalNumber(input.load1, 0, 100_000),
load5: optionalNumber(input.load5, 0, 100_000),
load15: optionalNumber(input.load15, 0, 100_000),
};
}
function normalizeMemory(input, field) {
assertPlainObject(input, `host_telemetry_${field}`);
return {
totalBytes: optionalInteger(input.totalBytes, Number.MAX_SAFE_INTEGER),
availableBytes: optionalInteger(input.availableBytes, Number.MAX_SAFE_INTEGER),
freeBytes: optionalInteger(input.freeBytes, Number.MAX_SAFE_INTEGER),
usedBytes: optionalInteger(input.usedBytes, Number.MAX_SAFE_INTEGER),
usedPercent: optionalPercent(input.usedPercent),
};
}
function normalizeSystem(input) {
assertPlainObject(input, "host_telemetry_system");
assertPlainObject(input.processes, "host_telemetry_processes");
return {
uptimeSeconds: optionalInteger(input.uptimeSeconds, Number.MAX_SAFE_INTEGER),
users: optionalInteger(input.users, 1_000_000),
processes: {
total: optionalInteger(input.processes.total, 1_000_000),
running: optionalInteger(input.processes.running, 1_000_000),
sleeping: optionalInteger(input.processes.sleeping, 1_000_000),
blocked: optionalInteger(input.processes.blocked, 1_000_000),
zombies: optionalInteger(input.processes.zombies, 1_000_000),
},
};
}
function normalizeDisk(input) {
assertPlainObject(input, "host_telemetry_disk");
return {
device: optionalText(input.device, 256),
mount: optionalText(input.mount, 512),
filesystem: optionalText(input.filesystem, 64),
totalBytes: optionalInteger(input.totalBytes, Number.MAX_SAFE_INTEGER),
freeBytes: optionalInteger(input.freeBytes, Number.MAX_SAFE_INTEGER),
usedBytes: optionalInteger(input.usedBytes, Number.MAX_SAFE_INTEGER),
usedPercent: optionalPercent(input.usedPercent),
};
}
function normalizeNetwork(input) {
assertPlainObject(input, "host_telemetry_network");
return {
interface: optionalText(input.interface, 64),
bytesReceived: optionalInteger(input.bytesReceived, Number.MAX_SAFE_INTEGER),
bytesSent: optionalInteger(input.bytesSent, Number.MAX_SAFE_INTEGER),
packetsReceived: optionalInteger(input.packetsReceived, Number.MAX_SAFE_INTEGER),
packetsSent: optionalInteger(input.packetsSent, Number.MAX_SAFE_INTEGER),
errorsReceived: optionalInteger(input.errorsReceived, Number.MAX_SAFE_INTEGER),
errorsSent: optionalInteger(input.errorsSent, Number.MAX_SAFE_INTEGER),
droppedReceived: optionalInteger(input.droppedReceived, Number.MAX_SAFE_INTEGER),
droppedSent: optionalInteger(input.droppedSent, Number.MAX_SAFE_INTEGER),
};
}
function normalizeService(input) {
assertPlainObject(input, "host_telemetry_service");
return {
name: optionalText(input.name, 160),
loadState: optionalText(input.loadState, 64),
activeState: optionalText(input.activeState, 64),
subState: optionalText(input.subState, 64),
memoryBytes: optionalInteger(input.memoryBytes, Number.MAX_SAFE_INTEGER),
restarts: optionalInteger(input.restarts, Number.MAX_SAFE_INTEGER),
pid: optionalInteger(input.pid, Number.MAX_SAFE_INTEGER),
};
}
function normalizeArray(value, mapper, maximum) {
if (!Array.isArray(value) || value.length > maximum) {
throw new TypeError("host_telemetry_collection_invalid");
}
return value.map(mapper);
}
function normalizeRef(value, field) {
if (typeof value !== "string" || !REF_RE.test(value)) {
throw new TypeError(`host_telemetry_${field}_invalid`);
}
return value;
}
function normalizeTimestamp(value, field) {
if (typeof value !== "string" || !ISO_TIMESTAMP_RE.test(value)) {
throw new TypeError(`host_telemetry_${field}_invalid`);
}
const timestamp = new Date(value);
if (!Number.isFinite(timestamp.valueOf())) {
throw new TypeError(`host_telemetry_${field}_invalid`);
}
return timestamp.toISOString();
}
function text(value, minimum, maximum, errorCode) {
if (typeof value !== "string") throw new TypeError(errorCode);
const normalized = value.trim();
if (normalized.length < minimum || normalized.length > maximum) {
throw new TypeError(errorCode);
}
return normalized;
}
function optionalText(value, maximum) {
if (value == null || value === "") return null;
return text(value, 1, maximum, "host_telemetry_text_invalid");
}
function textOrNull(value) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function optionalInteger(value, maximum) {
if (value == null) return null;
const normalized = Number(value);
if (!Number.isSafeInteger(normalized) || normalized < 0 || normalized > maximum) {
throw new TypeError("host_telemetry_integer_invalid");
}
return normalized;
}
function optionalNumber(value, minimum, maximum) {
if (value == null) return null;
const normalized = Number(value);
if (!Number.isFinite(normalized) || normalized < minimum || normalized > maximum) {
throw new TypeError("host_telemetry_number_invalid");
}
return normalized;
}
function optionalPercent(value) {
return optionalNumber(value, 0, 100);
}
function finiteInteger(value) {
const normalized = Number(value);
return Number.isSafeInteger(normalized) && normalized >= 0 ? normalized : null;
}
function finiteNumber(value) {
const normalized = Number(value);
return Number.isFinite(normalized) && normalized >= 0 ? normalized : null;
}
function percentFromIdle(value) {
const idle = finiteNumber(value);
return idle == null ? null : Math.max(0, Math.min(100, 100 - idle));
}
function assertPlainObject(value, field) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${field}_invalid`);
}
}
function deepFreeze(value) {
if (value && typeof value === "object" && !Object.isFrozen(value)) {
Object.freeze(value);
for (const nested of Object.values(value)) deepFreeze(nested);
}
return value;
}
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
HOST_TELEMETRY_SCHEMA,
telegrafBatchToHostTelemetry,
} from "../src/index.mjs";
test("normalizes a bounded Telegraf Linux batch", () => {
const value = telegrafBatchToHostTelemetry({ metrics: [
{ name: "cpu", tags: { cpu: "cpu-total", host: "edge-01" }, fields: { usage_active: 21.5 }, timestamp: 1_777_000_000 },
{ name: "mem", tags: { host: "edge-01" }, fields: { total: 1024, available: 700, used: 324, used_percent: 31.64 }, timestamp: 1_777_000_000 },
{ name: "system", tags: { host: "edge-01" }, fields: { load1: 0.2, load5: 0.1, load15: 0.05, uptime: 120, n_users: 1 }, timestamp: 1_777_000_000 },
{ name: "net", tags: { interface: "eth0", host: "edge-01" }, fields: { bytes_recv: 100, bytes_sent: 200 }, timestamp: 1_777_000_000 },
{ name: "systemd_units", tags: { name: "nodedc-device-edge-channel.service", load: "loaded", active: "active", sub: "running" }, fields: { mem_current: 2048, restarts: 0, pid: 42 }, timestamp: 1_777_000_000 },
] }, {
hostKey: "robot2b-b2-edge-vps",
architecture: "x64",
kernelRelease: "6.8.0",
cpuModel: "KVM CPU",
logicalProcessors: 1,
});
assert.equal(value.schemaVersion, HOST_TELEMETRY_SCHEMA);
assert.equal(value.cpu.usagePercent, 21.5);
assert.equal(value.memory.totalBytes, 1024);
assert.equal(value.network[0].interface, "eth0");
assert.equal(value.services[0].activeState, "active");
assert.equal(Object.isFrozen(value), true);
});
test("rejects an oversized Telegraf batch", () => {
assert.throws(
() => telegrafBatchToHostTelemetry({ metrics: Array.from({ length: 513 }, () => ({})) }, { hostKey: "host-01" }),
/host_telemetry_telegraf_batch_invalid/,
);
});
+1
View File
@@ -10,6 +10,7 @@ WORKDIR /app
COPY packages/device-protocol-contract ./packages/device-protocol-contract
COPY packages/device-edge-channel-contract ./packages/device-edge-channel-contract
COPY packages/infrastructure-telemetry-contract ./packages/infrastructure-telemetry-contract
COPY services/device-control-core ./services/device-control-core
USER node
@@ -27,6 +27,6 @@ alter table device_management_command_receipts
'device_binding.revoke',
'device_configuration_revision.create',
'device_configuration_desired.set'
));
)) not valid;
commit;
@@ -0,0 +1,284 @@
begin;
alter table device_management_command_receipts
drop constraint if exists device_management_command_receipts_command_kind_check;
alter table device_management_command_receipts
add constraint device_management_command_receipts_command_kind_check
check (command_kind in (
'owner_scope.ensure',
'project.ensure',
'collection.ensure',
'project_grant.upsert',
'adapter_package.ensure',
'adapter_version.register',
'model_profile.register',
'edge.ensure',
'route.ensure',
'enrollment_intent.ensure',
'device.claim',
'device.update',
'device.transfer',
'discovery.reject',
'discovery.expire',
'device_credential_binding.upsert',
'device_credential_binding.revoke',
'device_binding.ensure',
'device_binding.revoke',
'device_configuration_revision.create',
'device_configuration_desired.set',
'asset.ensure',
'asset_binding.ensure',
'asset_binding.close',
'infrastructure_host.ensure',
'infrastructure_endpoint.ensure',
'infrastructure_deployment.ensure',
'infrastructure_service_instance.ensure',
'health_observation.record'
));
create table if not exists device_assets (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
asset_key text not null
check (asset_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
asset_type_ref text not null
check (length(btrim(asset_type_ref)) between 3 and 256),
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'retired')),
ontology_entity_id text not null default 'asset.asset'
check (ontology_entity_id = 'asset.asset'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, asset_key),
unique (id, project_id, owner_scope_id),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id)
);
create index if not exists device_assets_project_state_idx
on device_assets (project_id, lifecycle_state, updated_at desc);
create table if not exists device_asset_bindings (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
binding_key text not null
check (binding_key ~ '^[a-z][a-z0-9-]{1,62}$'),
device_id uuid not null,
asset_id uuid not null,
binding_kind text not null default 'tracking'
check (binding_kind in ('tracking', 'installed', 'assigned')),
valid_from timestamptz not null,
valid_to timestamptz,
provenance_ref text not null
check (length(btrim(provenance_ref)) between 3 and 256),
ontology_entity_id text not null default 'device.asset_binding'
check (ontology_entity_id = 'device.asset_binding'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
closed_by_ref text
check (closed_by_ref is null or length(btrim(closed_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, binding_key),
foreign key (device_id, project_id, owner_scope_id)
references device_instances(id, project_id, owner_scope_id),
foreign key (asset_id, project_id, owner_scope_id)
references device_assets(id, project_id, owner_scope_id),
check (valid_to is null or valid_to > valid_from),
check ((valid_to is null and closed_by_ref is null) or (valid_to is not null and closed_by_ref is not null))
);
create unique index if not exists device_asset_bindings_active_device_idx
on device_asset_bindings (device_id)
where valid_to is null;
create index if not exists device_asset_bindings_asset_time_idx
on device_asset_bindings (asset_id, valid_from desc, valid_to);
create table if not exists device_infrastructure_hosts (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
host_key text not null
check (host_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
provider_ref text
check (provider_ref is null or length(btrim(provider_ref)) between 3 and 256),
external_ref text
check (external_ref is null or length(btrim(external_ref)) between 3 and 256),
management_credential_ref text
check (
management_credential_ref is null
or management_credential_ref ~ '^secret-ref:[A-Za-z0-9][A-Za-z0-9._:/+-]{2,244}$'
),
lifecycle_state text not null default 'provisioning'
check (lifecycle_state in ('provisioning', 'active', 'suspended', 'retired')),
ontology_entity_id text not null default 'infrastructure.host'
check (ontology_entity_id = 'infrastructure.host'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, host_key),
unique (id, project_id, owner_scope_id),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id)
);
create index if not exists device_infrastructure_hosts_project_state_idx
on device_infrastructure_hosts (project_id, lifecycle_state, updated_at desc);
create table if not exists device_infrastructure_endpoints (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
host_id uuid not null,
endpoint_key text not null
check (endpoint_key ~ '^[a-z][a-z0-9-]{1,62}$'),
purpose text not null
check (purpose in ('management', 'service', 'monitoring')),
endpoint_uri text not null
check (
length(btrim(endpoint_uri)) between 8 and 512
and endpoint_uri !~ '@'
),
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'disabled', 'retired')),
ontology_entity_id text not null default 'infrastructure.endpoint'
check (ontology_entity_id = 'infrastructure.endpoint'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (host_id, endpoint_key),
unique (id, project_id, owner_scope_id),
foreign key (host_id, project_id, owner_scope_id)
references device_infrastructure_hosts(id, project_id, owner_scope_id)
);
create table if not exists device_infrastructure_deployments (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
host_id uuid not null,
deployment_key text not null
check (deployment_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
artifact_ref text not null
check (length(btrim(artifact_ref)) between 3 and 256),
artifact_digest text not null
check (artifact_digest ~ '^sha256:[a-f0-9]{64}$'),
lifecycle_state text not null default 'desired'
check (lifecycle_state in ('desired', 'applying', 'active', 'failed', 'retired')),
ontology_entity_id text not null default 'infrastructure.deployment'
check (ontology_entity_id = 'infrastructure.deployment'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, deployment_key),
unique (id, project_id, owner_scope_id),
foreign key (host_id, project_id, owner_scope_id)
references device_infrastructure_hosts(id, project_id, owner_scope_id)
);
create table if not exists device_infrastructure_service_instances (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
host_id uuid not null,
deployment_id uuid not null,
edge_id uuid references device_edges(id),
service_key text not null
check (service_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
service_role text not null
check (service_role ~ '^[a-z][a-z0-9._-]{1,63}$'),
lifecycle_state text not null default 'provisioning'
check (lifecycle_state in ('provisioning', 'active', 'degraded', 'stopped', 'retired')),
ontology_entity_id text not null default 'infrastructure.service_instance'
check (ontology_entity_id = 'infrastructure.service_instance'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (host_id, service_key),
unique (id, project_id, owner_scope_id),
foreign key (host_id, project_id, owner_scope_id)
references device_infrastructure_hosts(id, project_id, owner_scope_id),
foreign key (deployment_id, project_id, owner_scope_id)
references device_infrastructure_deployments(id, project_id, owner_scope_id)
);
create table if not exists device_health_observations (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
host_id uuid,
service_instance_id uuid,
observed_state text not null
check (observed_state in ('reachable', 'degraded', 'unreachable')),
evidence_class text not null
check (evidence_class in ('agent_probe', 'channel', 'management_probe', 'manual')),
source_ref text not null
check (length(btrim(source_ref)) between 3 and 256),
schema_ref text not null
check (length(btrim(schema_ref)) between 3 and 256),
evidence_projection jsonb not null default '{}'::jsonb
check (
jsonb_typeof(evidence_projection) = 'object'
and octet_length(evidence_projection::text) <= 16384
),
observed_at timestamptz not null,
expires_at timestamptz not null,
ontology_entity_id text not null default 'observation.health_observation'
check (ontology_entity_id = 'observation.health_observation'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
recorded_by_ref text not null
check (length(btrim(recorded_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
foreign key (project_id, owner_scope_id)
references device_projects(id, owner_scope_id),
foreign key (host_id, project_id, owner_scope_id)
references device_infrastructure_hosts(id, project_id, owner_scope_id),
foreign key (service_instance_id, project_id, owner_scope_id)
references device_infrastructure_service_instances(id, project_id, owner_scope_id),
check (
(host_id is not null and service_instance_id is null)
or (host_id is null and service_instance_id is not null)
),
check (expires_at > observed_at)
);
create index if not exists device_health_observations_host_time_idx
on device_health_observations (host_id, observed_at desc)
where host_id is not null;
create index if not exists device_health_observations_service_time_idx
on device_health_observations (service_instance_id, observed_at desc)
where service_instance_id is not null;
commit;
@@ -0,0 +1,52 @@
begin;
create table if not exists device_infrastructure_host_telemetry_samples (
id uuid primary key,
owner_scope_id uuid not null,
project_id uuid not null,
host_id uuid not null,
service_instance_id uuid not null,
edge_id uuid not null,
observed_at timestamptz not null,
received_at timestamptz not null default now(),
expires_at timestamptz not null,
schema_version text not null
check (schema_version = 'nodedc.infrastructure.host-telemetry.v1'),
profile_ref text not null
check (profile_ref = 'linux-host-telegraf-v1'),
agent_name text not null
check (agent_name = 'telegraf'),
agent_version text not null
check (length(btrim(agent_version)) between 1 and 64),
collector_ref text not null
check (length(btrim(collector_ref)) between 3 and 160),
provenance_ref text not null
check (length(btrim(provenance_ref)) between 3 and 256),
snapshot jsonb not null,
ontology_entity_id text not null default 'observation.observation'
check (ontology_entity_id = 'observation.observation'),
ontology_catalog_hash text not null default '229c61c02a790906'
check (ontology_catalog_hash = '229c61c02a790906'),
created_at timestamptz not null default now(),
unique (edge_id, observed_at),
foreign key (host_id, project_id, owner_scope_id)
references device_infrastructure_hosts(id, project_id, owner_scope_id),
foreign key (service_instance_id, project_id, owner_scope_id)
references device_infrastructure_service_instances(id, project_id, owner_scope_id),
foreign key (edge_id) references device_edges(id),
check (expires_at > observed_at),
check (received_at >= observed_at - interval '5 minutes'),
check (jsonb_typeof(snapshot) = 'object')
);
create index if not exists device_host_telemetry_project_host_time_idx
on device_infrastructure_host_telemetry_samples (
project_id,
host_id,
observed_at desc
);
create index if not exists device_host_telemetry_retention_idx
on device_infrastructure_host_telemetry_samples (observed_at);
commit;
+61
View File
@@ -46,6 +46,29 @@ const managementRoutes = new Map([
"/internal/v1/management/device-configurations:set-desired",
"device_configuration_desired.set",
],
["/internal/v1/management/assets:ensure", "asset.ensure"],
["/internal/v1/management/asset-bindings:ensure", "asset_binding.ensure"],
["/internal/v1/management/asset-bindings:close", "asset_binding.close"],
[
"/internal/v1/management/infrastructure-hosts:ensure",
"infrastructure_host.ensure",
],
[
"/internal/v1/management/infrastructure-endpoints:ensure",
"infrastructure_endpoint.ensure",
],
[
"/internal/v1/management/infrastructure-deployments:ensure",
"infrastructure_deployment.ensure",
],
[
"/internal/v1/management/infrastructure-service-instances:ensure",
"infrastructure_service_instance.ensure",
],
[
"/internal/v1/management/health-observations:record",
"health_observation.record",
],
]);
export function createControlCoreApp({
@@ -287,11 +310,42 @@ export function createControlCoreApp({
commandTransport: typedCommandRuntime
? "typed-service-ping-v1"
: "disabled",
edgeChannelStatus: edgeChannelStatusProvider
? edgeChannelStatusProvider()
: null,
},
);
return writeJson(response, 200, { ok: true, workspace });
}
const ontologyProjectId = projectOntologyId(requestUrl.pathname);
if (request.method === "GET" && ontologyProjectId) {
if (!managementApiEnabled) {
return writeJson(response, 404, {
ok: false,
error: "device_management_api_disabled",
});
}
if (!matchesBearer(request.headers.authorization, managementToken)) {
return writeJson(response, 401, {
ok: false,
error: "device_management_auth_required",
});
}
if (typeof repository.getProjectOntologyProjection !== "function") {
return writeJson(response, 503, {
ok: false,
error: "device_query_repository_unavailable",
});
}
const actor = managementActorFromHeaders(request.headers);
const projection = await repository.getProjectOntologyProjection(
actor,
ontologyProjectId,
);
return writeJson(response, 200, { ok: true, projection });
}
if (
request.method === "POST"
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
@@ -405,6 +459,13 @@ function projectWorkspaceId(pathname) {
return match?.[1]?.toLowerCase() ?? null;
}
function projectOntologyId(pathname) {
const match = pathname.match(
/^\/internal\/v1\/query\/projects\/([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\/ontology$/i,
);
return match?.[1]?.toLowerCase() ?? null;
}
function managementActorFromHeaders(headers) {
return normalizeManagementActor({
userRef: singleHeader(headers["x-nodedc-user-ref"]),
@@ -18,6 +18,9 @@ import {
normalizeAdapterMessage,
normalizeDiscoverySignal,
} from "../../../packages/device-protocol-contract/src/index.mjs";
import {
normalizeHostTelemetrySnapshot,
} from "../../../packages/infrastructure-telemetry-contract/src/index.mjs";
// Runtime-owned transport implementation; kept inside the deployable Core context.
const CHANNEL_TRACKER_SESSION_ID = "channel:control";
@@ -277,7 +280,7 @@ export function createDeviceGatewayCoreChannelClient(options = {}) {
return;
}
if (envelope.messageKind === "channel.heartbeat") return;
if (["discovery.observed", "adapter.message", "command.status"].includes(envelope.messageKind)) {
if (["discovery.observed", "adapter.message", "command.status", "host.telemetry.observed"].includes(envelope.messageKind)) {
scheduleTrackerEvent(connection, envelope);
return;
}
@@ -297,7 +300,9 @@ export function createDeviceGatewayCoreChannelClient(options = {}) {
? acceptDiscovery(connection, envelope)
: envelope.messageKind === "adapter.message"
? acceptAdapterMessage(connection, envelope)
: acceptCommandStatus(connection, envelope))
: envelope.messageKind === "command.status"
? acceptCommandStatus(connection, envelope)
: acceptHostTelemetry(connection, envelope))
.catch((error) => failConnection(connection, error))
.finally(() => {
if (connection.sessionChains.get(envelope.trackerSessionId) === work) {
@@ -371,6 +376,23 @@ export function createDeviceGatewayCoreChannelClient(options = {}) {
}
}
async function acceptHostTelemetry(connection, envelope) {
try {
const snapshot = normalizeHostTelemetrySnapshot(envelope.payload?.snapshot);
const receipt = await config.recordHostTelemetry(snapshot, {
authenticatedEdgeRef: connection.registration.edgeRegistrationId,
});
if (receipt?.status !== "recorded") {
throw new Error("device_gateway_core_host_telemetry_receipt_invalid");
}
sendEventResult(connection, envelope, { status: "recorded" });
totalEventsAccepted += 1;
} catch (error) {
sendEventRejection(connection, envelope, error);
totalEventsRejected += 1;
}
}
async function acceptCommandStatus(connection, envelope) {
try {
await config.recordCommandStatus(envelope.payload?.status);
@@ -580,7 +602,13 @@ function normalizeConfig(options) {
}
const offerCommand = options.offerCommand ?? (async () => null);
const recordCommandStatus = options.recordCommandStatus ?? (async () => undefined);
if (typeof offerCommand !== "function" || typeof recordCommandStatus !== "function") {
const recordHostTelemetry = options.recordHostTelemetry
?? (async () => { throw new Error("device_host_telemetry_repository_unavailable"); });
if (
typeof offerCommand !== "function"
|| typeof recordCommandStatus !== "function"
|| typeof recordHostTelemetry !== "function"
) {
throw new TypeError("device_gateway_core_command_runtime_invalid");
}
const registrationProvider = typeof options.registrationProvider === "function"
@@ -627,6 +655,7 @@ function normalizeConfig(options) {
commandTransport,
offerCommand,
recordCommandStatus,
recordHostTelemetry,
keepaliveMs,
deadPeerMs,
connectTimeoutMs: normalizeInteger(
@@ -116,6 +116,15 @@ export function createDeviceEdgeChannelSupervisor(options = {}) {
message,
{ authenticatedEdgeRef: registration.edgeRegistrationId },
),
recordHostTelemetry: (snapshot, context) =>
typeof config.repository.recordInfrastructureHostTelemetry === "function"
? config.repository.recordInfrastructureHostTelemetry({
snapshot,
authenticatedEdgeRef: context.authenticatedEdgeRef,
})
: Promise.reject(new Error(
"device_host_telemetry_repository_unavailable",
)),
commandTransport: config.typedCommandRuntime
? "typed-service-ping-v1"
: "disabled",
@@ -0,0 +1,105 @@
import { randomUUID } from "node:crypto";
import {
normalizeHostTelemetrySnapshot,
} from "../../../packages/infrastructure-telemetry-contract/src/index.mjs";
const FRESHNESS_SECONDS = 15;
const RETENTION_DAYS = 7;
export async function recordInfrastructureHostTelemetry(client, input) {
const snapshot = normalizeHostTelemetrySnapshot(input?.snapshot);
const edgeId = entityId(input?.authenticatedEdgeRef, "edge");
const relation = await client.query(
`select disi.id as service_instance_id, disi.host_id,
disi.project_id, disi.owner_scope_id, dih.host_key
from device_infrastructure_service_instances disi
join device_infrastructure_hosts dih on dih.id = disi.host_id
where disi.edge_id = $1
and disi.lifecycle_state in ('active', 'degraded')
and dih.lifecycle_state = 'active'
order by disi.updated_at desc, disi.id
limit 2`,
[edgeId],
);
if (relation.rows.length !== 1) {
throw domainError("device_host_telemetry_edge_host_binding_invalid", 409);
}
const target = relation.rows[0];
if (target.host_key !== snapshot.hostKey) {
throw domainError("device_host_telemetry_host_key_mismatch", 409);
}
const observedAt = new Date(snapshot.observedAt);
const clockSkewMs = Math.abs(Date.now() - observedAt.valueOf());
if (!Number.isFinite(observedAt.valueOf()) || clockSkewMs > 5 * 60 * 1000) {
throw domainError("device_host_telemetry_clock_skew_invalid", 409);
}
const id = randomUUID();
const provenanceRef = `${input.authenticatedEdgeRef}:${snapshot.source.collectorRef}`;
const inserted = await client.query(
`with inserted as (
insert into device_infrastructure_host_telemetry_samples (
id, owner_scope_id, project_id, host_id, service_instance_id, edge_id,
observed_at, expires_at, schema_version, profile_ref,
agent_name, agent_version, collector_ref, provenance_ref, snapshot
) values (
$1, $2, $3, $4, $5, $6,
$7, $7::timestamptz + ($8 * interval '1 second'), $9, $10,
$11, $12, $13, $14, $15::jsonb
)
on conflict (edge_id, observed_at) do nothing
returning id, received_at, expires_at, false as replayed
)
select id, received_at, expires_at, replayed from inserted
union all
select id, received_at, expires_at, true as replayed
from device_infrastructure_host_telemetry_samples
where edge_id = $6 and observed_at = $7::timestamptz
and not exists (select 1 from inserted)
limit 1`,
[
id,
target.owner_scope_id,
target.project_id,
target.host_id,
target.service_instance_id,
edgeId,
snapshot.observedAt,
FRESHNESS_SECONDS,
snapshot.schemaVersion,
snapshot.profile,
snapshot.source.agent,
snapshot.source.agentVersion,
snapshot.source.collectorRef,
provenanceRef,
JSON.stringify(snapshot),
],
);
await client.query(
`delete from device_infrastructure_host_telemetry_samples
where observed_at < now() - ($1 * interval '1 day')`,
[RETENTION_DAYS],
);
const row = inserted.rows[0];
return Object.freeze({
status: "recorded",
replayed: row.replayed,
observationRef: `observation:${row.id}`,
hostRef: `host:${target.host_id}`,
observedAt: snapshot.observedAt,
receivedAt: new Date(row.received_at).toISOString(),
expiresAt: new Date(row.expires_at).toISOString(),
});
}
function entityId(value, kind) {
const match = new RegExp(`^${kind}:([0-9a-f-]{36})$`, "i").exec(String(value || ""));
if (!match) throw domainError(`device_${kind}_ref_invalid`, 400);
return match[1];
}
function domainError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
@@ -17,6 +17,11 @@ import {
DEVICE_MANAGEMENT_COMMAND_KINDS,
normalizeManagementCommand,
} from "./project-management.mjs";
import {
DEVICE_ONTOLOGY_COMMAND_KINDS,
isOntologyManagementCommand,
normalizeOntologyManagementCommand,
} from "./ontology-management.mjs";
import {
DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
isSensitiveReferenceManagementCommand,
@@ -29,9 +34,13 @@ export const ALL_DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([
...DEVICE_LIFECYCLE_COMMAND_KINDS,
...DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
...DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
...DEVICE_ONTOLOGY_COMMAND_KINDS,
]);
export function normalizeDeviceManagementCommand(kind, input) {
if (isOntologyManagementCommand(kind)) {
return normalizeOntologyManagementCommand(kind, input);
}
if (isControlResourceManagementCommand(kind)) {
return normalizeControlResourceManagementCommand(kind, input);
}
@@ -0,0 +1,319 @@
import {
assertSafeProjection,
} from "../../../packages/device-protocol-contract/src/index.mjs";
export const DEVICE_ONTOLOGY_COMMAND_KINDS = Object.freeze([
"asset.ensure",
"asset_binding.ensure",
"asset_binding.close",
"infrastructure_host.ensure",
"infrastructure_endpoint.ensure",
"infrastructure_deployment.ensure",
"infrastructure_service_instance.ensure",
"health_observation.record",
]);
export const DEVICE_ONTOLOGY_CATALOG_HASH = "229c61c02a790906";
const commandKindSet = new Set(DEVICE_ONTOLOGY_COMMAND_KINDS);
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
const opaqueRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{2,255}$/;
const secretRefPattern = /^secret-ref:[A-Za-z0-9][A-Za-z0-9._:/+-]{2,244}$/;
const digestPattern = /^sha256:[a-f0-9]{64}$/;
const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i;
export function isOntologyManagementCommand(kind) {
return commandKindSet.has(kind);
}
export function normalizeOntologyManagementCommand(kind, input) {
if (!commandKindSet.has(kind)) {
throw new TypeError("device_ontology_command_kind_invalid");
}
assertPlainObject(input, "device_ontology_command_invalid");
if (kind === "asset.ensure") {
assertAllowedKeys(input, [
"projectRef", "assetKey", "displayName", "assetTypeRef", "lifecycleState",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
assetKey: normalizeKey(input.assetKey, "device_asset_key_invalid"),
displayName: normalizeText(input.displayName, 160, "device_asset_name_invalid"),
assetTypeRef: normalizeOpaqueRef(input.assetTypeRef, "device_asset_type_ref_invalid"),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "active",
new Set(["active", "retired"]),
"device_asset_state_invalid",
),
});
}
if (kind === "asset_binding.ensure") {
assertAllowedKeys(input, [
"projectRef", "bindingKey", "deviceRef", "assetRef", "bindingKind",
"validFrom", "provenanceRef",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
bindingKey: normalizeKey(input.bindingKey, "device_asset_binding_key_invalid"),
deviceId: normalizeEntityRef(input.deviceRef, "device"),
assetId: normalizeEntityRef(input.assetRef, "asset"),
bindingKind: normalizeEnum(
input.bindingKind ?? "tracking",
new Set(["tracking", "installed", "assigned"]),
"device_asset_binding_kind_invalid",
),
validFrom: normalizeTimestamp(input.validFrom, "device_asset_binding_valid_from_invalid"),
provenanceRef: normalizeOpaqueRef(
input.provenanceRef,
"device_asset_binding_provenance_ref_invalid",
),
});
}
if (kind === "asset_binding.close") {
assertAllowedKeys(input, ["projectRef", "assetBindingRef", "validTo"]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
assetBindingId: normalizeEntityRef(input.assetBindingRef, "asset-binding"),
validTo: normalizeTimestamp(input.validTo, "device_asset_binding_valid_to_invalid"),
});
}
if (kind === "infrastructure_host.ensure") {
assertAllowedKeys(input, [
"projectRef", "hostKey", "displayName", "providerRef", "externalRef",
"managementCredentialRef", "lifecycleState",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
hostKey: normalizeKey(input.hostKey, "device_host_key_invalid"),
displayName: normalizeText(input.displayName, 160, "device_host_name_invalid"),
providerRef: normalizeOptionalOpaqueRef(input.providerRef, "device_host_provider_ref_invalid"),
externalRef: normalizeOptionalOpaqueRef(input.externalRef, "device_host_external_ref_invalid"),
managementCredentialRef: normalizeOptionalPattern(
input.managementCredentialRef,
secretRefPattern,
"device_host_management_credential_ref_invalid",
),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "provisioning",
new Set(["provisioning", "active", "suspended", "retired"]),
"device_host_state_invalid",
),
});
}
if (kind === "infrastructure_endpoint.ensure") {
assertAllowedKeys(input, [
"projectRef", "hostRef", "endpointKey", "purpose", "endpointUri",
"lifecycleState",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
hostId: normalizeEntityRef(input.hostRef, "host"),
endpointKey: normalizeKey(input.endpointKey, "device_endpoint_key_invalid"),
purpose: normalizeEnum(
input.purpose,
new Set(["management", "service", "monitoring"]),
"device_endpoint_purpose_invalid",
),
endpointUri: normalizeEndpointUri(input.endpointUri),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "active",
new Set(["active", "disabled", "retired"]),
"device_endpoint_state_invalid",
),
});
}
if (kind === "infrastructure_deployment.ensure") {
assertAllowedKeys(input, [
"projectRef", "hostRef", "deploymentKey", "displayName", "artifactRef",
"artifactDigest", "lifecycleState",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
hostId: normalizeEntityRef(input.hostRef, "host"),
deploymentKey: normalizeKey(input.deploymentKey, "device_deployment_key_invalid"),
displayName: normalizeText(
input.displayName,
160,
"device_deployment_name_invalid",
),
artifactRef: normalizeOpaqueRef(input.artifactRef, "device_deployment_artifact_ref_invalid"),
artifactDigest: normalizePattern(
input.artifactDigest,
digestPattern,
"device_deployment_artifact_digest_invalid",
),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "desired",
new Set(["desired", "applying", "active", "failed", "retired"]),
"device_deployment_state_invalid",
),
});
}
if (kind === "infrastructure_service_instance.ensure") {
assertAllowedKeys(input, [
"projectRef", "hostRef", "deploymentRef", "edgeRef", "serviceKey",
"displayName", "serviceRole", "lifecycleState",
]);
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
hostId: normalizeEntityRef(input.hostRef, "host"),
deploymentId: normalizeEntityRef(input.deploymentRef, "deployment"),
edgeId: input.edgeRef == null ? null : normalizeEntityRef(input.edgeRef, "edge"),
serviceKey: normalizeKey(input.serviceKey, "device_service_instance_key_invalid"),
displayName: normalizeText(
input.displayName,
160,
"device_service_instance_name_invalid",
),
serviceRole: normalizePattern(
input.serviceRole,
/^[a-z][a-z0-9._-]{1,63}$/,
"device_service_instance_role_invalid",
),
lifecycleState: normalizeEnum(
input.lifecycleState ?? "provisioning",
new Set(["provisioning", "active", "degraded", "stopped", "retired"]),
"device_service_instance_state_invalid",
),
});
}
assertAllowedKeys(input, [
"projectRef", "subjectKind", "subjectRef", "observedState", "evidenceClass",
"sourceRef", "schemaRef", "evidence", "observedAt", "expiresAt",
]);
const subjectKind = normalizeEnum(
input.subjectKind,
new Set(["host", "service-instance"]),
"device_health_subject_kind_invalid",
);
const evidence = structuredClone(assertSafeProjection(input.evidence ?? {}));
if (Buffer.byteLength(JSON.stringify(evidence), "utf8") > 16 * 1024) {
throw new TypeError("device_health_evidence_too_large");
}
const observedAt = normalizeTimestamp(
input.observedAt,
"device_health_observed_at_invalid",
);
const expiresAt = normalizeTimestamp(
input.expiresAt,
"device_health_expires_at_invalid",
);
if (Date.parse(expiresAt) <= Date.parse(observedAt)) {
throw new TypeError("device_health_freshness_window_invalid");
}
return Object.freeze({
projectId: normalizeEntityRef(input.projectRef, "project"),
subjectKind,
subjectId: normalizeEntityRef(input.subjectRef, subjectKind),
observedState: normalizeEnum(
input.observedState,
new Set(["reachable", "degraded", "unreachable"]),
"device_health_observed_state_invalid",
),
evidenceClass: normalizeEnum(
input.evidenceClass,
new Set(["agent_probe", "channel", "management_probe", "manual"]),
"device_health_evidence_class_invalid",
),
sourceRef: normalizeOpaqueRef(input.sourceRef, "device_health_source_ref_invalid"),
schemaRef: normalizeOpaqueRef(input.schemaRef, "device_health_schema_ref_invalid"),
evidence: Object.freeze(evidence),
observedAt,
expiresAt,
});
}
function normalizeEndpointUri(value) {
if (typeof value !== "string" || value.length > 512) {
throw new TypeError("device_endpoint_uri_invalid");
}
let parsed;
try {
parsed = new URL(value);
} catch {
throw new TypeError("device_endpoint_uri_invalid");
}
if (
!new Set(["https:", "ssh:", "tcp:"]).has(parsed.protocol)
|| !parsed.hostname
|| parsed.username
|| parsed.password
|| parsed.search
|| parsed.hash
) {
throw new TypeError("device_endpoint_uri_invalid");
}
return parsed.toString();
}
function normalizeEntityRef(value, kind) {
if (typeof value !== "string") throw new TypeError(`device_${kind}_ref_invalid`);
const match = value.match(new RegExp(`^${kind}:(${uuidPattern.source})$`, "i"));
if (!match) throw new TypeError(`device_${kind}_ref_invalid`);
return match[1].toLowerCase();
}
function normalizeKey(value, errorCode) {
return normalizePattern(value, keyPattern, errorCode);
}
function normalizeOpaqueRef(value, errorCode) {
return normalizePattern(value, opaqueRefPattern, errorCode);
}
function normalizeOptionalOpaqueRef(value, errorCode) {
return value == null || value === "" ? null : normalizeOpaqueRef(value, errorCode);
}
function normalizeOptionalPattern(value, pattern, errorCode) {
return value == null || value === "" ? null : normalizePattern(value, pattern, errorCode);
}
function normalizePattern(value, pattern, errorCode) {
if (typeof value !== "string" || !pattern.test(value)) throw new TypeError(errorCode);
return value;
}
function normalizeText(value, maximum, errorCode) {
if (typeof value !== "string") throw new TypeError(errorCode);
const normalized = value.trim();
if (normalized.length < 1 || normalized.length > maximum) throw new TypeError(errorCode);
return normalized;
}
function normalizeEnum(value, allowed, errorCode) {
if (typeof value !== "string" || !allowed.has(value)) throw new TypeError(errorCode);
return value;
}
function normalizeTimestamp(value, errorCode) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T/.test(value)) {
throw new TypeError(errorCode);
}
const timestamp = new Date(value);
if (!Number.isFinite(timestamp.valueOf())) throw new TypeError(errorCode);
return timestamp.toISOString();
}
function assertPlainObject(value, errorCode) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(errorCode);
}
}
function assertAllowedKeys(value, allowedKeys) {
const allowed = new Set(allowedKeys);
for (const key of Object.keys(value)) {
if (!allowed.has(key)) {
throw new TypeError(`device_ontology_command_field_unexpected:${key}`);
}
}
}
@@ -0,0 +1,305 @@
import { findProjectWithCapability } from "./lifecycle-repository.mjs";
export async function getDeviceProjectOntologyProjection(client, actor, projectId) {
await findProjectWithCapability(
client,
actor,
projectId,
"infrastructure.read",
{ lock: false },
);
const [assets, assetBindings, hosts, endpoints, deployments, services, telemetry] =
await Promise.all([
client.query(
`select * from device_assets
where project_id = $1
order by display_name, id`,
[projectId],
),
client.query(
`select dab.*, di.display_name as device_name,
da.display_name as asset_name
from device_asset_bindings dab
join device_instances di on di.id = dab.device_id
join device_assets da on da.id = dab.asset_id
where dab.project_id = $1
order by dab.valid_from desc, dab.id`,
[projectId],
),
client.query(
`select dih.id, dih.project_id, dih.host_key, dih.display_name,
dih.provider_ref, dih.external_ref, dih.lifecycle_state,
dih.ontology_entity_id, dih.ontology_catalog_hash,
(dih.management_credential_ref is not null) as management_credential_configured,
ho.id as health_observation_id,
ho.observed_state as health_observed_state,
ho.evidence_class as health_evidence_class,
ho.observed_at as health_observed_at,
ho.expires_at as health_expires_at
from device_infrastructure_hosts dih
left join lateral (
select dho.id, dho.observed_state, dho.evidence_class,
dho.observed_at, dho.expires_at
from device_health_observations dho
where dho.host_id = dih.id
order by dho.observed_at desc, dho.id desc
limit 1
) ho on true
where dih.project_id = $1
order by dih.display_name, dih.id`,
[projectId],
),
client.query(
`select * from device_infrastructure_endpoints
where project_id = $1
order by host_id, endpoint_key, id`,
[projectId],
),
client.query(
`select * from device_infrastructure_deployments
where project_id = $1
order by updated_at desc, id`,
[projectId],
),
client.query(
`select disi.*,
ho.id as health_observation_id,
ho.observed_state as health_observed_state,
ho.evidence_class as health_evidence_class,
ho.observed_at as health_observed_at,
ho.expires_at as health_expires_at
from device_infrastructure_service_instances disi
left join lateral (
select dho.id, dho.observed_state, dho.evidence_class,
dho.observed_at, dho.expires_at
from device_health_observations dho
where dho.service_instance_id = disi.id
order by dho.observed_at desc, dho.id desc
limit 1
) ho on true
where disi.project_id = $1
order by disi.display_name, disi.id`,
[projectId],
),
client.query(
`select * from (
select dihts.id, dihts.host_id, dihts.service_instance_id,
dihts.edge_id, dihts.observed_at, dihts.received_at,
dihts.expires_at, dihts.profile_ref, dihts.agent_name,
dihts.agent_version, dihts.collector_ref,
dihts.provenance_ref, dihts.snapshot,
dihts.ontology_entity_id, dihts.ontology_catalog_hash,
row_number() over (
partition by dihts.host_id order by dihts.observed_at desc, dihts.id desc
) as sample_rank
from device_infrastructure_host_telemetry_samples dihts
where dihts.project_id = $1
) ranked
where sample_rank <= 120
order by host_id, observed_at desc, id desc`,
[projectId],
),
]);
const telemetryByHost = telemetry.rows.reduce((result, row) => {
const rows = result.get(row.host_id) ?? [];
rows.push(row);
result.set(row.host_id, rows);
return result;
}, new Map());
return {
ontology: {
catalogHash: "229c61c02a790906",
packages: ["asset", "device", "infrastructure", "observation"],
},
assets: assets.rows.map(assetView),
assetBindings: assetBindings.rows.map(assetBindingView),
hosts: hosts.rows.map((row) => hostView(row, telemetryByHost.get(row.id) ?? [])),
endpoints: endpoints.rows.map(endpointView),
deployments: deployments.rows.map(deploymentView),
serviceInstances: services.rows.map(serviceInstanceView),
policies: {
restrictedIdentifiers: "masked-only",
managementCredentials: "opaque-reference-only",
missingHealthEvidence: "unobserved-not-unhealthy",
arbitraryConsole: "disabled",
},
};
}
function assetView(row) {
return {
assetRef: `asset:${row.id}`,
assetKey: row.asset_key,
displayName: row.display_name,
assetTypeRef: row.asset_type_ref,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
};
}
function assetBindingView(row) {
return {
assetBindingRef: `asset-binding:${row.id}`,
bindingKey: row.binding_key,
deviceRef: `device:${row.device_id}`,
deviceName: row.device_name,
assetRef: `asset:${row.asset_id}`,
assetName: row.asset_name,
bindingKind: row.binding_kind,
validFrom: toIso(row.valid_from),
validTo: toIso(row.valid_to),
provenanceRef: row.provenance_ref,
ontology: ontologyView(row),
};
}
function hostView(row, telemetryRows) {
return {
hostRef: `host:${row.id}`,
hostKey: row.host_key,
displayName: row.display_name,
providerRef: row.provider_ref ?? null,
externalRef: row.external_ref ?? null,
managementCredentialConfigured: row.management_credential_configured === true,
lifecycleState: row.lifecycle_state,
health: healthView(row),
telemetry: hostTelemetryView(telemetryRows),
ontology: ontologyView(row),
};
}
function hostTelemetryView(rows) {
const latest = rows[0];
if (!latest) {
return {
state: "unobserved",
freshness: "missing",
observedAt: null,
receivedAt: null,
expiresAt: null,
current: null,
history: [],
observation: null,
};
}
const fresh = new Date(latest.expires_at).valueOf() > Date.now();
return {
state: fresh ? "online" : "unobserved",
freshness: fresh ? "fresh" : "stale",
observedAt: toIso(latest.observed_at),
receivedAt: toIso(latest.received_at),
expiresAt: toIso(latest.expires_at),
current: latest.snapshot,
history: [...rows].reverse().map((row) => ({
observedAt: toIso(row.observed_at),
cpuUsagePercent: numberOrNull(row.snapshot?.cpu?.usagePercent),
memoryUsedPercent: numberOrNull(row.snapshot?.memory?.usedPercent),
network: Array.isArray(row.snapshot?.network)
? row.snapshot.network.map((item) => ({
interface: item.interface ?? null,
bytesReceived: numberOrNull(item.bytesReceived),
bytesSent: numberOrNull(item.bytesSent),
}))
: [],
})),
observation: {
observationRef: `observation:${latest.id}`,
entityId: latest.ontology_entity_id,
catalogHash: latest.ontology_catalog_hash,
targetRef: `host:${latest.host_id}`,
serviceInstanceRef: `service-instance:${latest.service_instance_id}`,
edgeRef: `edge:${latest.edge_id}`,
profileRef: latest.profile_ref,
source: {
agent: latest.agent_name,
agentVersion: latest.agent_version,
collectorRef: latest.collector_ref,
provenanceRef: latest.provenance_ref,
},
observedProperties: [
"host.cpu.utilization",
"host.memory.utilization",
"host.swap.utilization",
"host.disk.utilization",
"host.network.counters",
"host.process.counts",
"host.systemd.unit-state",
],
},
};
}
function endpointView(row) {
return {
endpointRef: `endpoint:${row.id}`,
hostRef: `host:${row.host_id}`,
endpointKey: row.endpoint_key,
purpose: row.purpose,
endpointUri: row.endpoint_uri,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
};
}
function deploymentView(row) {
return {
deploymentRef: `deployment:${row.id}`,
hostRef: `host:${row.host_id}`,
deploymentKey: row.deployment_key,
displayName: row.display_name,
artifactRef: row.artifact_ref,
artifactDigest: row.artifact_digest,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
};
}
function serviceInstanceView(row) {
return {
serviceInstanceRef: `service-instance:${row.id}`,
hostRef: `host:${row.host_id}`,
deploymentRef: `deployment:${row.deployment_id}`,
edgeRef: row.edge_id ? `edge:${row.edge_id}` : null,
serviceKey: row.service_key,
displayName: row.display_name,
serviceRole: row.service_role,
lifecycleState: row.lifecycle_state,
health: healthView(row),
ontology: ontologyView(row),
};
}
function healthView(row) {
if (!row.health_observation_id) {
return { state: "unobserved", freshness: "missing", observationRef: null };
}
const fresh = new Date(row.health_expires_at).valueOf() > Date.now();
return {
state: fresh ? row.health_observed_state : "unobserved",
freshness: fresh ? "fresh" : "stale",
lastObservedState: row.health_observed_state,
evidenceClass: row.health_evidence_class,
observedAt: toIso(row.health_observed_at),
expiresAt: toIso(row.health_expires_at),
observationRef: `health-observation:${row.health_observation_id}`,
};
}
function ontologyView(row) {
return {
entityId: row.ontology_entity_id,
catalogHash: row.ontology_catalog_hash,
};
}
function toIso(value) {
return value == null ? null : new Date(value).toISOString();
}
function numberOrNull(value) {
const normalized = Number(value);
return Number.isFinite(normalized) ? normalized : null;
}
@@ -0,0 +1,611 @@
import { randomUUID } from "node:crypto";
import { findProjectWithCapability } from "./lifecycle-repository.mjs";
import { isOntologyManagementCommand } from "./ontology-management.mjs";
import { toProjectRef } from "./project-management.mjs";
const commandCapabilities = Object.freeze({
"asset.ensure": "asset.manage",
"asset_binding.ensure": "binding.manage",
"asset_binding.close": "binding.manage",
"infrastructure_host.ensure": "infrastructure.manage",
"infrastructure_endpoint.ensure": "infrastructure.manage",
"infrastructure_deployment.ensure": "infrastructure.manage",
"infrastructure_service_instance.ensure": "infrastructure.manage",
"health_observation.record": "observation.write",
});
export async function applyOntologyManagementCommand(
client,
{ commandKind, actor, command },
) {
assertOntologyCommand(commandKind);
const project = await findProjectWithCapability(
client,
actor,
command.projectId,
commandCapabilities[commandKind],
);
if (commandKind === "asset.ensure") {
return ensureAsset(client, actor, project, command);
}
if (commandKind === "asset_binding.ensure") {
return ensureAssetBinding(client, actor, project, command);
}
if (commandKind === "asset_binding.close") {
return closeAssetBinding(client, actor, project, command);
}
if (commandKind === "infrastructure_host.ensure") {
return ensureHost(client, actor, project, command);
}
if (commandKind === "infrastructure_endpoint.ensure") {
return ensureEndpoint(client, actor, project, command);
}
if (commandKind === "infrastructure_deployment.ensure") {
return ensureDeployment(client, actor, project, command);
}
if (commandKind === "infrastructure_service_instance.ensure") {
return ensureServiceInstance(client, actor, project, command);
}
return recordHealthObservation(client, actor, project, command);
}
export async function authorizeOntologyManagementReplay(
client,
{ commandKind, actor, command },
) {
assertOntologyCommand(commandKind);
await findProjectWithCapability(
client,
actor,
command.projectId,
commandCapabilities[commandKind],
);
}
async function ensureAsset(client, actor, project, command) {
const result = await client.query(
`insert into device_assets (
id, owner_scope_id, project_id, asset_key, display_name,
asset_type_ref, lifecycle_state, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8)
on conflict (project_id, asset_key) do update set
display_name = excluded.display_name,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_assets.asset_type_ref = excluded.asset_type_ref
and (
device_assets.lifecycle_state = excluded.lifecycle_state
or (
device_assets.lifecycle_state = 'active'
and excluded.lifecycle_state = 'retired'
)
)
returning *, (xmax = 0) as created`,
[
randomUUID(),
project.owner_scope_id,
project.id,
command.assetKey,
command.displayName,
command.assetTypeRef,
command.lifecycleState,
actor.userRef,
],
);
const row = requireRow(result, "device_asset_identity_conflict");
await addAudit(client, {
actor,
project,
eventType: row.created ? "asset.created" : "asset.updated",
payload: {
assetRef: `asset:${row.id}`,
assetKey: row.asset_key,
assetTypeRef: row.asset_type_ref,
lifecycleState: row.lifecycle_state,
ontologyEntityId: row.ontology_entity_id,
},
});
return { created: row.created === true, asset: assetView(row) };
}
async function ensureAssetBinding(client, actor, project, command) {
const [deviceResult, assetResult] = await Promise.all([
client.query(
`select di.id, di.project_id, di.owner_scope_id, di.display_name,
di.lifecycle_state, dmp.device_type
from device_instances di
join device_model_profiles dmp on dmp.profile_ref = di.model_profile_ref
where di.id = $1 and di.project_id = $2
for share of di, dmp`,
[command.deviceId, project.id],
),
client.query(
`select id, project_id, owner_scope_id, display_name, lifecycle_state
from device_assets
where id = $1 and project_id = $2
for share`,
[command.assetId, project.id],
),
]);
const device = requireRow(deviceResult, "device_not_found", 404);
const asset = requireRow(assetResult, "device_asset_not_found", 404);
if (!["claimed", "online", "offline"].includes(device.lifecycle_state)) {
throw domainError("device_asset_binding_device_inactive", 409);
}
if (command.bindingKind === "tracking" && device.device_type !== "tracker") {
throw domainError("device_asset_binding_tracker_required", 409);
}
if (asset.lifecycle_state !== "active") {
throw domainError("device_asset_binding_asset_inactive", 409);
}
const result = await client.query(
`insert into device_asset_bindings (
id, owner_scope_id, project_id, binding_key, device_id, asset_id,
binding_kind, valid_from, provenance_ref, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
on conflict (project_id, binding_key) do update set
updated_at = now()
where device_asset_bindings.device_id = excluded.device_id
and device_asset_bindings.asset_id = excluded.asset_id
and device_asset_bindings.binding_kind = excluded.binding_kind
and device_asset_bindings.valid_from = excluded.valid_from
and device_asset_bindings.valid_to is null
and device_asset_bindings.provenance_ref = excluded.provenance_ref
returning *, (xmax = 0) as created`,
[
randomUUID(),
project.owner_scope_id,
project.id,
command.bindingKey,
command.deviceId,
command.assetId,
command.bindingKind,
command.validFrom,
command.provenanceRef,
actor.userRef,
],
);
const row = requireRow(result, "device_asset_binding_identity_conflict");
await addAudit(client, {
actor,
project,
deviceId: row.device_id,
eventType: row.created ? "asset_binding.created" : "asset_binding.confirmed",
payload: assetBindingAudit(row),
});
return { created: row.created === true, assetBinding: assetBindingView(row) };
}
async function closeAssetBinding(client, actor, project, command) {
const result = await client.query(
`update device_asset_bindings
set valid_to = $3,
closed_by_ref = $4,
updated_at = now()
where id = $1
and project_id = $2
and valid_to is null
and valid_from < $3
returning *`,
[command.assetBindingId, project.id, command.validTo, actor.userRef],
);
const row = requireRow(result, "device_asset_binding_not_closable", 409);
await addAudit(client, {
actor,
project,
deviceId: row.device_id,
eventType: "asset_binding.closed",
payload: assetBindingAudit(row),
});
return { closed: true, assetBinding: assetBindingView(row) };
}
async function ensureHost(client, actor, project, command) {
const result = await client.query(
`insert into device_infrastructure_hosts (
id, owner_scope_id, project_id, host_key, display_name, provider_ref,
external_ref, management_credential_ref, lifecycle_state, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
on conflict (project_id, host_key) do update set
display_name = excluded.display_name,
provider_ref = excluded.provider_ref,
external_ref = excluded.external_ref,
management_credential_ref = excluded.management_credential_ref,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_infrastructure_hosts.lifecycle_state <> 'retired'
or excluded.lifecycle_state = 'retired'
returning *, (xmax = 0) as created`,
[
randomUUID(), project.owner_scope_id, project.id, command.hostKey,
command.displayName, command.providerRef, command.externalRef,
command.managementCredentialRef, command.lifecycleState, actor.userRef,
],
);
const row = requireRow(result, "device_host_identity_conflict");
await addAudit(client, {
actor,
project,
eventType: row.created ? "infrastructure_host.created" : "infrastructure_host.updated",
payload: {
hostRef: `host:${row.id}`,
hostKey: row.host_key,
providerRef: row.provider_ref,
lifecycleState: row.lifecycle_state,
managementCredentialConfigured: row.management_credential_ref != null,
ontologyEntityId: row.ontology_entity_id,
},
});
return { created: row.created === true, host: hostView(row) };
}
async function ensureEndpoint(client, actor, project, command) {
await requireActiveHost(client, project.id, command.hostId);
const result = await client.query(
`insert into device_infrastructure_endpoints (
id, owner_scope_id, project_id, host_id, endpoint_key, purpose,
endpoint_uri, lifecycle_state, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
on conflict (host_id, endpoint_key) do update set
purpose = excluded.purpose,
endpoint_uri = excluded.endpoint_uri,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_infrastructure_endpoints.lifecycle_state <> 'retired'
or excluded.lifecycle_state = 'retired'
returning *, (xmax = 0) as created`,
[
randomUUID(), project.owner_scope_id, project.id, command.hostId,
command.endpointKey, command.purpose, command.endpointUri,
command.lifecycleState, actor.userRef,
],
);
const row = requireRow(result, "device_endpoint_identity_conflict");
await addAudit(client, {
actor,
project,
eventType: row.created ? "infrastructure_endpoint.created" : "infrastructure_endpoint.updated",
payload: {
endpointRef: `endpoint:${row.id}`,
hostRef: `host:${row.host_id}`,
purpose: row.purpose,
lifecycleState: row.lifecycle_state,
},
});
return { created: row.created === true, endpoint: endpointView(row) };
}
async function ensureDeployment(client, actor, project, command) {
await requireActiveHost(client, project.id, command.hostId);
const result = await client.query(
`insert into device_infrastructure_deployments (
id, owner_scope_id, project_id, host_id, deployment_key, display_name,
artifact_ref, artifact_digest, lifecycle_state, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
on conflict (project_id, deployment_key) do update set
display_name = excluded.display_name,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_infrastructure_deployments.host_id = excluded.host_id
and device_infrastructure_deployments.artifact_ref = excluded.artifact_ref
and device_infrastructure_deployments.artifact_digest = excluded.artifact_digest
and (
device_infrastructure_deployments.lifecycle_state <> 'retired'
or excluded.lifecycle_state = 'retired'
)
returning *, (xmax = 0) as created`,
[
randomUUID(), project.owner_scope_id, project.id, command.hostId,
command.deploymentKey, command.displayName, command.artifactRef,
command.artifactDigest, command.lifecycleState, actor.userRef,
],
);
const row = requireRow(result, "device_deployment_identity_conflict");
await addAudit(client, {
actor,
project,
eventType: row.created ? "infrastructure_deployment.created" : "infrastructure_deployment.updated",
payload: {
deploymentRef: `deployment:${row.id}`,
hostRef: `host:${row.host_id}`,
artifactRef: row.artifact_ref,
artifactDigest: row.artifact_digest,
lifecycleState: row.lifecycle_state,
},
});
return { created: row.created === true, deployment: deploymentView(row) };
}
async function ensureServiceInstance(client, actor, project, command) {
await requireActiveHost(client, project.id, command.hostId);
const deployment = await client.query(
`select id, host_id, lifecycle_state
from device_infrastructure_deployments
where id = $1 and project_id = $2
for share`,
[command.deploymentId, project.id],
);
const deploymentRow = requireRow(deployment, "device_deployment_not_found", 404);
if (deploymentRow.host_id !== command.hostId) {
throw domainError("device_service_instance_host_mismatch", 409);
}
if (deploymentRow.lifecycle_state === "retired") {
throw domainError("device_service_instance_deployment_inactive", 409);
}
if (command.edgeId) {
const edge = await client.query(
`select de.id
from device_edges de
where de.id = $1
and exists (
select 1 from device_routes dr
where dr.edge_id = de.id and dr.project_id = $2
)
for share`,
[command.edgeId, project.id],
);
requireRow(edge, "device_service_instance_edge_not_in_project", 409);
}
const result = await client.query(
`insert into device_infrastructure_service_instances (
id, owner_scope_id, project_id, host_id, deployment_id, edge_id,
service_key, display_name, service_role, lifecycle_state, created_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
on conflict (host_id, service_key) do update set
display_name = excluded.display_name,
edge_id = excluded.edge_id,
lifecycle_state = excluded.lifecycle_state,
updated_at = now()
where device_infrastructure_service_instances.deployment_id = excluded.deployment_id
and device_infrastructure_service_instances.service_role = excluded.service_role
and (
device_infrastructure_service_instances.lifecycle_state <> 'retired'
or excluded.lifecycle_state = 'retired'
)
returning *, (xmax = 0) as created`,
[
randomUUID(), project.owner_scope_id, project.id, command.hostId,
command.deploymentId, command.edgeId, command.serviceKey,
command.displayName, command.serviceRole, command.lifecycleState,
actor.userRef,
],
);
const row = requireRow(result, "device_service_instance_identity_conflict");
await addAudit(client, {
actor,
project,
eventType: row.created ? "infrastructure_service_instance.created" : "infrastructure_service_instance.updated",
payload: {
serviceInstanceRef: `service-instance:${row.id}`,
hostRef: `host:${row.host_id}`,
deploymentRef: `deployment:${row.deployment_id}`,
edgeRef: row.edge_id ? `edge:${row.edge_id}` : null,
serviceRole: row.service_role,
lifecycleState: row.lifecycle_state,
},
});
return { created: row.created === true, serviceInstance: serviceInstanceView(row) };
}
async function recordHealthObservation(client, actor, project, command) {
const subjectColumn = command.subjectKind === "host"
? "host_id"
: "service_instance_id";
const subjectTable = command.subjectKind === "host"
? "device_infrastructure_hosts"
: "device_infrastructure_service_instances";
const subject = await client.query(
`select id from ${subjectTable}
where id = $1 and project_id = $2 and lifecycle_state <> 'retired'
for share`,
[command.subjectId, project.id],
);
requireRow(subject, "device_health_subject_not_found", 404);
const result = await client.query(
`insert into device_health_observations (
id, owner_scope_id, project_id, ${subjectColumn}, observed_state,
evidence_class, source_ref, schema_ref, evidence_projection,
observed_at, expires_at, recorded_by_ref
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12)
returning *`,
[
randomUUID(), project.owner_scope_id, project.id, command.subjectId,
command.observedState, command.evidenceClass, command.sourceRef,
command.schemaRef, JSON.stringify(command.evidence), command.observedAt,
command.expiresAt, actor.userRef,
],
);
const row = result.rows[0];
await addAudit(client, {
actor,
project,
eventType: "health_observation.recorded",
payload: {
healthObservationRef: `health-observation:${row.id}`,
subjectKind: command.subjectKind,
subjectRef: `${command.subjectKind}:${command.subjectId}`,
observedState: row.observed_state,
evidenceClass: row.evidence_class,
observedAt: toIso(row.observed_at),
expiresAt: toIso(row.expires_at),
ontologyEntityId: row.ontology_entity_id,
},
});
return { recorded: true, healthObservation: healthObservationView(row) };
}
async function requireActiveHost(client, projectId, hostId) {
const result = await client.query(
`select id, lifecycle_state
from device_infrastructure_hosts
where id = $1 and project_id = $2
for share`,
[hostId, projectId],
);
const row = requireRow(result, "device_host_not_found", 404);
if (!new Set(["provisioning", "active"]).has(row.lifecycle_state)) {
throw domainError("device_host_inactive", 409);
}
return row;
}
async function addAudit(client, {
actor,
project,
eventType,
deviceId = null,
payload,
}) {
await client.query(
`insert into device_audit_events (
id, event_type, actor_ref, project_id, device_id, payload
) values ($1, $2, $3, $4, $5, $6::jsonb)`,
[
randomUUID(), eventType, actor.userRef, project.id, deviceId,
JSON.stringify({ projectRef: toProjectRef(project.id), ...payload }),
],
);
}
function assetView(row) {
return {
assetRef: `asset:${row.id}`,
assetKey: row.asset_key,
displayName: row.display_name,
assetTypeRef: row.asset_type_ref,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function assetBindingAudit(row) {
return {
assetBindingRef: `asset-binding:${row.id}`,
deviceRef: `device:${row.device_id}`,
assetRef: `asset:${row.asset_id}`,
bindingKind: row.binding_kind,
validFrom: toIso(row.valid_from),
validTo: toIso(row.valid_to),
provenanceRef: row.provenance_ref,
ontologyEntityId: row.ontology_entity_id,
};
}
function assetBindingView(row) {
return {
...assetBindingAudit(row),
bindingKey: row.binding_key,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function hostView(row) {
return {
hostRef: `host:${row.id}`,
hostKey: row.host_key,
displayName: row.display_name,
providerRef: row.provider_ref ?? null,
externalRef: row.external_ref ?? null,
managementCredentialConfigured: row.management_credential_ref != null,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function endpointView(row) {
return {
endpointRef: `endpoint:${row.id}`,
hostRef: `host:${row.host_id}`,
endpointKey: row.endpoint_key,
purpose: row.purpose,
endpointUri: row.endpoint_uri,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function deploymentView(row) {
return {
deploymentRef: `deployment:${row.id}`,
hostRef: `host:${row.host_id}`,
deploymentKey: row.deployment_key,
displayName: row.display_name,
artifactRef: row.artifact_ref,
artifactDigest: row.artifact_digest,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function serviceInstanceView(row) {
return {
serviceInstanceRef: `service-instance:${row.id}`,
hostRef: `host:${row.host_id}`,
deploymentRef: `deployment:${row.deployment_id}`,
edgeRef: row.edge_id ? `edge:${row.edge_id}` : null,
serviceKey: row.service_key,
displayName: row.display_name,
serviceRole: row.service_role,
lifecycleState: row.lifecycle_state,
ontology: ontologyView(row),
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function healthObservationView(row) {
const subjectKind = row.host_id ? "host" : "service-instance";
return {
healthObservationRef: `health-observation:${row.id}`,
subjectKind,
subjectRef: `${subjectKind}:${row.host_id ?? row.service_instance_id}`,
observedState: row.observed_state,
evidenceClass: row.evidence_class,
sourceRef: row.source_ref,
schemaRef: row.schema_ref,
evidence: row.evidence_projection,
observedAt: toIso(row.observed_at),
expiresAt: toIso(row.expires_at),
ontology: ontologyView(row),
};
}
function ontologyView(row) {
return {
entityId: row.ontology_entity_id,
catalogHash: row.ontology_catalog_hash,
};
}
function requireRow(result, code, statusCode = 409) {
const row = result?.rows?.[0];
if (!row) throw domainError(code, statusCode);
return row;
}
function assertOntologyCommand(commandKind) {
if (!isOntologyManagementCommand(commandKind)) {
throw new TypeError("device_ontology_command_kind_invalid");
}
}
function toIso(value) {
return value == null ? null : new Date(value).toISOString();
}
function domainError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
@@ -19,6 +19,9 @@ import {
getDeviceProjectWorkspace,
listAccessibleDeviceProjects,
} from "./project-query-repository.mjs";
import {
getDeviceProjectOntologyProjection,
} from "./ontology-query-repository.mjs";
import {
applyInfrastructureManagementCommand,
authorizeInfrastructureManagementReplay,
@@ -36,6 +39,11 @@ import {
import {
isSensitiveReferenceManagementCommand,
} from "./sensitive-reference-management.mjs";
import {
applyOntologyManagementCommand,
authorizeOntologyManagementReplay,
} from "./ontology-repository.mjs";
import { isOntologyManagementCommand } from "./ontology-management.mjs";
import {
assertActorCanManageOwnerScope,
assertGrantMutationAllowed,
@@ -47,6 +55,9 @@ import {
planTypedServicePing,
recordTypedCommandStatus,
} from "./typed-command-repository.mjs";
import {
recordInfrastructureHostTelemetry,
} from "./host-telemetry-repository.mjs";
const { Pool } = pg;
const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -66,6 +77,8 @@ const migrationFiles = [
"013_device_edge_channels.sql",
"014_device_registry_profile_commands.sql",
"015_device_integration_identity.sql",
"016_device_asset_infrastructure_ontology.sql",
"017_infrastructure_host_telemetry.sql",
];
export class PostgresDeviceRepository {
@@ -181,6 +194,12 @@ export class PostgresDeviceRepository {
);
}
async getProjectOntologyProjection(actor, projectId) {
return this.#executeRead((client) =>
getDeviceProjectOntologyProjection(client, actor, projectId)
);
}
async listActiveEdgeChannelRegistrations(limit = 64) {
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
throw new TypeError("device_edge_channel_registration_limit_invalid");
@@ -223,6 +242,12 @@ export class PostgresDeviceRepository {
return this.#executeWrite((client) => recordTypedCommandStatus(client, input));
}
async recordInfrastructureHostTelemetry(input) {
return this.#executeWrite((client) =>
recordInfrastructureHostTelemetry(client, input)
);
}
async #executeWrite(operation) {
const client = await this.pool.connect();
try {
@@ -320,6 +345,13 @@ async function completeManagementReceipt(client, receiptId, result) {
}
async function applyManagementCommand(client, { commandKind, actor, command }) {
if (isOntologyManagementCommand(commandKind)) {
return applyOntologyManagementCommand(client, {
commandKind,
actor,
command,
});
}
if (isControlResourceManagementCommand(commandKind)) {
return applyControlResourceManagementCommand(client, {
commandKind,
@@ -364,6 +396,13 @@ async function applyManagementCommand(client, { commandKind, actor, command }) {
}
async function authorizeManagementReplay(client, { commandKind, actor, command }) {
if (isOntologyManagementCommand(commandKind)) {
return authorizeOntologyManagementReplay(client, {
commandKind,
actor,
command,
});
}
if (isControlResourceManagementCommand(commandKind)) {
return authorizeControlResourceManagementReplay(client, {
commandKind,
@@ -3,6 +3,7 @@ export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([
"project.manage",
"access.manage",
"inventory.read",
"asset.manage",
"device.enroll",
"device.claim",
"device.transfer",
@@ -10,6 +11,9 @@ export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([
"route.manage",
"binding.manage",
"telemetry.observe",
"observation.write",
"infrastructure.read",
"infrastructure.manage",
"configuration.read",
"configuration.manage",
"command.plan",
@@ -53,6 +57,7 @@ const roleCapabilities = Object.freeze({
viewer: Object.freeze([
"project.read",
"inventory.read",
"infrastructure.read",
"telemetry.observe",
"configuration.read",
"audit.read",
@@ -61,6 +66,8 @@ const roleCapabilities = Object.freeze({
"project.read",
"inventory.read",
"telemetry.observe",
"observation.write",
"infrastructure.read",
"configuration.read",
"command.plan",
"command.confirm",
@@ -72,10 +79,14 @@ const roleCapabilities = Object.freeze({
"inventory.read",
"device.enroll",
"device.claim",
"asset.manage",
"collection.manage",
"route.manage",
"binding.manage",
"telemetry.observe",
"observation.write",
"infrastructure.read",
"infrastructure.manage",
"configuration.read",
"configuration.manage",
"command.plan",
@@ -88,10 +99,14 @@ const roleCapabilities = Object.freeze({
"inventory.read",
"device.enroll",
"device.claim",
"asset.manage",
"collection.manage",
"route.manage",
"binding.manage",
"telemetry.observe",
"observation.write",
"infrastructure.read",
"infrastructure.manage",
"configuration.read",
"configuration.manage",
"command.plan",
@@ -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`,
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFile } from "node:fs/promises";
import {
recordInfrastructureHostTelemetry,
} from "../src/host-telemetry-repository.mjs";
const edgeId = "73da0c42-a641-4559-b8f7-23509b60bfe9";
const hostId = "adf2a5b6-3c0b-4a39-998c-07dfb7818ad1";
const serviceId = "01f14736-f5c2-4867-9cbc-2d268996a871";
const projectId = "ad7b357c-c7ac-4bf8-a638-c7f956e9aa71";
const ownerId = "78da71d5-f48f-4de0-8e47-729f6d644151";
test("records a normalized host observation only through the Edge-to-host graph", async () => {
const queries = [];
const now = new Date();
const client = {
async query(sql, parameters) {
queries.push({ sql, parameters });
if (queries.length === 1) return { rows: [{
service_instance_id: serviceId,
host_id: hostId,
project_id: projectId,
owner_scope_id: ownerId,
host_key: "robot2b-b2-edge-vps",
}] };
if (queries.length === 2) return { rows: [{
id: "7ea94f66-6eed-4a27-8f04-e67060fa7e94",
received_at: now,
expires_at: new Date(now.valueOf() + 15_000),
replayed: false,
}] };
return { rows: [] };
},
};
const result = await recordInfrastructureHostTelemetry(client, {
authenticatedEdgeRef: `edge:${edgeId}`,
snapshot: snapshot(now.toISOString()),
});
assert.equal(result.status, "recorded");
assert.equal(result.replayed, false);
assert.equal(result.hostRef, `host:${hostId}`);
assert.match(queries[0].sql, /device_infrastructure_service_instances/);
assert.equal(queries[0].parameters[0], edgeId);
assert.match(queries[1].sql, /device_infrastructure_host_telemetry_samples/);
assert.equal(queries[1].parameters[5], edgeId);
assert.equal(JSON.stringify(queries).includes("password"), false);
assert.match(queries[2].sql, /delete from device_infrastructure_host_telemetry_samples/);
});
test("rejects telemetry whose host key disagrees with the canonical graph", async () => {
const client = {
async query() {
return { rows: [{
service_instance_id: serviceId,
host_id: hostId,
project_id: projectId,
owner_scope_id: ownerId,
host_key: "canonical-host",
}] };
},
};
await assert.rejects(
() => recordInfrastructureHostTelemetry(client, {
authenticatedEdgeRef: `edge:${edgeId}`,
snapshot: snapshot(new Date().toISOString()),
}),
/device_host_telemetry_host_key_mismatch/,
);
});
test("host telemetry migration is additive, observation-backed and secret-free", async () => {
const sql = await readFile(new URL("../migrations/017_infrastructure_host_telemetry.sql", import.meta.url), "utf8");
assert.match(sql, /create table if not exists device_infrastructure_host_telemetry_samples/);
assert.match(sql, /observation\.observation/);
assert.match(sql, /references device_infrastructure_hosts/);
assert.match(sql, /references device_infrastructure_service_instances/);
assert.doesNotMatch(sql, /insert into device_infrastructure_hosts/i);
assert.doesNotMatch(sql, /password|private.key|credential/i);
});
function snapshot(observedAt) {
return {
schemaVersion: "nodedc.infrastructure.host-telemetry.v1",
profile: "linux-host-telegraf-v1",
hostKey: "robot2b-b2-edge-vps",
observedAt,
source: { agent: "telegraf", agentVersion: "1.38.4", collectorRef: "service:nodedc-host-telemetry-agent" },
hardware: { hostname: "koffyvngij", architecture: "x64", platform: "linux", kernelRelease: "6.8.0", cpuModel: "KVM CPU", logicalProcessors: 1 },
cpu: { usagePercent: 20, load1: 0.2, load5: 0.1, load15: 0.05 },
memory: { totalBytes: 1024, availableBytes: 700, freeBytes: null, usedBytes: 324, usedPercent: 31.6 },
swap: { totalBytes: 0, availableBytes: null, freeBytes: 0, usedBytes: 0, usedPercent: 0 },
system: { uptimeSeconds: 100, users: 1, processes: { total: 10, running: 1, sleeping: 9, blocked: 0, zombies: 0 } },
disks: [],
network: [],
services: [],
};
}
@@ -7,8 +7,9 @@ const replayedIntermediateConstraintMigrations = Object.freeze([
"007_device_lifecycle_commands.sql",
"009_device_sensitive_reference_commands.sql",
"011_device_control_resource_commands.sql",
"014_device_registry_profile_commands.sql",
]);
const finalCommandKindMigration = "014_device_registry_profile_commands.sql";
const finalCommandKindMigration = "016_device_asset_infrastructure_ontology.sql";
const finalCommandKinds = Object.freeze([
"owner_scope.ensure",
"project.ensure",
@@ -31,6 +32,14 @@ const finalCommandKinds = Object.freeze([
"device_binding.revoke",
"device_configuration_revision.create",
"device_configuration_desired.set",
"asset.ensure",
"asset_binding.ensure",
"asset_binding.close",
"infrastructure_host.ensure",
"infrastructure_endpoint.ensure",
"infrastructure_deployment.ensure",
"infrastructure_service_instance.ensure",
"health_observation.record",
]);
const migrationUrl = new URL(
"../migrations/003_device_management_commands.sql",
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createControlCoreApp } from "../src/app.mjs";
const managementToken = "test-only-management-token-with-32-bytes";
const identifierPepper = "test-only-identifier-pepper-with-32-bytes";
const projectId = "11111111-1111-4111-8111-111111111111";
test("management API forwards a canonical asset command", async () => {
let executed;
const runtime = await startServer({
executeManagementCommand: async (input) => {
executed = input;
return { replayed: false, result: { created: true } };
},
});
try {
const response = await fetch(`${runtime.baseUrl}/internal/v1/management/assets:ensure`, {
method: "POST",
headers: managementHeaders("ontology-asset-0001"),
body: JSON.stringify({
projectRef: `project:${projectId}`,
assetKey: "trike-001",
displayName: "Trike 001",
assetTypeRef: "asset-type:delivery-trike",
}),
});
assert.equal(response.status, 200);
assert.equal(executed.commandKind, "asset.ensure");
assert.equal(executed.command.assetKey, "trike-001");
} finally {
await runtime.close();
}
});
test("query API exposes the ontology projection through Core", async () => {
let actor;
const runtime = await startServer({
getProjectOntologyProjection: async (value, requestedProjectId) => {
actor = value;
assert.equal(requestedProjectId, projectId);
return {
ontology: { catalogHash: "229c61c02a790906" },
assets: [],
hosts: [],
};
},
});
try {
const response = await fetch(
`${runtime.baseUrl}/internal/v1/query/projects/${projectId}/ontology`,
{ headers: managementHeaders("ontology-query-0001") },
);
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.projection.ontology.catalogHash, "229c61c02a790906");
assert.equal(actor.userRef, "user:test-owner");
} finally {
await runtime.close();
}
});
async function startServer(repositoryOverrides) {
const server = createControlCoreApp({
managementApiEnabled: true,
managementToken,
identifierPepper,
repository: {
health: async () => "ready",
executeManagementCommand: async () => ({ replayed: false, result: {} }),
...repositoryOverrides,
},
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: () => new Promise((resolve, reject) =>
server.close((error) => error ? reject(error) : resolve())),
};
}
function managementHeaders(idempotencyKey) {
return {
Authorization: `Bearer ${managementToken}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
"X-NODEDC-User-Ref": "user:test-owner",
"X-NODEDC-Hub-Role": "owner",
"X-NODEDC-Group-Refs": "",
"X-NODEDC-Owner-Scopes": "company=organization:test",
};
}
@@ -0,0 +1,154 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEVICE_ONTOLOGY_CATALOG_HASH,
normalizeOntologyManagementCommand,
} from "../src/ontology-management.mjs";
import {
ALL_DEVICE_MANAGEMENT_COMMAND_KINDS,
normalizeDeviceManagementCommand,
} from "../src/management-command.mjs";
const projectRef = "project:11111111-1111-4111-8111-111111111111";
const deviceRef = "device:22222222-2222-4222-8222-222222222222";
const assetRef = "asset:33333333-3333-4333-8333-333333333333";
const hostRef = "host:44444444-4444-4444-8444-444444444444";
const deploymentRef = "deployment:55555555-5555-4555-8555-555555555555";
test("publishes the production ontology catalog contract", () => {
assert.equal(DEVICE_ONTOLOGY_CATALOG_HASH, "229c61c02a790906");
for (const kind of [
"asset.ensure",
"asset_binding.ensure",
"infrastructure_host.ensure",
"health_observation.record",
]) {
assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes(kind), true);
}
});
test("normalizes an asset and a temporal tracker binding", () => {
const asset = normalizeDeviceManagementCommand("asset.ensure", {
projectRef,
assetKey: "trike-001",
displayName: "Trike 001",
assetTypeRef: "asset-type:delivery-trike",
});
const binding = normalizeDeviceManagementCommand("asset_binding.ensure", {
projectRef,
bindingKey: "trike-001-primary-tracker",
deviceRef,
assetRef,
bindingKind: "tracking",
validFrom: "2026-08-22T10:00:00.000Z",
provenanceRef: "onboarding:direct-b2",
});
assert.equal(asset.assetKey, "trike-001");
assert.equal(binding.deviceId, deviceRef.slice("device:".length));
assert.equal(binding.assetId, assetRef.slice("asset:".length));
assert.equal(binding.bindingKind, "tracking");
});
test("normalizes provider-neutral host topology without browser credentials", () => {
const host = normalizeOntologyManagementCommand("infrastructure_host.ensure", {
projectRef,
hostKey: "b2-edge-moscow",
displayName: "B2 Edge Moscow",
providerRef: "provider:beget",
externalRef: "provider-resource:vps-123",
managementCredentialRef: "secret-ref:device-core/b2-edge-moscow",
lifecycleState: "active",
});
const deployment = normalizeOntologyManagementCommand(
"infrastructure_deployment.ensure",
{
projectRef,
hostRef,
deploymentKey: "device-edge-001",
displayName: "Device Edge 001",
artifactRef: "artifact:device-edge/1.0.0",
artifactDigest: `sha256:${"a".repeat(64)}`,
},
);
const service = normalizeOntologyManagementCommand(
"infrastructure_service_instance.ensure",
{
projectRef,
hostRef,
deploymentRef,
serviceKey: "device-edge",
displayName: "Device Edge",
serviceRole: "device.edge",
},
);
assert.equal(host.managementCredentialRef.startsWith("secret-ref:"), true);
assert.equal(deployment.hostId, hostRef.slice("host:".length));
assert.equal(service.serviceRole, "device.edge");
assert.equal("password" in host, false);
});
test("rejects credential-bearing endpoints and secret-shaped health evidence", () => {
assert.throws(
() => normalizeOntologyManagementCommand("infrastructure_endpoint.ensure", {
projectRef,
hostRef,
endpointKey: "ssh",
purpose: "management",
endpointUri: "ssh://root:password@example.test:22/",
}),
/device_endpoint_uri_invalid/,
);
assert.throws(
() => normalizeOntologyManagementCommand("health_observation.record", {
projectRef,
subjectKind: "host",
subjectRef: hostRef,
observedState: "reachable",
evidenceClass: "management_probe",
sourceRef: "probe:device-core",
schemaRef: "schema:health.v1",
evidence: { token: "forbidden" },
observedAt: "2026-08-22T10:00:00.000Z",
expiresAt: "2026-08-22T10:01:00.000Z",
}),
/forbidden_device_field/,
);
});
test("health is a bounded observation and not a permanent online flag", () => {
const command = normalizeOntologyManagementCommand(
"health_observation.record",
{
projectRef,
subjectKind: "host",
subjectRef: hostRef,
observedState: "reachable",
evidenceClass: "management_probe",
sourceRef: "probe:device-core",
schemaRef: "schema:health.v1",
evidence: { latencyMs: 42 },
observedAt: "2026-08-22T10:00:00.000Z",
expiresAt: "2026-08-22T10:01:00.000Z",
},
);
assert.equal(command.observedState, "reachable");
assert.equal(command.expiresAt, "2026-08-22T10:01:00.000Z");
assert.throws(
() => normalizeOntologyManagementCommand("health_observation.record", {
projectRef,
subjectKind: "host",
subjectRef: hostRef,
observedState: "reachable",
evidenceClass: "management_probe",
sourceRef: "probe:device-core",
schemaRef: "schema:health.v1",
evidence: {},
observedAt: command.observedAt,
expiresAt: command.observedAt,
}),
/device_health_freshness_window_invalid/,
);
});
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
const migrationUrl = new URL(
"../migrations/016_device_asset_infrastructure_ontology.sql",
import.meta.url,
);
const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url);
test("ontology migration is additive and encodes the official entity identifiers", async () => {
const sql = await readFile(migrationUrl, "utf8");
for (const table of [
"device_assets",
"device_asset_bindings",
"device_infrastructure_hosts",
"device_infrastructure_deployments",
"device_infrastructure_service_instances",
"device_health_observations",
]) {
assert.match(sql, new RegExp(`create table if not exists ${table}`, "i"));
}
for (const entityId of [
"asset.asset",
"device.asset_binding",
"infrastructure.host",
"infrastructure.deployment",
"infrastructure.service_instance",
"observation.health_observation",
]) {
assert.equal(sql.includes(`'${entityId}'`), true);
}
assert.equal(sql.includes("229c61c02a790906"), true);
for (const commandKind of [
"asset.ensure",
"asset_binding.ensure",
"asset_binding.close",
"infrastructure_host.ensure",
"infrastructure_endpoint.ensure",
"infrastructure_deployment.ensure",
"infrastructure_service_instance.ensure",
"health_observation.record",
]) {
assert.equal(sql.includes(`'${commandKind}'`), true);
}
assert.doesNotMatch(sql, /insert\s+into\s+device_(?:assets|infrastructure_hosts)/i);
assert.doesNotMatch(sql, /gelios|arusnavi|beget|hetzner|aws/i);
});
test("ontology migration follows integration identity", async () => {
const source = await readFile(repositoryUrl, "utf8");
assert.ok(
source.indexOf("015_device_integration_identity.sql")
< source.indexOf("016_device_asset_infrastructure_ontology.sql"),
);
});
@@ -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,
}] };
@@ -14,6 +14,9 @@ import {
normalizeAdapterMessage,
normalizeDiscoverySignal,
} from "../../../packages/device-protocol-contract/src/index.mjs";
import {
normalizeHostTelemetrySnapshot,
} from "../../../packages/infrastructure-telemetry-contract/src/index.mjs";
const CHANNEL_TRACKER_SESSION_ID = "channel:control";
const CHANNEL_PROFILE_REF = "channel.control.v1";
@@ -195,6 +198,20 @@ export function createDeviceEdgeChannelServer(options = {}) {
}
return Object.freeze({ status: "recorded" });
},
async submitHostTelemetry(snapshot) {
const normalized = normalizeHostTelemetrySnapshot(snapshot);
const result = await submitEvent("host.telemetry.observed", {
snapshot: normalized,
}, {
trackerSessionId: `host-telemetry:${normalized.hostKey}`,
adapterProfileRef: normalized.profile,
eventAt: normalized.observedAt,
});
if (result?.status !== "recorded") {
throw new Error("device_edge_channel_host_telemetry_invalid");
}
return Object.freeze({ status: "recorded" });
},
status() {
return Object.freeze({
listening: started,
@@ -68,6 +68,33 @@ test("accepts synthetic discovery and durable message results over Core-initiate
}
});
test("delivers a normalized host observation over the existing pinned mTLS channel", async () => {
const recorded = [];
const pair = await startPair({
recordHostTelemetry: async (snapshot, context) => {
recorded.push({ snapshot, context });
return { status: "recorded" };
},
});
try {
const observation = hostTelemetrySnapshot();
assert.deepEqual(await pair.edge.submitHostTelemetry(observation), {
status: "recorded",
});
assert.equal(recorded.length, 1);
assert.equal(recorded[0].snapshot.hostKey, "robot2b-b2-edge-vps");
assert.equal(
recorded[0].context.authenticatedEdgeRef,
"edge:pilot-1",
);
assert.equal(pair.edge.status().eventsAccepted, 1);
assert.equal(pair.core.status().eventsAccepted, 1);
} finally {
await stopPair(pair);
}
});
test("recovers the claimed device from telemetry after a Core restart and completes a typed command", async () => {
const commandRef = "command:11111111-1111-4111-8111-111111111111";
const deviceRef = "device:22222222-2222-4222-8222-222222222222";
@@ -547,9 +574,61 @@ function createCoreClient(options) {
commandTransport: options.commandTransport,
offerCommand: options.offerCommand,
recordCommandStatus: options.recordCommandStatus,
recordHostTelemetry: options.recordHostTelemetry,
});
}
function hostTelemetrySnapshot() {
return {
schemaVersion: "nodedc.infrastructure.host-telemetry.v1",
profile: "linux-host-telegraf-v1",
hostKey: "robot2b-b2-edge-vps",
observedAt: new Date().toISOString(),
source: {
agent: "telegraf",
agentVersion: "1.38.4",
collectorRef: "service:nodedc-host-telemetry-agent",
},
hardware: {
hostname: "koffyvngij",
architecture: "x64",
platform: "linux",
kernelRelease: "6.8.0",
cpuModel: "KVM CPU",
logicalProcessors: 1,
},
cpu: { usagePercent: 20, load1: 0.2, load5: 0.1, load15: 0.05 },
memory: {
totalBytes: 1024,
availableBytes: 700,
freeBytes: null,
usedBytes: 324,
usedPercent: 31.6,
},
swap: {
totalBytes: 0,
availableBytes: null,
freeBytes: 0,
usedBytes: 0,
usedPercent: 0,
},
system: {
uptimeSeconds: 100,
users: 1,
processes: {
total: 10,
running: 1,
sleeping: 9,
blocked: 0,
zombies: 0,
},
},
disks: [],
network: [],
services: [],
};
}
function productionDiscoveryObserver() {
return createDeviceGatewayIngest({
identifierPepper: "test-only-device-edge-channel-identifier-pepper",
@@ -0,0 +1,52 @@
[agent]
interval = "2s"
round_interval = true
metric_batch_size = 1000
metric_buffer_limit = 10000
collection_jitter = "0s"
flush_interval = "2s"
flush_jitter = "0s"
precision = "1s"
debug = false
quiet = false
hostname = "koffyvngij"
omit_hostname = false
[global_tags]
nodedc_host_key = "robot2b-b2-edge-vps"
nodedc_profile = "linux-host-telegraf-v1"
[[inputs.cpu]]
percpu = false
totalcpu = true
collect_cpu_time = false
report_active = true
[[inputs.mem]]
[[inputs.swap]]
[[inputs.system]]
[[inputs.processes]]
[[inputs.disk]]
ignore_fs = ["tmpfs", "devtmpfs", "devfs", "iso9660", "overlay", "aufs", "squashfs", "nsfs"]
[[inputs.diskio]]
[[inputs.net]]
interfaces = ["*"]
ignore_protocol_stats = true
[[inputs.systemd_units]]
pattern = "nodedc-*.service ssh.service systemd-networkd.service"
details = true
[[outputs.http]]
url = "http://127.0.0.1:18223/internal/v1/host-telemetry"
method = "POST"
timeout = "10s"
data_format = "json"
use_batch_format = true
content_encoding = "identity"
+16 -2
View File
@@ -13,6 +13,7 @@ import {
import {
createDeviceGatewayRuntime,
} from "../../services/device-gateway/src/runtime.mjs";
import { createHostTelemetryCollector } from "./host-telemetry-runtime.mjs";
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
@@ -51,16 +52,26 @@ export async function main(environment = process.env) {
onMessage: (message) => channel.submitAdapterMessage(message),
onCommandStatus: (status) => channel.submitCommandStatus(status),
});
const health = createCombinedHealthServer(channel, gateway, base.health);
const telemetry = createHostTelemetryCollector({
submit: (snapshot) => channel.submitHostTelemetry(snapshot),
hostKey: environment.NODEDC_INFRASTRUCTURE_HOST_KEY
?? "robot2b-b2-edge-vps",
host: environment.NODEDC_HOST_TELEMETRY_HOST ?? "127.0.0.1",
port: environment.NODEDC_HOST_TELEMETRY_PORT ?? 18223,
agentVersion: environment.NODEDC_HOST_TELEMETRY_AGENT_VERSION ?? "1.38.4",
});
const health = createCombinedHealthServer(channel, gateway, telemetry, base.health);
let stopping = false;
try {
await channel.start();
await gateway.start();
await telemetry.start();
await listen(health, base.health.port, base.health.host);
} catch (error) {
await Promise.allSettled([
gateway.stop(),
telemetry.stop(),
channel.stop(),
closeServer(health),
]);
@@ -72,6 +83,7 @@ export async function main(environment = process.env) {
channel: `${base.channel.host}:${base.channel.port}`,
health: `${base.health.host}:${base.health.port}`,
trackerIngress: `${tracker.tcpHost}:${tracker.tcpPort}`,
hostTelemetry: `${telemetry.status().host}:${telemetry.status().port}`,
adapterProfile: tracker.protocolProfileRef,
edgeRegistrationId: base.channel.edgeRegistrationId,
channelGeneration: base.channel.channelGeneration,
@@ -87,6 +99,7 @@ export async function main(environment = process.env) {
stopping = true;
await Promise.allSettled([
gateway.stop(),
telemetry.stop(),
channel.stop(),
closeServer(health),
]);
@@ -161,7 +174,7 @@ export function normalizeTrackerIngressConfiguration(environment = {}, edgeRef)
});
}
function createCombinedHealthServer(channel, gateway, healthConfig) {
function createCombinedHealthServer(channel, gateway, telemetry, healthConfig) {
return createServer((request, response) => {
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
@@ -179,6 +192,7 @@ function createCombinedHealthServer(channel, gateway, healthConfig) {
...channel.status(),
trackerIngress: "telemetry-ingest",
tracker: gateway.status(),
hostTelemetry: telemetry.status(),
commandTransport: "typed-service-ping-v1",
}));
});
+161
View File
@@ -0,0 +1,161 @@
import { createServer } from "node:http";
import { arch, cpus, hostname, platform, release } from "node:os";
import {
telegrafBatchToHostTelemetry,
} from "../../packages/infrastructure-telemetry-contract/src/index.mjs";
const MAX_BODY_BYTES = 512 * 1024;
export function createHostTelemetryCollector(options = {}) {
const config = normalizeConfig(options);
let accepted = 0;
let rejected = 0;
let lastObservedAt = null;
let lastAcceptedAt = null;
let lastErrorCode = null;
let pending = false;
const server = createServer(async (request, response) => {
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
if (request.method !== "POST" || request.url !== "/internal/v1/host-telemetry") {
response.statusCode = 404;
response.end(JSON.stringify({ ok: false, error: "not_found" }));
return;
}
if (pending) {
rejected += 1;
response.statusCode = 429;
response.end(JSON.stringify({ ok: false, error: "host_telemetry_busy" }));
return;
}
pending = true;
try {
const body = await readJsonBody(request, MAX_BODY_BYTES);
const processors = cpus();
const snapshot = telegrafBatchToHostTelemetry(body, {
hostKey: config.hostKey,
hostname: hostname(),
architecture: arch(),
platform: platform(),
kernelRelease: release(),
cpuModel: processors[0]?.model ?? null,
logicalProcessors: processors.length || null,
agentVersion: config.agentVersion,
});
lastObservedAt = snapshot.observedAt;
await config.submit(snapshot);
accepted += 1;
lastAcceptedAt = new Date().toISOString();
lastErrorCode = null;
response.statusCode = 202;
response.end(JSON.stringify({ ok: true, accepted: true }));
} catch (error) {
rejected += 1;
lastErrorCode = safeErrorCode(error);
response.statusCode = lastErrorCode.includes("too_large") ? 413 : 503;
response.end(JSON.stringify({ ok: false, error: lastErrorCode }));
} finally {
pending = false;
}
});
return Object.freeze({
async start() {
await listen(server, config.port, config.host);
return server.address();
},
async stop() {
await closeServer(server);
},
status() {
return Object.freeze({
listening: server.listening,
host: config.host,
port: config.port,
profile: "linux-host-telegraf-v1",
accepted,
rejected,
pending,
lastObservedAt,
lastAcceptedAt,
lastErrorCode,
});
},
});
}
function normalizeConfig(options) {
if (typeof options.submit !== "function") {
throw new TypeError("host_telemetry_submit_required");
}
return Object.freeze({
submit: options.submit,
hostKey: normalizeRef(options.hostKey, "host_telemetry_host_key_invalid"),
agentVersion: String(options.agentVersion ?? "1.38.4"),
host: normalizeLoopbackHost(options.host ?? "127.0.0.1"),
port: normalizePort(options.port ?? 18223),
});
}
async function readJsonBody(request, maximumBytes) {
const chunks = [];
let total = 0;
for await (const chunk of request) {
total += chunk.length;
if (total > maximumBytes) throw new Error("host_telemetry_body_too_large");
chunks.push(chunk);
}
if (total < 2) throw new Error("host_telemetry_body_invalid");
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw new Error("host_telemetry_body_invalid");
}
}
function normalizeRef(value, errorCode) {
if (typeof value !== "string" || !/^[a-z][a-z0-9-]{1,62}$/.test(value)) {
throw new TypeError(errorCode);
}
return value;
}
function normalizeLoopbackHost(value) {
if (!["127.0.0.1", "::1"].includes(value)) {
throw new TypeError("host_telemetry_host_invalid");
}
return value;
}
function normalizePort(value) {
const normalized = Number(value);
if (!Number.isSafeInteger(normalized) || normalized < 0 || normalized > 65_535) {
throw new TypeError("host_telemetry_port_invalid");
}
return normalized;
}
function safeErrorCode(error) {
const value = String(error?.message || "host_telemetry_internal_error");
return /^[a-z0-9_.:-]{3,160}$/.test(value)
? value
: "host_telemetry_internal_error";
}
function listen(server, port, host) {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
resolve();
});
});
}
function closeServer(server) {
if (!server.listening) return Promise.resolve();
return new Promise((resolve) => server.close(() => resolve()));
}
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createHostTelemetryCollector } from "./host-telemetry-runtime.mjs";
test("accepts Telegraf only on the bounded local collector", async () => {
let submitted = null;
const collector = createHostTelemetryCollector({
hostKey: "robot2b-b2-edge-vps",
host: "127.0.0.1",
port: 0,
submit: async (value) => { submitted = value; },
});
const address = await collector.start();
try {
const response = await fetch(`http://127.0.0.1:${address.port}/internal/v1/host-telemetry`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ metrics: [
{ name: "cpu", tags: { cpu: "cpu-total", host: "edge" }, fields: { usage_active: 9 }, timestamp: Math.floor(Date.now() / 1000) },
] }),
});
assert.equal(response.status, 202);
assert.equal(submitted.hostKey, "robot2b-b2-edge-vps");
assert.equal(collector.status().accepted, 1);
} finally {
await collector.stop();
}
});
test("rejects non-loopback collector binds", () => {
assert.throws(() => createHostTelemetryCollector({
hostKey: "host-01",
host: "0.0.0.0",
submit: async () => {},
}), /host_telemetry_host_invalid/);
});
@@ -28,6 +28,10 @@ Environment=DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS=16
Environment=DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS=60
Environment=DEVICE_GATEWAY_MAX_TRACKED_SOURCE_ADDRESSES=2048
Environment=DEVICE_GATEWAY_SESSION_TIMEOUT_MS=10000
Environment=NODEDC_INFRASTRUCTURE_HOST_KEY=robot2b-b2-edge-vps
Environment=NODEDC_HOST_TELEMETRY_HOST=127.0.0.1
Environment=NODEDC_HOST_TELEMETRY_PORT=18223
Environment=NODEDC_HOST_TELEMETRY_AGENT_VERSION=1.38.4
Restart=always
RestartSec=2
TimeoutStartSec=20
@@ -0,0 +1,47 @@
[Unit]
Description=NODE.DC infrastructure host telemetry agent
Documentation=https://docs.influxdata.com/telegraf/v1/
After=network-online.target nodedc-device-edge-channel.service
Wants=network-online.target
Requires=nodedc-device-edge-channel.service
[Service]
Type=notify
NotifyAccess=all
User=nodedc-telemetry
Group=nodedc-telemetry
ExecStart=/opt/nodedc-b2-vps/runtime/telegraf/usr/bin/telegraf --config /opt/nodedc-b2-vps/vps/config/nodedc-host-telemetry-telegraf.conf
Restart=always
RestartSec=3
TimeoutStartSec=30
TimeoutStopSec=15
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=no
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET
IPAddressDeny=any
IPAddressAllow=localhost
CapabilityBoundingSet=
AmbientCapabilities=
UMask=0077
MemoryMax=96M
MemorySwapMax=0
CPUQuota=15%
TasksMax=64
LimitNOFILE=512
[Install]
WantedBy=multi-user.target