diff --git a/device-plane/services/device-control-core/migrations/004_device_registry_foundation.sql b/device-plane/services/device-control-core/migrations/004_device_registry_foundation.sql new file mode 100644 index 0000000..e1d5c05 --- /dev/null +++ b/device-plane/services/device-control-core/migrations/004_device_registry_foundation.sql @@ -0,0 +1,208 @@ +begin; + +create table if not exists device_adapter_packages ( + id uuid primary key, + package_key text not null + check (package_key ~ '^[a-z][a-z0-9-]{1,62}$'), + display_name text not null + check (length(btrim(display_name)) between 1 and 160), + publisher_ref text not null + check (length(btrim(publisher_ref)) between 3 and 256), + lifecycle_state text not null default 'active' + check (lifecycle_state in ('active', 'retired')), + 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 (package_key) +); + +create table if not exists device_adapter_versions ( + id uuid primary key, + adapter_package_id uuid not null references device_adapter_packages(id), + version text not null + check (version ~ '^[0-9]+\.[0-9]+\.[0-9]+([+-][A-Za-z0-9.-]+)?$'), + runtime_package_ref text not null + check (length(btrim(runtime_package_ref)) between 3 and 256), + content_digest text not null + check (content_digest ~ '^sha256:[a-f0-9]{64}$'), + contract_version text not null + check (contract_version ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$'), + capabilities text[] not null default '{}' + check ( + cardinality(capabilities) <= 64 + and array_position(capabilities, null) is null + ), + lifecycle_state text not null default 'draft' + check (lifecycle_state in ('draft', 'active', 'retired')), + registered_by_ref text not null + check (length(btrim(registered_by_ref)) between 3 and 256), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (adapter_package_id, version), + unique (runtime_package_ref, content_digest), + unique (id, adapter_package_id) +); + +alter table device_model_profiles + add column if not exists adapter_version_id uuid + references device_adapter_versions(id), + add column if not exists schema_artifact_ref text + check ( + schema_artifact_ref is null + or length(btrim(schema_artifact_ref)) between 3 and 256 + ), + add column if not exists profile_digest text + check ( + profile_digest is null + or profile_digest ~ '^sha256:[a-f0-9]{64}$' + ), + add column if not exists capabilities text[] not null default '{}' + check ( + cardinality(capabilities) <= 64 + and array_position(capabilities, null) is null + ), + add column if not exists lifecycle_state text not null default 'active' + check (lifecycle_state in ('draft', 'active', 'retired')); + +create index if not exists device_model_profiles_adapter_version_idx + on device_model_profiles (adapter_version_id, lifecycle_state, updated_at desc); + +create table if not exists device_edges ( + id uuid primary key, + edge_key text not null + check (edge_key ~ '^[a-z][a-z0-9-]{1,62}$'), + display_name text not null + check (length(btrim(display_name)) between 1 and 160), + deployment_ref text + check ( + deployment_ref is null + or length(btrim(deployment_ref)) between 3 and 256 + ), + lifecycle_state text not null default 'provisioning' + check (lifecycle_state in ('provisioning', 'active', 'suspended', 'retired')), + 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 (edge_key) +); + +create table if not exists device_routes ( + id uuid primary key, + project_id uuid not null references device_projects(id), + route_key text not null + check (route_key ~ '^[a-z][a-z0-9-]{1,62}$'), + display_name text not null + check (length(btrim(display_name)) between 1 and 160), + edge_id uuid not null references device_edges(id), + model_profile_ref text not null references device_model_profiles(profile_ref), + listener_ref text not null + check (length(btrim(listener_ref)) between 3 and 256), + protocol text not null + check (protocol ~ '^[A-Z][A-Z0-9_]{0,31}$'), + direction text not null default 'telemetry' + check (direction in ('telemetry', 'bidirectional')), + lifecycle_state text not null default 'draft' + check (lifecycle_state in ('draft', 'active', 'suspended', 'retired')), + 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, route_key), + unique (id, project_id), + unique (id, edge_id, project_id), + unique (id, project_id, model_profile_ref) +); + +create index if not exists device_routes_edge_state_idx + on device_routes (edge_id, lifecycle_state, updated_at desc); + +create index if not exists device_routes_project_state_idx + on device_routes (project_id, lifecycle_state, updated_at desc); + +create table if not exists device_sessions ( + id uuid primary key, + session_ref text not null + check (length(btrim(session_ref)) between 3 and 256), + edge_id uuid not null, + project_id uuid not null, + route_id uuid not null, + device_id uuid, + protocol text not null + check (protocol ~ '^[A-Z][A-Z0-9_]{0,31}$'), + lifecycle_state text not null default 'connecting' + check (lifecycle_state in ('connecting', 'online', 'closing', 'closed', 'rejected')), + connected_at timestamptz not null, + last_seen_at timestamptz not null, + disconnected_at timestamptz, + close_reason_code text + check ( + close_reason_code is null + or close_reason_code ~ '^[a-z][a-z0-9._-]{1,63}$' + ), + frame_count bigint not null default 0 check (frame_count >= 0), + byte_count bigint not null default 0 check (byte_count >= 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (edge_id, session_ref), + foreign key (route_id, edge_id, project_id) + references device_routes(id, edge_id, project_id), + foreign key (device_id, project_id) + references device_instances(id, project_id), + check (last_seen_at >= connected_at), + check ( + (lifecycle_state in ('connecting', 'online') and disconnected_at is null) + or + (lifecycle_state in ('closing', 'closed', 'rejected')) + ) +); + +create index if not exists device_sessions_route_state_seen_idx + on device_sessions (route_id, lifecycle_state, last_seen_at desc); + +create index if not exists device_sessions_device_seen_idx + on device_sessions (device_id, last_seen_at desc) + where device_id is not null; + +create table if not exists device_enrollment_intents ( + id uuid primary key, + project_id uuid not null references device_projects(id), + enrollment_key text not null + check (enrollment_key ~ '^[a-z][a-z0-9-]{1,62}$'), + route_id uuid not null, + model_profile_ref text not null references device_model_profiles(profile_ref), + display_name text not null + check (length(btrim(display_name)) between 1 and 160), + expected_identifier_kind text not null + check (expected_identifier_kind ~ '^[a-z][a-z0-9._-]{1,31}$'), + expected_identifier_digest text not null + check (expected_identifier_digest ~ '^hmac-sha256:[a-f0-9]{64}$'), + expected_identifier_masked text not null + check (length(btrim(expected_identifier_masked)) between 4 and 64), + lifecycle_state text not null default 'pending' + check (lifecycle_state in ('pending', 'observed', 'claimed', 'cancelled', 'expired')), + expires_at timestamptz, + claimed_device_id uuid, + 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, enrollment_key), + unique ( + project_id, + expected_identifier_kind, + expected_identifier_digest, + model_profile_ref + ), + foreign key (route_id, project_id, model_profile_ref) + references device_routes(id, project_id, model_profile_ref), + foreign key (claimed_device_id, project_id) + references device_instances(id, project_id), + check (expires_at is null or expires_at > created_at) +); + +create index if not exists device_enrollment_intents_project_state_idx + on device_enrollment_intents (project_id, lifecycle_state, updated_at desc); + +commit; diff --git a/device-plane/services/device-control-core/src/postgres-repository.mjs b/device-plane/services/device-control-core/src/postgres-repository.mjs index 6c05238..62af81d 100644 --- a/device-plane/services/device-control-core/src/postgres-repository.mjs +++ b/device-plane/services/device-control-core/src/postgres-repository.mjs @@ -19,6 +19,7 @@ const migrationFiles = [ "001_device_plane_foundation.sql", "002_device_project_access.sql", "003_device_management_commands.sql", + "004_device_registry_foundation.sql", ]; export class PostgresDeviceRepository { diff --git a/device-plane/services/device-control-core/test/device-registry-migration.test.mjs b/device-plane/services/device-control-core/test/device-registry-migration.test.mjs new file mode 100644 index 0000000..34734da --- /dev/null +++ b/device-plane/services/device-control-core/test/device-registry-migration.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const migrationUrl = new URL( + "../migrations/004_device_registry_foundation.sql", + import.meta.url, +); +const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); + +test("registry migration defines generic catalog, edge and runtime boundaries", async () => { + const sql = await readFile(migrationUrl, "utf8"); + + for (const table of [ + "device_adapter_packages", + "device_adapter_versions", + "device_edges", + "device_routes", + "device_sessions", + "device_enrollment_intents", + ]) { + assert.match(sql, new RegExp(`create table if not exists ${table}`)); + } + assert.match(sql, /add column if not exists adapter_version_id uuid/); + assert.match(sql, /content_digest ~ '\^sha256:\[a-f0-9\]\{64\}\$'/); + assert.match(sql, /expected_identifier_digest ~ '\^hmac-sha256:\[a-f0-9\]\{64\}\$'/); +}); + +test("registry migration enforces project, route, edge and device isolation", async () => { + const sql = await readFile(migrationUrl, "utf8"); + + assert.match( + sql, + /foreign key \(route_id, edge_id, project_id\)\s+references device_routes\(id, edge_id, project_id\)/, + ); + assert.match( + sql, + /foreign key \(route_id, project_id, model_profile_ref\)\s+references device_routes\(id, project_id, model_profile_ref\)/, + ); + assert.match( + sql, + /foreign key \(device_id, project_id\)\s+references device_instances\(id, project_id\)/, + ); + assert.match( + sql, + /foreign key \(claimed_device_id, project_id\)\s+references device_instances\(id, project_id\)/, + ); +}); + +test("registry migration stores no device, tenant, network or credential seed", async () => { + const sql = await readFile(migrationUrl, "utf8"); + + assert.doesNotMatch(sql, /insert\s+into/i); + assert.doesNotMatch(sql, /dcctouch|arusnavi|\bb2\b|imei|gelios/i); + assert.doesNotMatch(sql, /155\.212\.|device\.nodedc\.ru|synology/i); + assert.doesNotMatch(sql, /password|secret|private_key|credential_ref/i); +}); + +test("repository applies registry migration after management receipts", async () => { + const source = await readFile(repositoryUrl, "utf8"); + const managementIndex = source.indexOf("003_device_management_commands.sql"); + const registryIndex = source.indexOf("004_device_registry_foundation.sql"); + + assert.notEqual(managementIndex, -1); + assert.notEqual(registryIndex, -1); + assert.ok(managementIndex < registryIndex); +});