feat(core): add ontology-backed asset and host runtime
This commit is contained in:
@@ -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;
|
||||
@@ -46,6 +46,29 @@ const managementRoutes = new Map([
|
||||
"/internal/v1/management/device-configurations:set-desired",
|
||||
"device_configuration_desired.set",
|
||||
],
|
||||
["/internal/v1/management/assets:ensure", "asset.ensure"],
|
||||
["/internal/v1/management/asset-bindings:ensure", "asset_binding.ensure"],
|
||||
["/internal/v1/management/asset-bindings:close", "asset_binding.close"],
|
||||
[
|
||||
"/internal/v1/management/infrastructure-hosts:ensure",
|
||||
"infrastructure_host.ensure",
|
||||
],
|
||||
[
|
||||
"/internal/v1/management/infrastructure-endpoints:ensure",
|
||||
"infrastructure_endpoint.ensure",
|
||||
],
|
||||
[
|
||||
"/internal/v1/management/infrastructure-deployments:ensure",
|
||||
"infrastructure_deployment.ensure",
|
||||
],
|
||||
[
|
||||
"/internal/v1/management/infrastructure-service-instances:ensure",
|
||||
"infrastructure_service_instance.ensure",
|
||||
],
|
||||
[
|
||||
"/internal/v1/management/health-observations:record",
|
||||
"health_observation.record",
|
||||
],
|
||||
]);
|
||||
|
||||
export function createControlCoreApp({
|
||||
@@ -295,6 +318,34 @@ export function createControlCoreApp({
|
||||
return writeJson(response, 200, { ok: true, workspace });
|
||||
}
|
||||
|
||||
const ontologyProjectId = projectOntologyId(requestUrl.pathname);
|
||||
if (request.method === "GET" && ontologyProjectId) {
|
||||
if (!managementApiEnabled) {
|
||||
return writeJson(response, 404, {
|
||||
ok: false,
|
||||
error: "device_management_api_disabled",
|
||||
});
|
||||
}
|
||||
if (!matchesBearer(request.headers.authorization, managementToken)) {
|
||||
return writeJson(response, 401, {
|
||||
ok: false,
|
||||
error: "device_management_auth_required",
|
||||
});
|
||||
}
|
||||
if (typeof repository.getProjectOntologyProjection !== "function") {
|
||||
return writeJson(response, 503, {
|
||||
ok: false,
|
||||
error: "device_query_repository_unavailable",
|
||||
});
|
||||
}
|
||||
const actor = managementActorFromHeaders(request.headers);
|
||||
const projection = await repository.getProjectOntologyProjection(
|
||||
actor,
|
||||
ontologyProjectId,
|
||||
);
|
||||
return writeJson(response, 200, { ok: true, projection });
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === "POST"
|
||||
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
|
||||
@@ -408,6 +459,13 @@ function projectWorkspaceId(pathname) {
|
||||
return match?.[1]?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
function projectOntologyId(pathname) {
|
||||
const match = pathname.match(
|
||||
/^\/internal\/v1\/query\/projects\/([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\/ontology$/i,
|
||||
);
|
||||
return match?.[1]?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
function managementActorFromHeaders(headers) {
|
||||
return normalizeManagementActor({
|
||||
userRef: singleHeader(headers["x-nodedc-user-ref"]),
|
||||
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
DEVICE_MANAGEMENT_COMMAND_KINDS,
|
||||
normalizeManagementCommand,
|
||||
} from "./project-management.mjs";
|
||||
import {
|
||||
DEVICE_ONTOLOGY_COMMAND_KINDS,
|
||||
isOntologyManagementCommand,
|
||||
normalizeOntologyManagementCommand,
|
||||
} from "./ontology-management.mjs";
|
||||
import {
|
||||
DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
|
||||
isSensitiveReferenceManagementCommand,
|
||||
@@ -29,9 +34,13 @@ export const ALL_DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([
|
||||
...DEVICE_LIFECYCLE_COMMAND_KINDS,
|
||||
...DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
|
||||
...DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
|
||||
...DEVICE_ONTOLOGY_COMMAND_KINDS,
|
||||
]);
|
||||
|
||||
export function normalizeDeviceManagementCommand(kind, input) {
|
||||
if (isOntologyManagementCommand(kind)) {
|
||||
return normalizeOntologyManagementCommand(kind, input);
|
||||
}
|
||||
if (isControlResourceManagementCommand(kind)) {
|
||||
return normalizeControlResourceManagementCommand(kind, input);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import {
|
||||
assertSafeProjection,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
|
||||
export const DEVICE_ONTOLOGY_COMMAND_KINDS = Object.freeze([
|
||||
"asset.ensure",
|
||||
"asset_binding.ensure",
|
||||
"asset_binding.close",
|
||||
"infrastructure_host.ensure",
|
||||
"infrastructure_endpoint.ensure",
|
||||
"infrastructure_deployment.ensure",
|
||||
"infrastructure_service_instance.ensure",
|
||||
"health_observation.record",
|
||||
]);
|
||||
|
||||
export const DEVICE_ONTOLOGY_CATALOG_HASH = "229c61c02a790906";
|
||||
|
||||
const commandKindSet = new Set(DEVICE_ONTOLOGY_COMMAND_KINDS);
|
||||
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
|
||||
const opaqueRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{2,255}$/;
|
||||
const secretRefPattern = /^secret-ref:[A-Za-z0-9][A-Za-z0-9._:/+-]{2,244}$/;
|
||||
const digestPattern = /^sha256:[a-f0-9]{64}$/;
|
||||
const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i;
|
||||
|
||||
export function isOntologyManagementCommand(kind) {
|
||||
return commandKindSet.has(kind);
|
||||
}
|
||||
|
||||
export function normalizeOntologyManagementCommand(kind, input) {
|
||||
if (!commandKindSet.has(kind)) {
|
||||
throw new TypeError("device_ontology_command_kind_invalid");
|
||||
}
|
||||
assertPlainObject(input, "device_ontology_command_invalid");
|
||||
|
||||
if (kind === "asset.ensure") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef", "assetKey", "displayName", "assetTypeRef", "lifecycleState",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
assetKey: normalizeKey(input.assetKey, "device_asset_key_invalid"),
|
||||
displayName: normalizeText(input.displayName, 160, "device_asset_name_invalid"),
|
||||
assetTypeRef: normalizeOpaqueRef(input.assetTypeRef, "device_asset_type_ref_invalid"),
|
||||
lifecycleState: normalizeEnum(
|
||||
input.lifecycleState ?? "active",
|
||||
new Set(["active", "retired"]),
|
||||
"device_asset_state_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "asset_binding.ensure") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef", "bindingKey", "deviceRef", "assetRef", "bindingKind",
|
||||
"validFrom", "provenanceRef",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
bindingKey: normalizeKey(input.bindingKey, "device_asset_binding_key_invalid"),
|
||||
deviceId: normalizeEntityRef(input.deviceRef, "device"),
|
||||
assetId: normalizeEntityRef(input.assetRef, "asset"),
|
||||
bindingKind: normalizeEnum(
|
||||
input.bindingKind ?? "tracking",
|
||||
new Set(["tracking", "installed", "assigned"]),
|
||||
"device_asset_binding_kind_invalid",
|
||||
),
|
||||
validFrom: normalizeTimestamp(input.validFrom, "device_asset_binding_valid_from_invalid"),
|
||||
provenanceRef: normalizeOpaqueRef(
|
||||
input.provenanceRef,
|
||||
"device_asset_binding_provenance_ref_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "asset_binding.close") {
|
||||
assertAllowedKeys(input, ["projectRef", "assetBindingRef", "validTo"]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
assetBindingId: normalizeEntityRef(input.assetBindingRef, "asset-binding"),
|
||||
validTo: normalizeTimestamp(input.validTo, "device_asset_binding_valid_to_invalid"),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "infrastructure_host.ensure") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef", "hostKey", "displayName", "providerRef", "externalRef",
|
||||
"managementCredentialRef", "lifecycleState",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
hostKey: normalizeKey(input.hostKey, "device_host_key_invalid"),
|
||||
displayName: normalizeText(input.displayName, 160, "device_host_name_invalid"),
|
||||
providerRef: normalizeOptionalOpaqueRef(input.providerRef, "device_host_provider_ref_invalid"),
|
||||
externalRef: normalizeOptionalOpaqueRef(input.externalRef, "device_host_external_ref_invalid"),
|
||||
managementCredentialRef: normalizeOptionalPattern(
|
||||
input.managementCredentialRef,
|
||||
secretRefPattern,
|
||||
"device_host_management_credential_ref_invalid",
|
||||
),
|
||||
lifecycleState: normalizeEnum(
|
||||
input.lifecycleState ?? "provisioning",
|
||||
new Set(["provisioning", "active", "suspended", "retired"]),
|
||||
"device_host_state_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "infrastructure_endpoint.ensure") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef", "hostRef", "endpointKey", "purpose", "endpointUri",
|
||||
"lifecycleState",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
hostId: normalizeEntityRef(input.hostRef, "host"),
|
||||
endpointKey: normalizeKey(input.endpointKey, "device_endpoint_key_invalid"),
|
||||
purpose: normalizeEnum(
|
||||
input.purpose,
|
||||
new Set(["management", "service", "monitoring"]),
|
||||
"device_endpoint_purpose_invalid",
|
||||
),
|
||||
endpointUri: normalizeEndpointUri(input.endpointUri),
|
||||
lifecycleState: normalizeEnum(
|
||||
input.lifecycleState ?? "active",
|
||||
new Set(["active", "disabled", "retired"]),
|
||||
"device_endpoint_state_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "infrastructure_deployment.ensure") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef", "hostRef", "deploymentKey", "displayName", "artifactRef",
|
||||
"artifactDigest", "lifecycleState",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
hostId: normalizeEntityRef(input.hostRef, "host"),
|
||||
deploymentKey: normalizeKey(input.deploymentKey, "device_deployment_key_invalid"),
|
||||
displayName: normalizeText(
|
||||
input.displayName,
|
||||
160,
|
||||
"device_deployment_name_invalid",
|
||||
),
|
||||
artifactRef: normalizeOpaqueRef(input.artifactRef, "device_deployment_artifact_ref_invalid"),
|
||||
artifactDigest: normalizePattern(
|
||||
input.artifactDigest,
|
||||
digestPattern,
|
||||
"device_deployment_artifact_digest_invalid",
|
||||
),
|
||||
lifecycleState: normalizeEnum(
|
||||
input.lifecycleState ?? "desired",
|
||||
new Set(["desired", "applying", "active", "failed", "retired"]),
|
||||
"device_deployment_state_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "infrastructure_service_instance.ensure") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef", "hostRef", "deploymentRef", "edgeRef", "serviceKey",
|
||||
"displayName", "serviceRole", "lifecycleState",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
hostId: normalizeEntityRef(input.hostRef, "host"),
|
||||
deploymentId: normalizeEntityRef(input.deploymentRef, "deployment"),
|
||||
edgeId: input.edgeRef == null ? null : normalizeEntityRef(input.edgeRef, "edge"),
|
||||
serviceKey: normalizeKey(input.serviceKey, "device_service_instance_key_invalid"),
|
||||
displayName: normalizeText(
|
||||
input.displayName,
|
||||
160,
|
||||
"device_service_instance_name_invalid",
|
||||
),
|
||||
serviceRole: normalizePattern(
|
||||
input.serviceRole,
|
||||
/^[a-z][a-z0-9._-]{1,63}$/,
|
||||
"device_service_instance_role_invalid",
|
||||
),
|
||||
lifecycleState: normalizeEnum(
|
||||
input.lifecycleState ?? "provisioning",
|
||||
new Set(["provisioning", "active", "degraded", "stopped", "retired"]),
|
||||
"device_service_instance_state_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef", "subjectKind", "subjectRef", "observedState", "evidenceClass",
|
||||
"sourceRef", "schemaRef", "evidence", "observedAt", "expiresAt",
|
||||
]);
|
||||
const subjectKind = normalizeEnum(
|
||||
input.subjectKind,
|
||||
new Set(["host", "service-instance"]),
|
||||
"device_health_subject_kind_invalid",
|
||||
);
|
||||
const evidence = structuredClone(assertSafeProjection(input.evidence ?? {}));
|
||||
if (Buffer.byteLength(JSON.stringify(evidence), "utf8") > 16 * 1024) {
|
||||
throw new TypeError("device_health_evidence_too_large");
|
||||
}
|
||||
const observedAt = normalizeTimestamp(
|
||||
input.observedAt,
|
||||
"device_health_observed_at_invalid",
|
||||
);
|
||||
const expiresAt = normalizeTimestamp(
|
||||
input.expiresAt,
|
||||
"device_health_expires_at_invalid",
|
||||
);
|
||||
if (Date.parse(expiresAt) <= Date.parse(observedAt)) {
|
||||
throw new TypeError("device_health_freshness_window_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
subjectKind,
|
||||
subjectId: normalizeEntityRef(input.subjectRef, subjectKind),
|
||||
observedState: normalizeEnum(
|
||||
input.observedState,
|
||||
new Set(["reachable", "degraded", "unreachable"]),
|
||||
"device_health_observed_state_invalid",
|
||||
),
|
||||
evidenceClass: normalizeEnum(
|
||||
input.evidenceClass,
|
||||
new Set(["agent_probe", "channel", "management_probe", "manual"]),
|
||||
"device_health_evidence_class_invalid",
|
||||
),
|
||||
sourceRef: normalizeOpaqueRef(input.sourceRef, "device_health_source_ref_invalid"),
|
||||
schemaRef: normalizeOpaqueRef(input.schemaRef, "device_health_schema_ref_invalid"),
|
||||
evidence: Object.freeze(evidence),
|
||||
observedAt,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEndpointUri(value) {
|
||||
if (typeof value !== "string" || value.length > 512) {
|
||||
throw new TypeError("device_endpoint_uri_invalid");
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new TypeError("device_endpoint_uri_invalid");
|
||||
}
|
||||
if (
|
||||
!new Set(["https:", "ssh:", "tcp:"]).has(parsed.protocol)
|
||||
|| !parsed.hostname
|
||||
|| parsed.username
|
||||
|| parsed.password
|
||||
|| parsed.search
|
||||
|| parsed.hash
|
||||
) {
|
||||
throw new TypeError("device_endpoint_uri_invalid");
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function normalizeEntityRef(value, kind) {
|
||||
if (typeof value !== "string") throw new TypeError(`device_${kind}_ref_invalid`);
|
||||
const match = value.match(new RegExp(`^${kind}:(${uuidPattern.source})$`, "i"));
|
||||
if (!match) throw new TypeError(`device_${kind}_ref_invalid`);
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeKey(value, errorCode) {
|
||||
return normalizePattern(value, keyPattern, errorCode);
|
||||
}
|
||||
|
||||
function normalizeOpaqueRef(value, errorCode) {
|
||||
return normalizePattern(value, opaqueRefPattern, errorCode);
|
||||
}
|
||||
|
||||
function normalizeOptionalOpaqueRef(value, errorCode) {
|
||||
return value == null || value === "" ? null : normalizeOpaqueRef(value, errorCode);
|
||||
}
|
||||
|
||||
function normalizeOptionalPattern(value, pattern, errorCode) {
|
||||
return value == null || value === "" ? null : normalizePattern(value, pattern, errorCode);
|
||||
}
|
||||
|
||||
function normalizePattern(value, pattern, errorCode) {
|
||||
if (typeof value !== "string" || !pattern.test(value)) throw new TypeError(errorCode);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeText(value, maximum, errorCode) {
|
||||
if (typeof value !== "string") throw new TypeError(errorCode);
|
||||
const normalized = value.trim();
|
||||
if (normalized.length < 1 || normalized.length > maximum) throw new TypeError(errorCode);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeEnum(value, allowed, errorCode) {
|
||||
if (typeof value !== "string" || !allowed.has(value)) throw new TypeError(errorCode);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value, errorCode) {
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
const timestamp = new Date(value);
|
||||
if (!Number.isFinite(timestamp.valueOf())) throw new TypeError(errorCode);
|
||||
return timestamp.toISOString();
|
||||
}
|
||||
|
||||
function assertPlainObject(value, errorCode) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllowedKeys(value, allowedKeys) {
|
||||
const allowed = new Set(allowedKeys);
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) {
|
||||
throw new TypeError(`device_ontology_command_field_unexpected:${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
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] =
|
||||
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],
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
ontology: {
|
||||
catalogHash: "229c61c02a790906",
|
||||
packages: ["asset", "device", "infrastructure", "observation"],
|
||||
},
|
||||
assets: assets.rows.map(assetView),
|
||||
assetBindings: assetBindings.rows.map(assetBindingView),
|
||||
hosts: hosts.rows.map(hostView),
|
||||
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) {
|
||||
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),
|
||||
ontology: ontologyView(row),
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { findProjectWithCapability } from "./lifecycle-repository.mjs";
|
||||
import { isOntologyManagementCommand } from "./ontology-management.mjs";
|
||||
import { toProjectRef } from "./project-management.mjs";
|
||||
|
||||
const commandCapabilities = Object.freeze({
|
||||
"asset.ensure": "asset.manage",
|
||||
"asset_binding.ensure": "binding.manage",
|
||||
"asset_binding.close": "binding.manage",
|
||||
"infrastructure_host.ensure": "infrastructure.manage",
|
||||
"infrastructure_endpoint.ensure": "infrastructure.manage",
|
||||
"infrastructure_deployment.ensure": "infrastructure.manage",
|
||||
"infrastructure_service_instance.ensure": "infrastructure.manage",
|
||||
"health_observation.record": "observation.write",
|
||||
});
|
||||
|
||||
export async function applyOntologyManagementCommand(
|
||||
client,
|
||||
{ commandKind, actor, command },
|
||||
) {
|
||||
assertOntologyCommand(commandKind);
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
commandCapabilities[commandKind],
|
||||
);
|
||||
if (commandKind === "asset.ensure") {
|
||||
return ensureAsset(client, actor, project, command);
|
||||
}
|
||||
if (commandKind === "asset_binding.ensure") {
|
||||
return ensureAssetBinding(client, actor, project, command);
|
||||
}
|
||||
if (commandKind === "asset_binding.close") {
|
||||
return closeAssetBinding(client, actor, project, command);
|
||||
}
|
||||
if (commandKind === "infrastructure_host.ensure") {
|
||||
return ensureHost(client, actor, project, command);
|
||||
}
|
||||
if (commandKind === "infrastructure_endpoint.ensure") {
|
||||
return ensureEndpoint(client, actor, project, command);
|
||||
}
|
||||
if (commandKind === "infrastructure_deployment.ensure") {
|
||||
return ensureDeployment(client, actor, project, command);
|
||||
}
|
||||
if (commandKind === "infrastructure_service_instance.ensure") {
|
||||
return ensureServiceInstance(client, actor, project, command);
|
||||
}
|
||||
return recordHealthObservation(client, actor, project, command);
|
||||
}
|
||||
|
||||
export async function authorizeOntologyManagementReplay(
|
||||
client,
|
||||
{ commandKind, actor, command },
|
||||
) {
|
||||
assertOntologyCommand(commandKind);
|
||||
await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
commandCapabilities[commandKind],
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureAsset(client, actor, project, command) {
|
||||
const result = await client.query(
|
||||
`insert into device_assets (
|
||||
id, owner_scope_id, project_id, asset_key, display_name,
|
||||
asset_type_ref, lifecycle_state, created_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
on conflict (project_id, asset_key) do update set
|
||||
display_name = excluded.display_name,
|
||||
lifecycle_state = excluded.lifecycle_state,
|
||||
updated_at = now()
|
||||
where device_assets.asset_type_ref = excluded.asset_type_ref
|
||||
and (
|
||||
device_assets.lifecycle_state = excluded.lifecycle_state
|
||||
or (
|
||||
device_assets.lifecycle_state = 'active'
|
||||
and excluded.lifecycle_state = 'retired'
|
||||
)
|
||||
)
|
||||
returning *, (xmax = 0) as created`,
|
||||
[
|
||||
randomUUID(),
|
||||
project.owner_scope_id,
|
||||
project.id,
|
||||
command.assetKey,
|
||||
command.displayName,
|
||||
command.assetTypeRef,
|
||||
command.lifecycleState,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
const row = requireRow(result, "device_asset_identity_conflict");
|
||||
await addAudit(client, {
|
||||
actor,
|
||||
project,
|
||||
eventType: row.created ? "asset.created" : "asset.updated",
|
||||
payload: {
|
||||
assetRef: `asset:${row.id}`,
|
||||
assetKey: row.asset_key,
|
||||
assetTypeRef: row.asset_type_ref,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
ontologyEntityId: row.ontology_entity_id,
|
||||
},
|
||||
});
|
||||
return { created: row.created === true, asset: assetView(row) };
|
||||
}
|
||||
|
||||
async function ensureAssetBinding(client, actor, project, command) {
|
||||
const [deviceResult, assetResult] = await Promise.all([
|
||||
client.query(
|
||||
`select di.id, di.project_id, di.owner_scope_id, di.display_name,
|
||||
di.lifecycle_state, dmp.device_type
|
||||
from device_instances di
|
||||
join device_model_profiles dmp on dmp.profile_ref = di.model_profile_ref
|
||||
where di.id = $1 and di.project_id = $2
|
||||
for share of di, dmp`,
|
||||
[command.deviceId, project.id],
|
||||
),
|
||||
client.query(
|
||||
`select id, project_id, owner_scope_id, display_name, lifecycle_state
|
||||
from device_assets
|
||||
where id = $1 and project_id = $2
|
||||
for share`,
|
||||
[command.assetId, project.id],
|
||||
),
|
||||
]);
|
||||
const device = requireRow(deviceResult, "device_not_found", 404);
|
||||
const asset = requireRow(assetResult, "device_asset_not_found", 404);
|
||||
if (!["claimed", "online", "offline"].includes(device.lifecycle_state)) {
|
||||
throw domainError("device_asset_binding_device_inactive", 409);
|
||||
}
|
||||
if (command.bindingKind === "tracking" && device.device_type !== "tracker") {
|
||||
throw domainError("device_asset_binding_tracker_required", 409);
|
||||
}
|
||||
if (asset.lifecycle_state !== "active") {
|
||||
throw domainError("device_asset_binding_asset_inactive", 409);
|
||||
}
|
||||
const result = await client.query(
|
||||
`insert into device_asset_bindings (
|
||||
id, owner_scope_id, project_id, binding_key, device_id, asset_id,
|
||||
binding_kind, valid_from, provenance_ref, created_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
on conflict (project_id, binding_key) do update set
|
||||
updated_at = now()
|
||||
where device_asset_bindings.device_id = excluded.device_id
|
||||
and device_asset_bindings.asset_id = excluded.asset_id
|
||||
and device_asset_bindings.binding_kind = excluded.binding_kind
|
||||
and device_asset_bindings.valid_from = excluded.valid_from
|
||||
and device_asset_bindings.valid_to is null
|
||||
and device_asset_bindings.provenance_ref = excluded.provenance_ref
|
||||
returning *, (xmax = 0) as created`,
|
||||
[
|
||||
randomUUID(),
|
||||
project.owner_scope_id,
|
||||
project.id,
|
||||
command.bindingKey,
|
||||
command.deviceId,
|
||||
command.assetId,
|
||||
command.bindingKind,
|
||||
command.validFrom,
|
||||
command.provenanceRef,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
const row = requireRow(result, "device_asset_binding_identity_conflict");
|
||||
await addAudit(client, {
|
||||
actor,
|
||||
project,
|
||||
deviceId: row.device_id,
|
||||
eventType: row.created ? "asset_binding.created" : "asset_binding.confirmed",
|
||||
payload: assetBindingAudit(row),
|
||||
});
|
||||
return { created: row.created === true, assetBinding: assetBindingView(row) };
|
||||
}
|
||||
|
||||
async function closeAssetBinding(client, actor, project, command) {
|
||||
const result = await client.query(
|
||||
`update device_asset_bindings
|
||||
set valid_to = $3,
|
||||
closed_by_ref = $4,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
and project_id = $2
|
||||
and valid_to is null
|
||||
and valid_from < $3
|
||||
returning *`,
|
||||
[command.assetBindingId, project.id, command.validTo, actor.userRef],
|
||||
);
|
||||
const row = requireRow(result, "device_asset_binding_not_closable", 409);
|
||||
await addAudit(client, {
|
||||
actor,
|
||||
project,
|
||||
deviceId: row.device_id,
|
||||
eventType: "asset_binding.closed",
|
||||
payload: assetBindingAudit(row),
|
||||
});
|
||||
return { closed: true, assetBinding: assetBindingView(row) };
|
||||
}
|
||||
|
||||
async function ensureHost(client, actor, project, command) {
|
||||
const result = await client.query(
|
||||
`insert into device_infrastructure_hosts (
|
||||
id, owner_scope_id, project_id, host_key, display_name, provider_ref,
|
||||
external_ref, management_credential_ref, lifecycle_state, created_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
on conflict (project_id, host_key) do update set
|
||||
display_name = excluded.display_name,
|
||||
provider_ref = excluded.provider_ref,
|
||||
external_ref = excluded.external_ref,
|
||||
management_credential_ref = excluded.management_credential_ref,
|
||||
lifecycle_state = excluded.lifecycle_state,
|
||||
updated_at = now()
|
||||
where device_infrastructure_hosts.lifecycle_state <> 'retired'
|
||||
or excluded.lifecycle_state = 'retired'
|
||||
returning *, (xmax = 0) as created`,
|
||||
[
|
||||
randomUUID(), project.owner_scope_id, project.id, command.hostKey,
|
||||
command.displayName, command.providerRef, command.externalRef,
|
||||
command.managementCredentialRef, command.lifecycleState, actor.userRef,
|
||||
],
|
||||
);
|
||||
const row = requireRow(result, "device_host_identity_conflict");
|
||||
await addAudit(client, {
|
||||
actor,
|
||||
project,
|
||||
eventType: row.created ? "infrastructure_host.created" : "infrastructure_host.updated",
|
||||
payload: {
|
||||
hostRef: `host:${row.id}`,
|
||||
hostKey: row.host_key,
|
||||
providerRef: row.provider_ref,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
managementCredentialConfigured: row.management_credential_ref != null,
|
||||
ontologyEntityId: row.ontology_entity_id,
|
||||
},
|
||||
});
|
||||
return { created: row.created === true, host: hostView(row) };
|
||||
}
|
||||
|
||||
async function ensureEndpoint(client, actor, project, command) {
|
||||
await requireActiveHost(client, project.id, command.hostId);
|
||||
const result = await client.query(
|
||||
`insert into device_infrastructure_endpoints (
|
||||
id, owner_scope_id, project_id, host_id, endpoint_key, purpose,
|
||||
endpoint_uri, lifecycle_state, created_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
on conflict (host_id, endpoint_key) do update set
|
||||
purpose = excluded.purpose,
|
||||
endpoint_uri = excluded.endpoint_uri,
|
||||
lifecycle_state = excluded.lifecycle_state,
|
||||
updated_at = now()
|
||||
where device_infrastructure_endpoints.lifecycle_state <> 'retired'
|
||||
or excluded.lifecycle_state = 'retired'
|
||||
returning *, (xmax = 0) as created`,
|
||||
[
|
||||
randomUUID(), project.owner_scope_id, project.id, command.hostId,
|
||||
command.endpointKey, command.purpose, command.endpointUri,
|
||||
command.lifecycleState, actor.userRef,
|
||||
],
|
||||
);
|
||||
const row = requireRow(result, "device_endpoint_identity_conflict");
|
||||
await addAudit(client, {
|
||||
actor,
|
||||
project,
|
||||
eventType: row.created ? "infrastructure_endpoint.created" : "infrastructure_endpoint.updated",
|
||||
payload: {
|
||||
endpointRef: `endpoint:${row.id}`,
|
||||
hostRef: `host:${row.host_id}`,
|
||||
purpose: row.purpose,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
},
|
||||
});
|
||||
return { created: row.created === true, endpoint: endpointView(row) };
|
||||
}
|
||||
|
||||
async function ensureDeployment(client, actor, project, command) {
|
||||
await requireActiveHost(client, project.id, command.hostId);
|
||||
const result = await client.query(
|
||||
`insert into device_infrastructure_deployments (
|
||||
id, owner_scope_id, project_id, host_id, deployment_key, display_name,
|
||||
artifact_ref, artifact_digest, lifecycle_state, created_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
on conflict (project_id, deployment_key) do update set
|
||||
display_name = excluded.display_name,
|
||||
lifecycle_state = excluded.lifecycle_state,
|
||||
updated_at = now()
|
||||
where device_infrastructure_deployments.host_id = excluded.host_id
|
||||
and device_infrastructure_deployments.artifact_ref = excluded.artifact_ref
|
||||
and device_infrastructure_deployments.artifact_digest = excluded.artifact_digest
|
||||
and (
|
||||
device_infrastructure_deployments.lifecycle_state <> 'retired'
|
||||
or excluded.lifecycle_state = 'retired'
|
||||
)
|
||||
returning *, (xmax = 0) as created`,
|
||||
[
|
||||
randomUUID(), project.owner_scope_id, project.id, command.hostId,
|
||||
command.deploymentKey, command.displayName, command.artifactRef,
|
||||
command.artifactDigest, command.lifecycleState, actor.userRef,
|
||||
],
|
||||
);
|
||||
const row = requireRow(result, "device_deployment_identity_conflict");
|
||||
await addAudit(client, {
|
||||
actor,
|
||||
project,
|
||||
eventType: row.created ? "infrastructure_deployment.created" : "infrastructure_deployment.updated",
|
||||
payload: {
|
||||
deploymentRef: `deployment:${row.id}`,
|
||||
hostRef: `host:${row.host_id}`,
|
||||
artifactRef: row.artifact_ref,
|
||||
artifactDigest: row.artifact_digest,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
},
|
||||
});
|
||||
return { created: row.created === true, deployment: deploymentView(row) };
|
||||
}
|
||||
|
||||
async function ensureServiceInstance(client, actor, project, command) {
|
||||
await requireActiveHost(client, project.id, command.hostId);
|
||||
const deployment = await client.query(
|
||||
`select id, host_id, lifecycle_state
|
||||
from device_infrastructure_deployments
|
||||
where id = $1 and project_id = $2
|
||||
for share`,
|
||||
[command.deploymentId, project.id],
|
||||
);
|
||||
const deploymentRow = requireRow(deployment, "device_deployment_not_found", 404);
|
||||
if (deploymentRow.host_id !== command.hostId) {
|
||||
throw domainError("device_service_instance_host_mismatch", 409);
|
||||
}
|
||||
if (deploymentRow.lifecycle_state === "retired") {
|
||||
throw domainError("device_service_instance_deployment_inactive", 409);
|
||||
}
|
||||
if (command.edgeId) {
|
||||
const edge = await client.query(
|
||||
`select de.id
|
||||
from device_edges de
|
||||
where de.id = $1
|
||||
and exists (
|
||||
select 1 from device_routes dr
|
||||
where dr.edge_id = de.id and dr.project_id = $2
|
||||
)
|
||||
for share`,
|
||||
[command.edgeId, project.id],
|
||||
);
|
||||
requireRow(edge, "device_service_instance_edge_not_in_project", 409);
|
||||
}
|
||||
const result = await client.query(
|
||||
`insert into device_infrastructure_service_instances (
|
||||
id, owner_scope_id, project_id, host_id, deployment_id, edge_id,
|
||||
service_key, display_name, service_role, lifecycle_state, created_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
on conflict (host_id, service_key) do update set
|
||||
display_name = excluded.display_name,
|
||||
edge_id = excluded.edge_id,
|
||||
lifecycle_state = excluded.lifecycle_state,
|
||||
updated_at = now()
|
||||
where device_infrastructure_service_instances.deployment_id = excluded.deployment_id
|
||||
and device_infrastructure_service_instances.service_role = excluded.service_role
|
||||
and (
|
||||
device_infrastructure_service_instances.lifecycle_state <> 'retired'
|
||||
or excluded.lifecycle_state = 'retired'
|
||||
)
|
||||
returning *, (xmax = 0) as created`,
|
||||
[
|
||||
randomUUID(), project.owner_scope_id, project.id, command.hostId,
|
||||
command.deploymentId, command.edgeId, command.serviceKey,
|
||||
command.displayName, command.serviceRole, command.lifecycleState,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
const row = requireRow(result, "device_service_instance_identity_conflict");
|
||||
await addAudit(client, {
|
||||
actor,
|
||||
project,
|
||||
eventType: row.created ? "infrastructure_service_instance.created" : "infrastructure_service_instance.updated",
|
||||
payload: {
|
||||
serviceInstanceRef: `service-instance:${row.id}`,
|
||||
hostRef: `host:${row.host_id}`,
|
||||
deploymentRef: `deployment:${row.deployment_id}`,
|
||||
edgeRef: row.edge_id ? `edge:${row.edge_id}` : null,
|
||||
serviceRole: row.service_role,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
},
|
||||
});
|
||||
return { created: row.created === true, serviceInstance: serviceInstanceView(row) };
|
||||
}
|
||||
|
||||
async function recordHealthObservation(client, actor, project, command) {
|
||||
const subjectColumn = command.subjectKind === "host"
|
||||
? "host_id"
|
||||
: "service_instance_id";
|
||||
const subjectTable = command.subjectKind === "host"
|
||||
? "device_infrastructure_hosts"
|
||||
: "device_infrastructure_service_instances";
|
||||
const subject = await client.query(
|
||||
`select id from ${subjectTable}
|
||||
where id = $1 and project_id = $2 and lifecycle_state <> 'retired'
|
||||
for share`,
|
||||
[command.subjectId, project.id],
|
||||
);
|
||||
requireRow(subject, "device_health_subject_not_found", 404);
|
||||
const result = await client.query(
|
||||
`insert into device_health_observations (
|
||||
id, owner_scope_id, project_id, ${subjectColumn}, observed_state,
|
||||
evidence_class, source_ref, schema_ref, evidence_projection,
|
||||
observed_at, expires_at, recorded_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12)
|
||||
returning *`,
|
||||
[
|
||||
randomUUID(), project.owner_scope_id, project.id, command.subjectId,
|
||||
command.observedState, command.evidenceClass, command.sourceRef,
|
||||
command.schemaRef, JSON.stringify(command.evidence), command.observedAt,
|
||||
command.expiresAt, actor.userRef,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
await addAudit(client, {
|
||||
actor,
|
||||
project,
|
||||
eventType: "health_observation.recorded",
|
||||
payload: {
|
||||
healthObservationRef: `health-observation:${row.id}`,
|
||||
subjectKind: command.subjectKind,
|
||||
subjectRef: `${command.subjectKind}:${command.subjectId}`,
|
||||
observedState: row.observed_state,
|
||||
evidenceClass: row.evidence_class,
|
||||
observedAt: toIso(row.observed_at),
|
||||
expiresAt: toIso(row.expires_at),
|
||||
ontologyEntityId: row.ontology_entity_id,
|
||||
},
|
||||
});
|
||||
return { recorded: true, healthObservation: healthObservationView(row) };
|
||||
}
|
||||
|
||||
async function requireActiveHost(client, projectId, hostId) {
|
||||
const result = await client.query(
|
||||
`select id, lifecycle_state
|
||||
from device_infrastructure_hosts
|
||||
where id = $1 and project_id = $2
|
||||
for share`,
|
||||
[hostId, projectId],
|
||||
);
|
||||
const row = requireRow(result, "device_host_not_found", 404);
|
||||
if (!new Set(["provisioning", "active"]).has(row.lifecycle_state)) {
|
||||
throw domainError("device_host_inactive", 409);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
async function addAudit(client, {
|
||||
actor,
|
||||
project,
|
||||
eventType,
|
||||
deviceId = null,
|
||||
payload,
|
||||
}) {
|
||||
await client.query(
|
||||
`insert into device_audit_events (
|
||||
id, event_type, actor_ref, project_id, device_id, payload
|
||||
) values ($1, $2, $3, $4, $5, $6::jsonb)`,
|
||||
[
|
||||
randomUUID(), eventType, actor.userRef, project.id, deviceId,
|
||||
JSON.stringify({ projectRef: toProjectRef(project.id), ...payload }),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function assetView(row) {
|
||||
return {
|
||||
assetRef: `asset:${row.id}`,
|
||||
assetKey: row.asset_key,
|
||||
displayName: row.display_name,
|
||||
assetTypeRef: row.asset_type_ref,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
ontology: ontologyView(row),
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function assetBindingAudit(row) {
|
||||
return {
|
||||
assetBindingRef: `asset-binding:${row.id}`,
|
||||
deviceRef: `device:${row.device_id}`,
|
||||
assetRef: `asset:${row.asset_id}`,
|
||||
bindingKind: row.binding_kind,
|
||||
validFrom: toIso(row.valid_from),
|
||||
validTo: toIso(row.valid_to),
|
||||
provenanceRef: row.provenance_ref,
|
||||
ontologyEntityId: row.ontology_entity_id,
|
||||
};
|
||||
}
|
||||
|
||||
function assetBindingView(row) {
|
||||
return {
|
||||
...assetBindingAudit(row),
|
||||
bindingKey: row.binding_key,
|
||||
ontology: ontologyView(row),
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function hostView(row) {
|
||||
return {
|
||||
hostRef: `host:${row.id}`,
|
||||
hostKey: row.host_key,
|
||||
displayName: row.display_name,
|
||||
providerRef: row.provider_ref ?? null,
|
||||
externalRef: row.external_ref ?? null,
|
||||
managementCredentialConfigured: row.management_credential_ref != null,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
ontology: ontologyView(row),
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function endpointView(row) {
|
||||
return {
|
||||
endpointRef: `endpoint:${row.id}`,
|
||||
hostRef: `host:${row.host_id}`,
|
||||
endpointKey: row.endpoint_key,
|
||||
purpose: row.purpose,
|
||||
endpointUri: row.endpoint_uri,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
ontology: ontologyView(row),
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function deploymentView(row) {
|
||||
return {
|
||||
deploymentRef: `deployment:${row.id}`,
|
||||
hostRef: `host:${row.host_id}`,
|
||||
deploymentKey: row.deployment_key,
|
||||
displayName: row.display_name,
|
||||
artifactRef: row.artifact_ref,
|
||||
artifactDigest: row.artifact_digest,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
ontology: ontologyView(row),
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function serviceInstanceView(row) {
|
||||
return {
|
||||
serviceInstanceRef: `service-instance:${row.id}`,
|
||||
hostRef: `host:${row.host_id}`,
|
||||
deploymentRef: `deployment:${row.deployment_id}`,
|
||||
edgeRef: row.edge_id ? `edge:${row.edge_id}` : null,
|
||||
serviceKey: row.service_key,
|
||||
displayName: row.display_name,
|
||||
serviceRole: row.service_role,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
ontology: ontologyView(row),
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function healthObservationView(row) {
|
||||
const subjectKind = row.host_id ? "host" : "service-instance";
|
||||
return {
|
||||
healthObservationRef: `health-observation:${row.id}`,
|
||||
subjectKind,
|
||||
subjectRef: `${subjectKind}:${row.host_id ?? row.service_instance_id}`,
|
||||
observedState: row.observed_state,
|
||||
evidenceClass: row.evidence_class,
|
||||
sourceRef: row.source_ref,
|
||||
schemaRef: row.schema_ref,
|
||||
evidence: row.evidence_projection,
|
||||
observedAt: toIso(row.observed_at),
|
||||
expiresAt: toIso(row.expires_at),
|
||||
ontology: ontologyView(row),
|
||||
};
|
||||
}
|
||||
|
||||
function ontologyView(row) {
|
||||
return {
|
||||
entityId: row.ontology_entity_id,
|
||||
catalogHash: row.ontology_catalog_hash,
|
||||
};
|
||||
}
|
||||
|
||||
function requireRow(result, code, statusCode = 409) {
|
||||
const row = result?.rows?.[0];
|
||||
if (!row) throw domainError(code, statusCode);
|
||||
return row;
|
||||
}
|
||||
|
||||
function assertOntologyCommand(commandKind) {
|
||||
if (!isOntologyManagementCommand(commandKind)) {
|
||||
throw new TypeError("device_ontology_command_kind_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function toIso(value) {
|
||||
return value == null ? null : new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
getDeviceProjectWorkspace,
|
||||
listAccessibleDeviceProjects,
|
||||
} from "./project-query-repository.mjs";
|
||||
import {
|
||||
getDeviceProjectOntologyProjection,
|
||||
} from "./ontology-query-repository.mjs";
|
||||
import {
|
||||
applyInfrastructureManagementCommand,
|
||||
authorizeInfrastructureManagementReplay,
|
||||
@@ -36,6 +39,11 @@ import {
|
||||
import {
|
||||
isSensitiveReferenceManagementCommand,
|
||||
} from "./sensitive-reference-management.mjs";
|
||||
import {
|
||||
applyOntologyManagementCommand,
|
||||
authorizeOntologyManagementReplay,
|
||||
} from "./ontology-repository.mjs";
|
||||
import { isOntologyManagementCommand } from "./ontology-management.mjs";
|
||||
import {
|
||||
assertActorCanManageOwnerScope,
|
||||
assertGrantMutationAllowed,
|
||||
@@ -66,6 +74,7 @@ const migrationFiles = [
|
||||
"013_device_edge_channels.sql",
|
||||
"014_device_registry_profile_commands.sql",
|
||||
"015_device_integration_identity.sql",
|
||||
"016_device_asset_infrastructure_ontology.sql",
|
||||
];
|
||||
|
||||
export class PostgresDeviceRepository {
|
||||
@@ -181,6 +190,12 @@ export class PostgresDeviceRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getProjectOntologyProjection(actor, projectId) {
|
||||
return this.#executeRead((client) =>
|
||||
getDeviceProjectOntologyProjection(client, actor, projectId)
|
||||
);
|
||||
}
|
||||
|
||||
async listActiveEdgeChannelRegistrations(limit = 64) {
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
|
||||
throw new TypeError("device_edge_channel_registration_limit_invalid");
|
||||
@@ -320,6 +335,13 @@ async function completeManagementReceipt(client, receiptId, result) {
|
||||
}
|
||||
|
||||
async function applyManagementCommand(client, { commandKind, actor, command }) {
|
||||
if (isOntologyManagementCommand(commandKind)) {
|
||||
return applyOntologyManagementCommand(client, {
|
||||
commandKind,
|
||||
actor,
|
||||
command,
|
||||
});
|
||||
}
|
||||
if (isControlResourceManagementCommand(commandKind)) {
|
||||
return applyControlResourceManagementCommand(client, {
|
||||
commandKind,
|
||||
@@ -364,6 +386,13 @@ async function applyManagementCommand(client, { commandKind, actor, command }) {
|
||||
}
|
||||
|
||||
async function authorizeManagementReplay(client, { commandKind, actor, command }) {
|
||||
if (isOntologyManagementCommand(commandKind)) {
|
||||
return authorizeOntologyManagementReplay(client, {
|
||||
commandKind,
|
||||
actor,
|
||||
command,
|
||||
});
|
||||
}
|
||||
if (isControlResourceManagementCommand(commandKind)) {
|
||||
return authorizeControlResourceManagementReplay(client, {
|
||||
commandKind,
|
||||
|
||||
@@ -3,6 +3,7 @@ export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([
|
||||
"project.manage",
|
||||
"access.manage",
|
||||
"inventory.read",
|
||||
"asset.manage",
|
||||
"device.enroll",
|
||||
"device.claim",
|
||||
"device.transfer",
|
||||
@@ -10,6 +11,9 @@ export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([
|
||||
"route.manage",
|
||||
"binding.manage",
|
||||
"telemetry.observe",
|
||||
"observation.write",
|
||||
"infrastructure.read",
|
||||
"infrastructure.manage",
|
||||
"configuration.read",
|
||||
"configuration.manage",
|
||||
"command.plan",
|
||||
@@ -53,6 +57,7 @@ const roleCapabilities = Object.freeze({
|
||||
viewer: Object.freeze([
|
||||
"project.read",
|
||||
"inventory.read",
|
||||
"infrastructure.read",
|
||||
"telemetry.observe",
|
||||
"configuration.read",
|
||||
"audit.read",
|
||||
@@ -61,6 +66,8 @@ const roleCapabilities = Object.freeze({
|
||||
"project.read",
|
||||
"inventory.read",
|
||||
"telemetry.observe",
|
||||
"observation.write",
|
||||
"infrastructure.read",
|
||||
"configuration.read",
|
||||
"command.plan",
|
||||
"command.confirm",
|
||||
@@ -72,10 +79,14 @@ const roleCapabilities = Object.freeze({
|
||||
"inventory.read",
|
||||
"device.enroll",
|
||||
"device.claim",
|
||||
"asset.manage",
|
||||
"collection.manage",
|
||||
"route.manage",
|
||||
"binding.manage",
|
||||
"telemetry.observe",
|
||||
"observation.write",
|
||||
"infrastructure.read",
|
||||
"infrastructure.manage",
|
||||
"configuration.read",
|
||||
"configuration.manage",
|
||||
"command.plan",
|
||||
@@ -88,10 +99,14 @@ const roleCapabilities = Object.freeze({
|
||||
"inventory.read",
|
||||
"device.enroll",
|
||||
"device.claim",
|
||||
"asset.manage",
|
||||
"collection.manage",
|
||||
"route.manage",
|
||||
"binding.manage",
|
||||
"telemetry.observe",
|
||||
"observation.write",
|
||||
"infrastructure.read",
|
||||
"infrastructure.manage",
|
||||
"configuration.read",
|
||||
"configuration.manage",
|
||||
"command.plan",
|
||||
|
||||
@@ -8,7 +8,7 @@ const replayedIntermediateConstraintMigrations = Object.freeze([
|
||||
"009_device_sensitive_reference_commands.sql",
|
||||
"011_device_control_resource_commands.sql",
|
||||
]);
|
||||
const finalCommandKindMigration = "014_device_registry_profile_commands.sql";
|
||||
const finalCommandKindMigration = "016_device_asset_infrastructure_ontology.sql";
|
||||
const finalCommandKinds = Object.freeze([
|
||||
"owner_scope.ensure",
|
||||
"project.ensure",
|
||||
@@ -31,6 +31,14 @@ const finalCommandKinds = Object.freeze([
|
||||
"device_binding.revoke",
|
||||
"device_configuration_revision.create",
|
||||
"device_configuration_desired.set",
|
||||
"asset.ensure",
|
||||
"asset_binding.ensure",
|
||||
"asset_binding.close",
|
||||
"infrastructure_host.ensure",
|
||||
"infrastructure_endpoint.ensure",
|
||||
"infrastructure_deployment.ensure",
|
||||
"infrastructure_service_instance.ensure",
|
||||
"health_observation.record",
|
||||
]);
|
||||
const migrationUrl = new URL(
|
||||
"../migrations/003_device_management_commands.sql",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createControlCoreApp } from "../src/app.mjs";
|
||||
|
||||
const managementToken = "test-only-management-token-with-32-bytes";
|
||||
const identifierPepper = "test-only-identifier-pepper-with-32-bytes";
|
||||
const projectId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
test("management API forwards a canonical asset command", async () => {
|
||||
let executed;
|
||||
const runtime = await startServer({
|
||||
executeManagementCommand: async (input) => {
|
||||
executed = input;
|
||||
return { replayed: false, result: { created: true } };
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${runtime.baseUrl}/internal/v1/management/assets:ensure`, {
|
||||
method: "POST",
|
||||
headers: managementHeaders("ontology-asset-0001"),
|
||||
body: JSON.stringify({
|
||||
projectRef: `project:${projectId}`,
|
||||
assetKey: "trike-001",
|
||||
displayName: "Trike 001",
|
||||
assetTypeRef: "asset-type:delivery-trike",
|
||||
}),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(executed.commandKind, "asset.ensure");
|
||||
assert.equal(executed.command.assetKey, "trike-001");
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("query API exposes the ontology projection through Core", async () => {
|
||||
let actor;
|
||||
const runtime = await startServer({
|
||||
getProjectOntologyProjection: async (value, requestedProjectId) => {
|
||||
actor = value;
|
||||
assert.equal(requestedProjectId, projectId);
|
||||
return {
|
||||
ontology: { catalogHash: "229c61c02a790906" },
|
||||
assets: [],
|
||||
hosts: [],
|
||||
};
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/query/projects/${projectId}/ontology`,
|
||||
{ headers: managementHeaders("ontology-query-0001") },
|
||||
);
|
||||
const body = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.projection.ontology.catalogHash, "229c61c02a790906");
|
||||
assert.equal(actor.userRef, "user:test-owner");
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function startServer(repositoryOverrides) {
|
||||
const server = createControlCoreApp({
|
||||
managementApiEnabled: true,
|
||||
managementToken,
|
||||
identifierPepper,
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
executeManagementCommand: async () => ({ replayed: false, result: {} }),
|
||||
...repositoryOverrides,
|
||||
},
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
close: () => new Promise((resolve, reject) =>
|
||||
server.close((error) => error ? reject(error) : resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
function managementHeaders(idempotencyKey) {
|
||||
return {
|
||||
Authorization: `Bearer ${managementToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": idempotencyKey,
|
||||
"X-NODEDC-User-Ref": "user:test-owner",
|
||||
"X-NODEDC-Hub-Role": "owner",
|
||||
"X-NODEDC-Group-Refs": "",
|
||||
"X-NODEDC-Owner-Scopes": "company=organization:test",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEVICE_ONTOLOGY_CATALOG_HASH,
|
||||
normalizeOntologyManagementCommand,
|
||||
} from "../src/ontology-management.mjs";
|
||||
import {
|
||||
ALL_DEVICE_MANAGEMENT_COMMAND_KINDS,
|
||||
normalizeDeviceManagementCommand,
|
||||
} from "../src/management-command.mjs";
|
||||
|
||||
const projectRef = "project:11111111-1111-4111-8111-111111111111";
|
||||
const deviceRef = "device:22222222-2222-4222-8222-222222222222";
|
||||
const assetRef = "asset:33333333-3333-4333-8333-333333333333";
|
||||
const hostRef = "host:44444444-4444-4444-8444-444444444444";
|
||||
const deploymentRef = "deployment:55555555-5555-4555-8555-555555555555";
|
||||
|
||||
test("publishes the production ontology catalog contract", () => {
|
||||
assert.equal(DEVICE_ONTOLOGY_CATALOG_HASH, "229c61c02a790906");
|
||||
for (const kind of [
|
||||
"asset.ensure",
|
||||
"asset_binding.ensure",
|
||||
"infrastructure_host.ensure",
|
||||
"health_observation.record",
|
||||
]) {
|
||||
assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes(kind), true);
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizes an asset and a temporal tracker binding", () => {
|
||||
const asset = normalizeDeviceManagementCommand("asset.ensure", {
|
||||
projectRef,
|
||||
assetKey: "trike-001",
|
||||
displayName: "Trike 001",
|
||||
assetTypeRef: "asset-type:delivery-trike",
|
||||
});
|
||||
const binding = normalizeDeviceManagementCommand("asset_binding.ensure", {
|
||||
projectRef,
|
||||
bindingKey: "trike-001-primary-tracker",
|
||||
deviceRef,
|
||||
assetRef,
|
||||
bindingKind: "tracking",
|
||||
validFrom: "2026-08-22T10:00:00.000Z",
|
||||
provenanceRef: "onboarding:direct-b2",
|
||||
});
|
||||
|
||||
assert.equal(asset.assetKey, "trike-001");
|
||||
assert.equal(binding.deviceId, deviceRef.slice("device:".length));
|
||||
assert.equal(binding.assetId, assetRef.slice("asset:".length));
|
||||
assert.equal(binding.bindingKind, "tracking");
|
||||
});
|
||||
|
||||
test("normalizes provider-neutral host topology without browser credentials", () => {
|
||||
const host = normalizeOntologyManagementCommand("infrastructure_host.ensure", {
|
||||
projectRef,
|
||||
hostKey: "b2-edge-moscow",
|
||||
displayName: "B2 Edge Moscow",
|
||||
providerRef: "provider:beget",
|
||||
externalRef: "provider-resource:vps-123",
|
||||
managementCredentialRef: "secret-ref:device-core/b2-edge-moscow",
|
||||
lifecycleState: "active",
|
||||
});
|
||||
const deployment = normalizeOntologyManagementCommand(
|
||||
"infrastructure_deployment.ensure",
|
||||
{
|
||||
projectRef,
|
||||
hostRef,
|
||||
deploymentKey: "device-edge-001",
|
||||
displayName: "Device Edge 001",
|
||||
artifactRef: "artifact:device-edge/1.0.0",
|
||||
artifactDigest: `sha256:${"a".repeat(64)}`,
|
||||
},
|
||||
);
|
||||
const service = normalizeOntologyManagementCommand(
|
||||
"infrastructure_service_instance.ensure",
|
||||
{
|
||||
projectRef,
|
||||
hostRef,
|
||||
deploymentRef,
|
||||
serviceKey: "device-edge",
|
||||
displayName: "Device Edge",
|
||||
serviceRole: "device.edge",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(host.managementCredentialRef.startsWith("secret-ref:"), true);
|
||||
assert.equal(deployment.hostId, hostRef.slice("host:".length));
|
||||
assert.equal(service.serviceRole, "device.edge");
|
||||
assert.equal("password" in host, false);
|
||||
});
|
||||
|
||||
test("rejects credential-bearing endpoints and secret-shaped health evidence", () => {
|
||||
assert.throws(
|
||||
() => normalizeOntologyManagementCommand("infrastructure_endpoint.ensure", {
|
||||
projectRef,
|
||||
hostRef,
|
||||
endpointKey: "ssh",
|
||||
purpose: "management",
|
||||
endpointUri: "ssh://root:password@example.test:22/",
|
||||
}),
|
||||
/device_endpoint_uri_invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeOntologyManagementCommand("health_observation.record", {
|
||||
projectRef,
|
||||
subjectKind: "host",
|
||||
subjectRef: hostRef,
|
||||
observedState: "reachable",
|
||||
evidenceClass: "management_probe",
|
||||
sourceRef: "probe:device-core",
|
||||
schemaRef: "schema:health.v1",
|
||||
evidence: { token: "forbidden" },
|
||||
observedAt: "2026-08-22T10:00:00.000Z",
|
||||
expiresAt: "2026-08-22T10:01:00.000Z",
|
||||
}),
|
||||
/forbidden_device_field/,
|
||||
);
|
||||
});
|
||||
|
||||
test("health is a bounded observation and not a permanent online flag", () => {
|
||||
const command = normalizeOntologyManagementCommand(
|
||||
"health_observation.record",
|
||||
{
|
||||
projectRef,
|
||||
subjectKind: "host",
|
||||
subjectRef: hostRef,
|
||||
observedState: "reachable",
|
||||
evidenceClass: "management_probe",
|
||||
sourceRef: "probe:device-core",
|
||||
schemaRef: "schema:health.v1",
|
||||
evidence: { latencyMs: 42 },
|
||||
observedAt: "2026-08-22T10:00:00.000Z",
|
||||
expiresAt: "2026-08-22T10:01:00.000Z",
|
||||
},
|
||||
);
|
||||
assert.equal(command.observedState, "reachable");
|
||||
assert.equal(command.expiresAt, "2026-08-22T10:01:00.000Z");
|
||||
assert.throws(
|
||||
() => normalizeOntologyManagementCommand("health_observation.record", {
|
||||
projectRef,
|
||||
subjectKind: "host",
|
||||
subjectRef: hostRef,
|
||||
observedState: "reachable",
|
||||
evidenceClass: "management_probe",
|
||||
sourceRef: "probe:device-core",
|
||||
schemaRef: "schema:health.v1",
|
||||
evidence: {},
|
||||
observedAt: command.observedAt,
|
||||
expiresAt: command.observedAt,
|
||||
}),
|
||||
/device_health_freshness_window_invalid/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const migrationUrl = new URL(
|
||||
"../migrations/016_device_asset_infrastructure_ontology.sql",
|
||||
import.meta.url,
|
||||
);
|
||||
const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url);
|
||||
|
||||
test("ontology migration is additive and encodes the official entity identifiers", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
for (const table of [
|
||||
"device_assets",
|
||||
"device_asset_bindings",
|
||||
"device_infrastructure_hosts",
|
||||
"device_infrastructure_deployments",
|
||||
"device_infrastructure_service_instances",
|
||||
"device_health_observations",
|
||||
]) {
|
||||
assert.match(sql, new RegExp(`create table if not exists ${table}`, "i"));
|
||||
}
|
||||
for (const entityId of [
|
||||
"asset.asset",
|
||||
"device.asset_binding",
|
||||
"infrastructure.host",
|
||||
"infrastructure.deployment",
|
||||
"infrastructure.service_instance",
|
||||
"observation.health_observation",
|
||||
]) {
|
||||
assert.equal(sql.includes(`'${entityId}'`), true);
|
||||
}
|
||||
assert.equal(sql.includes("229c61c02a790906"), true);
|
||||
for (const commandKind of [
|
||||
"asset.ensure",
|
||||
"asset_binding.ensure",
|
||||
"asset_binding.close",
|
||||
"infrastructure_host.ensure",
|
||||
"infrastructure_endpoint.ensure",
|
||||
"infrastructure_deployment.ensure",
|
||||
"infrastructure_service_instance.ensure",
|
||||
"health_observation.record",
|
||||
]) {
|
||||
assert.equal(sql.includes(`'${commandKind}'`), true);
|
||||
}
|
||||
assert.doesNotMatch(sql, /insert\s+into\s+device_(?:assets|infrastructure_hosts)/i);
|
||||
assert.doesNotMatch(sql, /gelios|arusnavi|beget|hetzner|aws/i);
|
||||
});
|
||||
|
||||
test("ontology migration follows integration identity", async () => {
|
||||
const source = await readFile(repositoryUrl, "utf8");
|
||||
assert.ok(
|
||||
source.indexOf("015_device_integration_identity.sql")
|
||||
< source.indexOf("016_device_asset_infrastructure_ontology.sql"),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user