Compare commits
14
Commits
07ffb22d38
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebc264eb60 | ||
|
|
5d58dd6c37 | ||
|
|
11ddacd9cd | ||
|
|
7d03e87954 | ||
|
|
71da1dd42a | ||
|
|
79140ee7bf | ||
|
|
65c99613e5 | ||
|
|
6dad30c565 | ||
|
|
d12b172569 | ||
|
|
23ea96fbb2 | ||
|
|
a3e7a015de | ||
|
|
088686d67f | ||
|
|
b302b6ba1a | ||
|
|
6ed4414a97 |
@@ -66,4 +66,8 @@ sudo /usr/local/sbin/nodedc-deploy apply /volume1/docker/nodedc-deploy/inbox/<ar
|
|||||||
|
|
||||||
See [Repository boundary](docs/REPOSITORY_BOUNDARY.md) and
|
See [Repository boundary](docs/REPOSITORY_BOUNDARY.md) and
|
||||||
[Implementation baseline](docs/IMPLEMENTATION_BASELINE.md) for the security,
|
[Implementation baseline](docs/IMPLEMENTATION_BASELINE.md) for the security,
|
||||||
runtime and rollout constraints.
|
runtime and rollout constraints. The provider-neutral VPS/host expansion is
|
||||||
|
tracked as an explicit
|
||||||
|
[ontology candidate](docs/DEVICE_INFRASTRUCTURE_HOST_ONTOLOGY_CANDIDATE.md)
|
||||||
|
until the official ontology owner publishes canonical host, deployment,
|
||||||
|
service and health concepts.
|
||||||
|
|||||||
@@ -20,8 +20,17 @@ behavior; projects, inventory, collections and access remain shared Device Core
|
|||||||
|
|
||||||
The project workspace covers inventory, discovery, collections, adapter/profile metadata,
|
The project workspace covers inventory, discovery, collections, adapter/profile metadata,
|
||||||
Edges, routes, sessions, bindings, configuration state, the honest command ledger, immutable
|
Edges, routes, sessions, bindings, configuration state, the honest command ledger, immutable
|
||||||
audit metadata and project grants. Navigation and actions are derived from effective project
|
audit metadata and project grants. It also joins the official ontology projection for stable
|
||||||
capabilities. Global adapter/profile/Edge mutation is additionally restricted to a Hub owner.
|
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.
|
Command planning and transport intentionally have no Device Manager mutation route yet.
|
||||||
The UI never presents `sent` as success: `acknowledged` and `verified` remain different
|
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"],
|
["enrollment-intents:ensure", "/internal/v1/management/enrollment-intents:ensure"],
|
||||||
["devices:claim", "/internal/v1/management/devices:claim"],
|
["devices:claim", "/internal/v1/management/devices:claim"],
|
||||||
["devices:update", "/internal/v1/management/devices:update"],
|
["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:ensure", "/internal/v1/management/device-bindings:ensure"],
|
||||||
["device-bindings:revoke", "/internal/v1/management/device-bindings:revoke"],
|
["device-bindings:revoke", "/internal/v1/management/device-bindings:revoke"],
|
||||||
[
|
[
|
||||||
@@ -61,8 +69,13 @@ export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = {
|
|||||||
},
|
},
|
||||||
async getWorkspace(actor, projectRef) {
|
async getWorkspace(actor, projectRef) {
|
||||||
const projectId = entityId(projectRef, "project");
|
const projectId = entityId(projectRef, "project");
|
||||||
return request(`/internal/v1/query/projects/${projectId}/workspace`, actor)
|
const [workspace, ontology] = await Promise.all([
|
||||||
.then((body) => body.workspace);
|
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) {
|
async execute(command, actor, input, idempotencyKey) {
|
||||||
const pathname = commandRoutes.get(command);
|
const pathname = commandRoutes.get(command);
|
||||||
@@ -100,6 +113,13 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
|
|||||||
const configurationStates = new Map();
|
const configurationStates = new Map();
|
||||||
const auditEvents = [];
|
const auditEvents = [];
|
||||||
const commands = new Map();
|
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() {
|
function now() {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
@@ -166,6 +186,56 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
|
|||||||
identifierProjection: fixture === "arusnavi-b2" ? "authorized-full" : "masked-only",
|
identifierProjection: fixture === "arusnavi-b2" ? "authorized-full" : "masked-only",
|
||||||
auditPayloadProjection: "metadata-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,
|
routes,
|
||||||
sessions,
|
sessions,
|
||||||
configurationStates,
|
configurationStates,
|
||||||
|
hosts,
|
||||||
|
deployments,
|
||||||
|
serviceInstances,
|
||||||
|
healthObservations,
|
||||||
});
|
});
|
||||||
else if (fixture != null && fixture !== "") {
|
else if (fixture != null && fixture !== "") {
|
||||||
throw serviceError("device_manager_preview_fixture_invalid", 400);
|
throw serviceError("device_manager_preview_fixture_invalid", 400);
|
||||||
@@ -439,6 +513,12 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
|
|||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
deploymentRef: input.deploymentRef ?? null,
|
deploymentRef: input.deploymentRef ?? null,
|
||||||
lifecycleState: input.lifecycleState ?? "provisioning",
|
lifecycleState: input.lifecycleState ?? "provisioning",
|
||||||
|
channel: existing?.channel ?? {
|
||||||
|
lifecycleState: "disabled",
|
||||||
|
generationRef: null,
|
||||||
|
runtimeState: "disabled",
|
||||||
|
lastErrorCode: null,
|
||||||
|
},
|
||||||
createdAt: existing?.createdAt || now(),
|
createdAt: existing?.createdAt || now(),
|
||||||
updatedAt: now(),
|
updatedAt: now(),
|
||||||
};
|
};
|
||||||
@@ -602,6 +682,167 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
|
|||||||
if (command === "devices:claim") {
|
if (command === "devices:claim") {
|
||||||
throw serviceError("device_discovery_not_found", 404);
|
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") {
|
if (command === "devices:update") {
|
||||||
const device = devices.get(input.deviceRef);
|
const device = devices.get(input.deviceRef);
|
||||||
if (!device || device.projectRef !== input.projectRef) {
|
if (!device || device.projectRef !== input.projectRef) {
|
||||||
@@ -652,6 +893,92 @@ function createdEvent(existing, resource) {
|
|||||||
return `${resource}.${existing ? "updated" : "created"}`;
|
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) {
|
function requirePlatformOwner(actor) {
|
||||||
if (actor?.hubRole !== "owner") {
|
if (actor?.hubRole !== "owner") {
|
||||||
throw serviceError("device_platform_catalog_access_denied", 403);
|
throw serviceError("device_platform_catalog_access_denied", 403);
|
||||||
@@ -667,6 +994,10 @@ function seedArusnaviB2Preview({
|
|||||||
routes,
|
routes,
|
||||||
sessions,
|
sessions,
|
||||||
configurationStates,
|
configurationStates,
|
||||||
|
hosts,
|
||||||
|
deployments,
|
||||||
|
serviceInstances,
|
||||||
|
healthObservations,
|
||||||
}) {
|
}) {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const ownerScopeRef = "owner-scope:78da71d5-f48f-4de0-8e47-729f6d644151";
|
const ownerScopeRef = "owner-scope:78da71d5-f48f-4de0-8e47-729f6d644151";
|
||||||
@@ -675,6 +1006,9 @@ function seedArusnaviB2Preview({
|
|||||||
const edgeRef = "edge:73da0c42-a641-4559-b8f7-23509b60bfe9";
|
const edgeRef = "edge:73da0c42-a641-4559-b8f7-23509b60bfe9";
|
||||||
const routeRef = "route:fef9b7a0-a462-4d68-9991-af026203368b";
|
const routeRef = "route:fef9b7a0-a462-4d68-9991-af026203368b";
|
||||||
const sessionRef = "session:57ead610-47de-45f7-a42d-fbe4fa0aba38";
|
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 = {
|
const scope = {
|
||||||
ownerScopeRef,
|
ownerScopeRef,
|
||||||
scopeKind: "personal",
|
scopeKind: "personal",
|
||||||
@@ -716,9 +1050,61 @@ function seedArusnaviB2Preview({
|
|||||||
displayName: "Preview VPS edge",
|
displayName: "Preview VPS edge",
|
||||||
deploymentRef: "deployment:preview",
|
deploymentRef: "deployment:preview",
|
||||||
lifecycleState: "active",
|
lifecycleState: "active",
|
||||||
|
channel: {
|
||||||
|
lifecycleState: "active",
|
||||||
|
generationRef: "channel-generation:preview",
|
||||||
|
runtimeState: "accepted",
|
||||||
|
lastErrorCode: null,
|
||||||
|
},
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
});
|
});
|
||||||
|
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, {
|
routes.set(routeRef, {
|
||||||
routeRef,
|
routeRef,
|
||||||
projectRef,
|
projectRef,
|
||||||
@@ -857,6 +1243,7 @@ const ownerCapabilities = Object.freeze([
|
|||||||
"project.manage",
|
"project.manage",
|
||||||
"access.manage",
|
"access.manage",
|
||||||
"inventory.read",
|
"inventory.read",
|
||||||
|
"asset.manage",
|
||||||
"device.enroll",
|
"device.enroll",
|
||||||
"device.claim",
|
"device.claim",
|
||||||
"device.transfer",
|
"device.transfer",
|
||||||
@@ -864,6 +1251,9 @@ const ownerCapabilities = Object.freeze([
|
|||||||
"route.manage",
|
"route.manage",
|
||||||
"binding.manage",
|
"binding.manage",
|
||||||
"telemetry.observe",
|
"telemetry.observe",
|
||||||
|
"observation.write",
|
||||||
|
"infrastructure.read",
|
||||||
|
"infrastructure.manage",
|
||||||
"configuration.read",
|
"configuration.read",
|
||||||
"configuration.manage",
|
"configuration.manage",
|
||||||
"command.plan",
|
"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 () => {
|
test("local preview is empty and creates resources only through canonical commands", async () => {
|
||||||
const client = createLocalPreviewDeviceCore();
|
const client = createLocalPreviewDeviceCore();
|
||||||
assert.deepEqual(await client.listProjects(actor), []);
|
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);
|
assert.equal(created.result.created, true);
|
||||||
const projectRef = created.result.project.projectRef;
|
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, {
|
await client.execute("collections:ensure", actor, {
|
||||||
projectRef,
|
projectRef,
|
||||||
collectionKey: "field-devices",
|
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.equal(workspace.grants.length, 2);
|
||||||
assert.ok(workspace.auditEvents.some((event) => event.eventType === "device_binding.created"));
|
assert.ok(workspace.auditEvents.some((event) => event.eventType === "device_binding.created"));
|
||||||
assert.equal(workspace.policies.commandTransport, "disabled");
|
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, []);
|
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"'));
|
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", () => {
|
test("legacy single teaser migrates into the environment media playlist", () => {
|
||||||
const environment = normalizeEnvironmentPresentation({
|
const environment = normalizeEnvironmentPresentation({
|
||||||
defaultTeaser: {
|
defaultTeaser: {
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ const mutationRoutes = new Map([
|
|||||||
["/api/device-manager/enrollment-intents:ensure", "enrollment-intents:ensure"],
|
["/api/device-manager/enrollment-intents:ensure", "enrollment-intents:ensure"],
|
||||||
["/api/device-manager/devices:claim", "devices:claim"],
|
["/api/device-manager/devices:claim", "devices:claim"],
|
||||||
["/api/device-manager/devices:update", "devices:update"],
|
["/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:ensure", "device-bindings:ensure"],
|
||||||
["/api/device-manager/device-bindings:revoke", "device-bindings:revoke"],
|
["/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",
|
deploymentRef: "deployment:preview-edge",
|
||||||
lifecycleState: "provisioning",
|
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`, {
|
await postJson(`${baseUrl}/api/device-manager/project-grants:upsert`, {
|
||||||
projectRef,
|
projectRef,
|
||||||
principalKind: "group",
|
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.edges[0].edgeKey, "preview-edge");
|
||||||
assert.equal(workspace.workspace.grants.length, 2);
|
assert.equal(workspace.workspace.grants.length, 2);
|
||||||
assert.equal(workspace.workspace.policies.commandTransport, "disabled");
|
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, []);
|
assert.deepEqual(workspace.workspace.devices, []);
|
||||||
|
|
||||||
const missingKey = await fetch(`${baseUrl}/api/device-manager/projects:ensure`, {
|
const missingKey = await fetch(`${baseUrl}/api/device-manager/projects:ensure`, {
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
Button,
|
Button,
|
||||||
GlassSurface,
|
GlassSurface,
|
||||||
Icon,
|
Icon,
|
||||||
|
IconButton,
|
||||||
Select,
|
Select,
|
||||||
SettingsCard,
|
SettingsCard,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@@ -14,12 +15,20 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createConfigurationRevision,
|
createConfigurationRevision,
|
||||||
|
closeAssetBinding,
|
||||||
|
ensureAsset,
|
||||||
|
ensureAssetBinding,
|
||||||
ensureAdapterPackage,
|
ensureAdapterPackage,
|
||||||
ensureDeviceBinding,
|
ensureDeviceBinding,
|
||||||
ensureEdge,
|
ensureEdge,
|
||||||
|
ensureInfrastructureDeployment,
|
||||||
|
ensureInfrastructureEndpoint,
|
||||||
|
ensureInfrastructureHost,
|
||||||
|
ensureInfrastructureServiceInstance,
|
||||||
ensureRoute,
|
ensureRoute,
|
||||||
registerAdapterVersion,
|
registerAdapterVersion,
|
||||||
registerModelProfile,
|
registerModelProfile,
|
||||||
|
recordHealthObservation,
|
||||||
revokeDeviceBinding,
|
revokeDeviceBinding,
|
||||||
sendServicePing,
|
sendServicePing,
|
||||||
setDesiredConfiguration,
|
setDesiredConfiguration,
|
||||||
@@ -28,9 +37,12 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
AdapterPackageView,
|
AdapterPackageView,
|
||||||
AdapterVersionView,
|
AdapterVersionView,
|
||||||
|
AssetBindingView,
|
||||||
BindingView,
|
BindingView,
|
||||||
DeviceManagerSession,
|
DeviceManagerSession,
|
||||||
EdgeView,
|
EdgeView,
|
||||||
|
InfrastructureHostView,
|
||||||
|
InfrastructureServiceInstanceView,
|
||||||
ModelProfileView,
|
ModelProfileView,
|
||||||
ProjectWorkspace,
|
ProjectWorkspace,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -38,6 +50,7 @@ import type {
|
|||||||
export type ControlViewId =
|
export type ControlViewId =
|
||||||
| "catalog"
|
| "catalog"
|
||||||
| "infrastructure"
|
| "infrastructure"
|
||||||
|
| "hosts"
|
||||||
| "sessions"
|
| "sessions"
|
||||||
| "bindings"
|
| "bindings"
|
||||||
| "commands"
|
| "commands"
|
||||||
@@ -54,6 +67,13 @@ type DialogId =
|
|||||||
| "binding"
|
| "binding"
|
||||||
| "grant"
|
| "grant"
|
||||||
| "configuration"
|
| "configuration"
|
||||||
|
| "asset"
|
||||||
|
| "asset-binding"
|
||||||
|
| "host"
|
||||||
|
| "endpoint"
|
||||||
|
| "deployment"
|
||||||
|
| "service-instance"
|
||||||
|
| "health-observation"
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
export function DeviceControlView({
|
export function DeviceControlView({
|
||||||
@@ -61,12 +81,14 @@ export function DeviceControlView({
|
|||||||
workspace,
|
workspace,
|
||||||
session,
|
session,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
onPoll,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
view: ControlViewId;
|
view: ControlViewId;
|
||||||
workspace: ProjectWorkspace;
|
workspace: ProjectWorkspace;
|
||||||
session: DeviceManagerSession;
|
session: DeviceManagerSession;
|
||||||
onRefresh: () => Promise<void>;
|
onRefresh: () => Promise<void>;
|
||||||
|
onPoll: () => Promise<void>;
|
||||||
onError: (reason: unknown) => void;
|
onError: (reason: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
const [dialog, setDialog] = useState<DialogId>(null);
|
const [dialog, setDialog] = useState<DialogId>(null);
|
||||||
@@ -145,6 +167,28 @@ export function DeviceControlView({
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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 === "sessions" ? <SessionsView workspace={workspace} /> : null}
|
||||||
{view === "bindings" ? (
|
{view === "bindings" ? (
|
||||||
<BindingsView
|
<BindingsView
|
||||||
@@ -236,6 +280,55 @@ export function DeviceControlView({
|
|||||||
onCreated={completed}
|
onCreated={completed}
|
||||||
onError={onError}
|
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 }) {
|
function SessionsView({ workspace }: { workspace: ProjectWorkspace }) {
|
||||||
return (
|
return (
|
||||||
<ControlStack>
|
<ControlStack>
|
||||||
@@ -701,6 +1384,226 @@ function ModelProfileDialog({ versions, ...props }: DialogBaseProps & { versions
|
|||||||
</FormWindow>;
|
</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) {
|
function EdgeDialog(props: DialogBaseProps) {
|
||||||
const [edgeKey, setEdgeKey] = useState("");
|
const [edgeKey, setEdgeKey] = useState("");
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
@@ -931,9 +1834,9 @@ function commaList(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusTone(status: string): "neutral" | "success" | "warning" | "danger" {
|
function statusTone(status: string): "neutral" | "success" | "warning" | "danger" {
|
||||||
if (["active", "online", "verified", "applied", "recorded", "immutable"].includes(status)) return "success";
|
if (["active", "accepted", "online", "verified", "applied", "recorded", "immutable"].includes(status)) return "success";
|
||||||
if (["failed", "rejected", "revoked", "retired"].includes(status)) return "danger";
|
if (["absent", "failed", "rejected", "revoked", "retired"].includes(status)) return "danger";
|
||||||
if (["draft", "provisioning", "pending", "pending_external_approval", "unknown", "disabled"].includes(status)) return "warning";
|
if (["connecting", "draft", "provisioning", "pending", "pending_external_approval", "unknown", "unobserved", "disabled"].includes(status)) return "warning";
|
||||||
return "neutral";
|
return "neutral";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ const sectionNavigation: Record<PrimarySection, NavigationItem[]> = {
|
|||||||
{ id: "collections", label: "Коллекции", icon: "folder", capability: "inventory.read" },
|
{ id: "collections", label: "Коллекции", icon: "folder", capability: "inventory.read" },
|
||||||
],
|
],
|
||||||
infrastructure: [
|
infrastructure: [
|
||||||
|
{ id: "hosts", label: "VPS и хосты", icon: "building", capability: "telemetry.observe" },
|
||||||
{ id: "infrastructure", label: "Edges и маршруты", icon: "globe", capability: "telemetry.observe" },
|
{ id: "infrastructure", label: "Edges и маршруты", icon: "globe", capability: "telemetry.observe" },
|
||||||
{ id: "catalog", label: "Модели и адаптеры", icon: "database", capability: "project.read" },
|
{ id: "catalog", label: "Модели и адаптеры", icon: "database", capability: "project.read" },
|
||||||
],
|
],
|
||||||
@@ -799,12 +800,13 @@ function ProjectView({ view, workspace, canManageCollections, canClaim, canConfi
|
|||||||
onInventoryDetailChange: (detail: DeviceInventoryDetailState | null) => void;
|
onInventoryDetailChange: (detail: DeviceInventoryDetailState | null) => void;
|
||||||
}) {
|
}) {
|
||||||
if (!workspace) return <div className="device-manager-panel-empty">Загружаем проект…</div>;
|
if (!workspace) return <div className="device-manager-panel-empty">Загружаем проект…</div>;
|
||||||
if (["catalog", "infrastructure", "sessions", "bindings", "commands", "audit", "access", "settings"].includes(view)) {
|
if (["catalog", "hosts", "infrastructure", "sessions", "bindings", "commands", "audit", "access", "settings"].includes(view)) {
|
||||||
return <DeviceControlView
|
return <DeviceControlView
|
||||||
view={view as ControlViewId}
|
view={view as ControlViewId}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
session={session}
|
session={session}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
|
onPoll={onPoll}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
@@ -1455,6 +1457,7 @@ function viewTitle(view: ViewId) {
|
|||||||
inventory: "Устройства",
|
inventory: "Устройства",
|
||||||
collections: "Коллекции",
|
collections: "Коллекции",
|
||||||
catalog: "Модели и адаптеры",
|
catalog: "Модели и адаптеры",
|
||||||
|
hosts: "VPS и хосты",
|
||||||
infrastructure: "Edges и маршруты",
|
infrastructure: "Edges и маршруты",
|
||||||
sessions: "Gateway sessions",
|
sessions: "Gateway sessions",
|
||||||
bindings: "Data bindings",
|
bindings: "Data bindings",
|
||||||
@@ -1468,7 +1471,7 @@ function viewTitle(view: ViewId) {
|
|||||||
function sectionForView(view: ViewId): PrimarySection {
|
function sectionForView(view: ViewId): PrimarySection {
|
||||||
if (view === "overview") return "overview";
|
if (view === "overview") return "overview";
|
||||||
if (view === "inventory" || view === "collections" || view === "sessions") return "devices";
|
if (view === "inventory" || view === "collections" || view === "sessions") return "devices";
|
||||||
if (view === "catalog" || view === "infrastructure") return "infrastructure";
|
if (view === "catalog" || view === "hosts" || view === "infrastructure") return "infrastructure";
|
||||||
if (view === "bindings" || view === "commands" || view === "settings") return "management";
|
if (view === "bindings" || view === "commands" || view === "settings") return "management";
|
||||||
return "administration";
|
return "administration";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import type {
|
import type {
|
||||||
AdapterPackageView,
|
AdapterPackageView,
|
||||||
AdapterVersionView,
|
AdapterVersionView,
|
||||||
|
AssetBindingView,
|
||||||
|
AssetView,
|
||||||
BindingView,
|
BindingView,
|
||||||
ConfigurationRevisionView,
|
ConfigurationRevisionView,
|
||||||
EdgeView,
|
EdgeView,
|
||||||
ModelProfileView,
|
ModelProfileView,
|
||||||
|
InfrastructureDeploymentView,
|
||||||
|
InfrastructureEndpointView,
|
||||||
|
InfrastructureHostView,
|
||||||
|
InfrastructureServiceInstanceView,
|
||||||
ProjectGrantView,
|
ProjectGrantView,
|
||||||
DeviceManagerSession,
|
DeviceManagerSession,
|
||||||
DeviceManagerPresentation,
|
DeviceManagerPresentation,
|
||||||
@@ -144,6 +150,120 @@ export async function ensureEnrollmentIntent(input: {
|
|||||||
return mutate("/api/device-manager/enrollment-intents:ensure", 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: {
|
export async function upsertProjectGrant(input: {
|
||||||
projectRef: string;
|
projectRef: string;
|
||||||
principalKind: "user" | "group";
|
principalKind: "user" | "group";
|
||||||
|
|||||||
@@ -86,10 +86,15 @@
|
|||||||
.device-control-command-form {
|
.device-control-command-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto;
|
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto;
|
||||||
align-items: end;
|
align-items: start;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-control-command-form > .nodedc-select-anchor,
|
||||||
|
.device-control-command-form > .nodedc-button {
|
||||||
|
margin-top: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.device-control-command-policy p {
|
.device-control-command-policy p {
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
color: var(--nodedc-text-secondary);
|
color: var(--nodedc-text-secondary);
|
||||||
@@ -122,6 +127,655 @@
|
|||||||
padding-right: 3px;
|
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) {
|
@media (max-width: 760px) {
|
||||||
.device-control-resource-grid,
|
.device-control-resource-grid,
|
||||||
.device-control-policy-grid {
|
.device-control-policy-grid {
|
||||||
@@ -145,6 +799,11 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-control-command-form > .nodedc-select-anchor,
|
||||||
|
.device-control-command-form > .nodedc-button {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.device-control-command-policy > .nodedc-status {
|
.device-control-command-policy > .nodedc-status {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
@@ -158,6 +817,48 @@
|
|||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
justify-content: flex-start;
|
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,
|
html,
|
||||||
@@ -526,6 +1227,8 @@ body {
|
|||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
color: var(--nodedc-text-secondary);
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.55;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,12 @@ export interface EdgeView {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
deploymentRef: string | null;
|
deploymentRef: string | null;
|
||||||
lifecycleState: string;
|
lifecycleState: string;
|
||||||
|
channel: {
|
||||||
|
lifecycleState: string;
|
||||||
|
generationRef: string | null;
|
||||||
|
runtimeState: "accepted" | "connecting" | "absent" | "unobserved" | "disabled" | "revoked" | string;
|
||||||
|
lastErrorCode: string | null;
|
||||||
|
};
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
}
|
}
|
||||||
@@ -305,6 +311,160 @@ export interface ProjectGrantView {
|
|||||||
lifecycleState: string;
|
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 {
|
export interface ProjectWorkspace {
|
||||||
project: ProjectSummary;
|
project: ProjectSummary;
|
||||||
devices: DeviceView[];
|
devices: DeviceView[];
|
||||||
@@ -323,6 +483,7 @@ export interface ProjectWorkspace {
|
|||||||
commands: CommandView[];
|
commands: CommandView[];
|
||||||
auditEvents: AuditEventView[];
|
auditEvents: AuditEventView[];
|
||||||
grants: ProjectGrantView[];
|
grants: ProjectGrantView[];
|
||||||
|
ontology: ProjectOntologyProjection;
|
||||||
policies: {
|
policies: {
|
||||||
commandTransport: "disabled" | "typed-service-ping-v1";
|
commandTransport: "disabled" | "typed-service-ping-v1";
|
||||||
commandPlanningApi: "disabled" | "enabled";
|
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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "nodedc.device-plane.device-manager-release.v7",
|
||||||
|
"releaseId": "__PATCH_ID__",
|
||||||
|
"action": "upgrade",
|
||||||
|
"predecessor": {
|
||||||
|
"kind": "release",
|
||||||
|
"patchId": "device-manager-release-v6-20260822-035",
|
||||||
|
"artifactSha256": "193faabe930e2b3f212f8eb45288e39f850ec714528b28881095be654baf9a80"
|
||||||
|
},
|
||||||
|
"controlCorePredecessor": {
|
||||||
|
"patchId": "device-control-core-release-v2-20260822-036",
|
||||||
|
"artifactSha256": "8708cc4b59fa0cd5e9c6e6a7b2654ba01ea60271549167aca2631f94000d3da3"
|
||||||
|
},
|
||||||
|
"edgeChannelPredecessor": {
|
||||||
|
"patchId": "device-edge-core-channel-upgrade-v4-20260812-023",
|
||||||
|
"artifactSha256": "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc"
|
||||||
|
},
|
||||||
|
"service": "device-manager",
|
||||||
|
"publicIngress": "reverse-proxy-only",
|
||||||
|
"deviceCoreManagementApi": "file-token-authenticated",
|
||||||
|
"launcherTrust": "file-token-scoped-to-device-core-handoff",
|
||||||
|
"edgeChannel": "preserve-active-v4-core-initiated-pinned-mtls",
|
||||||
|
"edgeChannelIdentity": "reuse-runner-managed-host-local-private-key-public-certificate-export",
|
||||||
|
"edgeChannelEgress": "preserve-dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-443-only",
|
||||||
|
"healthGate": "bounded-container-grace+core-contract+persistent-data",
|
||||||
|
"commandTransport": "typed-service-ping-v1",
|
||||||
|
"commandCatalog": "allowlisted-adapter-typed-commands-only",
|
||||||
|
"credentialBoundary": "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned",
|
||||||
|
"presentationPersistence": "runner-managed-host-data-bind",
|
||||||
|
"presentationDataHostPath": "/volume1/docker/nodedc-device-plane/data/device-manager",
|
||||||
|
"presentationDataContainerPath": "/var/lib/nodedc-device-manager",
|
||||||
|
"presentationDataOwnership": "uid-1000-gid-1000-mode-0750",
|
||||||
|
"presentationDataLifecycle": "preserve-across-manager-recreate-and-source-rollback",
|
||||||
|
"presentationPath": "/var/lib/nodedc-device-manager/device-manager-presentation.json",
|
||||||
|
"mediaRoot": "/var/lib/nodedc-device-manager/media",
|
||||||
|
"defaultAccentHex": "#f5f5f5",
|
||||||
|
"overviewLayout": "mission-core-landing-stage-v1",
|
||||||
|
"faviconSet": "nodedc-adaptive-v1",
|
||||||
|
"commandFormLayout": "aligned-control-row-v1",
|
||||||
|
"secondaryEmptyTypography": "help-text-sm-v1",
|
||||||
|
"infrastructureHostProjection": "edge-registration-live-channel-v1",
|
||||||
|
"ontologyStatus": "generic-host-domain-candidate-not-canonical",
|
||||||
|
"gelios": "untouched-legacy-only",
|
||||||
|
"rollback": "restore-preapply-snapshot-preserve-manager-data"
|
||||||
|
}
|
||||||
@@ -0,0 +1,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"
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
+106
@@ -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,
|
predecessorSha256,
|
||||||
...extra
|
...extra
|
||||||
] = process.argv.slice(2);
|
] = process.argv.slice(2);
|
||||||
|
const isV4 = patchId.startsWith("device-control-core-release-v4-");
|
||||||
if (
|
if (
|
||||||
extra.length
|
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) !== (predecessorSha256 === undefined))
|
||||||
|| (
|
|| (
|
||||||
predecessorPatchId !== 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
|
|| predecessorPatchId === patchId
|
||||||
|| !/^[0-9a-f]{64}$/.test(predecessorSha256)
|
|| !/^[0-9a-f]{64}$/.test(predecessorSha256)
|
||||||
)
|
)
|
||||||
@@ -35,20 +37,53 @@ if (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isV3 = patchId.startsWith("device-control-core-release-v3-");
|
||||||
const isV2 = patchId.startsWith("device-control-core-release-v2-");
|
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({
|
const expectedV2Predecessor = Object.freeze({
|
||||||
patchId: predecessorPatchId ?? "device-control-core-release-20260812-024",
|
patchId: predecessorPatchId ?? "device-control-core-release-20260812-024",
|
||||||
artifactSha256: predecessorSha256 ?? "a289e909283109642e6bba3d9822a31f63423cfe0bbcd52705979681bd2bc793",
|
artifactSha256: predecessorSha256 ?? "a289e909283109642e6bba3d9822a31f63423cfe0bbcd52705979681bd2bc793",
|
||||||
});
|
});
|
||||||
const descriptorPath = isV2
|
const expectedV4Predecessor = Object.freeze({
|
||||||
? "deployment/device-control-core-release-v2.json"
|
patchId: "device-control-core-migration-replay-checkpoint-recovery-20260822-046",
|
||||||
: "deployment/device-control-core-release-v1.json";
|
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 = [
|
const entries = [
|
||||||
".dockerignore",
|
".dockerignore",
|
||||||
"package.json",
|
"package.json",
|
||||||
"package-lock.json",
|
"package-lock.json",
|
||||||
"packages/device-protocol-contract",
|
"packages/device-protocol-contract",
|
||||||
"packages/device-edge-channel-contract",
|
"packages/device-edge-channel-contract",
|
||||||
|
...(includesTelemetry ? ["packages/infrastructure-telemetry-contract"] : []),
|
||||||
"services/device-control-core",
|
"services/device-control-core",
|
||||||
descriptorPath,
|
descriptorPath,
|
||||||
];
|
];
|
||||||
@@ -67,7 +102,9 @@ try {
|
|||||||
descriptor.releaseId = patchId;
|
descriptor.releaseId = patchId;
|
||||||
if (predecessorPatchId !== undefined) {
|
if (predecessorPatchId !== undefined) {
|
||||||
descriptor.predecessor = {
|
descriptor.predecessor = {
|
||||||
kind: "release",
|
kind: isV4
|
||||||
|
? "migration-replay-checkpoint-recovery"
|
||||||
|
: "release",
|
||||||
patchId: predecessorPatchId,
|
patchId: predecessorPatchId,
|
||||||
artifactSha256: predecessorSha256,
|
artifactSha256: predecessorSha256,
|
||||||
};
|
};
|
||||||
@@ -88,6 +125,7 @@ try {
|
|||||||
"services/device-control-core/src/device-gateway-core-runtime.mjs",
|
"services/device-control-core/src/device-gateway-core-runtime.mjs",
|
||||||
"packages/device-protocol-contract/src/index.mjs",
|
"packages/device-protocol-contract/src/index.mjs",
|
||||||
"packages/device-edge-channel-contract/src/index.mjs",
|
"packages/device-edge-channel-contract/src/index.mjs",
|
||||||
|
...(includesTelemetry ? ["packages/infrastructure-telemetry-contract/src/index.mjs"] : []),
|
||||||
]) {
|
]) {
|
||||||
const imported = spawnSync(
|
const imported = spawnSync(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
@@ -101,7 +139,7 @@ try {
|
|||||||
|
|
||||||
const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8"));
|
const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8"));
|
||||||
if (
|
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.releaseId !== patchId
|
||||||
|| descriptor.action !== "upgrade"
|
|| descriptor.action !== "upgrade"
|
||||||
|| descriptor.service !== "device-control-core"
|
|| descriptor.service !== "device-control-core"
|
||||||
@@ -113,16 +151,37 @@ try {
|
|||||||
|| JSON.stringify(descriptor.coreNetworks) !== JSON.stringify(["device-plane-private", "device-plane-egress"])
|
|| JSON.stringify(descriptor.coreNetworks) !== JSON.stringify(["device-plane-private", "device-plane-egress"])
|
||||||
|| descriptor.publicIngress !== "none-on-synology"
|
|| descriptor.publicIngress !== "none-on-synology"
|
||||||
|| descriptor.edgeRegistrations !== "preserved"
|
|| descriptor.edgeRegistrations !== "preserved"
|
||||||
|| descriptor.commandTransport !== (isV2 ? "typed-service-ping-v1" : "disabled")
|
|| descriptor.commandTransport !== ((isV2 || isV3 || isV4) ? "typed-service-ping-v1" : "disabled")
|
||||||
|| descriptor.gelios !== (isV2 ? "untouched-legacy-only" : "untouched")
|
|| descriptor.gelios !== ((isV2 || isV3 || isV4) ? "untouched-legacy-only" : "untouched")
|
||||||
|| descriptor.rollback !== "restore-preapply-source-and-core-runtime"
|
|| descriptor.rollback !== "restore-preapply-source-and-core-runtime"
|
||||||
|| (
|
|| (
|
||||||
isV2
|
(isV2 || isV3 || isV4)
|
||||||
&& (
|
&& (
|
||||||
descriptor.commandCatalog !== "allowlisted-adapter-typed-commands-only"
|
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.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?.patchId !== (isV4 ? expectedV4Predecessor : expectedV2Predecessor).patchId
|
||||||
|| descriptor.predecessor?.artifactSha256 !== expectedV2Predecessor.artifactSha256
|
|| 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 upgradeV2 = !upgradeV4 && patchId.startsWith("device-edge-core-channel-upgrade-v2-");
|
||||||
const upgradeV1 = !upgradeV4 && !upgradeV2 && patchId.startsWith("device-edge-core-channel-upgrade-");
|
const upgradeV1 = !upgradeV4 && !upgradeV2 && patchId.startsWith("device-edge-core-channel-upgrade-");
|
||||||
const upgrade = upgradeV1 || upgradeV2 || upgradeV4;
|
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
|
const descriptorPath = upgradeV4
|
||||||
? "deployment/device-edge-core-channel-upgrade-v4.json"
|
? "deployment/device-edge-core-channel-upgrade-v4.json"
|
||||||
: upgradeV2
|
: upgradeV2
|
||||||
|
|||||||
@@ -38,11 +38,12 @@ if (
|
|||||||
"tailscale-retirement",
|
"tailscale-retirement",
|
||||||
"tracker-ingress",
|
"tracker-ingress",
|
||||||
"command-transport",
|
"command-transport",
|
||||||
|
"host-telemetry",
|
||||||
].includes(phase)
|
].includes(phase)
|
||||||
|| !/^[A-Za-z0-9._-]{1,96}$/.test(patchId || "")
|
|| !/^[A-Za-z0-9._-]{1,96}$/.test(patchId || "")
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
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");
|
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)) {
|
if (acceptedSharedSourcePhases.has(phase)) {
|
||||||
throw new Error(`accepted_vps_phase_rebuild_frozen:${phase}:ADR-0001`);
|
throw new Error(`accepted_vps_phase_rebuild_frozen:${phase}:ADR-0001`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeArchive = "node-v22.23.2-linux-x64.tar.xz";
|
const nodeArchive = "node-v22.23.2-linux-x64.tar.xz";
|
||||||
const tailscaleArchive = "tailscale_1.102.2_amd64.tgz";
|
const tailscaleArchive = "tailscale_1.102.2_amd64.tgz";
|
||||||
|
const telegrafArchive = "telegraf-1.38.4_linux_amd64.tar.gz";
|
||||||
const runtimeDigests = new Map([
|
const runtimeDigests = new Map([
|
||||||
[nodeArchive, "d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307"],
|
[nodeArchive, "d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307"],
|
||||||
[tailscaleArchive, "ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8"],
|
[tailscaleArchive, "ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8"],
|
||||||
|
[telegrafArchive, "81857e9745ebf26e058b6fdc27b9b2c210fd1fe61e57d7fad3d4bb9131f60041"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const entriesByPhase = {
|
const entriesByPhase = {
|
||||||
@@ -130,6 +137,21 @@ const entriesByPhase = {
|
|||||||
"vps/edge-process/device-edge-runtime.mjs",
|
"vps/edge-process/device-edge-runtime.mjs",
|
||||||
"deployment/device-edge-vps-command-transport-v1.json",
|
"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 entries = entriesByPhase[phase];
|
||||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||||
@@ -191,10 +213,10 @@ try {
|
|||||||
? "tcp/9921"
|
? "tcp/9921"
|
||||||
: ["core-channel", "tailscale-retirement"].includes(phase)
|
: ["core-channel", "tailscale-retirement"].includes(phase)
|
||||||
? "tcp/443-mtls-only"
|
? "tcp/443-mtls-only"
|
||||||
: ["tracker-ingress", "command-transport"].includes(phase)
|
: ["tracker-ingress", "command-transport", "host-telemetry"].includes(phase)
|
||||||
? "tcp/443-mtls+tcp/9921-telemetry"
|
? "tcp/443-mtls+tcp/9921-telemetry"
|
||||||
: "disabled",
|
: "disabled",
|
||||||
commandTransport: phase === "command-transport"
|
commandTransport: ["command-transport", "host-telemetry"].includes(phase)
|
||||||
? "typed-service-ping-v1"
|
? "typed-service-ping-v1"
|
||||||
: "disabled",
|
: "disabled",
|
||||||
gelios: "untouched",
|
gelios: "untouched",
|
||||||
@@ -212,7 +234,7 @@ async function assertBoundary() {
|
|||||||
if (
|
if (
|
||||||
descriptor.component !== "device-edge-vps"
|
descriptor.component !== "device-edge-vps"
|
||||||
|| descriptor.runtimeHost !== "koffyvngij"
|
|| descriptor.runtimeHost !== "koffyvngij"
|
||||||
|| descriptor.commandTransport !== (phase === "command-transport"
|
|| descriptor.commandTransport !== (["command-transport", "host-telemetry"].includes(phase)
|
||||||
? "typed-service-ping-v1"
|
? "typed-service-ping-v1"
|
||||||
: "disabled")
|
: "disabled")
|
||||||
|| !String(descriptor.gelios || "").startsWith("untouched")
|
|| !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() {
|
function canonicalTarScript() {
|
||||||
|
|||||||
@@ -11,11 +11,25 @@ const platformRoot = resolve(scriptDir, "../..");
|
|||||||
const devicePlaneRoot = platformRoot;
|
const devicePlaneRoot = platformRoot;
|
||||||
const managerRoot = resolve(platformRoot, "apps/device-manager");
|
const managerRoot = resolve(platformRoot, "apps/device-manager");
|
||||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||||
const [patchId = "device-manager-release-v6-20260822-035", ...extra] = process.argv.slice(2);
|
const [patchId = "device-manager-release-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]");
|
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-v6.json"
|
? "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-")
|
: patchId.startsWith("device-manager-release-v5-")
|
||||||
? "deployment/device-manager-release-v5.json"
|
? "deployment/device-manager-release-v5.json"
|
||||||
: patchId.startsWith("device-manager-release-v4-")
|
: patchId.startsWith("device-manager-release-v4-")
|
||||||
@@ -28,7 +42,14 @@ const isV3 = descriptorPath.endsWith("release-v3.json");
|
|||||||
const isV4 = descriptorPath.endsWith("release-v4.json");
|
const isV4 = descriptorPath.endsWith("release-v4.json");
|
||||||
const isV5 = descriptorPath.endsWith("release-v5.json");
|
const isV5 = descriptorPath.endsWith("release-v5.json");
|
||||||
const isV6 = descriptorPath.endsWith("release-v6.json");
|
const isV6 = descriptorPath.endsWith("release-v6.json");
|
||||||
const isPersistent = isV4 || isV5 || isV6;
|
const isV7 = descriptorPath.endsWith("release-v7.json");
|
||||||
|
const 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 isManagerOnly = isV3 || isPersistent;
|
||||||
const composeSource = resolve(devicePlaneRoot, "docker-compose.device-manager.yml");
|
const composeSource = resolve(devicePlaneRoot, "docker-compose.device-manager.yml");
|
||||||
const composeSourceSha256 = createHash("sha256").update(await readFile(composeSource)).digest("hex");
|
const composeSourceSha256 = createHash("sha256").update(await readFile(composeSource)).digest("hex");
|
||||||
@@ -167,7 +188,215 @@ try {
|
|||||||
: "restore-preapply-snapshot")
|
: "restore-preapply-snapshot")
|
||||||
);
|
);
|
||||||
if (commonContractInvalid) throw new Error("device_manager_activation_successor_contract_mismatch");
|
if (commonContractInvalid) throw new Error("device_manager_activation_successor_contract_mismatch");
|
||||||
if (descriptorPath.endsWith("release-v6.json")) {
|
if (descriptorPath.endsWith("release-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 (
|
if (
|
||||||
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v6"
|
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v6"
|
||||||
|| descriptor.predecessor?.kind !== "release"
|
|| descriptor.predecessor?.kind !== "release"
|
||||||
@@ -193,29 +422,7 @@ try {
|
|||||||
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|
|| descriptor.rollback !== "restore-preapply-snapshot-preserve-manager-data"
|
||||||
|| descriptor.gelios !== "untouched-legacy-only"
|
|| descriptor.gelios !== "untouched-legacy-only"
|
||||||
) throw new Error("device_manager_v6_favicon_contract_mismatch");
|
) throw new Error("device_manager_v6_favicon_contract_mismatch");
|
||||||
const faviconHashes = {
|
await validateFaviconBundle(payload, "device_manager_v6");
|
||||||
"favicon.ico": "f8933114a85646335ea5c94944f56d3cd8c48a6032016719f7905ff244ec0aa2",
|
|
||||||
"favicon/favicon.ico": "f8933114a85646335ea5c94944f56d3cd8c48a6032016719f7905ff244ec0aa2",
|
|
||||||
"favicon/icon-adaptive.svg": "481984e83997d786bb0a72ad1ee80037db13aef3a0792ab3109df95c2199b38e",
|
|
||||||
"favicon/apple-touch-icon.png": "afdccc28152a566e264e533ca218362f05d5bcec936c647f9a54f414c0bd4763",
|
|
||||||
"favicon/icon-192.png": "5b10a24feb4754f15c69761cef42f91a01f885d04095156b1a12e254875fdd4d",
|
|
||||||
"favicon/icon-512.png": "f98bac3dba59b7eefbe89f8bb8abc25567226a54ab7ed3b6b1a4caffbdd9ee15",
|
|
||||||
"favicon/manifest.webmanifest.json": "2a8ecdc6e6c64833f812ae02bbc0c7bd9b435e0cfa75cc21d6edf054d41275fc",
|
|
||||||
};
|
|
||||||
for (const [relativePath, expectedSha256] of Object.entries(faviconHashes)) {
|
|
||||||
const content = await readFile(join(payload, "services/device-manager/dist", relativePath));
|
|
||||||
const actualSha256 = createHash("sha256").update(content).digest("hex");
|
|
||||||
if (actualSha256 !== expectedSha256) throw new Error(`device_manager_v6_favicon_hash_mismatch:${relativePath}`);
|
|
||||||
}
|
|
||||||
const indexHtml = await readFile(join(payload, "services/device-manager/dist/index.html"), "utf8");
|
|
||||||
for (const requiredLink of [
|
|
||||||
'href="/favicon/icon-adaptive.svg"',
|
|
||||||
'href="/favicon/favicon.ico"',
|
|
||||||
'href="/favicon/apple-touch-icon.png"',
|
|
||||||
'href="/favicon/icon-192.png"',
|
|
||||||
'href="/favicon/icon-512.png"',
|
|
||||||
'href="/favicon/manifest.webmanifest.json"',
|
|
||||||
]) if (!indexHtml.includes(requiredLink)) throw new Error(`device_manager_v6_favicon_link_missing:${requiredLink}`);
|
|
||||||
} else if (descriptorPath.endsWith("release-v5.json")) {
|
} else if (descriptorPath.endsWith("release-v5.json")) {
|
||||||
if (
|
if (
|
||||||
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v5"
|
descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v5"
|
||||||
@@ -303,6 +510,41 @@ try {
|
|||||||
await rm(stage, { recursive: true, force: true });
|
await rm(stage, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function validateFaviconBundle(payloadRoot, errorPrefix) {
|
||||||
|
const faviconHashes = {
|
||||||
|
"favicon.ico": "f8933114a85646335ea5c94944f56d3cd8c48a6032016719f7905ff244ec0aa2",
|
||||||
|
"favicon/favicon.ico": "f8933114a85646335ea5c94944f56d3cd8c48a6032016719f7905ff244ec0aa2",
|
||||||
|
"favicon/icon-adaptive.svg": "481984e83997d786bb0a72ad1ee80037db13aef3a0792ab3109df95c2199b38e",
|
||||||
|
"favicon/apple-touch-icon.png": "afdccc28152a566e264e533ca218362f05d5bcec936c647f9a54f414c0bd4763",
|
||||||
|
"favicon/icon-192.png": "5b10a24feb4754f15c69761cef42f91a01f885d04095156b1a12e254875fdd4d",
|
||||||
|
"favicon/icon-512.png": "f98bac3dba59b7eefbe89f8bb8abc25567226a54ab7ed3b6b1a4caffbdd9ee15",
|
||||||
|
"favicon/manifest.webmanifest.json": "2a8ecdc6e6c64833f812ae02bbc0c7bd9b435e0cfa75cc21d6edf054d41275fc",
|
||||||
|
};
|
||||||
|
for (const [relativePath, expectedSha256] of Object.entries(faviconHashes)) {
|
||||||
|
const content = await readFile(join(payloadRoot, "services/device-manager/dist", relativePath));
|
||||||
|
const actualSha256 = createHash("sha256").update(content).digest("hex");
|
||||||
|
if (actualSha256 !== expectedSha256) {
|
||||||
|
throw new Error(`${errorPrefix}_favicon_hash_mismatch:${relativePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const indexHtml = await readFile(
|
||||||
|
join(payloadRoot, "services/device-manager/dist/index.html"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
for (const requiredLink of [
|
||||||
|
'href="/favicon/icon-adaptive.svg"',
|
||||||
|
'href="/favicon/favicon.ico"',
|
||||||
|
'href="/favicon/apple-touch-icon.png"',
|
||||||
|
'href="/favicon/icon-192.png"',
|
||||||
|
'href="/favicon/icon-512.png"',
|
||||||
|
'href="/favicon/manifest.webmanifest.json"',
|
||||||
|
]) {
|
||||||
|
if (!indexHtml.includes(requiredLink)) {
|
||||||
|
throw new Error(`${errorPrefix}_favicon_link_missing:${requiredLink}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function copySafe(source, destination, sourceBoundary) {
|
async function copySafe(source, destination, sourceBoundary) {
|
||||||
const sourceStat = await lstat(source);
|
const sourceStat = await lstat(source);
|
||||||
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(sourceBoundary, source)}`);
|
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(sourceBoundary, source)}`);
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ RELAY_USER = "nodedc-relay"
|
|||||||
RELAY_GROUP = "nodedc-relay"
|
RELAY_GROUP = "nodedc-relay"
|
||||||
CHANNEL_USER = "nodedc-channel"
|
CHANNEL_USER = "nodedc-channel"
|
||||||
CHANNEL_GROUP = "nodedc-channel"
|
CHANNEL_GROUP = "nodedc-channel"
|
||||||
|
TELEMETRY_USER = "nodedc-telemetry"
|
||||||
|
TELEMETRY_GROUP = "nodedc-telemetry"
|
||||||
TAILSCALE_REQUIRED_TAG = "tag:device-edge-vps"
|
TAILSCALE_REQUIRED_TAG = "tag:device-edge-vps"
|
||||||
MANAGEMENT_KEY_FINGERPRINT = (
|
MANAGEMENT_KEY_FINGERPRINT = (
|
||||||
"SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw"
|
"SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw"
|
||||||
@@ -67,10 +69,19 @@ TAILSCALE_ARCHIVE_SHA256 = (
|
|||||||
NODE_BIN_SHA256 = "3517c2df0b2f8cd7f422b4b8450ef81c6889f08eb03e281d6de9079b15e6a327"
|
NODE_BIN_SHA256 = "3517c2df0b2f8cd7f422b4b8450ef81c6889f08eb03e281d6de9079b15e6a327"
|
||||||
TAILSCALE_BIN_SHA256 = "58b0fa0907677ea6afe0d3022cc3e99b1a03f39a7ed60144843ed38252e00c80"
|
TAILSCALE_BIN_SHA256 = "58b0fa0907677ea6afe0d3022cc3e99b1a03f39a7ed60144843ed38252e00c80"
|
||||||
TAILSCALED_BIN_SHA256 = "5f17b092bac92326325f6c4ffd9991fad3c073975abe412d02ee68721a500394"
|
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"
|
NODE_BIN = LIVE_ROOT / "runtime/node/bin/node"
|
||||||
TAILSCALE_BIN = LIVE_ROOT / "runtime/tailscale/tailscale"
|
TAILSCALE_BIN = LIVE_ROOT / "runtime/tailscale/tailscale"
|
||||||
TAILSCALED_BIN = LIVE_ROOT / "runtime/tailscale/tailscaled"
|
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_SOCKET = Path("/run/nodedc-b2-vps/tailscaled.sock")
|
||||||
TAILSCALE_STATE = Path("/var/lib/nodedc-b2-vps/tailscale/tailscaled.state")
|
TAILSCALE_STATE = Path("/var/lib/nodedc-b2-vps/tailscale/tailscaled.state")
|
||||||
TRUST_ROOT = Path("/var/lib/nodedc-b2-vps/trust")
|
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_RUNTIME_CONFIG = CHANNEL_TRUST_ROOT / "runtime.json"
|
||||||
CHANNEL_HEALTH_PORT = 18222
|
CHANNEL_HEALTH_PORT = 18222
|
||||||
CHANNEL_PUBLIC_PORT = 443
|
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_PATCH = "device-edge-vps-core-channel-20260812-010"
|
||||||
CORE_CHANNEL_ACCEPTED_SHA256 = (
|
CORE_CHANNEL_ACCEPTED_SHA256 = (
|
||||||
"c8ef3c4bb45850cad32e881eba081bc4c891c2886e5500d02cb94616d82353f3"
|
"c8ef3c4bb45850cad32e881eba081bc4c891c2886e5500d02cb94616d82353f3"
|
||||||
@@ -116,6 +131,12 @@ TRACKER_INGRESS_ACCEPTED_PATCH = "device-edge-vps-tracker-ingress-20260812-012"
|
|||||||
TRACKER_INGRESS_ACCEPTED_SHA256 = (
|
TRACKER_INGRESS_ACCEPTED_SHA256 = (
|
||||||
"290acef118839c6b0c31aac864c47da1832a289537366af9322d4624a1dd81ec"
|
"290acef118839c6b0c31aac864c47da1832a289537366af9322d4624a1dd81ec"
|
||||||
)
|
)
|
||||||
|
COMMAND_TRANSPORT_ACCEPTED_PATCH = (
|
||||||
|
"device-edge-vps-command-transport-20260812-013"
|
||||||
|
)
|
||||||
|
COMMAND_TRANSPORT_ACCEPTED_SHA256 = (
|
||||||
|
"c7486ec879681ddd706f229b628c8556ca8c9ccc4f152a85debb409c302759ef"
|
||||||
|
)
|
||||||
|
|
||||||
FOUNDATION_ENTRIES = (
|
FOUNDATION_ENTRIES = (
|
||||||
"vps/config/00-nodedc-b2-vps.conf",
|
"vps/config/00-nodedc-b2-vps.conf",
|
||||||
@@ -181,6 +202,21 @@ COMMAND_TRANSPORT_ENTRIES = (
|
|||||||
"vps/edge-process/device-edge-runtime.mjs",
|
"vps/edge-process/device-edge-runtime.mjs",
|
||||||
"deployment/device-edge-vps-command-transport-v1.json",
|
"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 = {
|
PHASE_ENTRIES = {
|
||||||
"foundation": FOUNDATION_ENTRIES,
|
"foundation": FOUNDATION_ENTRIES,
|
||||||
@@ -191,6 +227,7 @@ PHASE_ENTRIES = {
|
|||||||
"tailscale-retirement": TAILSCALE_RETIREMENT_ENTRIES,
|
"tailscale-retirement": TAILSCALE_RETIREMENT_ENTRIES,
|
||||||
"tracker-ingress": TRACKER_INGRESS_ENTRIES,
|
"tracker-ingress": TRACKER_INGRESS_ENTRIES,
|
||||||
"command-transport": COMMAND_TRANSPORT_ENTRIES,
|
"command-transport": COMMAND_TRANSPORT_ENTRIES,
|
||||||
|
"host-telemetry": HOST_TELEMETRY_ENTRIES,
|
||||||
}
|
}
|
||||||
|
|
||||||
SUPERSEDED_TRANSPORT_PHASES = frozenset({"backhaul", "relay"})
|
SUPERSEDED_TRANSPORT_PHASES = frozenset({"backhaul", "relay"})
|
||||||
@@ -312,6 +349,35 @@ PHASE_FILE_SHA256 = {
|
|||||||
"deployment/device-edge-vps-command-transport-v1.json":
|
"deployment/device-edge-vps-command-transport-v1.json":
|
||||||
"971166143fe954b9c5043cce9a464d17efbc87933da1405b2517a4693a7bed09",
|
"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.
|
# Exact immutable baselines from terminally accepted predecessor artifacts.
|
||||||
@@ -472,7 +538,11 @@ def validate_payload(payload: Path, phase: str):
|
|||||||
descriptor.get("component") != COMPONENT
|
descriptor.get("component") != COMPONENT
|
||||||
or descriptor.get("runtimeHost") != RUNTIME_HOST
|
or descriptor.get("runtimeHost") != RUNTIME_HOST
|
||||||
or descriptor.get("commandTransport")
|
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 str(descriptor.get("gelios", "")).startswith("untouched")
|
||||||
or not descriptor.get("rollback")
|
or not descriptor.get("rollback")
|
||||||
):
|
):
|
||||||
@@ -737,6 +807,25 @@ def current_phase_preflight(phase: str):
|
|||||||
if (LIVE_ROOT / COMMAND_TRANSPORT_ENTRIES[-1]).exists():
|
if (LIVE_ROOT / COMMAND_TRANSPORT_ENTRIES[-1]).exists():
|
||||||
die("VPS command transport target path already exists")
|
die("VPS command transport target path already exists")
|
||||||
return {"predecessor": "accepted-tracker-ingress-012"}
|
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(
|
validate_foundation_runtime(
|
||||||
require_running_tailnet=phase in {"backhaul", "relay"},
|
require_running_tailnet=phase in {"backhaul", "relay"},
|
||||||
expected_key_user=BACKHAUL_USER if phase == "relay" else SERVICE_USER,
|
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]
|
return common + [CHANNEL_UNIT, NFTABLES_CONFIG]
|
||||||
if phase == "command-transport":
|
if phase == "command-transport":
|
||||||
return common + [CHANNEL_UNIT, NFTABLES_CONFIG]
|
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]
|
return common + [RELAY_UNIT, NFTABLES_CONFIG]
|
||||||
|
|
||||||
|
|
||||||
@@ -887,7 +982,13 @@ def create_backup(patch_id: str, phase: str):
|
|||||||
"serviceUserExisted": user_exists(),
|
"serviceUserExisted": user_exists(),
|
||||||
"serviceUsersExisted": {
|
"serviceUsersExisted": {
|
||||||
name: user_exists(name)
|
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": {
|
"services": {
|
||||||
name: {
|
name: {
|
||||||
@@ -902,6 +1003,7 @@ def create_backup(patch_id: str, phase: str):
|
|||||||
"nodedc-b2-backhaul.service",
|
"nodedc-b2-backhaul.service",
|
||||||
"nodedc-b2-relay.service",
|
"nodedc-b2-relay.service",
|
||||||
"nodedc-device-edge-channel.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):
|
def extract_vendor_binary(archive: Path, member_name: str, target: Path, mode=0o755):
|
||||||
with tarfile.open(archive, "r:*") as package:
|
with tarfile.open(archive, "r:*") as package:
|
||||||
try:
|
accepted_names = {member_name, f"./{member_name}"}
|
||||||
member = package.getmember(member_name)
|
matches = [
|
||||||
except KeyError:
|
candidate
|
||||||
|
for candidate in package.getmembers()
|
||||||
|
if candidate.name in accepted_names
|
||||||
|
]
|
||||||
|
if not matches:
|
||||||
die(f"vendor binary member missing: {member_name}")
|
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():
|
if not member.isfile() or member.issym() or member.islnk():
|
||||||
die(f"vendor binary member unsafe: {member_name}")
|
die(f"vendor binary member unsafe: {member_name}")
|
||||||
source = package.extractfile(member)
|
source = package.extractfile(member)
|
||||||
@@ -1336,6 +1445,32 @@ def apply_command_transport(_payload: Path):
|
|||||||
validate_command_transport_runtime()
|
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():
|
def sshd_effective():
|
||||||
return run(["/usr/sbin/sshd", "-T"]).stdout.lower()
|
return run(["/usr/sbin/sshd", "-T"]).stdout.lower()
|
||||||
|
|
||||||
@@ -1777,6 +1912,126 @@ def validate_command_transport_runtime():
|
|||||||
return health
|
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():
|
def validate_relay_runtime():
|
||||||
validate_backhaul_runtime()
|
validate_backhaul_runtime()
|
||||||
source_file_state("relay")
|
source_file_state("relay")
|
||||||
@@ -1835,6 +2090,7 @@ def restore_service_enablement(metadata):
|
|||||||
|
|
||||||
def rollback(backup: Path, phase: str):
|
def rollback(backup: Path, phase: str):
|
||||||
for service in (
|
for service in (
|
||||||
|
"nodedc-host-telemetry-agent.service",
|
||||||
"nodedc-b2-relay.service",
|
"nodedc-b2-relay.service",
|
||||||
"nodedc-b2-backhaul.service",
|
"nodedc-b2-backhaul.service",
|
||||||
"nodedc-device-edge-channel.service",
|
"nodedc-device-edge-channel.service",
|
||||||
@@ -1866,6 +2122,9 @@ def rollback(backup: Path, phase: str):
|
|||||||
if phase == "core-channel":
|
if phase == "core-channel":
|
||||||
if not users_before.get(CHANNEL_USER, False) and user_exists(CHANNEL_USER):
|
if not users_before.get(CHANNEL_USER, False) and user_exists(CHANNEL_USER):
|
||||||
run(["/usr/sbin/userdel", CHANNEL_USER], check=False)
|
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"):
|
if phase == "foundation" and not metadata.get("serviceUserExisted"):
|
||||||
runtime_state_root = Path("/var/lib/nodedc-b2-vps")
|
runtime_state_root = Path("/var/lib/nodedc-b2-vps")
|
||||||
if LIVE_ROOT.exists() and not LIVE_ROOT.is_symlink():
|
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("runtime_composition=single-non-root-process:core-channel+universal-gateway")
|
||||||
print("tailscale=preserved:absent")
|
print("tailscale=preserved:absent")
|
||||||
print("services=recreate:nodedc-device-edge-channel")
|
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("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"health=127.0.0.1:{CHANNEL_HEALTH_PORT}:combined-edge-runtime")
|
||||||
print("public_b2_ingress=155.212.211.15:9921/tcp:bidirectional-session")
|
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("runtime_composition=single-non-root-process:core-channel+universal-gateway")
|
||||||
print("tailscale=preserved:absent")
|
print("tailscale=preserved:absent")
|
||||||
print("services=recreate:nodedc-device-edge-channel")
|
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(
|
print(
|
||||||
"command_transport=typed-service-ping-v1:allowlisted-adapter-only"
|
"command_transport=typed-service-ping-v1:allowlisted-adapter-only"
|
||||||
if phase == "command-transport"
|
if phase in {"command-transport", "host-telemetry"}
|
||||||
else "command_transport=disabled"
|
else "command_transport=disabled"
|
||||||
)
|
)
|
||||||
if phase == "command-transport":
|
if phase in {"command-transport", "host-telemetry"}:
|
||||||
print("command_catalog=allowlisted-adapter-typed-commands-only")
|
print("command_catalog=allowlisted-adapter-typed-commands-only")
|
||||||
print(
|
print(
|
||||||
"gelios=untouched-legacy-only"
|
"gelios=untouched-legacy-only"
|
||||||
if phase == "command-transport"
|
if phase in {"command-transport", "host-telemetry"}
|
||||||
else "gelios=untouched"
|
else "gelios=untouched"
|
||||||
)
|
)
|
||||||
print("dns=unchanged")
|
print("dns=unchanged")
|
||||||
@@ -2013,8 +2283,10 @@ def apply_artifact(artifact_argument: str):
|
|||||||
apply_tailscale_retirement(loaded["payload"])
|
apply_tailscale_retirement(loaded["payload"])
|
||||||
elif loaded["phase"] == "tracker-ingress":
|
elif loaded["phase"] == "tracker-ingress":
|
||||||
apply_tracker_ingress(loaded["payload"])
|
apply_tracker_ingress(loaded["payload"])
|
||||||
else:
|
elif loaded["phase"] == "command-transport":
|
||||||
apply_command_transport(loaded["payload"])
|
apply_command_transport(loaded["payload"])
|
||||||
|
else:
|
||||||
|
apply_host_telemetry(loaded["payload"])
|
||||||
|
|
||||||
archived = archive_artifact(loaded["artifact"], APPLIED_ROOT)
|
archived = archive_artifact(loaded["artifact"], APPLIED_ROOT)
|
||||||
record = {
|
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()
|
||||||
+442
@@ -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
|
#!/usr/bin/env python3
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
import importlib.machinery
|
import importlib.machinery
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
@@ -82,7 +83,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
|||||||
RUNNER.preflight({"phase": phase})
|
RUNNER.preflight({"phase": phase})
|
||||||
|
|
||||||
def test_accepted_shared_source_phases_cannot_be_rebuilt(self):
|
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(
|
with self.subTest(phase=phase), tempfile.TemporaryDirectory(
|
||||||
prefix=f"nodedc-vps-frozen-{phase}-"
|
prefix=f"nodedc-vps-frozen-{phase}-"
|
||||||
) as directory:
|
) as directory:
|
||||||
@@ -102,6 +103,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
|||||||
for name, digest in (
|
for name, digest in (
|
||||||
(RUNNER.NODE_ARCHIVE, RUNNER.NODE_ARCHIVE_SHA256),
|
(RUNNER.NODE_ARCHIVE, RUNNER.NODE_ARCHIVE_SHA256),
|
||||||
(RUNNER.TAILSCALE_ARCHIVE, RUNNER.TAILSCALE_ARCHIVE_SHA256),
|
(RUNNER.TAILSCALE_ARCHIVE, RUNNER.TAILSCALE_ARCHIVE_SHA256),
|
||||||
|
(RUNNER.TELEGRAF_ARCHIVE, RUNNER.TELEGRAF_ARCHIVE_SHA256),
|
||||||
):
|
):
|
||||||
path = DEFAULT_RUNTIME_CACHE / name
|
path = DEFAULT_RUNTIME_CACHE / name
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
@@ -114,10 +116,19 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
|||||||
+ ", ".join(missing)
|
+ ", ".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):
|
def test_builders_are_deterministic_narrow_and_secret_free(self):
|
||||||
self.require_runtime_cache()
|
self.require_telegraf_cache()
|
||||||
for phase in (
|
for phase in (
|
||||||
"command-transport",
|
"host-telemetry",
|
||||||
):
|
):
|
||||||
with self.subTest(phase=phase), tempfile.TemporaryDirectory(
|
with self.subTest(phase=phase), tempfile.TemporaryDirectory(
|
||||||
prefix=f"nodedc-vps-{phase}-"
|
prefix=f"nodedc-vps-{phase}-"
|
||||||
@@ -183,7 +194,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
|||||||
self.assertIn("runtime_digest_mismatch", result.stderr)
|
self.assertIn("runtime_digest_mismatch", result.stderr)
|
||||||
|
|
||||||
def test_runner_loads_each_exact_phase(self):
|
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:
|
with tempfile.TemporaryDirectory(prefix="nodedc-vps-load-") as directory:
|
||||||
inbox = Path(directory) / "inbox"
|
inbox = Path(directory) / "inbox"
|
||||||
inbox.mkdir()
|
inbox.mkdir()
|
||||||
@@ -191,7 +202,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
|||||||
RUNNER.INBOX_ROOT = inbox
|
RUNNER.INBOX_ROOT = inbox
|
||||||
try:
|
try:
|
||||||
for phase in (
|
for phase in (
|
||||||
"command-transport",
|
"host-telemetry",
|
||||||
):
|
):
|
||||||
result = self.build(
|
result = self.build(
|
||||||
inbox,
|
inbox,
|
||||||
@@ -416,14 +427,15 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
|||||||
self.assertIn("command_transport=disabled", rendered)
|
self.assertIn("command_transport=disabled", rendered)
|
||||||
self.assertIn("gelios=untouched", rendered)
|
self.assertIn("gelios=untouched", rendered)
|
||||||
|
|
||||||
def test_command_transport_plan_is_typed_single_process_and_bounded(self):
|
def test_host_telemetry_plan_is_loopback_agent_over_existing_mtls(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-command-plan-") as directory:
|
self.require_telegraf_cache()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-vps-host-telemetry-plan-") as directory:
|
||||||
inbox = Path(directory) / "inbox"
|
inbox = Path(directory) / "inbox"
|
||||||
inbox.mkdir()
|
inbox.mkdir()
|
||||||
result = self.build(
|
result = self.build(
|
||||||
inbox,
|
inbox,
|
||||||
"command-transport",
|
"host-telemetry",
|
||||||
"device-edge-vps-command-transport-plan-001",
|
"device-edge-vps-host-telemetry-plan-001",
|
||||||
)
|
)
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||||
@@ -433,7 +445,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
|||||||
with patch.object(RUNNER, "assert_root"), patch.object(
|
with patch.object(RUNNER, "assert_root"), patch.object(
|
||||||
RUNNER,
|
RUNNER,
|
||||||
"preflight",
|
"preflight",
|
||||||
return_value={"predecessor": "accepted-tracker-ingress-012"},
|
return_value={"predecessor": "accepted-command-transport-013"},
|
||||||
), patch("builtins.print") as output:
|
), patch("builtins.print") as output:
|
||||||
RUNNER.plan_artifact(str(artifact))
|
RUNNER.plan_artifact(str(artifact))
|
||||||
finally:
|
finally:
|
||||||
@@ -442,14 +454,78 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
|||||||
" ".join(str(arg) for arg in call.args)
|
" ".join(str(arg) for arg in call.args)
|
||||||
for call in output.call_args_list
|
for call in output.call_args_list
|
||||||
)
|
)
|
||||||
self.assertIn("phase=command-transport", rendered)
|
self.assertIn("phase=host-telemetry", rendered)
|
||||||
self.assertIn("predecessor=accepted-tracker-ingress-012", 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_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)
|
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):
|
def test_publish_payload_preserves_unselected_executable_modes(self):
|
||||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-publish-scope-") as directory:
|
with tempfile.TemporaryDirectory(prefix="nodedc-vps-publish-scope-") as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
|
|||||||
@@ -144,6 +144,16 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
== RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V1_COMPOSE_SHA256
|
== 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):
|
def build(self, script, patch_id, artifact_dir):
|
||||||
environment = os.environ.copy()
|
environment = os.environ.copy()
|
||||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
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):
|
def test_historical_manager_builder_fails_closed_after_v4_compose(self):
|
||||||
if self.historical_manager_compose_is_current():
|
if self.historical_manager_compose_is_current():
|
||||||
self.skipTest("historical Manager Compose is still current")
|
self.skipTest("historical Manager Compose is still current")
|
||||||
@@ -598,7 +1004,40 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
completed.stderr,
|
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):
|
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(
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
"build-device-edge-core-channel-bootstrap-artifact.mjs",
|
"build-device-edge-core-channel-bootstrap-artifact.mjs",
|
||||||
"device-edge-core-channel-bootstrap-unit-001",
|
"device-edge-core-channel-bootstrap-unit-001",
|
||||||
@@ -635,6 +1074,8 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
self.assertEqual(checks[0]["expected_json"]["commandTransport"], "disabled")
|
self.assertEqual(checks[0]["expected_json"]["commandTransport"], "disabled")
|
||||||
|
|
||||||
def test_edge_core_channel_upgrade_is_core_only_and_pins_bootstrap_018(self):
|
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(
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
"build-device-edge-core-channel-bootstrap-artifact.mjs",
|
"build-device-edge-core-channel-bootstrap-artifact.mjs",
|
||||||
"device-edge-core-channel-upgrade-unit-001",
|
"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):
|
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"
|
patch_id = "device-edge-core-channel-upgrade-v2-unit-001"
|
||||||
manifest, entries, names, result = self.assert_deterministic_artifact(
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
"build-device-edge-core-channel-bootstrap-artifact.mjs",
|
"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):
|
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"
|
patch_id = "device-edge-core-channel-upgrade-v4-unit-001"
|
||||||
manifest, entries, names, result = self.assert_deterministic_artifact(
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
"build-device-edge-core-channel-bootstrap-artifact.mjs",
|
"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):
|
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"
|
patch_id = "device-control-core-release-unit-001"
|
||||||
manifest, entries, names, result = self.assert_deterministic_artifact(
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
"build-device-control-core-release-artifact.mjs",
|
"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):
|
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"
|
patch_id = "device-control-core-release-v2-unit-001"
|
||||||
manifest, entries, names, result = self.assert_deterministic_artifact(
|
manifest, entries, names, result = self.assert_deterministic_artifact(
|
||||||
"build-device-control-core-release-artifact.mjs",
|
"build-device-control-core-release-artifact.mjs",
|
||||||
@@ -911,7 +1360,96 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(descriptor["gelios"], "untouched-legacy-only")
|
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):
|
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(
|
with tempfile.TemporaryDirectory(
|
||||||
prefix="nodedc-control-core-successor-",
|
prefix="nodedc-control-core-successor-",
|
||||||
) as directory:
|
) as directory:
|
||||||
@@ -968,6 +1506,8 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_control_core_release_v2_builder_supports_v2_release_predecessor(self):
|
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(
|
with tempfile.TemporaryDirectory(
|
||||||
prefix="nodedc-control-core-v2-successor-",
|
prefix="nodedc-control-core-v2-successor-",
|
||||||
) as directory:
|
) as directory:
|
||||||
@@ -2440,7 +2980,6 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
)),
|
)),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
restored_core_id = "f" * 64
|
|
||||||
with (
|
with (
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
RUNNER,
|
RUNNER,
|
||||||
@@ -2453,29 +2992,20 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
RUNNER,
|
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,
|
) as restore_runtime,
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
RUNNER,
|
RUNNER,
|
||||||
"healthcheck_compose_service_with_grace",
|
"accept_device_plane_control_core_rollback_runtime",
|
||||||
) as restore_health,
|
) as rollback_acceptance,
|
||||||
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"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
RUNNER,
|
RUNNER,
|
||||||
"validate_device_manager_control_plane_runtime",
|
"validate_device_manager_control_plane_runtime",
|
||||||
@@ -2491,20 +3021,20 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
stop.assert_not_called()
|
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",
|
"device-plane",
|
||||||
existing,
|
existing,
|
||||||
|
)
|
||||||
|
restore_runtime.assert_called_once_with(
|
||||||
|
"device-plane",
|
||||||
("device-control-core",),
|
("device-control-core",),
|
||||||
|
existing,
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
rollback_acceptance.assert_called_once()
|
||||||
[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"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
runtime_acceptance.assert_called_once_with(
|
runtime_acceptance.assert_called_once_with(
|
||||||
require_edge_channel=True,
|
require_edge_channel=True,
|
||||||
core_network_mode="private-egress",
|
core_network_mode="private-egress",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const EDGE_TO_CORE_MESSAGE_KINDS = Object.freeze([
|
|||||||
"adapter.message",
|
"adapter.message",
|
||||||
"delivery.acknowledged",
|
"delivery.acknowledged",
|
||||||
"command.status",
|
"command.status",
|
||||||
|
"host.telemetry.observed",
|
||||||
"channel.counters",
|
"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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY packages/device-protocol-contract ./packages/device-protocol-contract
|
COPY packages/device-protocol-contract ./packages/device-protocol-contract
|
||||||
COPY packages/device-edge-channel-contract ./packages/device-edge-channel-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
|
COPY services/device-control-core ./services/device-control-core
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
|||||||
@@ -27,6 +27,6 @@ alter table device_management_command_receipts
|
|||||||
'device_binding.revoke',
|
'device_binding.revoke',
|
||||||
'device_configuration_revision.create',
|
'device_configuration_revision.create',
|
||||||
'device_configuration_desired.set'
|
'device_configuration_desired.set'
|
||||||
));
|
)) not valid;
|
||||||
|
|
||||||
commit;
|
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;
|
||||||
@@ -46,6 +46,29 @@ const managementRoutes = new Map([
|
|||||||
"/internal/v1/management/device-configurations:set-desired",
|
"/internal/v1/management/device-configurations:set-desired",
|
||||||
"device_configuration_desired.set",
|
"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({
|
export function createControlCoreApp({
|
||||||
@@ -287,11 +310,42 @@ export function createControlCoreApp({
|
|||||||
commandTransport: typedCommandRuntime
|
commandTransport: typedCommandRuntime
|
||||||
? "typed-service-ping-v1"
|
? "typed-service-ping-v1"
|
||||||
: "disabled",
|
: "disabled",
|
||||||
|
edgeChannelStatus: edgeChannelStatusProvider
|
||||||
|
? edgeChannelStatusProvider()
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return writeJson(response, 200, { ok: true, workspace });
|
return writeJson(response, 200, { ok: true, workspace });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
if (
|
||||||
request.method === "POST"
|
request.method === "POST"
|
||||||
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
|
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
|
||||||
@@ -405,6 +459,13 @@ function projectWorkspaceId(pathname) {
|
|||||||
return match?.[1]?.toLowerCase() ?? null;
|
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) {
|
function managementActorFromHeaders(headers) {
|
||||||
return normalizeManagementActor({
|
return normalizeManagementActor({
|
||||||
userRef: singleHeader(headers["x-nodedc-user-ref"]),
|
userRef: singleHeader(headers["x-nodedc-user-ref"]),
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import {
|
|||||||
normalizeAdapterMessage,
|
normalizeAdapterMessage,
|
||||||
normalizeDiscoverySignal,
|
normalizeDiscoverySignal,
|
||||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
} 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.
|
// Runtime-owned transport implementation; kept inside the deployable Core context.
|
||||||
const CHANNEL_TRACKER_SESSION_ID = "channel:control";
|
const CHANNEL_TRACKER_SESSION_ID = "channel:control";
|
||||||
@@ -277,7 +280,7 @@ export function createDeviceGatewayCoreChannelClient(options = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (envelope.messageKind === "channel.heartbeat") 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);
|
scheduleTrackerEvent(connection, envelope);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -297,7 +300,9 @@ export function createDeviceGatewayCoreChannelClient(options = {}) {
|
|||||||
? acceptDiscovery(connection, envelope)
|
? acceptDiscovery(connection, envelope)
|
||||||
: envelope.messageKind === "adapter.message"
|
: envelope.messageKind === "adapter.message"
|
||||||
? acceptAdapterMessage(connection, envelope)
|
? acceptAdapterMessage(connection, envelope)
|
||||||
: acceptCommandStatus(connection, envelope))
|
: envelope.messageKind === "command.status"
|
||||||
|
? acceptCommandStatus(connection, envelope)
|
||||||
|
: acceptHostTelemetry(connection, envelope))
|
||||||
.catch((error) => failConnection(connection, error))
|
.catch((error) => failConnection(connection, error))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (connection.sessionChains.get(envelope.trackerSessionId) === work) {
|
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) {
|
async function acceptCommandStatus(connection, envelope) {
|
||||||
try {
|
try {
|
||||||
await config.recordCommandStatus(envelope.payload?.status);
|
await config.recordCommandStatus(envelope.payload?.status);
|
||||||
@@ -580,7 +602,13 @@ function normalizeConfig(options) {
|
|||||||
}
|
}
|
||||||
const offerCommand = options.offerCommand ?? (async () => null);
|
const offerCommand = options.offerCommand ?? (async () => null);
|
||||||
const recordCommandStatus = options.recordCommandStatus ?? (async () => undefined);
|
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");
|
throw new TypeError("device_gateway_core_command_runtime_invalid");
|
||||||
}
|
}
|
||||||
const registrationProvider = typeof options.registrationProvider === "function"
|
const registrationProvider = typeof options.registrationProvider === "function"
|
||||||
@@ -627,6 +655,7 @@ function normalizeConfig(options) {
|
|||||||
commandTransport,
|
commandTransport,
|
||||||
offerCommand,
|
offerCommand,
|
||||||
recordCommandStatus,
|
recordCommandStatus,
|
||||||
|
recordHostTelemetry,
|
||||||
keepaliveMs,
|
keepaliveMs,
|
||||||
deadPeerMs,
|
deadPeerMs,
|
||||||
connectTimeoutMs: normalizeInteger(
|
connectTimeoutMs: normalizeInteger(
|
||||||
|
|||||||
@@ -116,6 +116,15 @@ export function createDeviceEdgeChannelSupervisor(options = {}) {
|
|||||||
message,
|
message,
|
||||||
{ authenticatedEdgeRef: registration.edgeRegistrationId },
|
{ 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
|
commandTransport: config.typedCommandRuntime
|
||||||
? "typed-service-ping-v1"
|
? "typed-service-ping-v1"
|
||||||
: "disabled",
|
: "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,
|
DEVICE_MANAGEMENT_COMMAND_KINDS,
|
||||||
normalizeManagementCommand,
|
normalizeManagementCommand,
|
||||||
} from "./project-management.mjs";
|
} from "./project-management.mjs";
|
||||||
|
import {
|
||||||
|
DEVICE_ONTOLOGY_COMMAND_KINDS,
|
||||||
|
isOntologyManagementCommand,
|
||||||
|
normalizeOntologyManagementCommand,
|
||||||
|
} from "./ontology-management.mjs";
|
||||||
import {
|
import {
|
||||||
DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
|
DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
|
||||||
isSensitiveReferenceManagementCommand,
|
isSensitiveReferenceManagementCommand,
|
||||||
@@ -29,9 +34,13 @@ export const ALL_DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([
|
|||||||
...DEVICE_LIFECYCLE_COMMAND_KINDS,
|
...DEVICE_LIFECYCLE_COMMAND_KINDS,
|
||||||
...DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
|
...DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
|
||||||
...DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
|
...DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
|
||||||
|
...DEVICE_ONTOLOGY_COMMAND_KINDS,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function normalizeDeviceManagementCommand(kind, input) {
|
export function normalizeDeviceManagementCommand(kind, input) {
|
||||||
|
if (isOntologyManagementCommand(kind)) {
|
||||||
|
return normalizeOntologyManagementCommand(kind, input);
|
||||||
|
}
|
||||||
if (isControlResourceManagementCommand(kind)) {
|
if (isControlResourceManagementCommand(kind)) {
|
||||||
return normalizeControlResourceManagementCommand(kind, input);
|
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,
|
getDeviceProjectWorkspace,
|
||||||
listAccessibleDeviceProjects,
|
listAccessibleDeviceProjects,
|
||||||
} from "./project-query-repository.mjs";
|
} from "./project-query-repository.mjs";
|
||||||
|
import {
|
||||||
|
getDeviceProjectOntologyProjection,
|
||||||
|
} from "./ontology-query-repository.mjs";
|
||||||
import {
|
import {
|
||||||
applyInfrastructureManagementCommand,
|
applyInfrastructureManagementCommand,
|
||||||
authorizeInfrastructureManagementReplay,
|
authorizeInfrastructureManagementReplay,
|
||||||
@@ -36,6 +39,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
isSensitiveReferenceManagementCommand,
|
isSensitiveReferenceManagementCommand,
|
||||||
} from "./sensitive-reference-management.mjs";
|
} from "./sensitive-reference-management.mjs";
|
||||||
|
import {
|
||||||
|
applyOntologyManagementCommand,
|
||||||
|
authorizeOntologyManagementReplay,
|
||||||
|
} from "./ontology-repository.mjs";
|
||||||
|
import { isOntologyManagementCommand } from "./ontology-management.mjs";
|
||||||
import {
|
import {
|
||||||
assertActorCanManageOwnerScope,
|
assertActorCanManageOwnerScope,
|
||||||
assertGrantMutationAllowed,
|
assertGrantMutationAllowed,
|
||||||
@@ -47,6 +55,9 @@ import {
|
|||||||
planTypedServicePing,
|
planTypedServicePing,
|
||||||
recordTypedCommandStatus,
|
recordTypedCommandStatus,
|
||||||
} from "./typed-command-repository.mjs";
|
} from "./typed-command-repository.mjs";
|
||||||
|
import {
|
||||||
|
recordInfrastructureHostTelemetry,
|
||||||
|
} from "./host-telemetry-repository.mjs";
|
||||||
|
|
||||||
const { Pool } = pg;
|
const { Pool } = pg;
|
||||||
const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
@@ -66,6 +77,8 @@ const migrationFiles = [
|
|||||||
"013_device_edge_channels.sql",
|
"013_device_edge_channels.sql",
|
||||||
"014_device_registry_profile_commands.sql",
|
"014_device_registry_profile_commands.sql",
|
||||||
"015_device_integration_identity.sql",
|
"015_device_integration_identity.sql",
|
||||||
|
"016_device_asset_infrastructure_ontology.sql",
|
||||||
|
"017_infrastructure_host_telemetry.sql",
|
||||||
];
|
];
|
||||||
|
|
||||||
export class PostgresDeviceRepository {
|
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) {
|
async listActiveEdgeChannelRegistrations(limit = 64) {
|
||||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
|
||||||
throw new TypeError("device_edge_channel_registration_limit_invalid");
|
throw new TypeError("device_edge_channel_registration_limit_invalid");
|
||||||
@@ -223,6 +242,12 @@ export class PostgresDeviceRepository {
|
|||||||
return this.#executeWrite((client) => recordTypedCommandStatus(client, input));
|
return this.#executeWrite((client) => recordTypedCommandStatus(client, input));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async recordInfrastructureHostTelemetry(input) {
|
||||||
|
return this.#executeWrite((client) =>
|
||||||
|
recordInfrastructureHostTelemetry(client, input)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async #executeWrite(operation) {
|
async #executeWrite(operation) {
|
||||||
const client = await this.pool.connect();
|
const client = await this.pool.connect();
|
||||||
try {
|
try {
|
||||||
@@ -320,6 +345,13 @@ async function completeManagementReceipt(client, receiptId, result) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyManagementCommand(client, { commandKind, actor, command }) {
|
async function applyManagementCommand(client, { commandKind, actor, command }) {
|
||||||
|
if (isOntologyManagementCommand(commandKind)) {
|
||||||
|
return applyOntologyManagementCommand(client, {
|
||||||
|
commandKind,
|
||||||
|
actor,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (isControlResourceManagementCommand(commandKind)) {
|
if (isControlResourceManagementCommand(commandKind)) {
|
||||||
return applyControlResourceManagementCommand(client, {
|
return applyControlResourceManagementCommand(client, {
|
||||||
commandKind,
|
commandKind,
|
||||||
@@ -364,6 +396,13 @@ async function applyManagementCommand(client, { commandKind, actor, command }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function authorizeManagementReplay(client, { commandKind, actor, command }) {
|
async function authorizeManagementReplay(client, { commandKind, actor, command }) {
|
||||||
|
if (isOntologyManagementCommand(commandKind)) {
|
||||||
|
return authorizeOntologyManagementReplay(client, {
|
||||||
|
commandKind,
|
||||||
|
actor,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (isControlResourceManagementCommand(commandKind)) {
|
if (isControlResourceManagementCommand(commandKind)) {
|
||||||
return authorizeControlResourceManagementReplay(client, {
|
return authorizeControlResourceManagementReplay(client, {
|
||||||
commandKind,
|
commandKind,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([
|
|||||||
"project.manage",
|
"project.manage",
|
||||||
"access.manage",
|
"access.manage",
|
||||||
"inventory.read",
|
"inventory.read",
|
||||||
|
"asset.manage",
|
||||||
"device.enroll",
|
"device.enroll",
|
||||||
"device.claim",
|
"device.claim",
|
||||||
"device.transfer",
|
"device.transfer",
|
||||||
@@ -10,6 +11,9 @@ export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([
|
|||||||
"route.manage",
|
"route.manage",
|
||||||
"binding.manage",
|
"binding.manage",
|
||||||
"telemetry.observe",
|
"telemetry.observe",
|
||||||
|
"observation.write",
|
||||||
|
"infrastructure.read",
|
||||||
|
"infrastructure.manage",
|
||||||
"configuration.read",
|
"configuration.read",
|
||||||
"configuration.manage",
|
"configuration.manage",
|
||||||
"command.plan",
|
"command.plan",
|
||||||
@@ -53,6 +57,7 @@ const roleCapabilities = Object.freeze({
|
|||||||
viewer: Object.freeze([
|
viewer: Object.freeze([
|
||||||
"project.read",
|
"project.read",
|
||||||
"inventory.read",
|
"inventory.read",
|
||||||
|
"infrastructure.read",
|
||||||
"telemetry.observe",
|
"telemetry.observe",
|
||||||
"configuration.read",
|
"configuration.read",
|
||||||
"audit.read",
|
"audit.read",
|
||||||
@@ -61,6 +66,8 @@ const roleCapabilities = Object.freeze({
|
|||||||
"project.read",
|
"project.read",
|
||||||
"inventory.read",
|
"inventory.read",
|
||||||
"telemetry.observe",
|
"telemetry.observe",
|
||||||
|
"observation.write",
|
||||||
|
"infrastructure.read",
|
||||||
"configuration.read",
|
"configuration.read",
|
||||||
"command.plan",
|
"command.plan",
|
||||||
"command.confirm",
|
"command.confirm",
|
||||||
@@ -72,10 +79,14 @@ const roleCapabilities = Object.freeze({
|
|||||||
"inventory.read",
|
"inventory.read",
|
||||||
"device.enroll",
|
"device.enroll",
|
||||||
"device.claim",
|
"device.claim",
|
||||||
|
"asset.manage",
|
||||||
"collection.manage",
|
"collection.manage",
|
||||||
"route.manage",
|
"route.manage",
|
||||||
"binding.manage",
|
"binding.manage",
|
||||||
"telemetry.observe",
|
"telemetry.observe",
|
||||||
|
"observation.write",
|
||||||
|
"infrastructure.read",
|
||||||
|
"infrastructure.manage",
|
||||||
"configuration.read",
|
"configuration.read",
|
||||||
"configuration.manage",
|
"configuration.manage",
|
||||||
"command.plan",
|
"command.plan",
|
||||||
@@ -88,10 +99,14 @@ const roleCapabilities = Object.freeze({
|
|||||||
"inventory.read",
|
"inventory.read",
|
||||||
"device.enroll",
|
"device.enroll",
|
||||||
"device.claim",
|
"device.claim",
|
||||||
|
"asset.manage",
|
||||||
"collection.manage",
|
"collection.manage",
|
||||||
"route.manage",
|
"route.manage",
|
||||||
"binding.manage",
|
"binding.manage",
|
||||||
"telemetry.observe",
|
"telemetry.observe",
|
||||||
|
"observation.write",
|
||||||
|
"infrastructure.read",
|
||||||
|
"infrastructure.manage",
|
||||||
"configuration.read",
|
"configuration.read",
|
||||||
"configuration.manage",
|
"configuration.manage",
|
||||||
"command.plan",
|
"command.plan",
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export async function getDeviceProjectWorkspace(
|
|||||||
client,
|
client,
|
||||||
actor,
|
actor,
|
||||||
projectId,
|
projectId,
|
||||||
{ commandTransport = "disabled" } = {},
|
{ commandTransport = "disabled", edgeChannelStatus = null } = {},
|
||||||
) {
|
) {
|
||||||
const project = await findProjectWithCapability(
|
const project = await findProjectWithCapability(
|
||||||
client,
|
client,
|
||||||
@@ -204,7 +204,8 @@ export async function getDeviceProjectWorkspace(
|
|||||||
);
|
);
|
||||||
const edges = await client.query(
|
const edges = await client.query(
|
||||||
`select de.id, de.edge_key, de.display_name, de.deployment_ref,
|
`select de.id, de.edge_key, de.display_name, de.deployment_ref,
|
||||||
de.lifecycle_state, de.created_at, de.updated_at
|
de.lifecycle_state, de.channel_lifecycle_state,
|
||||||
|
de.channel_generation_ref, de.created_at, de.updated_at
|
||||||
from device_edges de
|
from device_edges de
|
||||||
where $2::boolean
|
where $2::boolean
|
||||||
or exists (
|
or exists (
|
||||||
@@ -333,7 +334,10 @@ export async function getDeviceProjectWorkspace(
|
|||||||
adapterPackages: adapterPackages.rows.map(adapterPackageView),
|
adapterPackages: adapterPackages.rows.map(adapterPackageView),
|
||||||
adapterVersions: adapterVersions.rows.map(adapterVersionView),
|
adapterVersions: adapterVersions.rows.map(adapterVersionView),
|
||||||
modelProfiles: modelProfiles.rows.map(modelProfileView),
|
modelProfiles: modelProfiles.rows.map(modelProfileView),
|
||||||
edges: edges.rows.map(edgeView),
|
edges: edges.rows.map((row) => edgeView(
|
||||||
|
row,
|
||||||
|
edgeChannelRuntime(edgeChannelStatus, `edge:${row.id}`),
|
||||||
|
)),
|
||||||
routes: routes.rows.map(routeView),
|
routes: routes.rows.map(routeView),
|
||||||
sessions: sessions.rows.map(sessionView),
|
sessions: sessions.rows.map(sessionView),
|
||||||
bindings: bindings.rows.map(bindingView),
|
bindings: bindings.rows.map(bindingView),
|
||||||
@@ -496,18 +500,41 @@ function modelProfileView(row) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function edgeView(row) {
|
function edgeView(row, runtime) {
|
||||||
|
const channelLifecycleState = row.channel_lifecycle_state ?? "disabled";
|
||||||
return {
|
return {
|
||||||
edgeRef: `edge:${row.id}`,
|
edgeRef: `edge:${row.id}`,
|
||||||
edgeKey: row.edge_key,
|
edgeKey: row.edge_key,
|
||||||
displayName: row.display_name,
|
displayName: row.display_name,
|
||||||
deploymentRef: row.deployment_ref ?? null,
|
deploymentRef: row.deployment_ref ?? null,
|
||||||
lifecycleState: row.lifecycle_state,
|
lifecycleState: row.lifecycle_state,
|
||||||
|
channel: {
|
||||||
|
lifecycleState: channelLifecycleState,
|
||||||
|
generationRef: row.channel_generation_ref ?? null,
|
||||||
|
runtimeState: channelLifecycleState === "active"
|
||||||
|
? runtime?.channel ?? "unobserved"
|
||||||
|
: channelLifecycleState,
|
||||||
|
lastErrorCode: runtime?.lastErrorCode ?? null,
|
||||||
|
},
|
||||||
createdAt: toIso(row.created_at),
|
createdAt: toIso(row.created_at),
|
||||||
updatedAt: toIso(row.updated_at),
|
updatedAt: toIso(row.updated_at),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function edgeChannelRuntime(status, edgeRef) {
|
||||||
|
if (!status || !Array.isArray(status.edges)) return null;
|
||||||
|
const value = status.edges.find((item) => item?.edgeRegistrationId === edgeRef);
|
||||||
|
if (!value) return null;
|
||||||
|
return {
|
||||||
|
channel: ["accepted", "connecting", "absent"].includes(value.channel)
|
||||||
|
? value.channel
|
||||||
|
: "unobserved",
|
||||||
|
lastErrorCode: typeof value.lastErrorCode === "string"
|
||||||
|
? value.lastErrorCode.slice(0, 128)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function routeView(row) {
|
function routeView(row) {
|
||||||
return {
|
return {
|
||||||
routeRef: `route:${row.id}`,
|
routeRef: `route:${row.id}`,
|
||||||
|
|||||||
@@ -347,14 +347,23 @@ test("project query is service-authenticated and forwards only the trusted actor
|
|||||||
test("project workspace query accepts only a canonical project path", async () => {
|
test("project workspace query accepts only a canonical project path", async () => {
|
||||||
const projectId = "11111111-1111-4111-8111-111111111111";
|
const projectId = "11111111-1111-4111-8111-111111111111";
|
||||||
let queried;
|
let queried;
|
||||||
|
const edgeChannels = {
|
||||||
|
enabled: true,
|
||||||
|
edges: [{
|
||||||
|
edgeRegistrationId: "edge:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||||
|
channel: "accepted",
|
||||||
|
lastErrorCode: null,
|
||||||
|
}],
|
||||||
|
};
|
||||||
const runtime = await startTestServer({
|
const runtime = await startTestServer({
|
||||||
managementApiEnabled: true,
|
managementApiEnabled: true,
|
||||||
managementToken,
|
managementToken,
|
||||||
|
edgeChannelStatusProvider: () => edgeChannels,
|
||||||
repository: {
|
repository: {
|
||||||
health: async () => "ready",
|
health: async () => "ready",
|
||||||
executeManagementCommand: async () => ({ replayed: false, result: {} }),
|
executeManagementCommand: async () => ({ replayed: false, result: {} }),
|
||||||
getProjectWorkspace: async (actor, id) => {
|
getProjectWorkspace: async (actor, id, options) => {
|
||||||
queried = { actor, id };
|
queried = { actor, id, options };
|
||||||
return { project: { projectRef: `project:${id}` }, devices: [] };
|
return { project: { projectRef: `project:${id}` }, devices: [] };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -367,6 +376,7 @@ test("project workspace query accepts only a canonical project path", async () =
|
|||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
assert.equal((await response.json()).workspace.devices.length, 0);
|
assert.equal((await response.json()).workspace.devices.length, 0);
|
||||||
assert.equal(queried.id, projectId);
|
assert.equal(queried.id, projectId);
|
||||||
|
assert.equal(queried.options.edgeChannelStatus, edgeChannels);
|
||||||
|
|
||||||
const invalid = await fetch(
|
const invalid = await fetch(
|
||||||
`${runtime.baseUrl}/internal/v1/query/projects/not-a-project/workspace`,
|
`${runtime.baseUrl}/internal/v1/query/projects/not-a-project/workspace`,
|
||||||
|
|||||||
@@ -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",
|
"007_device_lifecycle_commands.sql",
|
||||||
"009_device_sensitive_reference_commands.sql",
|
"009_device_sensitive_reference_commands.sql",
|
||||||
"011_device_control_resource_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([
|
const finalCommandKinds = Object.freeze([
|
||||||
"owner_scope.ensure",
|
"owner_scope.ensure",
|
||||||
"project.ensure",
|
"project.ensure",
|
||||||
@@ -31,6 +32,14 @@ const finalCommandKinds = Object.freeze([
|
|||||||
"device_binding.revoke",
|
"device_binding.revoke",
|
||||||
"device_configuration_revision.create",
|
"device_configuration_revision.create",
|
||||||
"device_configuration_desired.set",
|
"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(
|
const migrationUrl = new URL(
|
||||||
"../migrations/003_device_management_commands.sql",
|
"../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 () => {
|
test("project workspace returns only masked identity projections", async () => {
|
||||||
const client = workspaceClient();
|
const client = workspaceClient();
|
||||||
const workspace = await getDeviceProjectWorkspace(client, actor, projectId);
|
const workspace = await getDeviceProjectWorkspace(client, actor, projectId, {
|
||||||
|
edgeChannelStatus: {
|
||||||
|
edges: [{
|
||||||
|
edgeRegistrationId: "edge:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||||
|
channel: "accepted",
|
||||||
|
lastErrorCode: null,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
assert.equal(workspace.project.projectRef, `project:${projectId}`);
|
assert.equal(workspace.project.projectRef, `project:${projectId}`);
|
||||||
assert.equal(workspace.devices[0].identifier.masked, "***********0001");
|
assert.equal(workspace.devices[0].identifier.masked, "***********0001");
|
||||||
@@ -66,6 +74,12 @@ test("project workspace returns only masked identity projections", async () => {
|
|||||||
assert.equal(workspace.adapterPackages[0].packageKey, "generic-tracker");
|
assert.equal(workspace.adapterPackages[0].packageKey, "generic-tracker");
|
||||||
assert.equal(workspace.modelProfiles[0].modelProfileRef, "vendor.model.v1");
|
assert.equal(workspace.modelProfiles[0].modelProfileRef, "vendor.model.v1");
|
||||||
assert.equal(workspace.routes[0].activeSessionCount, 1);
|
assert.equal(workspace.routes[0].activeSessionCount, 1);
|
||||||
|
assert.deepEqual(workspace.edges[0].channel, {
|
||||||
|
lifecycleState: "active",
|
||||||
|
generationRef: "channel-generation:1",
|
||||||
|
runtimeState: "accepted",
|
||||||
|
lastErrorCode: null,
|
||||||
|
});
|
||||||
assert.equal(workspace.sessions[0].frameCount, 12);
|
assert.equal(workspace.sessions[0].frameCount, 12);
|
||||||
assert.equal(workspace.bindings[0].lifecycleState, "pending_external_approval");
|
assert.equal(workspace.bindings[0].lifecycleState, "pending_external_approval");
|
||||||
assert.equal(workspace.configurationRevisions[0].revisionNumber, 1);
|
assert.equal(workspace.configurationRevisions[0].revisionNumber, 1);
|
||||||
@@ -103,6 +117,10 @@ test("project read source never selects identifier digests or credential refs",
|
|||||||
source,
|
source,
|
||||||
/\b(?:identifier_digest|expected_identifier_digest|credential_ref|parameters_digest|parameters_projection|transport_message_ref|external_approval_ref|external_approval_digest)\b/,
|
/\b(?:identifier_digest|expected_identifier_digest|credential_ref|parameters_digest|parameters_projection|transport_message_ref|external_approval_ref|external_approval_digest)\b/,
|
||||||
);
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/\b(?:channel_endpoint|channel_servername|channel_trust_bundle_ref|channel_certificate_identities)\b/,
|
||||||
|
);
|
||||||
assert.doesNotMatch(source, /\b(?:dae\.payload|dcr\.configuration)\b/);
|
assert.doesNotMatch(source, /\b(?:dae\.payload|dcr\.configuration)\b/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,6 +185,8 @@ function workspaceClient({ queries = [] } = {}) {
|
|||||||
display_name: "Edge one",
|
display_name: "Edge one",
|
||||||
deployment_ref: "deployment:edge-one",
|
deployment_ref: "deployment:edge-one",
|
||||||
lifecycle_state: "active",
|
lifecycle_state: "active",
|
||||||
|
channel_lifecycle_state: "active",
|
||||||
|
channel_generation_ref: "channel-generation:1",
|
||||||
created_at: timestamp,
|
created_at: timestamp,
|
||||||
updated_at: timestamp,
|
updated_at: timestamp,
|
||||||
}] };
|
}] };
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
normalizeAdapterMessage,
|
normalizeAdapterMessage,
|
||||||
normalizeDiscoverySignal,
|
normalizeDiscoverySignal,
|
||||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
} 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_TRACKER_SESSION_ID = "channel:control";
|
||||||
const CHANNEL_PROFILE_REF = "channel.control.v1";
|
const CHANNEL_PROFILE_REF = "channel.control.v1";
|
||||||
@@ -195,6 +198,20 @@ export function createDeviceEdgeChannelServer(options = {}) {
|
|||||||
}
|
}
|
||||||
return Object.freeze({ status: "recorded" });
|
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() {
|
status() {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
listening: started,
|
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 () => {
|
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 commandRef = "command:11111111-1111-4111-8111-111111111111";
|
||||||
const deviceRef = "device:22222222-2222-4222-8222-222222222222";
|
const deviceRef = "device:22222222-2222-4222-8222-222222222222";
|
||||||
@@ -547,9 +574,61 @@ function createCoreClient(options) {
|
|||||||
commandTransport: options.commandTransport,
|
commandTransport: options.commandTransport,
|
||||||
offerCommand: options.offerCommand,
|
offerCommand: options.offerCommand,
|
||||||
recordCommandStatus: options.recordCommandStatus,
|
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() {
|
function productionDiscoveryObserver() {
|
||||||
return createDeviceGatewayIngest({
|
return createDeviceGatewayIngest({
|
||||||
identifierPepper: "test-only-device-edge-channel-identifier-pepper",
|
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"
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createDeviceGatewayRuntime,
|
createDeviceGatewayRuntime,
|
||||||
} from "../../services/device-gateway/src/runtime.mjs";
|
} 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) {
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
await main();
|
await main();
|
||||||
@@ -51,16 +52,26 @@ export async function main(environment = process.env) {
|
|||||||
onMessage: (message) => channel.submitAdapterMessage(message),
|
onMessage: (message) => channel.submitAdapterMessage(message),
|
||||||
onCommandStatus: (status) => channel.submitCommandStatus(status),
|
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;
|
let stopping = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await channel.start();
|
await channel.start();
|
||||||
await gateway.start();
|
await gateway.start();
|
||||||
|
await telemetry.start();
|
||||||
await listen(health, base.health.port, base.health.host);
|
await listen(health, base.health.port, base.health.host);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
gateway.stop(),
|
gateway.stop(),
|
||||||
|
telemetry.stop(),
|
||||||
channel.stop(),
|
channel.stop(),
|
||||||
closeServer(health),
|
closeServer(health),
|
||||||
]);
|
]);
|
||||||
@@ -72,6 +83,7 @@ export async function main(environment = process.env) {
|
|||||||
channel: `${base.channel.host}:${base.channel.port}`,
|
channel: `${base.channel.host}:${base.channel.port}`,
|
||||||
health: `${base.health.host}:${base.health.port}`,
|
health: `${base.health.host}:${base.health.port}`,
|
||||||
trackerIngress: `${tracker.tcpHost}:${tracker.tcpPort}`,
|
trackerIngress: `${tracker.tcpHost}:${tracker.tcpPort}`,
|
||||||
|
hostTelemetry: `${telemetry.status().host}:${telemetry.status().port}`,
|
||||||
adapterProfile: tracker.protocolProfileRef,
|
adapterProfile: tracker.protocolProfileRef,
|
||||||
edgeRegistrationId: base.channel.edgeRegistrationId,
|
edgeRegistrationId: base.channel.edgeRegistrationId,
|
||||||
channelGeneration: base.channel.channelGeneration,
|
channelGeneration: base.channel.channelGeneration,
|
||||||
@@ -87,6 +99,7 @@ export async function main(environment = process.env) {
|
|||||||
stopping = true;
|
stopping = true;
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
gateway.stop(),
|
gateway.stop(),
|
||||||
|
telemetry.stop(),
|
||||||
channel.stop(),
|
channel.stop(),
|
||||||
closeServer(health),
|
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) => {
|
return createServer((request, response) => {
|
||||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||||
response.setHeader("Cache-Control", "no-store");
|
response.setHeader("Cache-Control", "no-store");
|
||||||
@@ -179,6 +192,7 @@ function createCombinedHealthServer(channel, gateway, healthConfig) {
|
|||||||
...channel.status(),
|
...channel.status(),
|
||||||
trackerIngress: "telemetry-ingest",
|
trackerIngress: "telemetry-ingest",
|
||||||
tracker: gateway.status(),
|
tracker: gateway.status(),
|
||||||
|
hostTelemetry: telemetry.status(),
|
||||||
commandTransport: "typed-service-ping-v1",
|
commandTransport: "typed-service-ping-v1",
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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_CONNECTIONS_PER_MINUTE_PER_ADDRESS=60
|
||||||
Environment=DEVICE_GATEWAY_MAX_TRACKED_SOURCE_ADDRESSES=2048
|
Environment=DEVICE_GATEWAY_MAX_TRACKED_SOURCE_ADDRESSES=2048
|
||||||
Environment=DEVICE_GATEWAY_SESSION_TIMEOUT_MS=10000
|
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
|
Restart=always
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
TimeoutStartSec=20
|
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
|
||||||
Reference in New Issue
Block a user