feat(manager): manage ontology assets and infrastructure
This commit is contained in:
@@ -13,6 +13,14 @@ const commandRoutes = new Map([
|
||||
["enrollment-intents:ensure", "/internal/v1/management/enrollment-intents:ensure"],
|
||||
["devices:claim", "/internal/v1/management/devices:claim"],
|
||||
["devices:update", "/internal/v1/management/devices:update"],
|
||||
["assets:ensure", "/internal/v1/management/assets:ensure"],
|
||||
["asset-bindings:ensure", "/internal/v1/management/asset-bindings:ensure"],
|
||||
["asset-bindings:close", "/internal/v1/management/asset-bindings:close"],
|
||||
["infrastructure-hosts:ensure", "/internal/v1/management/infrastructure-hosts:ensure"],
|
||||
["infrastructure-endpoints:ensure", "/internal/v1/management/infrastructure-endpoints:ensure"],
|
||||
["infrastructure-deployments:ensure", "/internal/v1/management/infrastructure-deployments:ensure"],
|
||||
["infrastructure-service-instances:ensure", "/internal/v1/management/infrastructure-service-instances:ensure"],
|
||||
["health-observations:record", "/internal/v1/management/health-observations:record"],
|
||||
["device-bindings:ensure", "/internal/v1/management/device-bindings:ensure"],
|
||||
["device-bindings:revoke", "/internal/v1/management/device-bindings:revoke"],
|
||||
[
|
||||
@@ -61,8 +69,13 @@ export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = {
|
||||
},
|
||||
async getWorkspace(actor, projectRef) {
|
||||
const projectId = entityId(projectRef, "project");
|
||||
return request(`/internal/v1/query/projects/${projectId}/workspace`, actor)
|
||||
.then((body) => body.workspace);
|
||||
const [workspace, ontology] = await Promise.all([
|
||||
request(`/internal/v1/query/projects/${projectId}/workspace`, actor)
|
||||
.then((body) => body.workspace),
|
||||
request(`/internal/v1/query/projects/${projectId}/ontology`, actor)
|
||||
.then((body) => body.projection),
|
||||
]);
|
||||
return { ...workspace, ontology };
|
||||
},
|
||||
async execute(command, actor, input, idempotencyKey) {
|
||||
const pathname = commandRoutes.get(command);
|
||||
@@ -100,6 +113,13 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
|
||||
const configurationStates = new Map();
|
||||
const auditEvents = [];
|
||||
const commands = new Map();
|
||||
const assets = new Map();
|
||||
const assetBindings = new Map();
|
||||
const hosts = new Map();
|
||||
const endpoints = new Map();
|
||||
const deployments = new Map();
|
||||
const serviceInstances = new Map();
|
||||
const healthObservations = new Map();
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
@@ -166,6 +186,53 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
|
||||
identifierProjection: fixture === "arusnavi-b2" ? "authorized-full" : "masked-only",
|
||||
auditPayloadProjection: "metadata-only",
|
||||
},
|
||||
ontology: ontologyProjection(projectRef),
|
||||
};
|
||||
}
|
||||
|
||||
function ontologyProjection(projectRef) {
|
||||
const projectHosts = projectValues(hosts, projectRef);
|
||||
const projectServices = projectValues(serviceInstances, projectRef);
|
||||
const withHealth = (subjectKind, value, subjectRef) => ({
|
||||
...value,
|
||||
health: latestPreviewHealth(projectRef, subjectKind, subjectRef),
|
||||
});
|
||||
return {
|
||||
ontology: {
|
||||
catalogHash: "229c61c02a790906",
|
||||
packages: ["asset", "device", "infrastructure", "observation"],
|
||||
},
|
||||
assets: projectValues(assets, projectRef),
|
||||
assetBindings: projectValues(assetBindings, projectRef),
|
||||
hosts: projectHosts.map((host) => withHealth("host", host, host.hostRef)),
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -608,6 +675,167 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
|
||||
if (command === "devices:claim") {
|
||||
throw serviceError("device_discovery_not_found", 404);
|
||||
}
|
||||
if (command === "assets:ensure") {
|
||||
if (!projects.has(input.projectRef)) throw serviceError("device_project_not_found", 404);
|
||||
const existing = projectValues(assets, input.projectRef)
|
||||
.find((item) => item.assetKey === input.assetKey);
|
||||
const assetRef = existing?.assetRef || `asset:${randomUUID()}`;
|
||||
const asset = {
|
||||
assetRef,
|
||||
projectRef: input.projectRef,
|
||||
assetKey: input.assetKey,
|
||||
displayName: input.displayName,
|
||||
assetTypeRef: input.assetTypeRef,
|
||||
lifecycleState: input.lifecycleState ?? "active",
|
||||
ontology: previewOntology("asset.asset"),
|
||||
};
|
||||
assets.set(assetRef, asset);
|
||||
audit(actor, input.projectRef, createdEvent(existing, "asset"));
|
||||
return { replayed: false, result: { created: !existing, asset } };
|
||||
}
|
||||
if (command === "asset-bindings:ensure") {
|
||||
const device = devices.get(input.deviceRef);
|
||||
const asset = assets.get(input.assetRef);
|
||||
if (!device || device.projectRef !== input.projectRef) {
|
||||
throw serviceError("device_not_found", 404);
|
||||
}
|
||||
if (!asset || asset.projectRef !== input.projectRef) {
|
||||
throw serviceError("device_asset_not_found", 404);
|
||||
}
|
||||
const existing = projectValues(assetBindings, input.projectRef)
|
||||
.find((item) => item.bindingKey === input.bindingKey);
|
||||
const assetBindingRef = existing?.assetBindingRef || `asset-binding:${randomUUID()}`;
|
||||
const assetBinding = {
|
||||
assetBindingRef,
|
||||
projectRef: input.projectRef,
|
||||
bindingKey: input.bindingKey,
|
||||
deviceRef: input.deviceRef,
|
||||
deviceName: device.displayName,
|
||||
assetRef: input.assetRef,
|
||||
assetName: asset.displayName,
|
||||
bindingKind: input.bindingKind ?? "tracking",
|
||||
validFrom: input.validFrom,
|
||||
validTo: null,
|
||||
provenanceRef: input.provenanceRef,
|
||||
ontology: previewOntology("device.asset_binding"),
|
||||
};
|
||||
assetBindings.set(assetBindingRef, assetBinding);
|
||||
audit(actor, input.projectRef, createdEvent(existing, "asset_binding"));
|
||||
return { replayed: false, result: { created: !existing, assetBinding } };
|
||||
}
|
||||
if (command === "asset-bindings:close") {
|
||||
const existing = assetBindings.get(input.assetBindingRef);
|
||||
if (!existing || existing.projectRef !== input.projectRef || existing.validTo) {
|
||||
throw serviceError("device_asset_binding_not_closable", 409);
|
||||
}
|
||||
const assetBinding = { ...existing, validTo: input.validTo };
|
||||
assetBindings.set(existing.assetBindingRef, assetBinding);
|
||||
audit(actor, input.projectRef, "asset_binding.closed");
|
||||
return { replayed: false, result: { closed: true, assetBinding } };
|
||||
}
|
||||
if (command === "infrastructure-hosts:ensure") {
|
||||
if (!projects.has(input.projectRef)) throw serviceError("device_project_not_found", 404);
|
||||
const existing = projectValues(hosts, input.projectRef)
|
||||
.find((item) => item.hostKey === input.hostKey);
|
||||
const hostRef = existing?.hostRef || `host:${randomUUID()}`;
|
||||
const host = {
|
||||
hostRef,
|
||||
projectRef: input.projectRef,
|
||||
hostKey: input.hostKey,
|
||||
displayName: input.displayName,
|
||||
providerRef: input.providerRef ?? null,
|
||||
externalRef: input.externalRef ?? null,
|
||||
managementCredentialConfigured: Boolean(input.managementCredentialRef),
|
||||
lifecycleState: input.lifecycleState ?? "provisioning",
|
||||
ontology: previewOntology("infrastructure.host"),
|
||||
};
|
||||
hosts.set(hostRef, host);
|
||||
audit(actor, input.projectRef, createdEvent(existing, "infrastructure_host"));
|
||||
return { replayed: false, result: { created: !existing, host } };
|
||||
}
|
||||
if (command === "infrastructure-endpoints:ensure") {
|
||||
const host = hosts.get(input.hostRef);
|
||||
if (!host || host.projectRef !== input.projectRef) throw serviceError("device_host_not_found", 404);
|
||||
const existing = projectValues(endpoints, input.projectRef)
|
||||
.find((item) => item.hostRef === input.hostRef && item.endpointKey === input.endpointKey);
|
||||
const endpointRef = existing?.endpointRef || `endpoint:${randomUUID()}`;
|
||||
const endpoint = {
|
||||
endpointRef,
|
||||
projectRef: input.projectRef,
|
||||
hostRef: input.hostRef,
|
||||
endpointKey: input.endpointKey,
|
||||
purpose: input.purpose,
|
||||
endpointUri: input.endpointUri,
|
||||
lifecycleState: input.lifecycleState ?? "active",
|
||||
ontology: previewOntology("infrastructure.endpoint"),
|
||||
};
|
||||
endpoints.set(endpointRef, endpoint);
|
||||
audit(actor, input.projectRef, createdEvent(existing, "infrastructure_endpoint"));
|
||||
return { replayed: false, result: { created: !existing, endpoint } };
|
||||
}
|
||||
if (command === "infrastructure-deployments:ensure") {
|
||||
const host = hosts.get(input.hostRef);
|
||||
if (!host || host.projectRef !== input.projectRef) throw serviceError("device_host_not_found", 404);
|
||||
const existing = projectValues(deployments, input.projectRef)
|
||||
.find((item) => item.deploymentKey === input.deploymentKey);
|
||||
const deploymentRef = existing?.deploymentRef || `deployment:${randomUUID()}`;
|
||||
const deployment = {
|
||||
deploymentRef,
|
||||
projectRef: input.projectRef,
|
||||
hostRef: input.hostRef,
|
||||
deploymentKey: input.deploymentKey,
|
||||
displayName: input.displayName,
|
||||
artifactRef: input.artifactRef,
|
||||
artifactDigest: input.artifactDigest,
|
||||
lifecycleState: input.lifecycleState ?? "desired",
|
||||
ontology: previewOntology("infrastructure.deployment"),
|
||||
};
|
||||
deployments.set(deploymentRef, deployment);
|
||||
audit(actor, input.projectRef, createdEvent(existing, "infrastructure_deployment"));
|
||||
return { replayed: false, result: { created: !existing, deployment } };
|
||||
}
|
||||
if (command === "infrastructure-service-instances:ensure") {
|
||||
const host = hosts.get(input.hostRef);
|
||||
const deployment = deployments.get(input.deploymentRef);
|
||||
if (!host || host.projectRef !== input.projectRef) throw serviceError("device_host_not_found", 404);
|
||||
if (!deployment || deployment.projectRef !== input.projectRef) {
|
||||
throw serviceError("device_deployment_not_found", 404);
|
||||
}
|
||||
const existing = projectValues(serviceInstances, input.projectRef)
|
||||
.find((item) => item.hostRef === input.hostRef && item.serviceKey === input.serviceKey);
|
||||
const serviceInstanceRef = existing?.serviceInstanceRef || `service-instance:${randomUUID()}`;
|
||||
const serviceInstance = {
|
||||
serviceInstanceRef,
|
||||
projectRef: input.projectRef,
|
||||
hostRef: input.hostRef,
|
||||
deploymentRef: input.deploymentRef,
|
||||
edgeRef: input.edgeRef ?? null,
|
||||
serviceKey: input.serviceKey,
|
||||
displayName: input.displayName,
|
||||
serviceRole: input.serviceRole,
|
||||
lifecycleState: input.lifecycleState ?? "provisioning",
|
||||
ontology: previewOntology("infrastructure.service_instance"),
|
||||
};
|
||||
serviceInstances.set(serviceInstanceRef, serviceInstance);
|
||||
audit(actor, input.projectRef, createdEvent(existing, "infrastructure_service_instance"));
|
||||
return { replayed: false, result: { created: !existing, serviceInstance } };
|
||||
}
|
||||
if (command === "health-observations:record") {
|
||||
const healthObservationRef = `health-observation:${randomUUID()}`;
|
||||
const healthObservation = {
|
||||
healthObservationRef,
|
||||
projectRef: input.projectRef,
|
||||
subjectKind: input.subjectKind,
|
||||
subjectRef: input.subjectRef,
|
||||
observedState: input.observedState,
|
||||
evidenceClass: input.evidenceClass,
|
||||
observedAt: input.observedAt,
|
||||
expiresAt: input.expiresAt,
|
||||
};
|
||||
healthObservations.set(healthObservationRef, healthObservation);
|
||||
audit(actor, input.projectRef, "health_observation.recorded");
|
||||
return { replayed: false, result: { recorded: true, healthObservation } };
|
||||
}
|
||||
if (command === "devices:update") {
|
||||
const device = devices.get(input.deviceRef);
|
||||
if (!device || device.projectRef !== input.projectRef) {
|
||||
@@ -658,6 +886,10 @@ function createdEvent(existing, resource) {
|
||||
return `${resource}.${existing ? "updated" : "created"}`;
|
||||
}
|
||||
|
||||
function previewOntology(entityId) {
|
||||
return { entityId, catalogHash: "229c61c02a790906" };
|
||||
}
|
||||
|
||||
function requirePlatformOwner(actor) {
|
||||
if (actor?.hubRole !== "owner") {
|
||||
throw serviceError("device_platform_catalog_access_denied", 403);
|
||||
@@ -869,6 +1101,7 @@ const ownerCapabilities = Object.freeze([
|
||||
"project.manage",
|
||||
"access.manage",
|
||||
"inventory.read",
|
||||
"asset.manage",
|
||||
"device.enroll",
|
||||
"device.claim",
|
||||
"device.transfer",
|
||||
@@ -876,6 +1109,9 @@ const ownerCapabilities = Object.freeze([
|
||||
"route.manage",
|
||||
"binding.manage",
|
||||
"telemetry.observe",
|
||||
"observation.write",
|
||||
"infrastructure.read",
|
||||
"infrastructure.manage",
|
||||
"configuration.read",
|
||||
"configuration.manage",
|
||||
"command.plan",
|
||||
|
||||
Reference in New Issue
Block a user