From 827bf0a58ac270435089481df0fc522af8569b96 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 21 Aug 2026 12:23:21 +0300 Subject: [PATCH] refactor: move Device Core source to standalone repository --- device-plane/.dockerignore | 12 - device-plane/README.md | 71 - .../device-control-core-release-v1.json | 33 - .../device-control-core-release-v2.json | 35 - .../device-edge-admission-gate-v1.json | 39 - .../deployment/device-edge-backhaul-v1.json | 25 - ...device-edge-core-channel-bootstrap-v1.json | 33 - .../device-edge-core-channel-source-v1.json | 72 - .../device-edge-core-channel-upgrade-v1.json | 28 - .../device-edge-core-channel-upgrade-v2.json | 28 - .../device-edge-core-channel-upgrade-v4.json | 39 - .../device-edge-core-channel-v1.json | 114 - .../device-edge-ingress-ipvlan-v1.json | 36 - .../device-edge-vps-backhaul-v1.json | 22 - .../device-edge-vps-command-transport-v1.json | 78 - .../device-edge-vps-core-channel-v1.json | 55 - .../device-edge-vps-foundation-v1.json | 22 - .../deployment/device-edge-vps-relay-v1.json | 27 - ...ce-edge-vps-runtime-reconciliation-v1.json | 17 - ...vice-edge-vps-tailscale-retirement-v1.json | 55 - .../device-edge-vps-tracker-ingress-v1.json | 69 - ...nager-control-plane-reconciliation-v1.json | 21 - .../device-manager-control-plane-v1.json | 10 - ...er-control-plane-v2-reconciliation-v1.json | 24 - .../device-manager-control-plane-v2.json | 17 - .../deployment/device-manager-release-v1.json | 18 - .../deployment/device-manager-release-v2.json | 21 - .../deployment/device-manager-release-v3.json | 31 - .../device-plane-b2-discovery-ingress-v1.json | 30 - ...ane-b2-discovery-loopback-recovery-v1.json | 23 - ...lane-backhaul-target-tailnet-serve-v1.json | 36 - ...vice-plane-backhaul-vps-enrollment-v1.json | 28 - ...ane-foundation-network-publication-v1.json | 24 - .../device-plane-foundation-recovery-v1.json | 16 - .../device-postgres-bootstrap-v1.json | 8 - ...superseded-vps-initiated-transport-v1.json | 33 - .../tailscale-device-edge-policy.hujson | 55 - ...ocker-compose.device-edge-core-channel.yml | 36 - .../docker-compose.device-edge.backhaul.yml | 82 - .../docker-compose.device-edge.ingress.yml | 35 - device-plane/docker-compose.device-edge.yml | 44 - .../docker-compose.device-manager.yml | 76 - ...r-compose.device-plane.backhaul-target.yml | 39 - device-plane/docker-compose.device-plane.yml | 158 -- .../ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md | 367 --- .../DEVICE_EDGE_B2_PUBLIC_PILOT_RUNBOOK.md | 114 - ..._MANAGER_DIRECT_B2_VPS_AUDIT_2026-08-06.md | 898 ------- device-plane/docs/IMPLEMENTATION_BASELINE.md | 287 --- ...B_B2_VPS_CONFIGURATION_BIBLE_2026-08-06.md | 539 ---- device-plane/docs/TEST_MATRIX.md | 38 - device-plane/package-lock.json | 278 --- device-plane/package.json | 16 - .../packages/arusnavi-b2-adapter/package.json | 15 - .../arusnavi-b2-adapter/src/index.mjs | 331 --- .../arusnavi-b2-adapter/test/adapter.test.mjs | 164 -- .../device-adapter-catalog/package.json | 15 - .../device-adapter-catalog/src/index.mjs | 13 - .../test/catalog.test.mjs | 17 - .../device-adapter-runtime/package.json | 15 - .../device-adapter-runtime/src/index.mjs | 151 -- .../test/registry.test.mjs | 60 - .../device-edge-channel-contract/package.json | 12 - .../src/index.mjs | 387 --- .../test/contract.test.mjs | 106 - .../device-protocol-contract/package.json | 15 - .../device-protocol-contract/src/index.mjs | 530 ---- .../test/contract.test.mjs | 265 -- .../test/core-edge-channel-contract.test.mjs | 123 - .../device-backhaul-target/Dockerfile | 12 - .../device-backhaul-target/sshd_config | 35 - .../services/device-control-core/Dockerfile | 17 - .../001_device_plane_foundation.sql | 99 - .../migrations/002_device_project_access.sql | 113 - .../003_device_management_commands.sql | 68 - .../004_device_registry_foundation.sql | 208 -- .../005_device_registry_commands.sql | 21 - .../006_device_lifecycle_ownership.sql | 326 --- .../007_device_lifecycle_commands.sql | 25 - .../008_device_sensitive_references.sql | 233 -- ...09_device_sensitive_reference_commands.sql | 27 - .../010_device_control_resources.sql | 610 ----- .../011_device_control_resource_commands.sql | 31 - .../012_device_gateway_message_receipts.sql | 66 - .../migrations/013_device_edge_channels.sql | 54 - .../device-control-core/package-lock.json | 164 -- .../services/device-control-core/package.json | 16 - .../services/device-control-core/src/app.mjs | 504 ---- .../src/control-resource-management.mjs | 284 --- .../src/control-resource-repository.mjs | 498 ---- .../src/credential-reference.mjs | 29 - .../src/database-config.mjs | 73 - .../src/device-gateway-core-runtime.mjs | 741 ------ .../src/discovery-repository.mjs | 307 --- .../src/edge-channel-supervisor.mjs | 376 --- .../src/gateway-ingest.mjs | 125 - .../src/gateway-message-repository.mjs | 203 -- .../src/inbound-route-repository.mjs | 111 - .../src/infrastructure-management.mjs | 505 ---- .../src/infrastructure-repository.mjs | 964 -------- .../src/lifecycle-management.mjs | 139 -- .../src/lifecycle-repository.mjs | 682 ------ .../src/management-command.mjs | 48 - .../src/postgres-repository.mjs | 914 ------- .../src/project-management.mjs | 538 ---- .../src/project-query-repository.mjs | 661 ----- .../src/sensitive-reference-management.mjs | 97 - .../src/sensitive-reference-repository.mjs | 269 -- .../device-control-core/src/server.mjs | 255 -- .../src/typed-command-repository.mjs | 299 --- .../src/typed-command-runtime.mjs | 149 -- .../device-control-core/test/app.test.mjs | 589 ----- .../test/control-resource-management.test.mjs | 158 -- .../test/control-resource-migration.test.mjs | 104 - .../test/control-resource-repository.test.mjs | 313 --- .../test/database-config.test.mjs | 50 - .../device-edge-channel-migration.test.mjs | 22 - ...evice-lifecycle-command-migration.test.mjs | 40 - .../test/device-lifecycle-migration.test.mjs | 60 - ...device-registry-command-migration.test.mjs | 43 - .../test/device-registry-migration.test.mjs | 67 - .../test/discovery-repository.test.mjs | 199 -- .../test/edge-channel-supervisor.test.mjs | 130 - .../test/gateway-ingest.test.mjs | 143 -- .../test/gateway-message-migration.test.mjs | 41 - .../test/gateway-message-repository.test.mjs | 166 -- .../test/inbound-route-repository.test.mjs | 69 - .../test/infrastructure-app.test.mjs | 322 --- ...infrastructure-lifecycle-contract.test.mjs | 43 - .../test/infrastructure-management.test.mjs | 210 -- .../test/infrastructure-repository.test.mjs | 516 ---- .../test/lifecycle-management.test.mjs | 111 - .../test/lifecycle-repository.test.mjs | 461 ---- .../management-command-migration.test.mjs | 39 - .../test/management-repository.test.mjs | 281 --- .../test/management-runtime-contract.test.mjs | 55 - .../test/migration.test.mjs | 32 - .../test/project-access-migration.test.mjs | 60 - .../test/project-management.test.mjs | 293 --- .../test/project-query-repository.test.mjs | 391 --- .../sensitive-reference-management.test.mjs | 157 -- .../sensitive-reference-migration.test.mjs | 49 - .../sensitive-reference-repository.test.mjs | 271 --- .../test/typed-command-runtime.test.mjs | 106 - .../services/device-edge-backhaul/Dockerfile | 9 - .../device-edge-backhaul/test/source.test.mjs | 34 - .../services/device-edge-channel/package.json | 12 - .../device-edge-channel/src/runtime.mjs | 616 ----- .../device-edge-channel/src/server.mjs | 203 -- .../test/channel-integration.test.mjs | 765 ------ .../test/server-config.test.mjs | 46 - .../test/vps-runtime-config.test.mjs | 32 - .../services/device-edge-relay/Dockerfile | 9 - .../services/device-edge-relay/package.json | 13 - .../device-edge-relay/src/runtime.mjs | 384 --- .../services/device-edge-relay/src/server.mjs | 85 - .../test/ingress-source.test.mjs | 110 - .../device-edge-relay/test/runtime.test.mjs | 163 -- .../services/device-gateway-core/package.json | 12 - .../device-gateway-core/src/runtime.mjs | 3 - .../services/device-gateway/Dockerfile | 18 - .../services/device-gateway/package.json | 13 - .../device-gateway/src/core-client.mjs | 147 -- .../services/device-gateway/src/runtime.mjs | 646 ----- .../services/device-gateway/src/server.mjs | 175 -- .../device-gateway/test/core-client.test.mjs | 118 - .../test/discovery-integration.test.mjs | 149 -- .../device-gateway/test/runtime.test.mjs | 413 ---- .../services/device-manager/Dockerfile | 16 - device-plane/vps/config/00-nodedc-b2-vps.conf | 16 - device-plane/vps/config/backhaul_ssh_config | 23 - .../vps/config/nftables-core-channel.conf | 26 - .../vps/config/nftables-foundation.conf | 22 - device-plane/vps/config/nftables-relay.conf | 24 - .../vps/config/nftables-tracker-ingress.conf | 27 - .../vps/edge-process/device-edge-runtime.mjs | 247 -- .../vps/systemd/nodedc-b2-backhaul.service | 33 - .../vps/systemd/nodedc-b2-relay.service | 48 - .../vps/systemd/nodedc-b2-tailscaled.service | 36 - .../nodedc-device-edge-channel.service | 50 - .../nodedc-device-edge-runtime.service | 62 - ...d-device-control-core-release-artifact.mjs | 213 -- ...e-edge-core-channel-bootstrap-artifact.mjs | 278 --- .../build-device-edge-ingress-artifact.mjs | 265 -- .../build-device-edge-vps-artifact.mjs | 465 ---- ...-device-manager-control-plane-artifact.mjs | 248 -- ...-control-plane-reconciliation-artifact.mjs | 111 - ...ntrol-plane-v2-reconciliation-artifact.mjs | 111 - .../build-device-plane-artifact.mjs | 201 -- ...ce-plane-b2-discovery-ingress-artifact.mjs | 271 --- ...2-discovery-loopback-recovery-artifact.mjs | 175 -- ...-device-plane-backhaul-target-artifact.mjs | 256 -- ...plane-backhaul-vps-enrollment-artifact.mjs | 160 -- ...oundation-network-publication-artifact.mjs | 274 --- ...ice-plane-foundation-recovery-artifact.mjs | 255 -- ...vice-plane-postgres-bootstrap-artifact.mjs | 162 -- ...vice-plane-foundation-internal-only-v1.yml | 118 - ...lane-foundation-network-publication-v1.yml | 126 - infra/deploy-runner/nodedc-b2-vps-deploy | 2106 ---------------- infra/deploy-runner/nodedc-edge-deploy | 868 ------- ...test_device_edge_core_channel_bootstrap.py | 274 --- .../test_device_edge_ingress_artifact.py | 323 --- .../test_device_edge_vps_artifact.py | 765 ------ ..._device_manager_control_plane_artifacts.py | 2157 ----------------- ...r_control_plane_reconciliation_artifact.py | 376 --- .../test_device_plane_artifact.py | 156 -- ...ice_plane_b2_discovery_ingress_artifact.py | 477 ---- ...b2_discovery_loopback_recovery_artifact.py | 323 --- ...t_device_plane_backhaul_target_artifact.py | 649 ----- ..._plane_backhaul_vps_enrollment_artifact.py | 236 -- ...foundation_network_publication_artifact.py | 302 --- ...vice_plane_foundation_recovery_artifact.py | 386 --- .../test_device_plane_postgres_bootstrap.py | 210 -- 212 files changed, 41120 deletions(-) delete mode 100644 device-plane/.dockerignore delete mode 100644 device-plane/README.md delete mode 100644 device-plane/deployment/device-control-core-release-v1.json delete mode 100644 device-plane/deployment/device-control-core-release-v2.json delete mode 100644 device-plane/deployment/device-edge-admission-gate-v1.json delete mode 100644 device-plane/deployment/device-edge-backhaul-v1.json delete mode 100644 device-plane/deployment/device-edge-core-channel-bootstrap-v1.json delete mode 100644 device-plane/deployment/device-edge-core-channel-source-v1.json delete mode 100644 device-plane/deployment/device-edge-core-channel-upgrade-v1.json delete mode 100644 device-plane/deployment/device-edge-core-channel-upgrade-v2.json delete mode 100644 device-plane/deployment/device-edge-core-channel-upgrade-v4.json delete mode 100644 device-plane/deployment/device-edge-core-channel-v1.json delete mode 100644 device-plane/deployment/device-edge-ingress-ipvlan-v1.json delete mode 100644 device-plane/deployment/device-edge-vps-backhaul-v1.json delete mode 100644 device-plane/deployment/device-edge-vps-command-transport-v1.json delete mode 100644 device-plane/deployment/device-edge-vps-core-channel-v1.json delete mode 100644 device-plane/deployment/device-edge-vps-foundation-v1.json delete mode 100644 device-plane/deployment/device-edge-vps-relay-v1.json delete mode 100644 device-plane/deployment/device-edge-vps-runtime-reconciliation-v1.json delete mode 100644 device-plane/deployment/device-edge-vps-tailscale-retirement-v1.json delete mode 100644 device-plane/deployment/device-edge-vps-tracker-ingress-v1.json delete mode 100644 device-plane/deployment/device-manager-control-plane-reconciliation-v1.json delete mode 100644 device-plane/deployment/device-manager-control-plane-v1.json delete mode 100644 device-plane/deployment/device-manager-control-plane-v2-reconciliation-v1.json delete mode 100644 device-plane/deployment/device-manager-control-plane-v2.json delete mode 100644 device-plane/deployment/device-manager-release-v1.json delete mode 100644 device-plane/deployment/device-manager-release-v2.json delete mode 100644 device-plane/deployment/device-manager-release-v3.json delete mode 100644 device-plane/deployment/device-plane-b2-discovery-ingress-v1.json delete mode 100644 device-plane/deployment/device-plane-b2-discovery-loopback-recovery-v1.json delete mode 100644 device-plane/deployment/device-plane-backhaul-target-tailnet-serve-v1.json delete mode 100644 device-plane/deployment/device-plane-backhaul-vps-enrollment-v1.json delete mode 100644 device-plane/deployment/device-plane-foundation-network-publication-v1.json delete mode 100644 device-plane/deployment/device-plane-foundation-recovery-v1.json delete mode 100644 device-plane/deployment/device-postgres-bootstrap-v1.json delete mode 100644 device-plane/deployment/superseded-vps-initiated-transport-v1.json delete mode 100644 device-plane/deployment/tailscale-device-edge-policy.hujson delete mode 100644 device-plane/docker-compose.device-edge-core-channel.yml delete mode 100644 device-plane/docker-compose.device-edge.backhaul.yml delete mode 100644 device-plane/docker-compose.device-edge.ingress.yml delete mode 100644 device-plane/docker-compose.device-edge.yml delete mode 100644 device-plane/docker-compose.device-manager.yml delete mode 100644 device-plane/docker-compose.device-plane.backhaul-target.yml delete mode 100644 device-plane/docker-compose.device-plane.yml delete mode 100644 device-plane/docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md delete mode 100644 device-plane/docs/DEVICE_EDGE_B2_PUBLIC_PILOT_RUNBOOK.md delete mode 100644 device-plane/docs/DEVICE_MANAGER_DIRECT_B2_VPS_AUDIT_2026-08-06.md delete mode 100644 device-plane/docs/IMPLEMENTATION_BASELINE.md delete mode 100644 device-plane/docs/ROBOT2B_B2_VPS_CONFIGURATION_BIBLE_2026-08-06.md delete mode 100644 device-plane/docs/TEST_MATRIX.md delete mode 100644 device-plane/package-lock.json delete mode 100644 device-plane/package.json delete mode 100644 device-plane/packages/arusnavi-b2-adapter/package.json delete mode 100644 device-plane/packages/arusnavi-b2-adapter/src/index.mjs delete mode 100644 device-plane/packages/arusnavi-b2-adapter/test/adapter.test.mjs delete mode 100644 device-plane/packages/device-adapter-catalog/package.json delete mode 100644 device-plane/packages/device-adapter-catalog/src/index.mjs delete mode 100644 device-plane/packages/device-adapter-catalog/test/catalog.test.mjs delete mode 100644 device-plane/packages/device-adapter-runtime/package.json delete mode 100644 device-plane/packages/device-adapter-runtime/src/index.mjs delete mode 100644 device-plane/packages/device-adapter-runtime/test/registry.test.mjs delete mode 100644 device-plane/packages/device-edge-channel-contract/package.json delete mode 100644 device-plane/packages/device-edge-channel-contract/src/index.mjs delete mode 100644 device-plane/packages/device-edge-channel-contract/test/contract.test.mjs delete mode 100644 device-plane/packages/device-protocol-contract/package.json delete mode 100644 device-plane/packages/device-protocol-contract/src/index.mjs delete mode 100644 device-plane/packages/device-protocol-contract/test/contract.test.mjs delete mode 100644 device-plane/packages/device-protocol-contract/test/core-edge-channel-contract.test.mjs delete mode 100644 device-plane/services/device-backhaul-target/Dockerfile delete mode 100644 device-plane/services/device-backhaul-target/sshd_config delete mode 100644 device-plane/services/device-control-core/Dockerfile delete mode 100644 device-plane/services/device-control-core/migrations/001_device_plane_foundation.sql delete mode 100644 device-plane/services/device-control-core/migrations/002_device_project_access.sql delete mode 100644 device-plane/services/device-control-core/migrations/003_device_management_commands.sql delete mode 100644 device-plane/services/device-control-core/migrations/004_device_registry_foundation.sql delete mode 100644 device-plane/services/device-control-core/migrations/005_device_registry_commands.sql delete mode 100644 device-plane/services/device-control-core/migrations/006_device_lifecycle_ownership.sql delete mode 100644 device-plane/services/device-control-core/migrations/007_device_lifecycle_commands.sql delete mode 100644 device-plane/services/device-control-core/migrations/008_device_sensitive_references.sql delete mode 100644 device-plane/services/device-control-core/migrations/009_device_sensitive_reference_commands.sql delete mode 100644 device-plane/services/device-control-core/migrations/010_device_control_resources.sql delete mode 100644 device-plane/services/device-control-core/migrations/011_device_control_resource_commands.sql delete mode 100644 device-plane/services/device-control-core/migrations/012_device_gateway_message_receipts.sql delete mode 100644 device-plane/services/device-control-core/migrations/013_device_edge_channels.sql delete mode 100644 device-plane/services/device-control-core/package-lock.json delete mode 100644 device-plane/services/device-control-core/package.json delete mode 100644 device-plane/services/device-control-core/src/app.mjs delete mode 100644 device-plane/services/device-control-core/src/control-resource-management.mjs delete mode 100644 device-plane/services/device-control-core/src/control-resource-repository.mjs delete mode 100644 device-plane/services/device-control-core/src/credential-reference.mjs delete mode 100644 device-plane/services/device-control-core/src/database-config.mjs delete mode 100644 device-plane/services/device-control-core/src/device-gateway-core-runtime.mjs delete mode 100644 device-plane/services/device-control-core/src/discovery-repository.mjs delete mode 100644 device-plane/services/device-control-core/src/edge-channel-supervisor.mjs delete mode 100644 device-plane/services/device-control-core/src/gateway-ingest.mjs delete mode 100644 device-plane/services/device-control-core/src/gateway-message-repository.mjs delete mode 100644 device-plane/services/device-control-core/src/inbound-route-repository.mjs delete mode 100644 device-plane/services/device-control-core/src/infrastructure-management.mjs delete mode 100644 device-plane/services/device-control-core/src/infrastructure-repository.mjs delete mode 100644 device-plane/services/device-control-core/src/lifecycle-management.mjs delete mode 100644 device-plane/services/device-control-core/src/lifecycle-repository.mjs delete mode 100644 device-plane/services/device-control-core/src/management-command.mjs delete mode 100644 device-plane/services/device-control-core/src/postgres-repository.mjs delete mode 100644 device-plane/services/device-control-core/src/project-management.mjs delete mode 100644 device-plane/services/device-control-core/src/project-query-repository.mjs delete mode 100644 device-plane/services/device-control-core/src/sensitive-reference-management.mjs delete mode 100644 device-plane/services/device-control-core/src/sensitive-reference-repository.mjs delete mode 100644 device-plane/services/device-control-core/src/server.mjs delete mode 100644 device-plane/services/device-control-core/src/typed-command-repository.mjs delete mode 100644 device-plane/services/device-control-core/src/typed-command-runtime.mjs delete mode 100644 device-plane/services/device-control-core/test/app.test.mjs delete mode 100644 device-plane/services/device-control-core/test/control-resource-management.test.mjs delete mode 100644 device-plane/services/device-control-core/test/control-resource-migration.test.mjs delete mode 100644 device-plane/services/device-control-core/test/control-resource-repository.test.mjs delete mode 100644 device-plane/services/device-control-core/test/database-config.test.mjs delete mode 100644 device-plane/services/device-control-core/test/device-edge-channel-migration.test.mjs delete mode 100644 device-plane/services/device-control-core/test/device-lifecycle-command-migration.test.mjs delete mode 100644 device-plane/services/device-control-core/test/device-lifecycle-migration.test.mjs delete mode 100644 device-plane/services/device-control-core/test/device-registry-command-migration.test.mjs delete mode 100644 device-plane/services/device-control-core/test/device-registry-migration.test.mjs delete mode 100644 device-plane/services/device-control-core/test/discovery-repository.test.mjs delete mode 100644 device-plane/services/device-control-core/test/edge-channel-supervisor.test.mjs delete mode 100644 device-plane/services/device-control-core/test/gateway-ingest.test.mjs delete mode 100644 device-plane/services/device-control-core/test/gateway-message-migration.test.mjs delete mode 100644 device-plane/services/device-control-core/test/gateway-message-repository.test.mjs delete mode 100644 device-plane/services/device-control-core/test/inbound-route-repository.test.mjs delete mode 100644 device-plane/services/device-control-core/test/infrastructure-app.test.mjs delete mode 100644 device-plane/services/device-control-core/test/infrastructure-lifecycle-contract.test.mjs delete mode 100644 device-plane/services/device-control-core/test/infrastructure-management.test.mjs delete mode 100644 device-plane/services/device-control-core/test/infrastructure-repository.test.mjs delete mode 100644 device-plane/services/device-control-core/test/lifecycle-management.test.mjs delete mode 100644 device-plane/services/device-control-core/test/lifecycle-repository.test.mjs delete mode 100644 device-plane/services/device-control-core/test/management-command-migration.test.mjs delete mode 100644 device-plane/services/device-control-core/test/management-repository.test.mjs delete mode 100644 device-plane/services/device-control-core/test/management-runtime-contract.test.mjs delete mode 100644 device-plane/services/device-control-core/test/migration.test.mjs delete mode 100644 device-plane/services/device-control-core/test/project-access-migration.test.mjs delete mode 100644 device-plane/services/device-control-core/test/project-management.test.mjs delete mode 100644 device-plane/services/device-control-core/test/project-query-repository.test.mjs delete mode 100644 device-plane/services/device-control-core/test/sensitive-reference-management.test.mjs delete mode 100644 device-plane/services/device-control-core/test/sensitive-reference-migration.test.mjs delete mode 100644 device-plane/services/device-control-core/test/sensitive-reference-repository.test.mjs delete mode 100644 device-plane/services/device-control-core/test/typed-command-runtime.test.mjs delete mode 100644 device-plane/services/device-edge-backhaul/Dockerfile delete mode 100644 device-plane/services/device-edge-backhaul/test/source.test.mjs delete mode 100644 device-plane/services/device-edge-channel/package.json delete mode 100644 device-plane/services/device-edge-channel/src/runtime.mjs delete mode 100644 device-plane/services/device-edge-channel/src/server.mjs delete mode 100644 device-plane/services/device-edge-channel/test/channel-integration.test.mjs delete mode 100644 device-plane/services/device-edge-channel/test/server-config.test.mjs delete mode 100644 device-plane/services/device-edge-channel/test/vps-runtime-config.test.mjs delete mode 100644 device-plane/services/device-edge-relay/Dockerfile delete mode 100644 device-plane/services/device-edge-relay/package.json delete mode 100644 device-plane/services/device-edge-relay/src/runtime.mjs delete mode 100644 device-plane/services/device-edge-relay/src/server.mjs delete mode 100644 device-plane/services/device-edge-relay/test/ingress-source.test.mjs delete mode 100644 device-plane/services/device-edge-relay/test/runtime.test.mjs delete mode 100644 device-plane/services/device-gateway-core/package.json delete mode 100644 device-plane/services/device-gateway-core/src/runtime.mjs delete mode 100644 device-plane/services/device-gateway/Dockerfile delete mode 100644 device-plane/services/device-gateway/package.json delete mode 100644 device-plane/services/device-gateway/src/core-client.mjs delete mode 100644 device-plane/services/device-gateway/src/runtime.mjs delete mode 100644 device-plane/services/device-gateway/src/server.mjs delete mode 100644 device-plane/services/device-gateway/test/core-client.test.mjs delete mode 100644 device-plane/services/device-gateway/test/discovery-integration.test.mjs delete mode 100644 device-plane/services/device-gateway/test/runtime.test.mjs delete mode 100644 device-plane/services/device-manager/Dockerfile delete mode 100644 device-plane/vps/config/00-nodedc-b2-vps.conf delete mode 100644 device-plane/vps/config/backhaul_ssh_config delete mode 100644 device-plane/vps/config/nftables-core-channel.conf delete mode 100644 device-plane/vps/config/nftables-foundation.conf delete mode 100644 device-plane/vps/config/nftables-relay.conf delete mode 100644 device-plane/vps/config/nftables-tracker-ingress.conf delete mode 100644 device-plane/vps/edge-process/device-edge-runtime.mjs delete mode 100644 device-plane/vps/systemd/nodedc-b2-backhaul.service delete mode 100644 device-plane/vps/systemd/nodedc-b2-relay.service delete mode 100644 device-plane/vps/systemd/nodedc-b2-tailscaled.service delete mode 100644 device-plane/vps/systemd/nodedc-device-edge-channel.service delete mode 100644 device-plane/vps/systemd/nodedc-device-edge-runtime.service delete mode 100644 infra/deploy-runner/build-device-control-core-release-artifact.mjs delete mode 100644 infra/deploy-runner/build-device-edge-core-channel-bootstrap-artifact.mjs delete mode 100644 infra/deploy-runner/build-device-edge-ingress-artifact.mjs delete mode 100644 infra/deploy-runner/build-device-edge-vps-artifact.mjs delete mode 100644 infra/deploy-runner/build-device-manager-control-plane-artifact.mjs delete mode 100644 infra/deploy-runner/build-device-manager-control-plane-reconciliation-artifact.mjs delete mode 100644 infra/deploy-runner/build-device-manager-control-plane-v2-reconciliation-artifact.mjs delete mode 100644 infra/deploy-runner/build-device-plane-artifact.mjs delete mode 100644 infra/deploy-runner/build-device-plane-b2-discovery-ingress-artifact.mjs delete mode 100644 infra/deploy-runner/build-device-plane-b2-discovery-loopback-recovery-artifact.mjs delete mode 100644 infra/deploy-runner/build-device-plane-backhaul-target-artifact.mjs delete mode 100644 infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs delete mode 100644 infra/deploy-runner/build-device-plane-foundation-network-publication-artifact.mjs delete mode 100755 infra/deploy-runner/build-device-plane-foundation-recovery-artifact.mjs delete mode 100644 infra/deploy-runner/build-device-plane-postgres-bootstrap-artifact.mjs delete mode 100644 infra/deploy-runner/fixtures/device-plane-foundation-internal-only-v1.yml delete mode 100644 infra/deploy-runner/fixtures/device-plane-foundation-network-publication-v1.yml delete mode 100755 infra/deploy-runner/nodedc-b2-vps-deploy delete mode 100644 infra/deploy-runner/nodedc-edge-deploy delete mode 100644 infra/deploy-runner/test_device_edge_core_channel_bootstrap.py delete mode 100644 infra/deploy-runner/test_device_edge_ingress_artifact.py delete mode 100644 infra/deploy-runner/test_device_edge_vps_artifact.py delete mode 100644 infra/deploy-runner/test_device_manager_control_plane_artifacts.py delete mode 100644 infra/deploy-runner/test_device_manager_control_plane_reconciliation_artifact.py delete mode 100644 infra/deploy-runner/test_device_plane_artifact.py delete mode 100644 infra/deploy-runner/test_device_plane_b2_discovery_ingress_artifact.py delete mode 100644 infra/deploy-runner/test_device_plane_b2_discovery_loopback_recovery_artifact.py delete mode 100644 infra/deploy-runner/test_device_plane_backhaul_target_artifact.py delete mode 100644 infra/deploy-runner/test_device_plane_backhaul_vps_enrollment_artifact.py delete mode 100644 infra/deploy-runner/test_device_plane_foundation_network_publication_artifact.py delete mode 100644 infra/deploy-runner/test_device_plane_foundation_recovery_artifact.py delete mode 100644 infra/deploy-runner/test_device_plane_postgres_bootstrap.py diff --git a/device-plane/.dockerignore b/device-plane/.dockerignore deleted file mode 100644 index e478899..0000000 --- a/device-plane/.dockerignore +++ /dev/null @@ -1,12 +0,0 @@ -.git -.DS_Store -.env -.env.* -docs -node_modules -**/test -**/*.log -**/*.prev-* -**/*.next-* -runtime -secrets diff --git a/device-plane/README.md b/device-plane/README.md deleted file mode 100644 index 0027249..0000000 --- a/device-plane/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# NDC Device Plane - -`device-plane` is the provider-neutral runtime boundary for physical devices. -It is intentionally separate from Foundry, Engine L2, External Data Plane and -the preserved Gelios integration. - -Recorded runtime status: the private Synology foundation and the isolated Mini -IPvlan relay are running through their reviewed artifacts. Synology has no -public device listener; the Mini relay is LAN-addressable only and router/NAT -exposure remains a separate manual gate. The admission-gate source below is -prepared and staged, but has not yet been applied. - -- `packages/device-protocol-contract` owns safe discovery and presentation - contracts. -- `packages/arusnavi-b2-adapter` owns the first model-profile evidence and a - fail-closed framing boundary. -- `services/device-control-core` owns the initial PostgreSQL schema, health - boundary and disabled-by-default quarantine ingest. -- `services/device-gateway` owns a disabled-by-default, loopback-only TCP - evidence listener that sends no bytes and extracts no identifier until the - official framing contract is known. -- `docker-compose.device-plane.yml` publishes only loopback health endpoints - and keeps the raw TCP listener unpublished. -- No device command can be built or sent. -- No real IMEI, ICCID, password, packet or provider credential is stored in - this source tree. - -The planned runtime services are: - -- `device-control-core`: contours, discoveries, devices, bindings, policy and - audit; -- `device-gateway`: raw TCP sessions, bounded codecs and presence; -- `device-postgres`: private persistent state. - -`services/device-edge-relay` is a separate, disabled-by-default runtime for the -dedicated Debian ingress host. It is an L4 byte relay: it neither decodes -ARUSNAVI B2, acknowledges a device nor holds a Core token, database credential -or command capability. The implemented backhaul forwards only through a -restricted outbound SSH path to the Synology Gateway loopback listener. The -reviewed ingress target gives only this container a fixed IPvlan L2 address on -the Mini's single Ethernet link, so the host Amnezia full tunnel stays intact -and Docker publishes no host port. The fixed relay address is -`192.168.71.253`, explicitly outside the documented Deco DHCP pool -`192.168.68.50`–`192.168.71.250`. See `docs/IMPLEMENTATION_BASELINE.md` for -the remaining activation gates. The staged admission update keeps the relay -opaque but requires a public IPv4 source and bounds its source table and bytes -per direction; it does not enable router/NAT exposure. - -The standalone Hub application `Device Core` / `Device Manager` is the human -control-plane shell. Its server-owned BFF calls the disabled-by-default -management and query API in `device-control-core`; browsers never receive the -Core bearer token and never author actor, role, group or owner-scope headers. -The authorized project workspace exposes only bounded metadata for catalog, -routes/sessions, bindings, configuration state, command state, audit and access; -raw audit/configuration payloads, command parameters/transport refs, external -approval proofs, credential refs and restricted identifier digests remain in Core. -Foundry remains a downstream consumer for project-approved device data and is -not the device registry or administration boundary. - -Run the foundation tests: - -```bash -npm test -``` - -See [IMPLEMENTATION_BASELINE.md](docs/IMPLEMENTATION_BASELINE.md) for the -placement, security and rollout contract. - -The canonical runner registry and deterministic artifact builder live in -`../infra/deploy-runner`. The runner must be separately promoted and verified -before any Device Plane artifact is staged or planned. diff --git a/device-plane/deployment/device-control-core-release-v1.json b/device-plane/deployment/device-control-core-release-v1.json deleted file mode 100644 index 561f48e..0000000 --- a/device-plane/deployment/device-control-core-release-v1.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.device-control-core-release.v1", - "releaseId": "__PATCH_ID__", - "action": "upgrade", - "predecessor": { - "kind": "edge-core-channel-upgrade-v4", - "patchId": "device-edge-core-channel-upgrade-v4-20260812-023", - "artifactSha256": "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc" - }, - "service": "device-control-core", - "composeActivation": "preserve-active-v4-topology", - "identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export", - "identityRecovery": "forbidden-valid-existing-identity-required", - "tlsPurpose": "clientAuth", - "direction": "core-initiated", - "endpointPolicy": "public-ipv4-standard-https-tcp-443-only", - "coreNetworks": [ - "device-plane-private", - "device-plane-egress" - ], - "publicIngress": "none-on-synology", - "edgeRegistrations": "preserved", - "commandTransport": "disabled", - "gelios": "untouched", - "preservedServices": [ - "device-manager", - "device-gateway", - "device-postgres", - "device-backhaul-target" - ], - "healthGate": "bounded-container-grace+core-edge-contract+exact-private-egress-network-boundary", - "rollback": "restore-preapply-source-and-core-runtime" -} diff --git a/device-plane/deployment/device-control-core-release-v2.json b/device-plane/deployment/device-control-core-release-v2.json deleted file mode 100644 index 5d25430..0000000 --- a/device-plane/deployment/device-control-core-release-v2.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.device-control-core-release.v2", - "releaseId": "__PATCH_ID__", - "action": "upgrade", - "predecessor": { - "kind": "release", - "patchId": "device-control-core-release-20260812-024", - "artifactSha256": "a289e909283109642e6bba3d9822a31f63423cfe0bbcd52705979681bd2bc793" - }, - "service": "device-control-core", - "composeActivation": "preserve-active-v4-topology", - "identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export", - "identityRecovery": "forbidden-valid-existing-identity-required", - "tlsPurpose": "clientAuth", - "direction": "core-initiated", - "endpointPolicy": "public-ipv4-standard-https-tcp-443-only", - "coreNetworks": [ - "device-plane-private", - "device-plane-egress" - ], - "publicIngress": "none-on-synology", - "edgeRegistrations": "preserved", - "commandTransport": "typed-service-ping-v1", - "commandCatalog": "allowlisted-adapter-typed-commands-only", - "credentialBoundary": "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned", - "gelios": "untouched-legacy-only", - "preservedServices": [ - "device-manager", - "device-gateway", - "device-postgres", - "device-backhaul-target" - ], - "healthGate": "bounded-container-grace+core-edge-contract+exact-private-egress-network-boundary", - "rollback": "restore-preapply-source-and-core-runtime" -} diff --git a/device-plane/deployment/device-edge-admission-gate-v1.json b/device-plane/deployment/device-edge-admission-gate-v1.json deleted file mode 100644 index 2b728e4..0000000 --- a/device-plane/deployment/device-edge-admission-gate-v1.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "schemaVersion": "nodedc.device-edge.admission-gate.v1", - "mode": "single-nic-ipvlan-b2-relay-only", - "runtimeHost": "ndcmini12", - "component": "device-edge", - "selectedServices": [ - "device-edge-relay" - ], - "preservedServices": [ - "device-edge-backhaul", - "tailnet" - ], - "composeProject": "nodedc-device-edge", - "composeFiles": [ - "docker-compose.device-edge.yml", - "docker-compose.device-edge.ingress.yml" - ], - "parentInterface": "enp1s0f0", - "lanSubnet": "192.168.68.0/22", - "lanGateway": "192.168.68.1", - "ingressIpv4": "192.168.71.253", - "ingressIpv4Approval": "approved-outside-dhcp-pool", - "ingressNetwork": "nodedc-device-edge-ingress", - "deviceTcpListen": "192.168.71.253:9921", - "hostPortPublication": "disabled", - "healthPublication": "disabled", - "privateUpstream": "device-edge-backhaul:19921", - "sourceAdmission": "public-ipv4-only", - "maxTrackedSourceAddresses": 2048, - "maxBytesPerDirection": 262144, - "protocolInspection": "gateway-owned", - "identityTrust": "claimed-not-ownership-proof", - "discoveryLifecycle": "quarantine", - "commandTransport": "disabled", - "gelios": "untouched", - "amneziaHostFullTunnel": "preserved", - "routerNatFirewall": "separate-manual-gate", - "rollback": "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation" -} diff --git a/device-plane/deployment/device-edge-backhaul-v1.json b/device-plane/deployment/device-edge-backhaul-v1.json deleted file mode 100644 index 7826b70..0000000 --- a/device-plane/deployment/device-edge-backhaul-v1.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "schemaVersion": "nodedc.device-edge.backhaul.v1", - "mode": "private-tailnet-ssh-local-forward", - "runtimeHost": "ndcmini12", - "selectedServices": [ - "device-edge-backhaul" - ], - "preservedServices": [ - "device-edge-relay", - "tailnet" - ], - "tailnetSocksTarget": "nodedc-device-edge-tailnet-1:1055", - "sshTarget": "100.109.216.21:2222", - "sshUser": "device-backhaul", - "localForward": "0.0.0.0:19921", - "permittedRemoteTarget": "127.0.0.1:9921", - "hostPortPublication": "disabled", - "deviceIngress": "disabled", - "protocolInspection": "disabled", - "commandTransport": "disabled", - "privateKey": "runtime-only-read-only", - "knownHosts": "runner-prepared-exact-ed25519", - "routerNatFirewall": "unchanged", - "gelios": "untouched" -} diff --git a/device-plane/deployment/device-edge-core-channel-bootstrap-v1.json b/device-plane/deployment/device-edge-core-channel-bootstrap-v1.json deleted file mode 100644 index 16d5f78..0000000 --- a/device-plane/deployment/device-edge-core-channel-bootstrap-v1.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.device-edge-core-channel-bootstrap.v1", - "transitionId": "__PATCH_ID__", - "action": "activate", - "managerPredecessor": { - "patchId": "device-manager-release-20260811-010", - "artifactSha256": "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e" - }, - "failedPredecessor": { - "patchId": "device-manager-release-20260811-016", - "artifactSha256": "590405821b95b54088f926e0d3b2cdf9c704b339f6749da500c2bb64fe0e952d", - "backupId": "device-plane-device-manager-release-20260811-016-20260811-215941", - "invalidCoreCertificateSha256Fingerprint": "56:16:E0:3A:F4:03:85:FD:42:86:85:AF:2A:AF:1E:90:16:C8:F7:91:7C:AD:02:7D:B7:C2:ED:07:06:56:81:F6" - }, - "service": "device-control-core", - "composeActivation": "dedicated-additive-override", - "identity": "runner-managed-host-local-private-key-public-certificate-export", - "identityRecovery": "exact-invalid-unexported-failed-predecessor-only", - "tlsPurpose": "clientAuth", - "direction": "core-initiated", - "publicIngress": "none-on-synology", - "edgeRegistrations": "preserved", - "commandTransport": "disabled", - "gelios": "untouched", - "preservedServices": [ - "device-manager", - "device-gateway", - "device-postgres", - "device-backhaul-target" - ], - "healthGate": "bounded-container-grace+core-edge-contract", - "rollback": "restore-source-and-preapply-core-runtime" -} diff --git a/device-plane/deployment/device-edge-core-channel-source-v1.json b/device-plane/deployment/device-edge-core-channel-source-v1.json deleted file mode 100644 index 58b8dc2..0000000 --- a/device-plane/deployment/device-edge-core-channel-source-v1.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "schemaVersion": "nodedc.device-edge.core-channel-source.v1", - "status": "source-accepted", - "authority": "DCPLATFORM-76/ADR-0001", - "designContract": "nodedc.device-edge.core-channel.v1", - "implementedAt": "2026-08-11", - "transport": { - "initiator": "device-gateway-core", - "listener": "device-edge-channel", - "protocol": "http2-bidirectional-ndjson", - "tls": "TLSv1.3-mutual-authentication", - "bearerAuthentication": false, - "genericTcpForwarding": false - }, - "sourceComponents": [ - "packages/device-edge-channel-contract", - "services/device-edge-channel", - "services/device-gateway-core" - ], - "identity": { - "edgeRegistrationRequired": true, - "edgeCertificatePinRequired": true, - "coreCertificateAllowlistRequired": true, - "unknownOrRevokedIdentity": "reject", - "rotation": "one-active-plus-one-staged-generation", - "retiredFingerprint": "reject", - "privateKeysInSource": false, - "privateKeysInArtifact": false - }, - "messageBoundary": { - "schema": "nodedc.device-edge.channel-envelope.v1", - "maximumEnvelopeBytes": 1048576, - "directionLocalSequence": true, - "unknownSchemaOrKind": "close-logical-session", - "acceptanceWindow": 128, - "trackerAckRule": "core-acceptance-required" - }, - "realtime": { - "polling": false, - "keepaliveSeconds": 15, - "deadPeerSeconds": 45, - "reconnectMinimumSeconds": 1, - "reconnectMaximumSeconds": 30, - "delivery": "at-least-once-with-core-idempotency" - }, - "sourceAcceptance": { - "devicePlaneTestsPassed": 193, - "tls13MutualAuthenticationTested": true, - "keepaliveTested": true, - "disconnectReconnectTested": true, - "certificateRotationOverlapAndRetirementTested": true, - "idempotentReplayTested": true, - "unknownAndRevokedIdentityTested": true, - "oversizedEnvelopeTested": true, - "coreUnavailableRejectionTested": true, - "crossSessionProgressAndPerSessionOrderingTested": true, - "boundedAcceptanceWindowTested": true, - "existingCoreImageBuild": "passed-no-cache", - "existingGatewayImageBuild": "passed-no-cache" - }, - "runtime": { - "mutationInThisTransition": false, - "edgePort8443Published": false, - "trackerPort9921Published": false, - "synologyPublicIngress": false, - "trackerIngress": "disabled", - "commandTransport": "disabled", - "gelios": "untouched" - }, - "nextGate": "closed-port-synthetic-runtime-transition-under-DCPLATFORM-21", - "rollback": "source-revert-only-runtime-unchanged" -} diff --git a/device-plane/deployment/device-edge-core-channel-upgrade-v1.json b/device-plane/deployment/device-edge-core-channel-upgrade-v1.json deleted file mode 100644 index a013b86..0000000 --- a/device-plane/deployment/device-edge-core-channel-upgrade-v1.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.device-edge-core-channel-upgrade.v1", - "transitionId": "__PATCH_ID__", - "action": "upgrade", - "bootstrapPredecessor": { - "patchId": "device-edge-core-channel-bootstrap-20260812-018", - "artifactSha256": "5598b7388b491fe524ab46038ce476482a93a6cf07d8ca5e00206c69ded02931" - }, - "service": "device-control-core", - "composeActivation": "preserve-dedicated-additive-override", - "identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export", - "identityRecovery": "forbidden-valid-existing-identity-required", - "tlsPurpose": "clientAuth", - "direction": "core-initiated", - "endpointPolicy": "public-ipv4-standard-https-tcp-443-only", - "publicIngress": "none-on-synology", - "edgeRegistrations": "preserved-requires-explicit-443-reconciliation", - "commandTransport": "disabled", - "gelios": "untouched", - "preservedServices": [ - "device-manager", - "device-gateway", - "device-postgres", - "device-backhaul-target" - ], - "healthGate": "bounded-container-grace+core-edge-contract", - "rollback": "restore-bootstrap-018-source-and-preapply-core-runtime" -} diff --git a/device-plane/deployment/device-edge-core-channel-upgrade-v2.json b/device-plane/deployment/device-edge-core-channel-upgrade-v2.json deleted file mode 100644 index f1d6752..0000000 --- a/device-plane/deployment/device-edge-core-channel-upgrade-v2.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.device-edge-core-channel-upgrade.v2", - "transitionId": "__PATCH_ID__", - "action": "upgrade", - "upgradePredecessor": { - "patchId": "device-edge-core-channel-upgrade-20260812-019", - "artifactSha256": "8e9a220275959f378c1c4b00be5c7192e79afe2134eaab808a64e515870a8438" - }, - "service": "device-control-core", - "composeActivation": "preserve-dedicated-additive-override", - "identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export", - "identityRecovery": "forbidden-valid-existing-identity-required", - "tlsPurpose": "clientAuth", - "direction": "core-initiated", - "endpointPolicy": "public-ipv4-standard-https-tcp-443-only", - "publicIngress": "none-on-synology", - "edgeRegistrations": "preserved", - "commandTransport": "disabled", - "gelios": "untouched", - "preservedServices": [ - "device-manager", - "device-gateway", - "device-postgres", - "device-backhaul-target" - ], - "healthGate": "bounded-container-grace+core-edge-contract", - "rollback": "restore-upgrade-019-source-and-preapply-core-runtime" -} diff --git a/device-plane/deployment/device-edge-core-channel-upgrade-v4.json b/device-plane/deployment/device-edge-core-channel-upgrade-v4.json deleted file mode 100644 index 888fd49..0000000 --- a/device-plane/deployment/device-edge-core-channel-upgrade-v4.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.device-edge-core-channel-upgrade.v4", - "transitionId": "__PATCH_ID__", - "action": "upgrade", - "upgradePredecessor": { - "patchId": "device-edge-core-channel-upgrade-v2-20260812-021", - "artifactSha256": "e40a6fd24edfecac09e42cd82635a77850541bcf047788db3e9c55d2b9e58867" - }, - "failedAttempt": { - "patchId": "device-edge-core-channel-upgrade-v3-20260812-022", - "artifactSha256": "9e2b409a4b2d19711db434e90d03ac8e3db77bd74949f83cace7949f33caf613", - "backupId": "device-plane-device-edge-core-channel-upgrade-v3-20260812-022-20260812-123620" - }, - "service": "device-control-core", - "composeActivation": "replace-core-network-membership-with-private-plus-egress", - "identity": "reuse-existing-runner-managed-host-local-private-key-public-certificate-export", - "identityRecovery": "forbidden-valid-existing-identity-required", - "tlsPurpose": "clientAuth", - "direction": "core-initiated", - "endpointPolicy": "public-ipv4-standard-https-tcp-443-only", - "coreNetworks": [ - "device-plane-private", - "device-plane-egress" - ], - "removedCoreNetwork": "device-plane-control", - "composeCompatibility": "synology-compose-v2.20-no-gw-priority", - "publicIngress": "none-on-synology", - "edgeRegistrations": "preserved", - "commandTransport": "disabled", - "gelios": "untouched", - "preservedServices": [ - "device-manager", - "device-gateway", - "device-postgres", - "device-backhaul-target" - ], - "healthGate": "bounded-container-grace+core-edge-contract+exact-private-egress-network-boundary", - "rollback": "restore-upgrade-v2-021-source-and-preapply-core-runtime" -} diff --git a/device-plane/deployment/device-edge-core-channel-v1.json b/device-plane/deployment/device-edge-core-channel-v1.json deleted file mode 100644 index 56323ba..0000000 --- a/device-plane/deployment/device-edge-core-channel-v1.json +++ /dev/null @@ -1,114 +0,0 @@ -{ - "schemaVersion": "nodedc.device-edge.core-channel.v1", - "status": "accepted-design", - "authority": "DCPLATFORM-76/ADR-0001", - "direction": "device-gateway-core-initiated", - "transport": { - "protocol": "http2-bidirectional-stream", - "tls": "TLSv1.3-mutual-authentication", - "edgeListen": "0.0.0.0:443", - "endpointSource": "device-control-core.edge-registration", - "browserAccess": "forbidden", - "bearerOnlyAuthentication": "forbidden", - "genericTcpForwarding": "forbidden" - }, - "identity": { - "corePrivateKeyLocation": "synology-canonical-secret-boundary", - "edgePrivateKeyLocation": "edge-runner-managed-trust-boundary", - "privateKeysInArtifacts": false, - "certificateRotation": "generation-bound-audited", - "unknownOrRevokedEdge": "reject" - }, - "networkBoundary": { - "synologyPublicIngress": false, - "synologyPortForward": false, - "vpsInitiatedSynologyConnection": false, - "subnetRoutes": false, - "exitNode": false, - "tailscaleSsh": false, - "dockerSocket": false, - "allowedEdgeListeners": [ - "management-ssh", - "raw-device-tcp/9921", - "core-channel-mtls/443" - ] - }, - "messageContract": { - "versioned": true, - "bounded": true, - "requiredKeys": [ - "schemaVersion", - "edgeRegistrationId", - "channelGeneration", - "trackerSessionId", - "adapterProfileRef", - "sequence", - "eventAt", - "receivedAt", - "messageKind", - "correlationId" - ], - "unknownKind": "close-logical-session", - "rawArbitraryDestination": "forbidden" - }, - "acknowledgement": { - "trackerPackageAck": "only-after-bounded-core-acceptance", - "coreUnavailable": "do-not-acknowledge-tracker-package", - "deduplicationKey": [ - "edgeRegistrationId", - "channelGeneration", - "trackerSessionId", - "packageNumber", - "contentDigest" - ], - "deliverySemantics": "at-least-once" - }, - "pilotLimits": { - "maxTrackerSessions": 128, - "maxSessionsPerObservedSource": 16, - "maxNewConnectionsPerMinutePerObservedSource": 60, - "maxBufferedBytesPerTrackerSession": 262144, - "maxAggregateBufferedBytes": 33554432, - "maxEnvelopePayloadBytes": 1048576, - "keepaliveSeconds": 15, - "deadPeerSeconds": 45, - "reconnectMinimumSeconds": 1, - "reconnectMaximumSeconds": 30, - "durableEdgeSpool": false - }, - "pilotSlo": { - "trackerAckBeforeDurableCoreAcceptance": 0, - "lossOfCoreAcceptedPackages": 0, - "edgeReceiveToCoreAcceptanceP95Milliseconds": 2000, - "edgeReceiveToCoreAcceptanceP99Milliseconds": 5000, - "channelReestablishmentP95Seconds": 60, - "channelReestablishmentHardCeilingSeconds": 120, - "deadCoreDetectionHardCeilingSeconds": 45, - "malformedOrUnauthenticatedAcceptedRecords": 0, - "availabilityCommitment": "deferred-until-measured" - }, - "commandBoundary": { - "typedOnly": true, - "rawPayload": "forbidden", - "durableEdgeQueue": false, - "sentEqualsSuccess": false, - "protocolAckMeans": "acknowledged-not-verified", - "unsafeAutomaticRetry": "forbidden" - }, - "preserved": [ - "device-control-core-database", - "device-gateway-core", - "hub-authentik", - "engine", - "foundry-runtime", - "gelios-production-path" - ], - "rollout": [ - "source-and-ops-contract", - "closed-port-synthetic-core-channel", - "negative-network-and-identity-acceptance", - "separate-public-device-ingress-transition", - "one-device-pilot" - ], - "rollback": "restore-closed-port-predecessor-without-vps-initiated-backhaul" -} diff --git a/device-plane/deployment/device-edge-ingress-ipvlan-v1.json b/device-plane/deployment/device-edge-ingress-ipvlan-v1.json deleted file mode 100644 index b453b99..0000000 --- a/device-plane/deployment/device-edge-ingress-ipvlan-v1.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "schemaVersion": "nodedc.device-edge.ingress-ipvlan.v1", - "mode": "single-nic-ipvlan-b2-relay-only", - "runtimeHost": "ndcmini12", - "component": "device-edge", - "selectedServices": [ - "device-edge-relay" - ], - "preservedServices": [ - "device-edge-backhaul", - "tailnet" - ], - "composeProject": "nodedc-device-edge", - "composeFiles": [ - "docker-compose.device-edge.yml", - "docker-compose.device-edge.ingress.yml" - ], - "parentInterface": "enp1s0f0", - "lanSubnet": "192.168.68.0/22", - "lanGateway": "192.168.68.1", - "ingressIpv4": "192.168.71.253", - "ingressIpv4Approval": "approved-outside-dhcp-pool", - "ingressNetwork": "nodedc-device-edge-ingress", - "deviceTcpListen": "192.168.71.253:9921", - "hostPortPublication": "disabled", - "healthPublication": "disabled", - "privateUpstream": "device-edge-backhaul:19921", - "protocolInspection": "gateway-owned", - "identityTrust": "claimed-not-ownership-proof", - "discoveryLifecycle": "quarantine", - "commandTransport": "disabled", - "gelios": "untouched", - "amneziaHostFullTunnel": "preserved", - "routerNatFirewall": "separate-manual-gate", - "rollback": "restore-predecessor-relay-remove-unused-ingress-network" -} diff --git a/device-plane/deployment/device-edge-vps-backhaul-v1.json b/device-plane/deployment/device-edge-vps-backhaul-v1.json deleted file mode 100644 index e2c2969..0000000 --- a/device-plane/deployment/device-edge-vps-backhaul-v1.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "schemaVersion": "nodedc.device-edge-vps.backhaul.v1", - "mode": "tailscale-userspace-key-only-ssh-local-forward", - "runtimeHost": "koffyvngij", - "component": "device-edge-vps", - "tailscaleNodeName": "nodedc-b2-vps", - "tailnetDnsSuffix": "tail8d32ac.ts.net", - "targetHost": "100.109.216.21", - "targetPort": 2222, - "targetHostKeyFingerprint": "SHA256:QERJ5CIUXRj0nLChGT6HMtoX+WTaeaEY5ZgaWqT8d30", - "targetUser": "device-backhaul", - "runtimeUser": "nodedc-backhaul", - "credentialBoundary": "private-key-readable-only-by-nodedc-backhaul", - "permitOpen": "127.0.0.1:9921", - "localForward": "127.0.0.1:19921", - "proxy": "tailscale-userspace-socks5-127.0.0.1:1055", - "keyIdentity": "nodedc-device-edge-vps-backhaul", - "publicB2Ingress": "disabled", - "commandTransport": "disabled", - "gelios": "untouched", - "rollback": "remove-backhaul-unit-and-user-restore-foundation-key-ownership" -} diff --git a/device-plane/deployment/device-edge-vps-command-transport-v1.json b/device-plane/deployment/device-edge-vps-command-transport-v1.json deleted file mode 100644 index aa9a658..0000000 --- a/device-plane/deployment/device-edge-vps-command-transport-v1.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "schemaVersion": "nodedc.device-edge-vps.command-transport.v1", - "mode": "provider-neutral-typed-command-transport-over-accepted-core-channel", - "status": "active-typed-command-transport", - "authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001", - "component": "device-edge-vps", - "phase": "command-transport", - "runtimeHost": "koffyvngij", - "predecessorPatch": "device-edge-vps-tracker-ingress-20260812-012", - "predecessorArtifactSha256": "290acef118839c6b0c31aac864c47da1832a289537366af9322d4624a1dd81ec", - "runtimeUser": "nodedc-channel", - "runtimeService": "nodedc-device-edge-channel.service", - "runtimeComposition": "single-process-core-channel-plus-universal-device-gateway", - "publicIngress": "tcp/443-mtls-core-channel+tcp/9921-bidirectional-tracker-session", - "trackerIngress": "preserved:allowlisted-adapters-only", - "initialAdapterProfile": "arusnavi.b2.internal.v1", - "commandTransport": "typed-service-ping-v1", - "commandCatalog": "allowlisted-adapter-typed-commands-only", - "allowedCommands": [ - "service.ping" - ], - "credentialBoundary": "transient-over-pinned-mtls-in-memory-until-single-tracker-write-never-stored-never-logged-never-returned", - "responseBoundary": "exact-adapter-parser-serv-ok-only", - "health": "127.0.0.1:18222", - "adapterHealth": "127.0.0.1:18221", - "rawDeviceTcp9921": "public-bidirectional-tracker-session-no-generic-forwarding", - "gelios": "untouched-legacy-only", - "tailscale": "absent", - "dataBoundary": "no-vps-database-no-business-logic-no-synology-route", - "resourceCeilings": { - "memory": "192M", - "swap": "0", - "cpu": "75%", - "tasks": 128, - "openFiles": 1024, - "sessions": 128, - "sessionsPerAddress": 16, - "connectionsPerMinutePerAddress": 60, - "sessionBufferBytes": 65536, - "aggregateBufferBytes": 33554432 - }, - "preserved": [ - "management-ssh-key", - "accepted-node-runtime", - "accepted-core-channel-trust-and-registration", - "accepted-tracker-ingress", - "retired-tailnet-boundary", - "gelios-production-path" - ], - "forbidden": [ - "vps-initiated-synology-connection", - "generic-tcp-forwarding", - "tailscale-runtime", - "docker", - "public-health", - "vps-database", - "vps-business-logic", - "unregistered-adapter", - "raw-command", - "firmware-command", - "reboot-command", - "persistent-command-credential" - ], - "acceptance": [ - "exact-tracker-ingress-012-predecessor", - "single-non-root-edge-process", - "core-channel-remains-accepted", - "public-tracker-tcp-9921-listening", - "adapter-profile-allowlisted", - "typed-service-ping-only", - "exact-serv-ok-response-parser", - "bounded-session-and-buffer-limits", - "tailscale-remains-absent", - "no-vps-to-synology-route", - "gelios-untouched" - ], - "rollback": "restore-exact-tracker-ingress-012-source-and-existing-runtime" -} diff --git a/device-plane/deployment/device-edge-vps-core-channel-v1.json b/device-plane/deployment/device-edge-vps-core-channel-v1.json deleted file mode 100644 index dedc68c..0000000 --- a/device-plane/deployment/device-edge-vps-core-channel-v1.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "schemaVersion": "nodedc.device-edge-vps.core-channel.v1", - "mode": "provider-neutral-core-initiated-mtls-http2", - "status": "closed-tracker-ingress", - "authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001", - "component": "device-edge-vps", - "phase": "core-channel", - "runtimeHost": "koffyvngij", - "runtimeUser": "nodedc-channel", - "runtimeService": "nodedc-device-edge-channel.service", - "runtime": "accepted-node-v22.23.2-no-docker", - "publicIngress": "tcp/443-mtls-only", - "health": "127.0.0.1:18222", - "trackerIngress": "disabled", - "rawDeviceTcp9921": "closed", - "commandTransport": "disabled", - "gelios": "untouched", - "privateKeyBoundary": "runner-managed-host-local-only", - "peerTrustPrerequisite": "exact-pinned-self-signed-core-certificate-and-fingerprint", - "tls": "TLSv1.3+h2+mutual-authentication", - "networkPrivilege": "CAP_NET_BIND_SERVICE-only-for-non-root-tcp-443", - "resourceCeilings": { - "memory": "128M", - "swap": "0", - "cpu": "50%", - "tasks": 64, - "openFiles": 1024 - }, - "preserved": [ - "management-ssh-key", - "accepted-node-runtime", - "foundation-source", - "gelios-production-path" - ], - "forbidden": [ - "vps-initiated-synology-connection", - "generic-tcp-forwarding", - "tailscale-ssh-backhaul", - "docker", - "public-health", - "tracker-tcp/9921" - ], - "acceptance": [ - "exact-non-root-runtime-identity", - "tls13-h2-mutual-authentication", - "edge-server-and-core-client-self-signed-identities-mutually-pinned", - "core-initiated-channel-accepted", - "unknown-core-certificate-rejected", - "public-443-only-beside-management-ssh", - "tracker-tcp-9921-closed", - "loopback-health-contract", - "resource-ceilings-present" - ], - "rollback": "close-443-stop-channel-restore-exact-accepted-foundation-without-backhaul-or-relay" -} diff --git a/device-plane/deployment/device-edge-vps-foundation-v1.json b/device-plane/deployment/device-edge-vps-foundation-v1.json deleted file mode 100644 index b4ce848..0000000 --- a/device-plane/deployment/device-edge-vps-foundation-v1.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "schemaVersion": "nodedc.device-edge-vps.foundation.v1", - "mode": "static-runtime-key-only-ssh-default-deny-no-public-b2", - "runtimeHost": "koffyvngij", - "publicIpv4": "155.212.211.15", - "component": "device-edge-vps", - "nodeVersion": "22.23.2", - "nodeArchiveSha256": "d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307", - "tailscaleVersion": "1.102.2", - "tailscaleArchiveSha256": "ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8", - "serviceUser": "nodedc-edge", - "managementSsh": "root-key-only", - "managementKeyFingerprint": "SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw", - "serverHostKeyFingerprint": "SHA256:mhqNn2S6zstkYL7VFdvt3SYHv1nLjB4J7/s57RrKG6w", - "firewall": "default-deny-public-22-only", - "tailscale": "userspace-needs-external-enrollment", - "backhaulKey": "runner-managed-new-ed25519", - "publicB2Ingress": "disabled", - "commandTransport": "disabled", - "gelios": "untouched", - "rollback": "restore-exact-ssh-firewall-service-and-absent-runtime-predecessor" -} diff --git a/device-plane/deployment/device-edge-vps-relay-v1.json b/device-plane/deployment/device-edge-vps-relay-v1.json deleted file mode 100644 index c382c0f..0000000 --- a/device-plane/deployment/device-edge-vps-relay-v1.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "schemaVersion": "nodedc.device-edge-vps.relay.v1", - "mode": "public-b2-opaque-bounded-relay", - "runtimeHost": "koffyvngij", - "publicIpv4": "155.212.211.15", - "component": "device-edge-vps", - "runtimeUser": "nodedc-relay", - "credentialAccess": "none", - "listen": "0.0.0.0:9921", - "health": "127.0.0.1:18221", - "privateUpstream": "127.0.0.1:19921", - "sourceAdmission": "public-ipv4-only", - "maxSessions": 128, - "maxSessionsPerAddress": 16, - "maxConnectionsPerMinutePerAddress": 60, - "maxTrackedSourceAddresses": 4096, - "maxBytesPerDirection": 67108864, - "sessionTimeoutMs": 300000, - "protocolInspection": "gateway-owned", - "identityTrust": "claimed-not-ownership-proof", - "discoveryLifecycle": "quarantine", - "commandTransport": "disabled", - "gelios": "untouched", - "dns": "unchanged", - "b2Routes": "unchanged", - "rollback": "close-9921-stop-relay-remove-user-and-restore-accepted-backhaul" -} diff --git a/device-plane/deployment/device-edge-vps-runtime-reconciliation-v1.json b/device-plane/deployment/device-edge-vps-runtime-reconciliation-v1.json deleted file mode 100644 index dbea64b..0000000 --- a/device-plane/deployment/device-edge-vps-runtime-reconciliation-v1.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "schemaVersion": "nodedc.device-edge-vps.runtime-reconciliation.v1", - "component": "device-edge-vps", - "runtimeHost": "koffyvngij", - "publicIpv4": "155.212.211.15", - "transition": "recover-exact-runtime-executable-modes-after-failed-core-channel-publish", - "acceptedFoundationPatch": "device-edge-vps-foundation-20260806-003", - "acceptedFoundationArtifactSha256": "1be852f144e9f0fea32af70bebd07a2607b6a1818825094bd4c1b4062064716a", - "failedPatch": "device-edge-vps-core-channel-20260812-001", - "failedArtifactSha256": "c199980e5754cf3e874a09f42e408fc88885cdbf7c872eac36c0ab768a7bab00", - "runtimeMutation": "restore-root-owned-executable-mode-0755-for-exact-known-binaries", - "publicCoreChannel": "disabled", - "trackerIngress": "disabled", - "commandTransport": "disabled", - "gelios": "untouched", - "rollback": "restore-exact-runtime-files-modes-and-reconciliation-marker" -} diff --git a/device-plane/deployment/device-edge-vps-tailscale-retirement-v1.json b/device-plane/deployment/device-edge-vps-tailscale-retirement-v1.json deleted file mode 100644 index c88a992..0000000 --- a/device-plane/deployment/device-edge-vps-tailscale-retirement-v1.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "schemaVersion": "nodedc.device-edge-vps.tailscale-retirement.v1", - "mode": "retire-superseded-vps-tailnet-after-accepted-core-channel", - "status": "core-channel-only", - "authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001", - "component": "device-edge-vps", - "phase": "tailscale-retirement", - "runtimeHost": "koffyvngij", - "predecessorPatch": "device-edge-vps-core-channel-20260812-010", - "predecessorArtifactSha256": "c8ef3c4bb45850cad32e881eba081bc4c891c2886e5500d02cb94616d82353f3", - "runtimeAction": "stop-disable-remove-userspace-tailscale-runtime-state-and-superseded-trust", - "publicIngress": "tcp/443-mtls-only", - "trackerIngress": "disabled", - "rawDeviceTcp9921": "closed", - "commandTransport": "disabled", - "gelios": "untouched", - "preserved": [ - "management-ssh-key", - "accepted-node-runtime", - "accepted-core-channel-source-runtime-and-trust", - "core-channel-registration", - "foundation-source-for-audit-and-rollback", - "gelios-production-path" - ], - "retired": [ - "nodedc-b2-tailscaled.service", - "userspace-socks5-127.0.0.1:1055", - "tailscale-local-state", - "tailscale-runtime-binaries", - "superseded-backhaul-private-key" - ], - "forbidden": [ - "tailscale-runtime", - "tailnet-address", - "vps-initiated-synology-connection", - "generic-tcp-forwarding", - "tailscale-ssh-backhaul", - "docker", - "public-health", - "tracker-tcp/9921" - ], - "acceptance": [ - "exact-core-channel-010-predecessor", - "core-channel-remains-accepted", - "tailscale-service-absent-inactive-and-disabled", - "tailscale-userspace-listeners-absent", - "tailscale-local-state-and-runtime-binaries-absent", - "superseded-backhaul-private-key-absent", - "public-443-only-beside-management-ssh", - "tracker-tcp-9921-closed", - "command-transport-disabled" - ], - "externalRevocation": "delete-exact-nodedc-b2-vps-machine-in-tailnet-after-deploy-ok", - "rollback": "before-external-tailnet-revocation-restore-backed-up-local-tailscale-runtime-state-unit-and-core-channel-predecessor" -} diff --git a/device-plane/deployment/device-edge-vps-tracker-ingress-v1.json b/device-plane/deployment/device-edge-vps-tracker-ingress-v1.json deleted file mode 100644 index ae36755..0000000 --- a/device-plane/deployment/device-edge-vps-tracker-ingress-v1.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "schemaVersion": "nodedc.device-edge-vps.tracker-ingress.v1", - "mode": "provider-neutral-allowlisted-adapter-ingress-over-accepted-core-channel", - "status": "active-tracker-ingress", - "authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001", - "component": "device-edge-vps", - "phase": "tracker-ingress", - "runtimeHost": "koffyvngij", - "predecessorPatch": "device-edge-vps-tailscale-retirement-20260812-011", - "predecessorArtifactSha256": "e7b61ec9c83122fa5631467010eff871b98935746df6a1326b6ff6bb9713d877", - "runtimeUser": "nodedc-channel", - "runtimeService": "nodedc-device-edge-channel.service", - "runtimeComposition": "single-process-core-channel-plus-universal-device-gateway", - "publicIngress": "tcp/443-mtls-core-channel+tcp/9921-tracker-telemetry", - "trackerIngress": "enabled:allowlisted-adapters-only", - "initialAdapterProfile": "arusnavi.b2.internal.v1", - "acknowledgementBoundary": "tracker-ack-only-after-core-durable-acceptance", - "health": "127.0.0.1:18222", - "adapterHealth": "127.0.0.1:18221", - "rawDeviceTcp9921": "public-telemetry-ingest", - "commandTransport": "disabled", - "gelios": "untouched", - "tailscale": "absent", - "dataBoundary": "no-vps-database-no-business-logic-no-synology-route", - "resourceCeilings": { - "memory": "192M", - "swap": "0", - "cpu": "75%", - "tasks": 128, - "openFiles": 1024, - "sessions": 128, - "sessionsPerAddress": 16, - "connectionsPerMinutePerAddress": 60, - "sessionBufferBytes": 65536, - "aggregateBufferBytes": 33554432 - }, - "preserved": [ - "management-ssh-key", - "accepted-node-runtime", - "accepted-core-channel-trust-and-registration", - "retired-tailnet-boundary", - "gelios-production-path" - ], - "forbidden": [ - "vps-initiated-synology-connection", - "generic-tcp-forwarding", - "tailscale-runtime", - "docker", - "public-health", - "vps-database", - "vps-business-logic", - "unregistered-adapter", - "device-command" - ], - "acceptance": [ - "exact-tailscale-retirement-011-predecessor", - "single-non-root-edge-process", - "core-channel-remains-accepted", - "public-tracker-tcp-9921-listening", - "adapter-profile-allowlisted", - "bounded-session-and-buffer-limits", - "tracker-ack-after-core-acceptance", - "tailscale-remains-absent", - "no-vps-to-synology-route", - "command-transport-disabled", - "gelios-untouched" - ], - "rollback": "close-9921-restore-exact-tailscale-retirement-011-source-unit-firewall-and-accepted-core-channel-runtime" -} diff --git a/device-plane/deployment/device-manager-control-plane-reconciliation-v1.json b/device-plane/deployment/device-manager-control-plane-reconciliation-v1.json deleted file mode 100644 index d34ac3f..0000000 --- a/device-plane/deployment/device-manager-control-plane-reconciliation-v1.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.device-manager-control-plane-reconciliation.v1", - "mode": "failed-control-plane-baseline-adoption", - "failedPatchId": "device-manager-control-plane-20260810-001", - "failedArtifactSha256": "50e275c1085286bcb3bb2b273aefc8bbba70f446ca2c7bd464dc745710a291a6", - "backupId": "device-plane-device-manager-control-plane-20260810-001-20260811-000321", - "sourceAction": "publish-reconciliation-marker-only", - "runtimeAction": "read-only-acceptance", - "preservedServices": [ - "device-control-core", - "device-gateway", - "device-postgres", - "device-backhaul-target" - ], - "absentService": "device-manager", - "databaseVolume": "nodedc-device-plane-postgres-data", - "publicIngress": "disabled", - "commandTransport": "disabled", - "gelios": "untouched", - "rollback": "marker-only-runtime-unchanged" -} diff --git a/device-plane/deployment/device-manager-control-plane-v1.json b/device-plane/deployment/device-manager-control-plane-v1.json deleted file mode 100644 index d9e0635..0000000 --- a/device-plane/deployment/device-manager-control-plane-v1.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.device-manager-control-plane.v1", - "action": "activate", - "service": "device-manager", - "publicIngress": "reverse-proxy-only", - "deviceCoreManagementApi": "file-token-authenticated", - "launcherTrust": "file-token-scoped-to-device-core-handoff", - "commandTransport": "disabled", - "gelios": "untouched" -} diff --git a/device-plane/deployment/device-manager-control-plane-v2-reconciliation-v1.json b/device-plane/deployment/device-manager-control-plane-v2-reconciliation-v1.json deleted file mode 100644 index 8099437..0000000 --- a/device-plane/deployment/device-manager-control-plane-v2-reconciliation-v1.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.device-manager-control-plane-v2-reconciliation.v1", - "mode": "failed-v2-control-plane-baseline-adoption", - "failedPatchId": "device-manager-control-plane-20260811-003", - "failedArtifactSha256": "ba29618ffbfed55448768794f28b18dda439ddb39a1d2a4f1dece19de7f29990", - "backupId": "device-plane-device-manager-control-plane-20260811-003-20260811-012505", - "failureClass": "deterministic-runtime-module-resolution", - "missingModule": "/packages/external-provider-contract/src/credential-reference.mjs", - "correctiveAction": "runtime-local-contract-adapter+staged-module-import-gate", - "sourceAction": "publish-reconciliation-marker-only", - "runtimeAction": "read-only-acceptance", - "preservedServices": [ - "device-control-core", - "device-gateway", - "device-postgres", - "device-backhaul-target" - ], - "absentService": "device-manager", - "databaseVolume": "nodedc-device-plane-postgres-data", - "publicIngress": "disabled", - "commandTransport": "disabled", - "gelios": "untouched", - "rollback": "marker-only-runtime-unchanged" -} diff --git a/device-plane/deployment/device-manager-control-plane-v2.json b/device-plane/deployment/device-manager-control-plane-v2.json deleted file mode 100644 index ec3ccc1..0000000 --- a/device-plane/deployment/device-manager-control-plane-v2.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.device-manager-control-plane.v2", - "action": "activate", - "predecessor": { - "patchId": "device-manager-control-plane-reconciliation-20260811-002", - "artifactSha256": "dd86dd58e4f649db0981db5089e003caf3961356179f2abb514662351487e1e6", - "mode": "failed-control-plane-baseline-adoption" - }, - "service": "device-manager", - "publicIngress": "reverse-proxy-only", - "deviceCoreManagementApi": "file-token-authenticated", - "launcherTrust": "file-token-scoped-to-device-core-handoff", - "healthGate": "bounded-container-grace+core-contract", - "commandTransport": "disabled", - "gelios": "untouched", - "rollback": "restore-reconciled-baseline" -} diff --git a/device-plane/deployment/device-manager-release-v1.json b/device-plane/deployment/device-manager-release-v1.json deleted file mode 100644 index 43cd3df..0000000 --- a/device-plane/deployment/device-manager-release-v1.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.device-manager-release.v1", - "releaseId": "__PATCH_ID__", - "action": "upgrade", - "predecessor": { - "kind": "release", - "patchId": "device-manager-release-20260811-010", - "artifactSha256": "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e" - }, - "service": "device-manager", - "publicIngress": "reverse-proxy-only", - "deviceCoreManagementApi": "file-token-authenticated", - "launcherTrust": "file-token-scoped-to-device-core-handoff", - "healthGate": "bounded-container-grace+core-contract", - "commandTransport": "disabled", - "gelios": "untouched", - "rollback": "restore-preapply-snapshot" -} diff --git a/device-plane/deployment/device-manager-release-v2.json b/device-plane/deployment/device-manager-release-v2.json deleted file mode 100644 index d2f1002..0000000 --- a/device-plane/deployment/device-manager-release-v2.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.device-manager-release.v2", - "releaseId": "__PATCH_ID__", - "action": "upgrade", - "predecessor": { - "kind": "release", - "patchId": "device-manager-release-20260811-010", - "artifactSha256": "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e" - }, - "service": "device-manager", - "publicIngress": "reverse-proxy-only", - "deviceCoreManagementApi": "file-token-authenticated", - "launcherTrust": "file-token-scoped-to-device-core-handoff", - "edgeChannel": "core-initiated-pinned-mtls-enabled-zero-or-more-registered-edges", - "edgeChannelIdentity": "runner-managed-host-local-private-key-public-certificate-export", - "edgeChannelEgress": "dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-8443-registration-policy", - "healthGate": "bounded-container-grace+core-contract", - "commandTransport": "disabled", - "gelios": "untouched", - "rollback": "restore-preapply-snapshot" -} diff --git a/device-plane/deployment/device-manager-release-v3.json b/device-plane/deployment/device-manager-release-v3.json deleted file mode 100644 index afc896c..0000000 --- a/device-plane/deployment/device-manager-release-v3.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.device-manager-release.v3", - "releaseId": "__PATCH_ID__", - "action": "upgrade", - "predecessor": { - "kind": "release", - "patchId": "device-manager-release-20260811-010", - "artifactSha256": "d4132993216eb674967dc6fc65d9670cfc2a9efdf46186ca019030f259de2d0e" - }, - "controlCorePredecessor": { - "patchId": "device-control-core-release-v2-20260812-025", - "artifactSha256": "c61b1f0de1bae23de0caa7289036865ea419ff5705611416f736ca929d1592db" - }, - "edgeChannelPredecessor": { - "patchId": "device-edge-core-channel-upgrade-v4-20260812-023", - "artifactSha256": "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc" - }, - "service": "device-manager", - "publicIngress": "reverse-proxy-only", - "deviceCoreManagementApi": "file-token-authenticated", - "launcherTrust": "file-token-scoped-to-device-core-handoff", - "edgeChannel": "preserve-active-v4-core-initiated-pinned-mtls", - "edgeChannelIdentity": "reuse-runner-managed-host-local-private-key-public-certificate-export", - "edgeChannelEgress": "preserve-dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-443-only", - "healthGate": "bounded-container-grace+core-contract", - "commandTransport": "typed-service-ping-v1", - "commandCatalog": "allowlisted-adapter-typed-commands-only", - "credentialBoundary": "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned", - "gelios": "untouched-legacy-only", - "rollback": "restore-preapply-snapshot" -} diff --git a/device-plane/deployment/device-plane-b2-discovery-ingress-v1.json b/device-plane/deployment/device-plane-b2-discovery-ingress-v1.json deleted file mode 100644 index 6e9018d..0000000 --- a/device-plane/deployment/device-plane-b2-discovery-ingress-v1.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.b2-discovery-ingress.v1", - "mode": "verified-b2-loopback-discovery-only", - "predecessorPatchId": "device-plane-foundation-network-publication-20260725-003", - "predecessorArtifactSha256": "6fdd5a12c310786db1753882fc1378184fe378d2cc533633a8c73c951521b7bf", - "sourceAction": "publish-verified-b2-loopback-discovery-source", - "runtimeAction": "build-and-recreate-stateless-services", - "selectedServices": [ - "device-control-core", - "device-gateway" - ], - "preservedServices": [ - "device-postgres" - ], - "privateNetwork": "nodedc-device-plane-private", - "controlNetwork": "nodedc-device-plane-control", - "publishedPorts": [ - "127.0.0.1:18120:18120", - "127.0.0.1:18121:18121", - "127.0.0.1:9921:9921/tcp" - ], - "protocolProfile": "arusnavi.b2.internal.v1", - "framingSpecification": "arusnavi.internal.protocol-sheet.gid-12.v1", - "identityTrust": "claimed-not-ownership-proof", - "discoveryLifecycle": "quarantine", - "commandTransport": "disabled", - "gelios": "untouched", - "databaseVolume": "nodedc-device-plane-postgres-data", - "rollback": "restore-source-and-predecessor-stateless-runtime" -} diff --git a/device-plane/deployment/device-plane-b2-discovery-loopback-recovery-v1.json b/device-plane/deployment/device-plane-b2-discovery-loopback-recovery-v1.json deleted file mode 100644 index bd74451..0000000 --- a/device-plane/deployment/device-plane-b2-discovery-loopback-recovery-v1.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.b2-discovery-loopback-recovery.v1", - "mode": "failed-b2-loopback-build-reconciliation", - "failedPatchId": "device-plane-b2-discovery-loopback-20260801-003", - "failedArtifactSha256": "7273c5bf67fe6bc1f1da66ad726009240d39ee3aee58201b96c23d6f707a3d84", - "failedBackupId": "device-plane-device-plane-b2-discovery-loopback-20260801-003-20260802-154311", - "sourceAction": "publish-reconciliation-marker-only", - "runtimeAction": "read-only-acceptance", - "preservedServices": [ - "device-control-core", - "device-gateway", - "device-postgres" - ], - "expectedLoopbackPorts": [ - "127.0.0.1:18120:18120", - "127.0.0.1:18121:18121" - ], - "closedPort": "127.0.0.1:9921/tcp", - "databaseVolume": "nodedc-device-plane-postgres-data", - "commandTransport": "disabled", - "gelios": "untouched", - "rollback": "marker-only-runtime-unchanged" -} diff --git a/device-plane/deployment/device-plane-backhaul-target-tailnet-serve-v1.json b/device-plane/deployment/device-plane-backhaul-target-tailnet-serve-v1.json deleted file mode 100644 index 9f8943a..0000000 --- a/device-plane/deployment/device-plane-backhaul-target-tailnet-serve-v1.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.backhaul-target-tailnet-serve.v1", - "mode": "failed-backhaul-target-to-loopback-tailnet-serve", - "failedPatchId": "device-plane-backhaul-target-20260803-001", - "failedArtifactSha256": "ed0bda4110a756c32be68990e2e0f647409d5a77eec7e26c18502bafbdc1bb76", - "failedBackupId": "device-plane-device-plane-backhaul-target-20260803-001-20260804-035519", - "predecessorPatchId": "device-plane-b2-discovery-loopback-20260803-006", - "predecessorArtifactSha256": "25f9e9e55e283e9b7bb5e128ff14a244f848b1c063acca9724a23206131c9adf", - "sourceAction": "publish-loopback-backhaul-target-source", - "runtimeAction": "build-create-target-and-register-private-tailnet-serve", - "composeOverlay": "docker-compose.device-plane.backhaul-target.yml", - "selectedServices": [ - "device-backhaul-target" - ], - "preservedServices": [ - "device-control-core", - "device-gateway", - "device-postgres" - ], - "loopbackListenAddress": "127.0.0.1", - "listenPort": 2222, - "tailnetAddress": "100.109.216.21", - "tailnetExposure": "tailscale-serve-private", - "tailscaleServeTarget": "tcp://127.0.0.1:2222", - "permittedTarget": "127.0.0.1:9921", - "networkMode": "host", - "dockerPortPublication": "disabled", - "routerNatFirewall": "unchanged", - "edgePublicIngress": "disabled", - "funnel": "disabled", - "commandTransport": "disabled", - "gelios": "untouched", - "databaseVolume": "nodedc-device-plane-postgres-data", - "runtimeTrust": "runner-managed", - "rollback": "remove-tailnet-serve-target-and-restore-source" -} diff --git a/device-plane/deployment/device-plane-backhaul-vps-enrollment-v1.json b/device-plane/deployment/device-plane-backhaul-vps-enrollment-v1.json deleted file mode 100644 index 44c0670..0000000 --- a/device-plane/deployment/device-plane-backhaul-vps-enrollment-v1.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.backhaul-vps-enrollment.v1", - "mode": "rotate-backhaul-client-mini-to-vps", - "predecessorPatchId": "device-plane-backhaul-target-tailnet-serve-20260804-002", - "predecessorArtifactSha256": "219408705dd4d80a962ed00eeb53a69df0b9ab6458443734d5c9cd1d1f795eba", - "sourceAction": "publish-vps-enrollment-marker-only", - "runtimeAction": "rotate-authorized-key-and-recreate-backhaul-target", - "selectedServices": [ - "device-backhaul-target" - ], - "preservedServices": [ - "device-control-core", - "device-gateway", - "device-postgres" - ], - "previousEnrollment": "device-edge-backhaul.pub", - "nextEnrollment": "device-edge-vps-backhaul.pub", - "nextKeyFingerprint": "SHA256:HHTiDYiCRxSiKjBLCip6JMSzGfLGrDz5g8SIkosJcVw", - "permittedTarget": "127.0.0.1:9921", - "tailnetAddress": "100.109.216.21", - "dockerPortPublication": "disabled", - "routerNatFirewall": "unchanged", - "edgePublicIngress": "disabled", - "funnel": "disabled", - "commandTransport": "disabled", - "gelios": "untouched", - "rollback": "restore-previous-authorized-key-and-recreate-target" -} diff --git a/device-plane/deployment/device-plane-foundation-network-publication-v1.json b/device-plane/deployment/device-plane-foundation-network-publication-v1.json deleted file mode 100644 index a4ae825..0000000 --- a/device-plane/deployment/device-plane-foundation-network-publication-v1.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.foundation-network-publication.v1", - "mode": "failed-foundation-network-publication-correction", - "failedRecoveryPatchId": "device-plane-foundation-recovery-20260725-002", - "failedRecoveryArtifactSha256": "9183cc385142584bfd12510bb0a3e6b833b2fd26607436f2486a564c628ea1bf", - "failedRecoveryBackupId": "device-plane-device-plane-foundation-recovery-20260725-002-20260725-232447", - "sourceAction": "publish-network-corrected-foundation-source", - "runtimeAction": "recreate-stateless-services-no-build", - "selectedServices": [ - "device-control-core", - "device-gateway" - ], - "preservedServices": [ - "device-postgres" - ], - "privateNetwork": "nodedc-device-plane-private", - "controlNetwork": "nodedc-device-plane-control", - "publishedLoopbackPorts": [ - "127.0.0.1:18120:18120", - "127.0.0.1:18121:18121" - ], - "databaseVolume": "nodedc-device-plane-postgres-data", - "rollback": "restore-partial-source-and-internal-only-stateless-runtime" -} diff --git a/device-plane/deployment/device-plane-foundation-recovery-v1.json b/device-plane/deployment/device-plane-foundation-recovery-v1.json deleted file mode 100644 index fbdb05b..0000000 --- a/device-plane/deployment/device-plane-foundation-recovery-v1.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.foundation-recovery.v1", - "mode": "failed-foundation-live-runtime-adoption", - "failedPatchId": "device-plane-foundation-20260725-001", - "failedArtifactSha256": "23d428de547854ad8b1a026671e2f850386ab0be98bde80f016f1e9db631ee24", - "backupId": "device-plane-device-plane-foundation-20260725-001-20260725-223441", - "sourceAction": "publish-exact-failed-artifact-source", - "runtimeAction": "read-only-acceptance", - "preservedServices": [ - "device-control-core", - "device-gateway", - "device-postgres" - ], - "databaseVolume": "nodedc-device-plane-postgres-data", - "rollback": "source-only-runtime-unchanged" -} diff --git a/device-plane/deployment/device-postgres-bootstrap-v1.json b/device-plane/deployment/device-postgres-bootstrap-v1.json deleted file mode 100644 index 3261b67..0000000 --- a/device-plane/deployment/device-postgres-bootstrap-v1.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "schemaVersion": "nodedc.device-plane.postgres-bootstrap.v1", - "service": "device-postgres", - "volume": "nodedc-device-plane-postgres-data", - "mode": "create-if-absent", - "ordinaryApplicationSelection": "forbidden", - "rollbackVolumePolicy": "preserve" -} diff --git a/device-plane/deployment/superseded-vps-initiated-transport-v1.json b/device-plane/deployment/superseded-vps-initiated-transport-v1.json deleted file mode 100644 index e52cb41..0000000 --- a/device-plane/deployment/superseded-vps-initiated-transport-v1.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "schemaVersion": "nodedc.device-edge.superseded-transport.v1", - "status": "frozen", - "frozenAt": "2026-08-10", - "authority": "DCPLATFORM-76/ADR-0001", - "reason": "The public VPS must not initiate a private connection to Synology.", - "successor": "nodedc.device-edge.core-channel.v1", - "forbiddenForNewPlanOrApply": [ - "nodedc.device-edge-vps.backhaul.v1", - "nodedc.device-edge-vps.relay.v1:privateUpstream=127.0.0.1:19921", - "nodedc.device-plane.backhaul-vps-enrollment.v1", - "tailscale-userspace-key-only-ssh-local-forward", - "rotate-backhaul-client-mini-to-vps" - ], - "historicalSource": [ - "deployment/device-edge-vps-backhaul-v1.json", - "deployment/device-edge-vps-relay-v1.json", - "deployment/device-plane-backhaul-vps-enrollment-v1.json", - "deployment/tailscale-device-edge-policy.hujson", - "vps/config/backhaul_ssh_config", - "vps/systemd/nodedc-b2-backhaul.service", - "vps/systemd/nodedc-b2-relay.service", - "infra/deploy-runner/build-device-edge-vps-artifact.mjs:backhaul|relay", - "infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs", - "infra/deploy-runner/nodedc-b2-vps-deploy:backhaul|relay" - ], - "testOnlyReconstruction": { - "environment": "NODEDC_ALLOW_SUPERSEDED_TRANSPORT", - "value": "test-only", - "deployCandidate": false - }, - "runtimeMutationInPhase0": false -} diff --git a/device-plane/deployment/tailscale-device-edge-policy.hujson b/device-plane/deployment/tailscale-device-edge-policy.hujson deleted file mode 100644 index 6d8dba3..0000000 --- a/device-plane/deployment/tailscale-device-edge-policy.hujson +++ /dev/null @@ -1,55 +0,0 @@ -// NODE.DC tailnet policy: user devices retain self-access while the public -// Robot2B Device Edge VPS receives one purpose-bound egress grant. -{ - "tagOwners": { - "tag:device-edge-vps": ["autogroup:admin"], - }, - - "hosts": { - "device-plane-backhaul": "100.109.216.21", - "nodedc-admin-macbook": "100.114.248.4", - "nodedc-device-edge": "100.64.19.31", - }, - - "grants": [ - // Preserve unrestricted connectivity only between devices owned by the - // same authenticated tailnet member. Tagged service nodes are excluded. - { - "src": ["autogroup:member"], - "dst": ["autogroup:self"], - "ip": ["*"], - }, - - // The public VPS can reach exactly the private SSH forwarding target. - { - "src": ["tag:device-edge-vps"], - "dst": ["device-plane-backhaul"], - "ip": ["tcp:2222"], - }, - ], - - // Preserve the existing Tailscale SSH policy for user-owned devices. - "ssh": [ - { - "action": "check", - "src": ["autogroup:member"], - "dst": ["autogroup:self"], - "users": ["autogroup:nonroot", "root"], - }, - ], - - // These assertions are evaluated by Tailscale before every policy save. - "tests": [ - { - "src": "tag:device-edge-vps", - "proto": "tcp", - "accept": ["device-plane-backhaul:2222"], - "deny": [ - "device-plane-backhaul:22", - "device-plane-backhaul:5001", - "nodedc-admin-macbook:22", - "nodedc-device-edge:22", - ], - }, - ], -} diff --git a/device-plane/docker-compose.device-edge-core-channel.yml b/device-plane/docker-compose.device-edge-core-channel.yml deleted file mode 100644 index eb12eb4..0000000 --- a/device-plane/docker-compose.device-edge-core-channel.yml +++ /dev/null @@ -1,36 +0,0 @@ -services: - device-control-core: - environment: - DEVICE_EDGE_CHANNEL_ENABLED: "true" - DEVICE_EDGE_CHANNEL_CORE_KEY_FILE: /run/nodedc-secrets/device-edge-channel/core-private-key.pem - DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE: /run/nodedc-secrets/device-edge-channel/core-certificate.pem - DEVICE_EDGE_CHANNEL_TRUST_ROOT: /run/nodedc-secrets/device-edge-channel/peers - DEVICE_EDGE_CHANNEL_MAX_EDGES: "32" - DEVICE_EDGE_CHANNEL_RECONCILE_INTERVAL_MS: "15000" - volumes: - - type: bind - source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-private-key.pem - target: /run/nodedc-secrets/device-edge-channel/core-private-key.pem - read_only: true - bind: - create_host_path: false - - type: bind - source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-certificate.pem - target: /run/nodedc-secrets/device-edge-channel/core-certificate.pem - read_only: true - bind: - create_host_path: false - - type: bind - source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/peers - target: /run/nodedc-secrets/device-edge-channel/peers - read_only: true - bind: - create_host_path: false - networks: - - device-plane-egress - -networks: - device-plane-egress: - name: nodedc-device-plane-egress - driver: bridge - internal: false diff --git a/device-plane/docker-compose.device-edge.backhaul.yml b/device-plane/docker-compose.device-edge.backhaul.yml deleted file mode 100644 index 6bee823..0000000 --- a/device-plane/docker-compose.device-edge.backhaul.yml +++ /dev/null @@ -1,82 +0,0 @@ -services: - device-edge-backhaul: - build: - context: . - dockerfile: services/device-edge-backhaul/Dockerfile - image: nodedc/device-edge-backhaul:local - pull_policy: never - restart: unless-stopped - user: "1000:1000" - read_only: true - tmpfs: - - /tmp:size=8m,mode=1777 - volumes: - - ../secrets/backhaul/id_ed25519:/run/keys/edge-to-synology:ro - - ../trust/synology-backhaul-known_hosts:/run/trust/known_hosts:ro - command: - - -N - - -T - - -p - - "2222" - - -i - - /run/keys/edge-to-synology - - -L - - 0.0.0.0:19921:127.0.0.1:9921 - - -o - - BatchMode=yes - - -o - - PasswordAuthentication=no - - -o - - KbdInteractiveAuthentication=no - - -o - - PubkeyAuthentication=yes - - -o - - IdentitiesOnly=yes - - -o - - StrictHostKeyChecking=yes - - -o - - UserKnownHostsFile=/run/trust/known_hosts - - -o - - UpdateHostKeys=no - - -o - - ExitOnForwardFailure=yes - - -o - - ServerAliveInterval=15 - - -o - - ServerAliveCountMax=3 - - -o - - TCPKeepAlive=yes - - -o - - LogLevel=VERBOSE - - -o - - ProxyCommand=nc -X 5 -x nodedc-device-edge-tailnet-1:1055 %h %p - - device-backhaul@100.109.216.21 - networks: - - device-edge-private - - device-edge-tailnet - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - pids_limit: 32 - healthcheck: - test: - - CMD - - nc - - -z - - -w - - "3" - - 127.0.0.1 - - "19921" - interval: 10s - timeout: 5s - retries: 12 - start_period: 10s - -networks: - device-edge-private: - name: nodedc-device-edge-private - external: true - device-edge-tailnet: - name: nodedc-device-edge-tailnet - external: true diff --git a/device-plane/docker-compose.device-edge.ingress.yml b/device-plane/docker-compose.device-edge.ingress.yml deleted file mode 100644 index a3d4006..0000000 --- a/device-plane/docker-compose.device-edge.ingress.yml +++ /dev/null @@ -1,35 +0,0 @@ -services: - device-edge-relay: - environment: - DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true" - DEVICE_EDGE_RELAY_TCP_HOST: 0.0.0.0 - DEVICE_EDGE_RELAY_TCP_PORT: "9921" - DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul - DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921" - DEVICE_EDGE_RELAY_MAX_SESSIONS: "100" - DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS: "10" - DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30" - DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only - DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048" - DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144" - DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS: "10000" - networks: - device-edge-private: - gw_priority: 0 - device-edge-ingress: - ipv4_address: 192.168.71.253 - gw_priority: 100 - -networks: - device-edge-private: - name: nodedc-device-edge-private - device-edge-ingress: - name: nodedc-device-edge-ingress - driver: ipvlan - driver_opts: - parent: enp1s0f0 - ipvlan_mode: l2 - ipam: - config: - - subnet: 192.168.68.0/22 - gateway: 192.168.68.1 diff --git a/device-plane/docker-compose.device-edge.yml b/device-plane/docker-compose.device-edge.yml deleted file mode 100644 index 95da66f..0000000 --- a/device-plane/docker-compose.device-edge.yml +++ /dev/null @@ -1,44 +0,0 @@ -services: - device-edge-relay: - build: - context: . - dockerfile: services/device-edge-relay/Dockerfile - image: nodedc/device-edge-relay:local - pull_policy: never - restart: unless-stopped - user: "1000:1000" - read_only: true - tmpfs: - - /tmp:size=16m,mode=1777 - environment: - DEVICE_EDGE_RELAY_HEALTH_HOST: 127.0.0.1 - DEVICE_EDGE_RELAY_HEALTH_PORT: "18221" - DEVICE_EDGE_RELAY_INGRESS_ENABLED: "false" - DEVICE_EDGE_RELAY_TCP_PORT: "9921" - DEVICE_EDGE_RELAY_MAX_SESSIONS: "100" - DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS: "10" - DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30" - DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048" - DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144" - DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS: "10000" - networks: - device-edge-private: {} - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - healthcheck: - test: - - CMD - - node - - -e - - fetch('http://127.0.0.1:18221/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 10s - timeout: 5s - retries: 12 - start_period: 10s - -networks: - device-edge-private: - name: nodedc-device-edge-private - internal: true diff --git a/device-plane/docker-compose.device-manager.yml b/device-plane/docker-compose.device-manager.yml deleted file mode 100644 index f62052a..0000000 --- a/device-plane/docker-compose.device-manager.yml +++ /dev/null @@ -1,76 +0,0 @@ -services: - device-control-core: - environment: - DEVICE_MANAGEMENT_API_ENABLED: "true" - DEVICE_MANAGEMENT_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token - volumes: - - type: bind - source: /volume1/docker/nodedc-device-plane/secrets/management-core-token - target: /run/nodedc-secrets/management-core-token - read_only: true - bind: - create_host_path: false - - device-manager: - image: nodedc/device-manager:local - pull_policy: never - build: - context: ./services/device-manager - restart: unless-stopped - user: "1000:1000" - read_only: true - tmpfs: - - /tmp:size=16m,mode=1777 - environment: - NODE_ENV: production - HOST: 0.0.0.0 - PORT: "18122" - NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "true" - NODEDC_DEVICE_MANAGER_COOKIE_SECURE: "true" - NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW: "false" - NODEDC_DEVICE_MANAGER_SERVICE_SLUG: device-core - NODEDC_LAUNCHER_BASE_URL: https://hub.nodedc.ru - NODEDC_LAUNCHER_INTERNAL_URL: http://launcher:5173 - NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token - NODEDC_DEVICE_CORE_INTERNAL_URL: http://device-control-core:18120 - NODEDC_DEVICE_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token - volumes: - - type: bind - source: /volume1/docker/nodedc-platform/secrets/device-core-internal-token - target: /run/nodedc-secrets/device-core-internal-token - read_only: true - bind: - create_host_path: false - - type: bind - source: /volume1/docker/nodedc-device-plane/secrets/management-core-token - target: /run/nodedc-secrets/management-core-token - read_only: true - bind: - create_host_path: false - expose: - - "18122" - networks: - - device-plane-private - - platform-edge - depends_on: - device-control-core: - condition: service_healthy - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - healthcheck: - test: - - CMD - - node - - -e - - fetch('http://127.0.0.1:18122/healthz').then(r=>r.json()).then(v=>{if(!v.ok||!v.authRequired||!v.deviceCoreConfigured)process.exit(1)}).catch(()=>process.exit(1)) - interval: 10s - timeout: 5s - retries: 12 - start_period: 10s - -networks: - platform-edge: - external: true - name: nodedc-platform_edge diff --git a/device-plane/docker-compose.device-plane.backhaul-target.yml b/device-plane/docker-compose.device-plane.backhaul-target.yml deleted file mode 100644 index 6f23580..0000000 --- a/device-plane/docker-compose.device-plane.backhaul-target.yml +++ /dev/null @@ -1,39 +0,0 @@ -services: - device-backhaul-target: - image: nodedc/device-backhaul-target:local - pull_policy: never - restart: unless-stopped - network_mode: host - read_only: true - tmpfs: - - /run:size=8m,mode=0755 - - /tmp:size=8m,mode=1777 - volumes: - - type: bind - source: /volume1/docker/nodedc-device-plane/secrets/backhaul-target/ssh_host_ed25519_key - target: /run/nodedc-secrets/ssh_host_ed25519_key - read_only: true - bind: - create_host_path: false - - type: bind - source: /volume1/docker/nodedc-device-plane/secrets/backhaul-target/authorized_keys - target: /run/nodedc-secrets/authorized_keys - read_only: true - bind: - create_host_path: false - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - cap_add: - - CHOWN - - DAC_OVERRIDE - - SETGID - - SETUID - - SYS_CHROOT - healthcheck: - test: ["CMD", "nc", "-z", "-w", "3", "127.0.0.1", "2222"] - interval: 10s - timeout: 5s - retries: 12 - start_period: 10s diff --git a/device-plane/docker-compose.device-plane.yml b/device-plane/docker-compose.device-plane.yml deleted file mode 100644 index f36bd16..0000000 --- a/device-plane/docker-compose.device-plane.yml +++ /dev/null @@ -1,158 +0,0 @@ -services: - device-postgres: - image: postgres:16-alpine - pull_policy: missing - restart: unless-stopped - environment: - POSTGRES_DB: device_plane - POSTGRES_USER: device_plane - POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password - volumes: - - type: volume - source: device-plane-postgres-data - target: /var/lib/postgresql/data - - type: bind - source: /volume1/docker/nodedc-device-plane/secrets/postgres-password - target: /run/nodedc-secrets/postgres-password - read_only: true - bind: - create_host_path: false - networks: - - device-plane-private - healthcheck: - test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"] - interval: 10s - timeout: 5s - retries: 12 - start_period: 20s - - device-control-core: - image: nodedc/device-control-core:local - pull_policy: never - restart: unless-stopped - user: "1000:1000" - read_only: true - tmpfs: - - /tmp:size=16m,mode=1777 - environment: - HOST: 0.0.0.0 - PORT: "18120" - DEVICE_DATABASE_HOST: device-postgres - DEVICE_DATABASE_PORT: "5432" - DEVICE_DATABASE_NAME: device_plane - DEVICE_DATABASE_USER: device_plane - DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password - DEVICE_DATABASE_POOL_SIZE: "10" - DEVICE_DISCOVERY_INGEST_ENABLED: "true" - DEVICE_EDGE_CHANNEL_ENABLED: "false" - DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token - DEVICE_IDENTIFIER_PEPPER_FILE: /run/nodedc-secrets/identifier-pepper - volumes: - - type: bind - source: /volume1/docker/nodedc-device-plane/secrets/postgres-password - target: /run/nodedc-secrets/postgres-password - read_only: true - bind: - create_host_path: false - - type: bind - source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token - target: /run/nodedc-secrets/gateway-core-token - read_only: true - bind: - create_host_path: false - - type: bind - source: /volume1/docker/nodedc-device-plane/secrets/identifier-pepper - target: /run/nodedc-secrets/identifier-pepper - read_only: true - bind: - create_host_path: false - ports: - - "127.0.0.1:18120:18120" - networks: - - device-plane-private - depends_on: - device-postgres: - condition: service_healthy - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - healthcheck: - test: - - CMD - - node - - -e - - fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 10s - timeout: 5s - retries: 12 - start_period: 20s - - device-gateway: - image: nodedc/device-gateway:local - pull_policy: never - restart: unless-stopped - user: "1000:1000" - read_only: true - tmpfs: - - /tmp:size=16m,mode=1777 - environment: - DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0 - DEVICE_GATEWAY_HEALTH_PORT: "18121" - DEVICE_GATEWAY_LISTEN_ENABLED: "true" - DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false" - DEVICE_GATEWAY_TCP_HOST: 127.0.0.1 - DEVICE_GATEWAY_TCP_PORT: "9921" - DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120 - DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token - DEVICE_GATEWAY_CORE_TIMEOUT_MS: "5000" - DEVICE_GATEWAY_MAX_BUFFERED_BYTES: "65536" - DEVICE_GATEWAY_MAX_SESSIONS: "100" - DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS: "10" - DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30" - DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000" - volumes: - - type: bind - source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token - target: /run/nodedc-secrets/gateway-core-token - read_only: true - bind: - create_host_path: false - ports: - - "127.0.0.1:18121:18121" - - "127.0.0.1:9921:9921" - networks: - - device-plane-private - - device-plane-control - depends_on: - device-control-core: - condition: service_healthy - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - healthcheck: - test: - - CMD - - node - - -e - - fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 10s - timeout: 5s - retries: 12 - start_period: 10s - -networks: - device-plane-private: - name: nodedc-device-plane-private - internal: true - device-plane-control: - name: nodedc-device-plane-control - driver: bridge - internal: false - driver_opts: - com.docker.network.bridge.enable_ip_masquerade: "false" - -volumes: - device-plane-postgres-data: - name: nodedc-device-plane-postgres-data diff --git a/device-plane/docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md b/device-plane/docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md deleted file mode 100644 index 4be99d6..0000000 --- a/device-plane/docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md +++ /dev/null @@ -1,367 +0,0 @@ -# ADR 0001: Core-initiated Device Gateway Edge channel - -Status: accepted for implementation on 2026-08-10. - -Scope: NODE.DC Device Core / Device Gateway / public Device Edge VPS. - -Authority: NDC PLATFORM `DCPLATFORM-76`, with deploy implementation governed by -`DCPLATFORM-21`. - -## Decision - -The NODE.DC Core side initiates and owns the only private control/data channel -to a public Device Gateway Edge. The Edge never initiates a network connection -to Synology, DSM, Docker, Hub, Engine, Foundry, PostgreSQL, a Mac workstation or -another LAN service. - -The target transport is TLS 1.3 mutual authentication over one long-lived -HTTP/2 bidirectional session: - -```text -ARUSNAVI B2 - -> raw TCP/9921 - -> Device Gateway Edge on the VPS - -> accepted Core-owned mTLS session on standard HTTPS TCP/443 - -> Device Gateway Core on Synology - -> Device Control Core -``` - -The Core dials an endpoint selected from an approved Edge registration. The -endpoint is not hardcoded into an adapter, device record, Foundry application, -Engine workflow or deployment artifact. - -`device.nodedc.ru` remains the HTTPS user surface for Device Core. It is not the -raw B2 endpoint and is not moved to the VPS for this transport. - -## Why this direction is mandatory - -The VPS is a public, replaceable and partially untrusted edge host. A VPS owner -or a compromised root account must not gain a route into the private NODE.DC -network. An outbound Core connection works through NAT without a Synology port -forward and gives the Core a single explicit peer and protocol to validate. - -The previous design used a tagged userspace Tailscale process on the VPS, -SOCKS5, SSH local forwarding and a Synology backhaul target. Although that -design had narrow ACLs, the trust direction was still VPS-initiated and it kept -an avoidable private-network membership on the public host. It is superseded. - -## Component ownership - -Device Gateway Edge owns only: - -- public tracker TCP sessions; -- allowlisted adapter framing and protocol timing; -- the minimum HEADER/PACKAGE acknowledgement state; -- bounded in-memory buffers and flow-control counters; -- the server side of the mutually authenticated Core channel; -- typed command delivery to an already connected tracker session. - -Device Gateway Core and Device Control Core own: - -- Edge registration and certificate identity; -- owner scopes, Device Projects and project access; -- enrollment, quarantine, claim and transfer policy; -- restricted identifiers and credential references; -- complete telemetry decoding/normalization and data classification; -- command policy, confirmation, ledger and audit; -- Engine/Data Product and Foundry bindings. - -The Edge does not contain a business database, durable telemetry store, Hub or -Authentik credentials, Engine/Foundry tokens, PostgreSQL credentials or the -Core client private key. - -## Adapter split - -One versioned adapter package exposes explicit role-scoped interfaces: - -- `edge-session`: framing, bounded validation, HEADER/PACKAGE ACK and typed - command wire encoding required by the active socket; -- `core-decoder`: complete tag decoding, safe observation normalization and - model/firmware capability mapping; -- `command-contract`: typed commands and acknowledgement semantics shared by - policy and delivery code. - -The Edge artifact contains only the allowlisted `edge-session` and required -wire command implementation. It does not contain owner, workflow or Foundry -logic. The Core artifact may contain the full adapter package. A new device -model is added through the adapter registry; it does not create another Device -Manager application or a model-specific Synology service. - -## Authentication and key ownership - -- The Core has a unique client certificate and private key stored only in the - canonical Synology secret/trust boundary. -- The Edge has a unique server certificate and private key stored only in the - Edge runtime trust boundary. -- Both certificates chain to the dedicated Device Edge private CA or an - equivalent separately approved workload-identity issuer. -- The Core verifies the Edge registration id, certificate identity, CA, - validity and configured endpoint. -- The Edge accepts only an approved Core workload identity and never accepts a - browser, bearer-token-only or anonymous channel. -- Certificates have bounded lifetime, explicit generation and audited rotation. -- No private key, enrollment token or certificate bundle is carried in Git, - Ops, MCP payloads or ordinary deploy artifacts. - -TLS terminates on the Edge process. Root compromise of the VPS can therefore -read tracker traffic and impersonate that Edge identity until it is revoked. -It cannot obtain the Core private key or open a new connection into Synology. - -## Session protocol - -The application protocol is versioned and fail-closed. Every envelope has: - -- schema version; -- Edge registration id; -- channel generation; -- tracker session id; -- adapter/profile reference and version; -- monotonically increasing direction-local sequence; -- event timestamp and receive timestamp; -- bounded payload length; -- message kind and correlation id. - -Allowed Edge-to-Core messages are limited to channel hello/health, tracker -session opened/closed, discovery evidence, bounded verified frame, delivery -acknowledgement and bounded counters. Allowed Core-to-Edge messages are limited -to channel acceptance, flow-control window, session disposition and typed -command delivery. - -Unknown schema versions, message kinds, adapters, Edge ids or oversized frames -close the logical session and create a safe audit event. They never fall back -to arbitrary TCP forwarding. - -## Tracker acknowledgement rule - -The VPS has no durable business store. It must not acknowledge a valid tracker -PACKAGE merely because bytes reached the VPS. - -The Edge sends the verified frame to Core and waits for a bounded Core -acceptance acknowledgement. Only then may the Edge send the protocol PACKAGE -acknowledgement to the tracker. If the Core channel is unavailable or the -acceptance deadline expires, the Edge does not acknowledge the PACKAGE; the -tracker remains responsible for its documented retry behavior. - -HEADER acknowledgement follows the same ownership boundary: discovery must be -accepted into Core quarantine or matched to an admitted device session before -the Edge completes the handshake. An unavailable Core means no admitted -tracker session. - -This gives at-least-once delivery without a VPS database. Core deduplicates by -Edge generation, tracker session, package number and content digest. - -## Realtime and flow control - -The Core does not poll the VPS for batches. Telemetry returns immediately over -the established channel. The initial implementation contract is: - -- one Core channel per Edge generation; -- maximum 128 concurrent tracker sessions on the pilot VPS; -- maximum 16 sessions per observed source address; -- maximum 60 new tracker connections per minute per observed source; -- maximum 256 KiB buffered per tracker session; -- maximum 32 MiB aggregate tracker/channel buffering; -- maximum 1 MiB for one protocol frame before adapter-specific lower limits; -- 15-second keepalive and 45-second dead-peer deadline; -- reconnect with jittered exponential delay from 1 to 30 seconds; -- no unbounded disk spool; -- explicit accepted, duplicate, late, dropped, rejected and throttled counters. - -These are pilot ceilings, not a claim that one 961 MiB VPS supports production -scale. Load gates at 1, 100 and 1000 synthetic sessions determine the production -Edge topology. - -## Pilot service objectives - -These are acceptance objectives for the controlled pilot, not a published -production SLA: - -- zero tracker PACKAGE acknowledgements before durable Core acceptance in all - normal, timeout, disconnect and Core-restart tests; -- zero loss of Core-accepted packages in the acceptance run; duplicate delivery - is permitted on reconnect but must collapse to one normalized observation; -- Edge receive to Core acceptance latency at 128 concurrent synthetic sessions: - p95 at or below 2 seconds and p99 at or below 5 seconds; -- after both peers and the network are healthy, channel re-establishment: p95 at - or below 60 seconds and hard acceptance ceiling of 120 seconds; -- dead Core detection at the Edge no later than 45 seconds after the last valid - channel activity; -- memory stays inside the configured 32 MiB aggregate application buffer plus - the separately measured fixed runtime baseline; exceeding a bound throttles - or closes the offender and never expands the limit; -- malformed, unknown, unauthenticated and revoked-identity inputs produce zero - accepted telemetry records and zero command deliveries; -- a typed command for a currently connected test tracker reaches Edge wire - delivery or a conclusive rejection in p95 at or below 2 seconds; verified - device execution is a separate adapter-defined objective. - -Availability percentage and the 1000-session production capacity are explicitly -uncommitted until measured on the target host and recorded by a later transition. - -## Commands - -Commands use the same Core-owned channel and the existing tracker TCP session. -The VPS never exposes a command HTTP API and never accepts an arbitrary raw -payload. - -Core sends a typed command containing exact device/session, adapter/profile, -command catalog version, parameters, idempotency key, expiry and correlation -id. Edge either rejects it before wire delivery or returns delivery evidence. -Protocol acknowledgement is `acknowledged`, not `verified`. Verification needs -a subsequent device observation or explicit readback defined by the adapter. - -No command is queued durably on the VPS. Channel loss before a conclusive -outcome yields `unknown` or `failed` according to the command contract. Unsafe -automatic retry is forbidden. - -## Network boundary - -The target Edge exposes only: - -- management SSH according to the separately accepted management policy; -- raw tracker ingress TCP/9921; -- Core channel TCP/443 with mandatory mTLS; this is not a browser or bearer-token endpoint. - -Synology exposes no public device or backhaul port. The Edge receives no subnet -route, exit-node capability, Tailscale SSH, Docker socket or generic proxy. The -Core channel cannot request an arbitrary destination or port. - -Firewall policy is default deny. Raw ingress and Core channel have independent -connection/rate/resource limits. DDoS and malformed traffic terminate at the -provider/VPS and cannot be forwarded as a generic byte stream to Synology. - -## Threat model - -Protected assets are the Synology/LAN network, Device Core data and credentials, -Hub/Authentik identities, Engine and Foundry capabilities, command authority and -the integrity of admitted telemetry. - -The design assumes any of the following can happen independently: VPS root is -compromised; the VPS owner makes an unsafe change; an Internet client floods or -sends malformed B2 traffic; an Edge certificate is copied; a tracker identifier -is spoofed; the Core channel is interrupted; a valid package is replayed; or a -command outcome becomes unknown during disconnect. - -The boundary mitigates lateral entry into the private platform, arbitrary TCP -proxying, anonymous/bearer-only channel access, unbounded memory growth, replay -as a second normalized observation, command injection through a raw API, secret -distribution to the VPS and acknowledgement of telemetry that only reached -volatile Edge memory. - -Residual risks are explicit: compromised VPS root can read, drop, delay or forge -traffic attributed to that Edge until revocation, attack trackers connected to -it and exhaust the VPS or its uplink. Provider-scale DDoS protection, tracker -firmware trust and physical tracker compromise are outside this component. None -of those residual risks grants an inbound route or credential to Synology. - -## Tailscale decision - -Direct mTLS is the accepted target. Tailscale is not required for the product -channel. - -The current VPS userspace Tailscale foundation is a live predecessor and is not -removed in Phase 0. A later canonical transition removes it after the mTLS -channel is accepted. If direct mTLS proves impossible for an externally -evidenced reason, a new ADR may admit Tailscale only with all of the following: - -- Core still initiates the application session; -- tagged service identity; -- no user ownership, subnet routes, DNS, exit node or Tailscale SSH; -- an ACL to one exact Edge application endpoint only; -- negative tests for DSM 22/5001, Docker, MacBook and LAN; -- no SSH LocalForward or generic SOCKS backhaul. - -The old VPS-to-Synology SSH local-forward is not an allowed fallback. - -## Superseded source - -The following source is historical/recovery evidence and must not be used for a -new plan or apply: - -- `deployment/device-edge-vps-backhaul-v1.json`; -- `deployment/device-edge-vps-relay-v1.json` when it forwards to port 19921; -- `deployment/device-plane-backhaul-vps-enrollment-v1.json`; -- `deployment/tailscale-device-edge-policy.hujson` for the old SSH target; -- `vps/config/backhaul_ssh_config`; -- `vps/systemd/nodedc-b2-backhaul.service`; -- `vps/systemd/nodedc-b2-relay.service` with the old upstream; -- `infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs`; -- `infra/deploy-runner/nodedc-b2-vps-deploy` backhaul/relay phases. - -Builders fail closed by default. A test-only environment switch may reconstruct -historical artifacts for deterministic regression tests, but artifacts built in -that mode are not deploy candidates. Both reviewed runner sources reject the -superseded VPS phases and Synology enrollment even if such an archive exists. - -## Deployment boundaries - -The successor is a new additive Edge transport generation, not a weakened edit -of the old phase. Before an application artifact exists, DCPLATFORM-21 must -define: - -- component and transition identity; -- exact payload allowlist/denylist; -- fixed Edge roots, systemd units and trust roots; -- runtime-secret ownership and rotation; -- predecessor checks for the current Tailscale foundation; -- services stopped/started and preserved state; -- health and negative network acceptance inside apply; -- automatic rollback to the current closed-port predecessor. - -The first successor apply must leave TCP/9921 closed. It accepts only the mTLS -Core channel in a no-device/synthetic mode. Public tracker ingress is a separate -later transition after Core-channel acceptance. - -## Acceptance gates - -Phase 0 is accepted when: - -- this ADR and a machine-readable contract are present; -- the old builders fail closed by default; -- the reviewed VPS runner source rejects old backhaul/relay phases; -- regression tests prove the freeze and the new direction; -- Ops records exact SLO, threat and rollback boundaries; -- no runtime, DNS, route, port or tracker setting changed. - -The future Core-channel slice is accepted only when: - -- Synology has no new public listener or router mapping; -- a Core client without the exact identity cannot connect; -- an Edge with an unknown/revoked identity is rejected by Core; -- the Edge cannot reach DSM, Docker, Hub, Engine, Foundry, PostgreSQL, MacBook - or LAN targets; -- unknown/oversized/replayed envelopes fail closed; -- Core loss causes no tracker PACKAGE acknowledgement after the deadline; -- reconnect and deduplication preserve at-least-once behavior; -- secrets and unrestricted identifiers are absent from artifacts, logs, Ops, - metrics and MCP; -- rollback stops the successor channel, restores the closed-port predecessor - and preserves Device Plane DB, Gelios, Foundry and Engine. - -## Rollback - -Phase 0 changes only source and Ops; rollback is a source revert with no runtime -effect. - -The future transport apply owns an automatic rollback partition containing the -new Edge/Core channel units, configs, certificate references, firewall entries -and source publication. Rollback must: - -- stop and disable only the candidate channel units; -- remove only candidate firewall rules/listeners; -- restore the exact accepted predecessor files and unit states; -- keep public B2/9921 closed unless it was already an accepted predecessor; -- preserve Device Core/PostgreSQL, Gateway Core, Hub, Engine, Foundry and - Gelios; -- preserve audit evidence and record the failed generation; -- never restore or invent the superseded VPS-initiated SSH backhaul. - -## Consequences - -The Edge contains protocol-session code and can be replaced independently. A -VPS compromise can disrupt or falsify its tracker observations and can attack -connected trackers, but it does not become a path into the private platform. - -The design deliberately accepts temporary telemetry unavailability when Core -is unreachable instead of acknowledging data into an untrusted, non-durable -VPS buffer. This is the correct failure mode for the stated trust boundary. diff --git a/device-plane/docs/DEVICE_EDGE_B2_PUBLIC_PILOT_RUNBOOK.md b/device-plane/docs/DEVICE_EDGE_B2_PUBLIC_PILOT_RUNBOOK.md deleted file mode 100644 index f932980..0000000 --- a/device-plane/docs/DEVICE_EDGE_B2_PUBLIC_PILOT_RUNBOOK.md +++ /dev/null @@ -1,114 +0,0 @@ -# Device Edge B2 public pilot runbook - -> Frozen historical runbook — 2026-08-10 -> -> Do not configure these NAT rules, Mini relay, DNS changes or tracker route. -> This path is superseded by -> `docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md`. The public pilot will receive -> a new runbook only after the Core-initiated mTLS channel and VPS Edge pass -> their separate security/deploy acceptance. - -Status: router/NAT is **not configured** by this document. It is a one-pilot, -human-operated exposure gate for the already accepted Mini relay. It never -changes Synology, Gelios, VPN, Device Plane command transport or device -ownership. - -## Exact traffic path - -```text -ARUSNAVI B2 pilot - -> public IPv4 : TCP/9921 - -> provider router : TCP/9921 -> 192.168.1.151:9921 - -> Deco X55 : TCP/9921 -> 192.168.71.253:9921 - -> Mini IPvlan relay - -> restricted private backhaul - -> Synology Gateway 127.0.0.1:9921 -``` - -The observed Deco WAN is `192.168.1.151` with gateway `192.168.0.1`; it is an -RFC1918 address. Therefore this is a double-NAT topology. A Deco rule alone -cannot make the tracker reachable from the internet. - -## Immutable safety boundary - -- Forward **TCP only**, external and internal port `9921`. -- Deco target is exactly `192.168.71.253`, never the Mini host - `192.168.68.54` and never Synology. -- Do not enable DMZ, UPnP, port ranges, UDP, 443 forwarding or any catch-all - rule. -- Do not remove, replace or edit the Gelios monitoring route. -- The relay accepts connections only from a public IPv4 source, keeps - quarantine-only discovery and has no command transport. A LAN client will - intentionally be rejected; that is not a test failure. -- The rule must be deleted again if the single pilot is abandoned or the - expected quarantine evidence is not obtained. - -## Gate 1 — DNS and public address - -1. Determine the actual public IPv4 on the provider-facing edge. Do not use - `192.168.0.1`, `192.168.1.151`, `192.168.68.1` or `192.168.71.253` as a - DNS answer: all are private addresses. -2. Point `device.nodedc.ru` to that public IPv4 only if the tracker UI accepts - a hostname. Otherwise configure the public IPv4 directly. -3. If the provider router has no public WAN IPv4, or an internet check still - cannot reach it after both rules below, stop. This is provider CGNAT/bridge - territory: request a public IPv4 or a bridge/forwarding option from the - provider. Do not introduce a VPS as an unreviewed workaround. - -## Gate 2 — provider router - -On the upstream/provider router, create one port-forward rule: - -| Field | Value | -| --- | --- | -| Name | `NDC B2 pilot to Deco` | -| Protocol | `TCP` | -| External port | `9921` | -| Target address | `192.168.1.151` | -| Target port | `9921` | - -Save only that rule. It targets the Deco WAN address, not a service host. - -## Gate 3 — Deco X55 - -In the Deco mobile app: **More → Advanced → NAT Forwarding → Port Forwarding → -Add**. Create exactly: - -| Field | Value | -| --- | --- | -| Name | `NDC B2 pilot` | -| Protocol | `TCP` | -| External port | `9921` | -| Internal IP | `192.168.71.253` | -| Internal port | `9921` | - -If the app forces a client selection, select the IPvlan relay only if its -address is shown as `192.168.71.253`. Do not select the Mini host or any NAS. -If the UI will not accept the fixed IPvlan address, stop and record that fact; -do not substitute a DHCP address. - -## Gate 4 — ARUSNAVI B2 route - -Use one known pilot B2 and one unused monitoring-server slot. Configure an -additional route with the documented **INTERNAL** protocol and the public -hostname/IP from Gate 1, TCP port `9921`. Preserve the existing Gelios route in -its current slot. The ARUSNAVI account password stays in the operator surface; -it is not entered into Foundry, Device Plane or the relay. - -The device IMEI observed in `HEADER2` is a claimed identifier only. It becomes -a quarantine discovery, not an owned device and never a command target. - -## Acceptance and failure handling - -The first valid HEADER/PACKAGE through the pilot route must produce a masked, -quarantine-only discovery in Device Control Core and the existing Gelios map -path must continue independently. Do not issue a device command. - -On any unexpected behavior, remove the two NAT rules in reverse order: - -1. remove the Deco `NDC B2 pilot` rule; -2. remove the provider-router `NDC B2 pilot to Deco` rule. - -This ends external reachability while leaving the Mini, VPN, backhaul, -Synology and Gelios unchanged. Do not use a LAN port probe as acceptance: the -relay correctly rejects private source addresses. diff --git a/device-plane/docs/DEVICE_MANAGER_DIRECT_B2_VPS_AUDIT_2026-08-06.md b/device-plane/docs/DEVICE_MANAGER_DIRECT_B2_VPS_AUDIT_2026-08-06.md deleted file mode 100644 index 0a0bb38..0000000 --- a/device-plane/docs/DEVICE_MANAGER_DIRECT_B2_VPS_AUDIT_2026-08-06.md +++ /dev/null @@ -1,898 +0,0 @@ -# NDC Device Manager / Direct ARUSNAVI B2 / VPS - -> Historical audit notice — 2026-08-10 -> -> Live evidence in this document remains useful, but the product and transport -> decisions are superseded by `DCPLATFORM-76` and -> `docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md`. Device Core is a standalone -> Hub application, not a Foundry Page; the Edge contains a bounded adapter -> session role; Synology/Core initiates the private channel to the VPS; the old -> VPS-to-Synology relay/backhaul must not be deployed. - -Актуализированный архитектурный аудит и план перехода на Direct-primary с Gelios read fallback. - -Дата фиксации: 6 августа 2026 года. - -## 1. Итоговое решение - -Целевая read-архитектура зафиксирована так: - -- ARUSNAVI B2 отправляет данные параллельно в два monitoring server slot; -- прямой route B2 → NODE.DC становится основным источником позиции и текущей телеметрии; -- Gelios остаётся постоянно работающим legacy/read-only кандидатом для fallback; -- при недоступности прямого потока переключение выполняется отдельно для каждого устройства, а не глобально по одному health endpoint; -- наружу публикуется один канонический факт на один трайк; два источника не пишут одновременно в одну current/history projection; -- identity трайка, существующие `sourceId`, joins Foundry и Timescale-инфраструктура сохраняются; -- команды через Gelios не используются; -- команды через Direct B2 не входят в текущий этап и остаются выключенными; -- Mac Mini окончательно исключён из production ingress; его артефакты остаются историческим прототипом; -- VPS выполняет только публичный L4 ingress и зашифрованный backhaul. На нём нет БД, Engine, EDP, Foundry, Device Control Core, decoder, ACK-логики или command transport. - -Важная терминологическая поправка: B2 приходит на VPS не HTTP-пакетами, а сырым TCP-потоком `INTERNAL`. HTTP/HTTPS может использоваться только внутри закрытого контура после Gateway/decoder. Шифрование начинается на плече VPS → private NODE.DC contour; первый hop B2 → VPS остаётся обычным raw TCP, если сам B2 не поддерживает иной транспорт. - -## 2. Что проверено live - -Этот документ опирается не только на Ops, но и на live MCP и текущий source: - -- Ops: проекты `NDC PLATFORM` и `ROBOT2B`, полные карточки и комментарии; -- Engine: granted L2 targets, графы, runtime, executions, credential binding status, output profiles и telemetry catalog; -- Foundry: application instance, Map page, bindings, profiles и server-owned consumer progress; -- Ontology: live catalog, entities, relations и guardrails; -- source: Device Plane, ARUSNAVI adapter, Gateway, Control Core, EDP writer/reader scope и deployment artifacts; -- DNS: текущий A-record; -- SSH: доступные локальные aliases и наличие однозначной VPS identity. - -Ops в этом аудите является журналом решений и статусов. Реализация считается подтверждённой только там, где она совпадает с live MCP, runtime или source. - -## 3. Что в исходном аудите устарело - -### 3.1 MCP уже работает - -Исходный текст говорил, что Ops, Engine, Ontology и Foundry MCP недоступны. На момент этой актуализации все четыре live boundary доступны и прочитаны. - -### 3.2 Канонический продукт — v5, не v3 - -Production Map получает: - -```text -fleet.positions.current.v5@5.0.0 -ontology.map.moving_object.v3 -delivery = snapshot+patch -history = latest observation / 60 s bucket -retention = 90 days -``` - -Определение продукта находится в `platform/services/external-data-plane/definitions/fleet.positions.current.v5.json`. - -### 3.3 Device Manager Page ещё не существует - -Live Page Library Foundry содержит только `map@0.1.0`. Канонического шаблона `Device Manager`, server-owned `device-plane-control` provider и соответствующего UI сейчас нет. - -Device Manager не блокирует Direct ingestion: текущий этап можно завершить через Device Plane, Engine/EDP и существующую Map. UI управления устройствами остаётся отдельной более поздней фазой. - -### 3.4 VPS проверен live по SSH - -После получения точной SSH identity выполнен read-only аудит `root@155.212.211.15`. Изменений на host не выполнялось. - -Проверенная identity: - -- hostname `koffyvngij`; -- KVM/QEMU; -- Ubuntu 24.04.4 LTS, kernel `6.8.0-137-generic`; -- SSH host key уже был pinned локально; ED25519 fingerprint `SHA256:mhqNn2S6zstkYL7VFdvt3SYHv1nLjB4J7/s57RrKG6w`. -- используемый client key имеет mode `0600` и fingerprint `SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw`; -- в `/root/.ssh/authorized_keys` находятся два unrestricted key lines: этот Mac key и отдельный provider `beget-access-key`; их дальнейшая судьба должна быть explicit management-access policy, а не ручное удаление во время аудита. - -Проверенная ёмкость: - -- 1 vCPU; -- 961 MiB RAM, около 621 MiB available во время аудита; -- swap отсутствует; -- root filesystem 8.7 GiB, 2.3 GiB used, 6.4 GiB available; -- inode usage 11%; -- system clock synchronized, NTP active, timezone UTC; -- failed systemd units отсутствуют; -- journal занимает 9.9 MiB. - -Проверенная сеть/runtime: - -- `eth0` имеет public `155.212.211.15/32`, default route через `100.100.1.1` on-link; -- наружу слушает только SSH на `0.0.0.0:22` и `[::]:22`; `9921/TCP` не слушает; -- Docker, Podman, containerd, Tailscale, WireGuard и relay отсутствуют; -- Fail2Ban активен для `sshd`; unattended upgrades и time sync активны. - -Текущий security baseline не принят для production: - -- UFW inactive; -- nftables/iptables INPUT policy `ACCEPT`; единственное правило — Fail2Ban reject set для SSH; -- `PermitRootLogin yes`; -- `PasswordAuthentication yes`, root password установлен; -- `X11Forwarding yes`; -- `AllowTcpForwarding yes`, `PermitOpen any`, `DisableForwarding no`. - -Следовательно, VPS подходит по мощности для одного bounded relay и encrypted backhaul, но public `9921` нельзя включать до canonical firewall/SSH/runtime bootstrap и rollback acceptance. - -### 3.5 Домен сейчас указывает не на VPS - -`device.nodedc.ru` на 6 августа 2026 года резолвится в `95.165.91.235`. По `DCPLATFORM-34` это внешний адрес Synology/DSM, а не подтверждённый адрес нового VPS. - -`device.dc.ru`, упомянутый устно, A-record не вернул. Каноническое имя в архитектуре — `device.nodedc.ru`. - -До VPS acceptance и DNS cutover нельзя настраивать B2 на текущий `device.nodedc.ru:9921`: raw ingress на Synology запрещён. - -### 3.6 Полный IMEI уже существует в restricted Foundry contour - -Live Foundry получает `device_imei` в двух restricted bindings: - -- `fleet.units.contacts.current.v1`; -- `fleet.units.identity.current.v1`. - -Карточка объекта показывает полный IMEI как `restricted` text field из aspect `unit_identity`. Это не public/unrestricted поле, но это и не маскированное значение. - -Новая принятая политика: - -- IMEI не является паролем или secret; -- он может проходить внутри NODE.DC contour и использоваться для сопоставления устройства; -- он остаётся classified `restricted` identifier; -- его нельзя без необходимости писать в Ops, обычные логи, публичные payload, metrics labels или unrestricted MCP output; -- VPS видит IMEI только как часть непрозрачного TCP stream и не должен его извлекать или логировать; -- основной lookup в Device Plane может оставаться HMAC digest; полный IMEI разрешён в restricted projection там, где он действительно нужен. - -IMEI по-прежнему не является доказательством ownership. Он является идентификатором для lookup/crosswalk, а claim/tenant assignment остаются административным действием. - -## 4. Фактическая production read-цепочка - -```mermaid -flowchart LR - B2["ARUSNAVI B2"] --> GELIOS["Gelios monitoring route"] - GELIOS --> L2["Engine alxvw4fn\nGELIOS — REALTIME POSITIONS"] - L2 --> EDP["EDP / Timescale\nfleet.positions.current.v5"] - EDP --> FOUNDRY["Foundry\nRobot2B Trike Positions"] - - B2 -. "Direct route пока не подключён" .-> VPS["VPS Device Edge\nfoundation accepted"] - VPS -. "backhaul/relay gated" .-> GW["Synology Device Gateway"] - GW -. "PACKAGE сейчас ACK + discard" .-> STOP["Нет decoder / EDP publish"] -``` - -### 4.1 Live Engine - -Production owner позиции и телеметрии: - -- L1 workflow: `WCb62yGL8v`; -- target: `alxvw4fn`; -- name: `GELIOS — REALTIME POSITIONS`; -- revision: `68f4c852b8bc25b99c40b22fb4ca724fa6a0a34adbff74d1224b12ca7087bad8`; -- runtime workflow: `RupsyGGlawBzmUFm`; -- runtime active; -- schedule: каждые 10 секунд; -- рабочий request: `GET /api/v1/units` с `incltrip=true`, `inclcntrs=true`, `inclsnsrs=true`, `incllsv=true`; -- writer bindings и Gelios rotating credential зарегистрированы со status `ok`. - -Во время проверки executions завершались успешно примерно за 5–10 секунд. Execution `1297364` был `success` и дал структурный профиль 107 subjects. - -Normalized fact содержит: - -- `sourceId = gelios-unit-`; -- `semanticType = map.moving_object`; -- geometry Point; -- `display_name`; -- `position_source = gelios`; -- `signal_state`; -- `movement_state`; -- speed/course/elevation; -- satellite count, HDOP/accuracy при наличии; -- mileage, engine hours; -- `sensor_readings`. - -Live telemetry catalog execution `1297364` подтвердил 17 безопасных reading IDs без rejected/unsupported values: - -```text -sensor.param.call_btn -sensor.param.gps_mod -sensor.param.gsm -sensor.param.gsm_level -sensor.param.gsm_st -sensor.param.gyro -sensor.param.ign_virt -sensor.param.in_0 -sensor.param.in_1 -sensor.param.in0 -sensor.param.in1 -sensor.param.nav_st -sensor.param.pwr_ext -sensor.param.pwr_int -sensor.param.sim1_st -sensor.param.sim2_st -sensor.param.v_in -``` - -`in_0` и `in_1` имеют provider-configured label conflict; это уже отражено в telemetry catalog и не должно скрываться при Direct comparison. - -### 4.2 Инертный duplicate target - -Target `g7q86421` (`gelios.positions.current.realtime.v7`) не является вторым production writer. - -В его live graph соединены только: - -```text -manual trigger -→ monitoring config -→ units request -→ extraction -→ ontology map -``` - -Scheduled trigger и `NDC Data Product Publish • fleet.positions.current.v5` физически отсоединены. Schedule всё ещё создаёт короткие успешные пустые executions примерно раз в две минуты, но публикации не выполняются. - -Это подтверждает текущий правильный инвариант: у `fleet.positions.current.v5` один активный producer path. - -### 4.3 Engine platform debt - -При будущей переработке L2 нельзя игнорировать открытые карточки: - -- `DCPLATFORM-72`: UI `node.parameters` и executable `data.n8n.parameters` могут расходиться; -- `DCPLATFORM-73`: managed writer не всегда корректно перепривязывается к новой graph revision без revoke/recreate. - -Любое изменение arbiter graph требует post-write graph equality, deep validation, свежего execution и exact writer acceptance. Успешный patch preview сам по себе недостаточен. - -## 5. Live Foundry - -Application: - -- ID: `1c7dcdbb-6e50-4272-b1bc-aa5ece77ae99`; -- name: `Robot2B Trike Positions`; -- slug: `robot2b-trike-positions`; -- status: `draft`; -- version: `0.1.0`; -- одна page `map` на `/`; -- Map template `map@0.1.0`; -- updated: `2026-08-05T17:29:28.077Z`. - -Live server-owned consumers: - -| Binding | Product | Subjects | Cursor | Last error | -|---|---|---:|---:|---| -| `trike-current-positions` | `fleet.positions.current.v5` | 107 | 124720 | null | -| `trike-unit-profile` | `fleet.units.profile.current.v1` | 107 | 74 | null | -| `trike-unit-contacts` | `fleet.units.contacts.current.v1` | 107 | 55 | null | -| `trike-unit-identity` | `fleet.units.identity.current.v1` | 107 | 3651 | null | -| `depttrans-pmd-slow-zones` | `map.zones.current.v2` | 903 | 10 | null | - -Все consumers включены и используют `target-scoped-server-only` reader grant. Positions consumer получил свежий patch во время аудита. - -Существующее представление уже provider-neutral по renderer и composition: - -- один primary moving-object binding; -- profile/identity joins выполняются по стабильному `sourceId`; -- в Data tab уже показываются `sourceId`, `semanticType`, `position_source`, `dataProductId`, `receivedAt`; -- отдельный Direct pin или отдельная Direct Map не нужны. - -Чего нет: - -- source-selection mode `primary|fallback|shadow|stale`; -- причина выбора источника; -- последнее Direct и последнее Gelios observation одновременно; -- source health/freshness; -- source badge/facet/counter; -- Arnavi/Direct contract; -- Device Manager Page. - -Consumer policy v5 имеет `freshness=none`, `staleAfterMs=null` и `staleTransitions=0`. Следовательно, Foundry сам не определит падение VPS: selection и freshness должны приходить из server-owned upstream contract. - -Отдельная визуальная деталь: сохранённый state `trike-current-positions` имеет `visible=true`, но filters `movement_state=[]` и `signal_state=[]`. По контракту Foundry пустой массив matches nothing. Это может объяснять пустую Map при наличии 107 subjects. Состояние не изменялось в ходе аудита; перед visual acceptance надо отдельно подтвердить, намеренно ли сохранён explicit empty view. - -`trike-unit-contacts` выглядит избыточным: subject detail profile его не использует, а нужные IMEI/contacts уже присутствуют в `unit_identity`. Удалять binding до отдельного UI review не следует. - -## 6. Live Ontology - -Live catalog: - -- hash: `cdf3f5310359cb36`; -- 204 entities; -- 183 relations; -- 143 aliases; -- 46 guardrails; -- 41 blocked conflations. - -Provider-neutral каркас уже существует: - -```text -integration.provider -integration.connection -integration.collection_profile -integration.collection_run -integration.raw_envelope -integration.canonical_subject -integration.read_model -integration.realtime_channel -map.moving_object -map.state_facet -``` - -Поэтому не нужен новый renderer, новая Map, новый сайт или отдельная пользовательская сущность `Arnavi trike`. - -Но семантический разрыв реальный: - -- tracker entity есть только как `gelios.tracker_device`; -- IMEI/device identifier не определён provider-neutral; -- Arnavi/ARUSNAVI provider package отсутствует; -- source selection/failover semantics отсутствуют; -- `gelios.telemetry_snapshot`, `gelios.signal_state` и `gelios.movement_state` имеют Gelios-specific authority. - -Нельзя публиковать Direct B2 под видом Gelios mapping. Особенно нельзя молча объявить Direct `signal_state` результатом Gelios monitoring-config или добавить `fallback/stale` в закрытые `active|inactive` / `moving|stopped` enum. - -Минимальное canonical изменение — не новая бизнес-сущность, а: - -1. source-evidenced ARUSNAVI B2 provider/mapping package; -2. provider-neutral policy для выбранного источника и freshness; -3. нейтральное определение `signal_state`/`movement_state` для successor product; -4. restricted device identifier/crosswalk semantics по открытой `DCPLATFORM-70`. - -## 7. Текущий Direct B2 код - -### 7.1 Что реализовано - -ARUSNAVI adapter реализует: - -- HEADER2 `FF 23`; -- 8-byte little-endian IMEI; -- 15-digit validation; -- PACKAGE boundaries; -- packet length и checksum; -- HEADER acknowledgement с Unix time; -- package-number acknowledgement; -- bounded frame/buffer limits; -- commands disabled. - -Основные source anchors: - -- `platform/device-plane/packages/arusnavi-b2-adapter/src/index.mjs:56` — HEADER2; -- `.../index.mjs:93` — HEADER ACK; -- `.../index.mjs:110` — PACKAGE framing/checksum; -- `.../index.mjs:187` — PACKAGE ACK; -- `platform/device-plane/services/device-gateway/src/runtime.mjs:144` — session state machine. - -Device Plane test suite: 41 passed, 0 failed. - -### 7.2 Критический ACK/data-loss gap - -`tryParseB2Package()` возвращает только: - -```text -bytesConsumed -packageNumber -packetCount -``` - -Он не возвращает packet data или decoded tags. - -Gateway после успешного parse: - -```text -buffer = buffer after package -ACK counter++ -send PACKAGE ACK -``` - -Package sink, durable queue, EDP publish и decoder отсутствуют. Иными словами, текущий Gateway сообщает B2 «пакет принят», после чего payload теряется. - -Этот режим допустим только как discovery/framing pilot. Он непригоден для Direct-primary telemetry. - -### 7.3 Реализован только quarantine observe - -Core имеет только: - -```text -POST /internal/v1/device-discoveries:observe -``` - -Он HMAC-хэширует IMEI, создаёт/обновляет quarantine discovery и отдаёт masked projection. Claim endpoint, inventory import и device CRUD отсутствуют. - -Дополнительный blocker: Gateway Core client принимает только response `lifecycleState=quarantine`. Если discovery будет переведён в `claimed`, текущий client отклонит response, а HEADER ACK не будет отправлен. - -### 7.4 Нет связи device → canonical trike - -`device_bindings` сейчас содержит только: - -```text -contour_id -target_kind -target_ref -capabilities -``` - -В нём нет `device_id`. В `device_instances` нет `canonical_subject_ref`. - -Следовательно, из одного contour binding нельзя доказать, какой IMEI соответствует какому `gelios-unit-*`. Без explicit crosswalk Direct publisher либо создаст дубликаты, либо присвоит наблюдение неправильному трайку. - -Минимальное исправление без новой доменной сущности: - -- добавить `canonical_subject_ref` к существующему `device_instance`/claim contract; -- сопоставлять IMEI digest с restricted Gelios identity inventory; -- сохранять существующий `gelios-unit-*` как canonical `sourceId` на переходном этапе; -- неизвестный/duplicate IMEI оставлять unmatched quarantine; -- автоматический matcher может только предложить mapping; принятие принадлежности остаётся explicit admin action. - -## 8. Почему Mini artifacts нельзя применить к VPS - -Mini deployment жёстко фиксирует: - -- runtime host `ndcmini12`; -- NIC `enp1s0f0`; -- IPvlan `192.168.71.253`; -- LAN `192.168.68.0/22`; -- gateway `192.168.68.1`; -- Mini host `192.168.68.54/22`; -- Amnezia routes; -- userspace Tailnet SOCKS container; -- Deco/double-NAT topology. - -Эти assumptions находятся в: - -- `docker-compose.device-edge.ingress.yml`; -- `deployment/device-edge-ingress-ipvlan-v1.json`; -- `deployment/device-edge-admission-gate-v1.json`; -- `deployment/device-edge-backhaul-v1.json`; -- `infra/deploy-runner/build-device-edge-ingress-artifact.mjs`; -- `infra/deploy-runner/nodedc-edge-deploy`; -- `docs/DEVICE_EDGE_B2_PUBLIC_PILOT_RUNBOOK.md`. - -Переиспользовать можно relay implementation и fail-closed limits. Переиспользовать Mini Compose overlay, descriptor, builder или runner нельзя. - -VPS требует отдельный versioned placement/transition того же логического component `device-edge`, с собственными: - -- exact host identity и predecessor; -- public interface/bind; -- firewall contract; -- Tailscale/WireGuard route; -- SSH backhaul trust; -- resource limits; -- acceptance; -- backup/journal/rollback. - -Это additive extension `DCPLATFORM-21`, а не ручной `docker compose up` и не ослабление канона. - -## 9. Целевая архитектура - -```mermaid -flowchart TB - B2["ARUSNAVI B2\nдва server slot"] - - B2 -->|"INTERNAL raw TCP"| GELIOS["Gelios legacy read route"] - B2 -->|"INTERNAL raw TCP :9921"| VPS["VPS Device Edge\nopaque relay only"] - - VPS -->|"encrypted private backhaul"| GW["Synology Device Gateway\nframing + decoder + ACK owner"] - GW --> CORE["Device Control Core\nclaim + canonical subject crosswalk"] - GW -->|"after decode and durable acceptance"| DIRECT["Direct candidate Data Product\nworking contract"] - - GELIOS --> GL2["Existing Gelios L2 alxvw4fn"] - GL2 --> V5["fleet.positions.current.v5\nGelios candidate + legacy history"] - - DIRECT --> ARB["Server-owned per-device arbiter"] - V5 --> ARB - ARB --> V6["fleet.positions.current.v6\none canonical writer"] - V6 --> MAP["Existing Foundry Map binding\nsame page/profile/sourceId"] - - CORE -. "future, separate red domain" .-> CMD["Commands disabled"] -``` - -### 9.1 Что остаётся существующим - -- Device Plane PostgreSQL/Core/Gateway на Synology; -- EDP/Timescale; -- Robot2B L1; -- Gelios collector `alxvw4fn`; -- `fleet.positions.current.v5` как legacy candidate и immutable pre-cutover history; -- Foundry application, page, presentation profile и detail profile; -- `sourceId = gelios-unit-*` как переходная canonical identity; -- profile/identity products и joins; -- Gelios route на B2. - -### 9.2 Какие технические контракты неизбежно добавляются - -«Без новых сущностей» достижимо на уровне продукта/UI: не нужны новый сайт, новый backend, новая DB, новая Map или второй трайк. - -Но полностью без новых технических контрактов нельзя: - -- нужен Direct candidate product или другой durable server-owned candidate store; -- нужен ARUSNAVI provider/mapping package; -- нужен один canonical arbiter writer scope; -- нужен successor `fleet.positions.current.v6`, потому что v5 не разрешает поля selection/fallback provenance и имеет старую семантическую authority; -- нужен exact device → canonical subject field/claim contract. - -Это versioning и integration configuration, а не новые пользовательские domain objects. - -### 9.3 Где должен жить arbiter - -Не на VPS и не в Foundry. - -Engine подходит для чтения candidate products, нормализации и orchestration. EDP должен оставаться durable state и единственным publish authority. Практический вариант: - -1. Gelios продолжает писать v5. -2. Gateway/Direct adapter пишет отдельный candidate product после durable acceptance. -3. Arbiter L2 читает оба products отдельными managed reader grants. -4. Arbiter читает предыдущий canonical state для hysteresis или хранит selection state в каноническом fact. -5. Только arbiter имеет writer grant на v6. - -Существующий `g7q86421` можно рассмотреть как runtime slot для arbiter только после neutral rematerialization. Сейчас он Gelios-scoped; публиковать Direct под его Gelios connection нельзя. Если Engine не позволяет безопасно сменить connection authority, нужен новый служебный L2 target внутри существующего Robot2B L1. Это не новая бизнес-сущность. - -### 9.4 Правило выбора источника - -Выбор выполняется по каждому `canonical_subject_ref`. - -Direct eligible, только если одновременно выполнены: - -- device claimed и crosswalk однозначен; -- HEADER/PACKAGE framing и checksum валидны; -- telemetry packet decoded по versioned ARUSNAVI contract; -- observed time валиден и не уходит недопустимо в будущее; -- observation монотонно либо явно допустимо out-of-order; -- EDP candidate receipt durable; -- direct observation свежее per-device threshold; -- Gateway/backhaul не сообщает terminal fault. - -Fallback: - -```text -direct stale or invalid -→ grace period -→ select latest valid Gelios observation -→ selection_mode=fallback -``` - -Возврат: - -```text -direct returns -→ shadow only -→ N consecutive valid observations / accepted time window -→ identity and timestamp checks pass -→ selection_mode=primary -``` - -Точные grace/N/timeout не следует угадывать. Их надо вывести из pilot packet cadence и shadow statistics. - -Первый arbiter должен выбирать цельный position+telemetry observation атомарно. Не следует молча смешивать координаты Direct с sensor readings Gelios в одном fact. Cold profile и restricted identity могут временно продолжать приходить из Gelios отдельными joined aspects. - -### 9.5 Provenance successor product - -Рабочий набор полей v6: - -```text -position_source -telemetry_source -source_selection_mode -source_selection_reason -selected_observed_at -direct_last_observed_at -gelios_last_observed_at -selection_changed_at -``` - -Точные field IDs и enum должны сначала пройти Ontology/Data Product authority. `fallback` нельзя перегружать в `signal_state`. - -Для визуальной совместимости можно сохранить enum: - -```text -signal_state = active | inactive -movement_state = moving | stopped -``` - -Но v6 должен определить их как neutral Robot2B policy. Direct `signal_state` выводится из свежести direct observation, а не из Gelios monitoring-config. Movement threshold, если сохраняется `speed > 2`, должен быть явно принят как platform policy, а не назван «нативным B2 статусом». - -### 9.6 История - -Timescale/Postgres остаётся тем же сервисом и volume, но история EDP scoped по: - -```text -tenant + connection + provider + dataProductId + sourceId -``` - -Поэтому смена writer connection/product не создаёт автоматическую бесшовную history query через старые v5 rows. - -Правильный cutover: - -- v5 history остаётся immutable legacy history до даты переключения; -- v6 пишет новую canonical history с тем же `sourceId`; -- `NDC Robot2B History and Reports` получает version-aware read: v5 до cutover, v6 после cutover; -- никакой второй Timescale DB и никакая destructive migration не создаются. - -## 10. Durable ACK contract - -Production Direct path должен иметь такой порядок: - -```text -HEADER2 -→ resolve/create discovery -→ resolve claimed device and canonical subject when available -→ HEADER ACK - -PACKAGE -→ validate length/checksum -→ decode packet tags -→ normalize candidate observation -→ idempotent durable EDP acceptance -→ PACKAGE ACK -``` - -Если durable acceptance недоступен, PACKAGE ACK не отправляется, и B2 получает возможность повторить пакет. - -Idempotency нельзя строить только по package number: диапазон ограничен и номер переиспользуется. Candidate key должен включать device identity, session/package context и digest подтверждённых bytes/packet timestamp. Raw payload не обязан сохраняться; можно сохранять digest и normalized facts. - -Для quarantine pilot допустим отдельный режим framing proof: validated PACKAGE ACK после безопасного quarantine evidence без заявления, что телеметрия сохранена. Этот режим должен быть явно отличим от production telemetry acceptance. - -Gateway, а не VPS, остаётся ACK owner. - -## 11. VPS contract - -### 11.1 Runtime role - -VPS: - -- слушает public `TCP/9921`; -- применяет bounded session/rate/buffer policy; -- непрозрачно передаёт stream на private backhaul; -- держит минимальный loopback/internal health; -- не читает IMEI и telemetry; -- не ACK’ает B2; -- не хранит raw packet или DB; -- не имеет Core/EDP/Engine/Foundry credentials; -- не выполняет commands. - -### 11.2 Предлагаемый минимальный placement - -На слабом VPS достаточно: - -- host Tailscale или WireGuard; -- `device-edge-relay` container; -- `device-edge-backhaul` container с key-only SSH local forward в Synology target; -- Docker Compose plugin; -- system firewall default-deny; -- time sync; -- bounded Docker logs; -- root-owned deploy runner и state outside artifact. - -Live baseline подтверждает, что host нельзя использовать для production image builds или тяжёлого runtime. Начальные resource limits для reviewed candidate: - -- relay memory limit около 192 MiB; -- backhaul около 64 MiB; -- PID limits; -- logs `10 MiB × 3`; -- image build не выполнять на production host, если 1 GiB RAM не выдерживает; использовать reviewed prebuilt/digest-pinned image или swap policy, утверждённую отдельно. - -До application artifact нужен отдельный bootstrap transition, который fail-closed переводит host из текущего baseline: - -- firewall INPUT `ACCEPT` → default-deny с сохранением проверенного SSH access; -- root/password/X11/unrestricted forwarding → отдельный key-only management boundary; -- установить только утверждённый container/runtime и encrypted backhaul prerequisites; -- создать versioned service identities, fixed roots, bounded logs/resources и rollback; -- не открывать `9921` в bootstrap transition. - -Текущие Mini defaults `10 s session timeout` и `256 KiB per direction` являются pilot constraints. Их нельзя автоматически переносить в production B2: packet cadence и session lifetime должны быть измерены на одном реальном B2. - -### 11.3 Public surface - -Наружу: - -- `9921/TCP` для B2; -- management SSH только key-only и максимально ограниченно, предпочтительно через Tailnet/allowlist. - -Не публикуются: - -- health endpoint; -- Docker API; -- UDP range; -- Core/Gateway/EDP ports; -- database; -- DSM; -- reverse proxy `443 → 9921`. - -### 11.4 DNS - -DNS меняется только после: - -1. exact VPS identity; -2. canonical deploy-ok; -3. public listener/firewall acceptance; -4. encrypted backhaul acceptance; -5. external synthetic TCP proof; -6. rollback proof. - -Только затем `device.nodedc.ru` переводится с `95.165.91.235` на VPS. Изменение B2 server slot выполняется после DNS convergence и только для одного pilot; Gelios slot не трогается. - -## 12. Фазовый план - -### Phase 0 — authority и точный VPS target - -- точный target `root@155.212.211.15` получен; -- pinned ED25519 fingerprint проверен; -- live read-only inventory завершён; -- VPS placement зафиксирован в `DCPLATFORM-21` и `DCPLATFORM-74`; -- не менять DNS, B2 и Synology. - -### Phase 1 — canonical VPS bootstrap - -- отдельный VPS `device-edge` descriptor/runner; -- install Docker/Compose, runtime user, firewall, time sync, log policy, private network client; -- deploy relay/backhaul exact artifact; -- prove resource limits, ports, health, backup/journal и automatic rollback; -- terminal state только `deploy-ok`. - -### Phase 2 — transport acceptance без трекера - -- synthetic TCP from external host → VPS `9921` → encrypted backhaul → loopback Gateway; -- prove VPS does not parse/ACK; -- prove Synology remains non-public; -- prove closing/rollback removes only VPS `9921` and preserves Gelios; -- no DNS/B2 change yet. - -### Phase 3 — один B2, quarantine shadow - -- вручную добавить свободный B2 INTERNAL slot на accepted VPS address/hostname; -- Gelios slot сохранить; -- доказать HEADER2 → masked quarantine; -- доказать PACKAGE framing/checksum/ACK; -- commands remain disabled; -- не объявлять telemetry production, пока PACKAGE всё ещё discard. - -### Phase 4 — decoder, claim и durable candidate - -- принять точную официальную tag/framing specification для firmware pilot B2; -- parser возвращает packet data/typed records; -- реализовать explicit claim/crosswalk в существующем Device Instance contract; -- исправить Gateway/Core lifecycle для claimed devices; -- добавить Direct candidate product и managed writer; -- ACK только после durable candidate receipt; -- unknown tags fail closed или попадают в bounded classification evidence, но не в unrestricted Data Product. - -### Phase 5 — shadow comparison - -Для одного pilot сравнивать Direct и Gelios без canonical switch: - -- identity match; -- observation timestamps и clock skew; -- coordinate distance; -- speed/course/elevation; -- satellites/HDOP/accuracy; -- mileage/engine hours; -- все 17 текущих telemetry reading IDs; -- missing/extra tags; -- duplicate/retry/out-of-order behavior; -- reconnect cadence и session lifetime. - -До этой фазы утверждение «Gelios отдаёт ровно те же B2 пакеты» не считается доказанным. Сейчас доказано лишь, что Gelios предоставляет данные, относящиеся к B2 units, и текущий normalized surface совпадает с ожидаемым B2 профилем. Exact packet equivalence требует Direct shadow. - -### Phase 6 — arbiter и v6 - -- neutral Ontology/source-selection contract; -- per-device eligibility, grace и hysteresis; -- один arbiter writer; -- successor v6; -- history cutover policy; -- no field-level silent mixing; -- simulate Direct failure and recovery; -- Gelios continues collecting at all times. - -### Phase 7 — Foundry cutover - -- существующий `trike-current-positions` binding переводится на v6; -- page, binding ID, presentation/detail profiles и sourceId сохраняются; -- Data tab получает selection provenance; -- optional source badge/facet добавляется только по versioned field contract; -- решить explicit empty filters; -- проверить все joins profile/identity и 107 subjects; -- rollback возвращает binding на v5. - -### Phase 8 — эксплуатационная приёмка - -- sustained Direct-primary window; -- controlled VPS/backhaul/Gateway failure → per-device Gelios fallback; -- controlled recovery → shadow → Direct promotion; -- no duplicate current facts/history; -- no timestamp regression; -- bounded logs/resources; -- alerts/metrics без full IMEI; -- documented rollback. - -### Phase 9 — команды, отдельная работа - -Только после принятого Direct read path: - -- official command contract; -- draft/plan/confirm/queue/dispatch/ACK/reconciliation; -- immutable audit и idempotency; -- no automatic Gelios command fallback; -- no blind retry after unknown outcome. - -## 13. Ops reconciliation - -Фактическое значение карточек после аудита: - -- `DCPLATFORM-74` — master architecture; Mini placement устарел, VPS должен стать новым accepted Device Edge placement; -- `ROBOT2B-5` — старый pause superseded: Direct track возобновлён, SSH access подтверждён, но работа пока blocked на canonical VPS bootstrap/backhaul и Direct data path gaps; -- `ROBOT2B-6` — current production Gelios baseline; теперь это сохраняемый legacy candidate/fallback, а не конечная архитектура; -- `DCPLATFORM-21` — authority для отдельного VPS runner/artifact/rollback; -- `DCPLATFORM-70` — provider-neutral device/identifier/crosswalk debt; -- `DCPLATFORM-72` и `DCPLATFORM-73` — обязательные Engine change acceptance risks. - -Актуализация записана в Ops 6 августа 2026 года: - -- `DCPLATFORM-74`: comment `fcdb49c3-8cfc-4c94-8e67-137de14ec9f2`; -- `ROBOT2B-5`: comment `eb6284b5-4d0e-4064-8eda-659485b94115`, карточка переведена из Backlog в In Progress; -- `ROBOT2B-6`: comment `5fecce6e-fec0-4119-814e-0e2bb7a6a41e`; -- `DCPLATFORM-21`: comment `c9bbe692-d301-4127-8734-9c1e91bb5083`. - -После live SSH-аудита VPS добавлены уточнения: - -- `DCPLATFORM-74`: comment `fa26ac68-17c2-4d56-8b6b-f483b1275739`; -- `DCPLATFORM-21`: comment `8db0d7e2-1bb4-4390-93e3-aebb68429a1b`; -- `ROBOT2B-5`: comment `750aeab7-b2eb-4ea1-bb4a-e6e098c5a02c`. - -Ops не должен утверждать, что: - -- Direct telemetry уже принимается; -- VPS relay/bootstrap уже развёрнут и принят; -- `device.nodedc.ru` уже указывает на VPS; -- Device Manager Page существует; -- IMEI полностью отсутствует в Foundry; -- текущий Gateway выполняет durable PACKAGE acceptance; -- Gelios и Direct уже переключаются автоматически. - -## 14. Текущие блокеры - -1. VPS firewall/SSH baseline не hardened и не принят canonical runner-ом. -2. Не выбран и не enrolled exact encrypted VPS → private NODE.DC backhaul identity/target. -3. Current DNS указывает на Synology. -4. Mini deploy artifacts не переносимы на VPS. -5. Gateway ACK’ает и отбрасывает PACKAGE. -6. Нет telemetry tag decoder. -7. Нет claim endpoint и claimed-device handshake. -8. Нет device → canonical trike crosswalk. -9. Нет Direct candidate product/writer. -10. Нет neutral source-selection ontology/product contract. -11. Нет arbiter и successor product/history cutover. -12. Foundry source provenance минимальна, а saved filters могут скрывать все subjects. -13. Команды намеренно выключены. - -## 15. Следующее разрешённое действие - -Следующий шаг — зафиксировать exact encrypted backhaul choice/identity и подготовить additive VPS deployment domain: root-owned runner, deterministic bootstrap artifact, exact predecessor, firewall/SSH acceptance и rollback. Первый bootstrap не открывает `9921`. Никакой B2, DNS или production Map mutation до отдельного transport acceptance не требуется. - -## 16. Implementation update — 2026-08-06 - -Этот раздел заменяет устаревшие operational assertions в разделах 3.4, 11.2, -12 Phase 0–1, 13–15. Архитектурные выводы остальных разделов сохраняются. - -Реализовано: - -- созданы отдельные Ops cards `DCPLATFORM-75` и `ROBOT2B-7`; -- создан отдельный root-owned VPS deploy domain - `/usr/local/sbin/nodedc-b2-vps-deploy`; -- Docker на 961 MiB VPS сознательно не устанавливался; -- foundation `device-edge-vps-foundation-20260806-003` принят с `deploy-ok`; -- SSH переведён в key-only, nftables — в default-deny, публично только TCP/22; -- Node.js 22.23.2 и Tailscale 1.102.2 установлены из pinned static archives; -- Tailscale runtime user `nodedc-edge` и отдельный ED25519 backhaul key созданы - runner-ом; isolation-aware backhaul/relay transitions используют отдельные - `nodedc-backhaul` и `nodedc-relay`; -- default wildcard tailnet grant удалён; VPS переведён из user ownership в - `tag:device-edge-vps` с единственным egress `100.109.216.21:2222/tcp`; -- отрицательные проверки с VPS подтвердили запрет Synology SSH/DSM, MacBook и - второго edge-узла; -- TCP/9921 остаётся закрыт; -- backhaul и relay artifacts собраны и стадированы, но не применены до - predecessor acceptance; -- public VPS key стадирован в Synology enrollment; -- marker-only Synology key-rotation artifact и runner candidate стадированы. - -Текущие blockers/gates: - -1. Root на Synology должен promote/verify exact runner, review plan и выполнить - exact enrollment apply. -2. После этого VPS runner может принять backhaul, затем relay. -3. DNS и B2 routes остаются неизменными до отдельного transport pilot. -4. Provider recovery key `beget-access-key` — RSA-1024; его отзыв требует - отдельного owner decision. - -Полный повторяемый manual и release evidence находятся в -`device-plane/docs/ROBOT2B_B2_VPS_CONFIGURATION_BIBLE_2026-08-06.md`. diff --git a/device-plane/docs/IMPLEMENTATION_BASELINE.md b/device-plane/docs/IMPLEMENTATION_BASELINE.md deleted file mode 100644 index a4acdcf..0000000 --- a/device-plane/docs/IMPLEMENTATION_BASELINE.md +++ /dev/null @@ -1,287 +0,0 @@ -# Device Plane Implementation Baseline - -> Superseded topology notice — 2026-08-10 -> -> The historical Foundry-Page product boundary, Mini ingress, VPS-initiated -> Tailscale/SSH backhaul and `device.nodedc.ru` raw-TCP assumptions below are -> retained only as implementation history. They must not be used for a new -> plan/apply. The accepted successor is -> `docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md`: Device Core is a standalone -> Hub application, Synology/Core initiates a mutually authenticated full-duplex -> channel to the VPS, and `device.nodedc.ru` remains the HTTPS UI surface. - -Status: PostgreSQL, Control Core and Gateway foundation are running healthy on -Synology. The accepted foundation has public ingress and discovery ingest -disabled. The next additive transition enables only an authenticated, -quarantine-only ARUSNAVI B2 discovery path on raw TCP 9921. Command transport -remains disabled. - -## Product boundary - -The Device Manager user interface is a canonical Foundry Page Library -template. Foundry owns page instances, layout, presentation and an opaque -`device-plane-control` binding. It does not own device records, credentials, -raw protocol or command delivery. - -The independent NDC Device Plane owns physical-device state and direct -connections: - -```text -Foundry Device Manager Page - | - | device-plane-control (typed server boundary) - v -Device Control Core <-> Device PostgreSQL - | - v -Device Gateway <-> physical devices -``` - -The isolated ingress placement replaces the direct physical-device arrow when -the raw route must not terminate on the multi-service Synology: - -```text -ARUSNAVI B2 device - | - | raw TCP 9921 (future, separately approved) - v -Device Edge Relay on dedicated mini - | - | outbound restricted SSH local-forward; opaque byte stream only - v -Synology loopback 127.0.0.1:9921 -> Device Gateway -> Device Control Core -``` - -The Edge Relay owns neither protocol acknowledgement nor device identity. It -does not receive the Gateway/Core token, PostgreSQL credentials, Foundry -bindings or any command capability. The Synology Gateway remains the sole B2 -codec and acknowledgement owner. - -Engine L2 may consume safe decoded observations and build workflows/Data -Products. It does not own TCP sessions, secrets or the command transport. - -## Preserved production path - -The existing Gelios -> Engine L2 -> External Data Plane -> Foundry Map path is -outside this implementation slice. Its credentials, workflows, Data Products, -bindings and map presentation must not be changed or restarted by a Device -Plane artifact. - -The first B2 pilot adds an NDC server route in parallel and keeps the existing -Gelios route unchanged. - -## Source and runtime placement - -Source: - -```text -platform/device-plane/ - packages/device-protocol-contract/ - packages/arusnavi-b2-adapter/ - services/device-control-core/ - services/device-gateway/ - services/device-edge-relay/ - docker-compose.device-plane.yml - docker-compose.device-edge.yml -``` - -Planned Synology runtime: - -```text -/volume1/docker/nodedc-device-plane -``` - -Planned Compose project and services: - -```text -nodedc-device-plane - device-control-core - device-gateway - device-postgres -``` - -`device-postgres` is a private persistent prerequisite. Application overlays -must never force-recreate it or its volume. - -The canonical runner selects only `device-control-core` and `device-gateway` -with `--no-deps`. Its health acceptance is scoped to the selected services and -requires the fail-closed fields to remain disabled. A failed first activation -removes only candidate stateless services and never requests volume removal. -Rollback now records an explicit pre-apply service inventory in the backup; -the existence of the shared Compose file does not imply that Core or Gateway -existed before apply. - -The exact foundation recovery validates the failed archive, journal, backup, -partial live source and observed healthy image/container generations. It then -publishes the matching source and performs read-only runtime acceptance. It -does not build, restart, recreate or remove any service. - -## Network boundary - -The accepted Synology foundation publishes no device port. Device Gateway's -raw B2 listener is reachable only through `127.0.0.1:9921`; its health -endpoints are loopback-only. The only planned external raw-TCP termination is -the dedicated Mini Edge Relay described below. - -`device.nodedc.ru` is a DNS name, not an HTTP/TCP mode. The same name may later -serve an HTTPS Control API on 443 and the B2 raw TCP protocol on 9921. - -DSM HTTP/HTTPS Reverse Proxy is not a raw TCP ingress and must not be configured -as `443 -> 9921`. - -The artifact never changes DSM firewall, DSM Router Configuration, DNS or a -physical router. - -### Dedicated mini Device Edge - -The Debian mini is the isolated raw-TCP edge. Its accepted predecessor keeps the -relay disabled and publishes health only on `127.0.0.1:18221`. The reviewed -target removes even that host publication: health remains container-internal, -the relay stays on the `internal: true` private bridge for backhaul, and a -second IPvlan L2 attachment gives only the relay a LAN-routable address for -`9921/TCP`. The relay has bounded global/per-address sessions and connection -rate, a bounded source-rate table and a per-direction byte budget. It emits no -bytes of its own and does not inspect device payloads. - -The admission-gate transition is deliberately fail-closed at the relay: an -ingress instance accepts only a syntactically public IPv4 source, limits its -in-memory source table to 2,048 addresses and closes either direction after -262,144 bytes. Private, loopback, link-local, carrier-grade NAT, multicast, -reserved and documentation addresses are rejected before an upstream connection -is made. This is a connection-admission and resource-boundary control, not a -claim that Docker IPvlan traffic is filtered by a host firewall. A raw B2 -protocol has no TLS client identity and cellular devices do not offer a stable -source-IP allowlist, so a router/NAT mapping remains prohibited until its -separate exposure and abuse controls are reviewed. - -IPvlan deliberately reuses the Mini's one physical parent `enp1s0f0`; a second -Ethernet adapter is not required. The host keeps `192.168.68.54/22` and the -Amnezia `0.0.0.0/1` plus `128.0.0.0/1` routes. The relay has its own fixed LAN -IPv4 and default route through `192.168.68.1`, while its private connected route -continues to reach `device-edge-backhaul:19921`. No Docker host `ports:` entry, -host-network mode, privileged container or VPN teardown is allowed. - -Enabling public ingress is a separate reviewed operation and requires all of -the following evidence: - -1. A distinct, no-shell Synology SSH account and key whose sole permitted open - target is `127.0.0.1:9921`; host-key pinning and a persistent, monitored - tunnel are required. -2. A private backhaul sidecar/network; the raw listener may forward only to - that tunnel. The Core token and all Core/Database secrets remain on - Synology. -3. Router evidence proving the fixed relay IPv4 is outside DHCP. The artifact - cannot choose an address and never changes router, firewall or DHCP state. - A manual router/NAT rule is a later independent approval, after the relay's - admission gate and external-exposure runbook have been accepted. -4. The host full-tunnel VPN remains active. Before production activation, the - exact single-NIC IPvlan design must pass duplicate-address detection, - gateway reachability, external return-path and private-backhaul checks. -5. One pre-authorized B2 pilot route, quarantine-only Gateway/Core ingest and - disabled command transport. - -## Identity and onboarding - -An IMEI is a claimed protocol identifier, not proof of tenant ownership. - -- An unknown connection produces a quarantine-only discovery. -- A discovery never receives commands. -- Pilot claim requires an explicit platform-admin action. -- Production assignment requires authoritative pre-enrollment or an audited - inventory import. -- First-claim-wins by IMEI is forbidden. - -The ARUSNAVI Web account login/password is used only by the human operator to -configure the additional device route. It is not a Device Plane credential. - -## Protocol evidence - -The official B2 material proves: - -- four simultaneous monitoring server routes; -- `INTERNAL`, `EXTERNAL`, `USER_AG` and EGTS variants; -- INTERNAL server-side identification by modem IMEI; -- server route fields for DNS/IP, TCP port, protocol and optional ID; -- SMS/TCP command families and a six-digit device access password. - -The official ARUSNAVI INTERNAL protocol sheet now provides the first read-path -framing contract: - -- HEADER2 for GPRS is `FF 23` followed by an eight-byte little-endian IMEI; -- the server confirms HEADER2 with a bounded `SERVER_COM` carrying Unix time; -- a PACKAGE begins with `5B`, carries a package number in `01..FB`, contains - one or more length-framed PACKET records and ends with `5D`; -- every PACKET checksum is verified before acknowledgement; -- every valid PACKAGE is acknowledged by package number; -- without acknowledgement the tracker repeats the transmission. - -The pilot codec implements only that verified read/acknowledgement subset. It -does not decode telemetry tags, export command builders or accept arbitrary -server commands. An IMEI parsed from a valid HEADER2 remains a claimed -identifier and never proves tenant ownership. - -## Command boundary - -Outbound command transport is disabled in this baseline. No command builder is -exported. - -Later lifecycle: - -```text -draft -> planned -> awaiting_confirmation -> queued -> dispatched - -> acknowledged | failed | expired | unknown -``` - -`send` is not success. An `unknown` result forbids automatic retry. - -Erase, factory reset, firmware/custom firmware, physical outputs and arbitrary -raw TCP remain forbidden until separate reviewed acceptance slices. - -## Implemented local foundation - -- Provider-neutral discovery, contour and opaque Foundry-binding contracts. -- B2 model profile with four parallel routes and INTERNAL/IMEI evidence. -- PostgreSQL migration for model profiles, contours, quarantine discoveries, - claimed devices, Foundry bindings and append-only audit events. -- Core health endpoint and an authenticated quarantine-ingest boundary that is - disabled unless explicitly enabled with file-backed secrets. -- Gateway discovery-only HEADER2/PACKAGE state machine with bounded buffers, - handshake timeout, concurrent/per-source session limits and per-source - connection rate limits. -- Authenticated Gateway-to-Core discovery ingest. Core HMAC-hashes the full IMEI - and persists only its digest, masked view and verified framing evidence. -- Only HEADER2 and valid PACKAGE acknowledgements are emitted; no command - builder or command transport is present. -- Recursive rejection of secret-like fields, raw payloads and command-shaped - input in presentation contracts. -- Automated contract, adapter, migration, Core and Gateway tests. -- Additive `component=device-plane` runner registry with exact roots, builds, - services, allowlist/denylist, runner-owned secrets, health contracts and - automatic source/runtime rollback. -- Deterministic data-only artifact builder and positive/negative regression - tests. -- Compose foundation with a private internal network, preserved PostgreSQL - volume, file-backed database password and loopback-only health publishing. -- Exact one-time PostgreSQL bootstrap descriptor, deterministic builder and - absence preflight: an existing database container or volume fails closed, - and rollback never removes the volume. - -## Next activation slice - -1. The Deco DHCP range has been recorded as `192.168.68.50` through - `192.168.71.250`; the fixed Relay IPv4 is `192.168.71.253`, outside that - pool and independently DAD-tested. It is pinned in Compose, descriptor, - builder and the separate Edge runner. -2. Build the deterministic `component=device-edge` artifact, promote the - root-owned Edge runner and review its `plan`. The Synology runner and inbox - are not used for this host. -3. Apply the admission-gate update only to `device-edge-relay`; prove exact - IPvlan runtime, no host ports, `public-ipv4-only` admission, byte/source - limits, internal health, private backhaul reachability, unchanged - backhaul/tailnet identities and preserved Amnezia routes. Automatic rollback - restores the reviewed IPvlan predecessor and leaves router state unchanged. -4. Independently review and add the single router/NAT rule for TCP `9921` only, - then verify that Synology still exposes no public device port. -5. Add the NDC route to one approved B2 free server slot while preserving - Gelios, then prove HEADER/discovery/PACKAGE acknowledgement. Claim and tenant - assignment remain a later explicit platform-admin operation. diff --git a/device-plane/docs/ROBOT2B_B2_VPS_CONFIGURATION_BIBLE_2026-08-06.md b/device-plane/docs/ROBOT2B_B2_VPS_CONFIGURATION_BIBLE_2026-08-06.md deleted file mode 100644 index a40eb11..0000000 --- a/device-plane/docs/ROBOT2B_B2_VPS_CONFIGURATION_BIBLE_2026-08-06.md +++ /dev/null @@ -1,539 +0,0 @@ -# Robot2B B2 VPS — конфигурационная Библия - -> Замороженная историческая конфигурация — 10.08.2026 -> -> Не применять описанные ниже `backhaul`, `relay`, Tailscale/SSH LocalForward, -> Synology enrollment или открытие `9921`. Целевой транспорт заменён на -> Core-initiated mTLS full-duplex channel по -> `docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md`. Документ сохраняется как -> evidence уже выполненных экспериментов и текущего predecessor VPS. - -Статус документа: живой manual реализации от 2026-08-06. - -Контур: Robot2B / NODE.DC Device Plane. - -VPS: `155.212.211.15`, hostname `koffyvngij`. - -## 1. Назначение - -VPS является минимальным публичным Device Edge для трекеров Arnavi B2. Он не -владеет бизнес-логикой, протоколом, БД, карточками устройств, визуализацией или -командами. Его единственная целевая функция — принять ограниченный поток raw TCP -на `155.212.211.15:9921`, не интерпретируя пакет, и передать его по шифрованному -private backhaul в уже существующий `device-gateway` NODE.DC. - -Gelios остаётся отдельным legacy-источником. Эта конфигурация не меняет Gelios, -DNS `device.nodedc.ru`, маршруты B2 или command transport. - -```mermaid -flowchart LR - B2["Arnavi B2 trackers"] - VPS["Device Edge VPS\n155.212.211.15:9921"] - TS["Tailscale userspace\nSOCKS5 127.0.0.1:1055"] - SSH["SSH local forward\n127.0.0.1:19921"] - TARGET["Synology device-backhaul-target\n100.109.216.21:2222"] - GW["device-gateway\n127.0.0.1:9921"] - CORE["device-control-core + PostgreSQL"] - FOUNDRY["Foundry / visualization"] - GELIOS["Gelios legacy source"] - - B2 -->|"raw TCP, planned route"| VPS - VPS --> TS -->|"grant: tag:device-edge-vps → TCP/2222 only"| SSH --> TARGET --> GW --> CORE --> FOUNDRY - GELIOS -->|"independent legacy ingest"| CORE -``` - -## 2. Красные границы - -- На VPS нет базы данных и durable telemetry storage. -- VPS не разбирает Arnavi/B2 protocol и не принимает решения по IMEI. -- IMEI остаётся claimed identifier, а не доказательством владения устройством. -- Неизвестное устройство должно попадать в quarantine/discovery lifecycle. -- Command transport отключён. Команды через VPS или Gelios не вводятся. -- Gelios не выключается и не перетирается новым потоком. -- На Synology не публикуется Docker-порт `2222`; доступ только через private - Tailscale Serve. -- Public VPS не является пользовательским tailnet-узлом. Его service identity - `tag:device-edge-vps` может инициировать только TCP к `100.109.216.21:2222`. -- VPS не принимает subnet routes, DNS tailnet, exit-node capability или - Tailscale SSH. Доступ к DSM, MacBook и другим tailnet-узлам запрещён. -- Приватные ключи, auth keys, токены и пароли не входят в Git, deploy artifacts, - Ops или этот документ. -- До отдельного pilot/cutover не меняются `device.nodedc.ru` и настройки B2. - -## 3. Фактический predecessor VPS - -Снято до первого apply: - -- Ubuntu `24.04.4 LTS`, kernel `6.8.0-137-generic`, KVM/QEMU. -- 1 vCPU, 961 MiB RAM, swap отсутствует. -- Root filesystem около 8.7 GiB; свободно около 6.4 GiB. -- `eth0`: `155.212.211.15/32`, default gateway `100.100.1.1`. -- Публично слушал только TCP/22; TCP/9921 был закрыт. -- Docker, Node.js, Tailscale и WireGuard отсутствовали. -- `fail2ban`, NTP и unattended upgrades были активны. -- UFW был inactive, nftables/iptables использовали INPUT ACCEPT. -- SSH допускал root/password и forwarding; это устранено foundation-переходом. - -## 4. Канонические переходы - -Конфигурация разделена на независимые data-only артефакты. Порядок нельзя -переставлять. - -1. `foundation`: - - pin статических Node.js и Tailscale runtimes; - - service account `nodedc-edge`; - - key-only SSH; - - default-deny nftables, публично только TCP/22; - - отдельный ED25519 backhaul key, сгенерированный на VPS; - - userspace `tailscaled`, но без скрытого auth key; - - TCP/9921 закрыт. -2. Внешняя регистрация и сегментация `nodedc-b2-vps` в существующем tailnet: - - заменить default wildcard policy на проверяемую deny-by-default policy; - - назначить `tag:device-edge-vps`, тем самым удалить user ownership; - - разрешить тегу только `100.109.216.21:2222/tcp`; - - отрицательно проверить Synology `22/5001`, MacBook `22` и другие узлы; - - только после этого вернуть Tailscale service в состояние Running. -3. Synology `backhaul-vps-enrollment`: - - принять только публичный VPS key через enrollment; - - заменить прежний Mini key; - - пересоздать только `device-backhaul-target`; - - сохранить Device Plane, PostgreSQL, Tailscale Serve и Gelios; - - при ошибке автоматически вернуть прежний key и target generation. -4. VPS `backhaul`: - - pinned Synology host key; - - key-only SSH через Tailscale userspace SOCKS5; - - `127.0.0.1:19921 -> 127.0.0.1:9921`; - - публичный TCP/9921 всё ещё закрыт. -5. VPS `relay`: - - открыть публичный TCP/9921; - - bounded opaque relay на `127.0.0.1:19921`; - - loopback health на `127.0.0.1:18221`. -6. Отдельный pilot: изменить адрес сервера у ограниченной группы B2. Это не - часть конфигурации VPS. - -## 5. Идентичности и ключи - -### 5.1 Management SSH - -Команда с MacBook: - -```bash -ssh -i ~/.ssh/nodedc_b2_vps \ - -o IdentitiesOnly=yes \ - -o StrictHostKeyChecking=yes \ - root@155.212.211.15 -``` - -В Ops и manual фиксируются только путь и fingerprints: - -- локальный private key path: `~/.ssh/nodedc_b2_vps`; -- MacBook management public key fingerprint: - `SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw` (ED25519); -- VPS SSH server host key fingerprint: - `SHA256:mhqNn2S6zstkYL7VFdvt3SYHv1nLjB4J7/s57RrKG6w` (ED25519). - -Foundation принудительно задаёт: - -- `PermitRootLogin prohibit-password`; -- `AuthenticationMethods publickey`; -- `PasswordAuthentication no`; -- `KbdInteractiveAuthentication no`; -- forwarding, agent forwarding, X11 и tunnels запрещены; -- `MaxAuthTries 3`, `LoginGraceTime 20`. - -В `/root/.ssh/authorized_keys` остаются две management identity: - -- MacBook ED25519 — fingerprint выше; -- provider recovery key `beget-access-key`, RSA 1024, - fingerprint `SHA256:9W1cgovqOlegteEWV0r5j4OjJgG0PGr97eVmpUBjg54`. - -RSA-1024 provider key — остаточный риск. Он не удалён автоматически, потому -что отзыв внешнего recovery-доступа является отдельным необратимым решением. -После подтверждения владельца нужно либо удалить его отдельным каноническим -переходом, либо документировать как принятый break-glass access. - -### 5.2 VPS backhaul client identity - -Private key генерирует root-owned runner непосредственно на VPS: - -- private: `/var/lib/nodedc-b2-vps/trust/backhaul_ed25519`, owner - `nodedc-edge` на foundation и `nodedc-backhaul` после backhaul apply, - mode `0400`; -- public: `/var/lib/nodedc-b2-vps/trust/backhaul_ed25519.pub`, mode `0444`; -- fingerprint: - `SHA256:HHTiDYiCRxSiKjBLCip6JMSzGfLGrDz5g8SIkosJcVw`; -- public key: - `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGUlvJ8a83qov9DHO2S/BHbVAEH4Chyc4v/DKwOIWeES nodedc-device-edge-vps-backhaul`. - -В Synology staging этот public key хранится по пути: - -`/volume1/docker/nodedc-device-plane/enrollment/device-edge-vps-backhaul.pub` - -Private key никогда не покидает VPS. - -Foundation генерирует ключ до появления публичного relay, поэтому его -временным владельцем является `nodedc-edge`. Backhaul transition атомарно -создаёт `nodedc-backhaul`, передаёт ему каталог trust и ключ и валидирует mode. -Relay запускается как третий пользователь `nodedc-relay`; он не может читать ни -private key, ни Tailscale state. Даже локальный доступ relay к SOCKS5 не расширяет -полномочия: tailnet grant разрешает только target `2222`, где аутентификация -дополнительно требует private key пользователя `nodedc-backhaul`. - -### 5.3 Synology backhaul target identity - -- Tailnet IP: `100.109.216.21`. -- Private SSH endpoint: `100.109.216.21:2222` через Tailscale Serve. -- User: `device-backhaul`. -- PermitOpen: только `127.0.0.1:9921`. -- Host key fingerprint: - `SHA256:QERJ5CIUXRj0nLChGT6HMtoX+WTaeaEY5ZgaWqT8d30`. -- Public host key: - `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJsmoyS+0Tbhz9VXxrSxXwNMFpfbdTckCilObOnKdlEc nodedc-device-plane-backhaul-target`. -- VPS pinned known_hosts: - `/var/lib/nodedc-b2-vps/trust/backhaul_known_hosts`. - -### 5.4 Tailscale service identity и grant - -Живая policy сохранена 2026-08-06. Каноническая копия: -`device-plane/deployment/tailscale-device-edge-policy.hujson`. - -- `autogroup:member -> autogroup:self`, все протоколы: сохраняет привычный - доступ пользователя только между собственными user-owned устройствами; -- `tag:device-edge-vps -> device-plane-backhaul`, только `tcp:2222`; -- `device-plane-backhaul = 100.109.216.21`; -- tag owner: только `autogroup:admin`; -- policy test требует accept `100.109.216.21:2222` и deny для Synology - `22/5001`, MacBook `22`, `nodedc-device-edge:22`. - -Назначение тега удалило `dcctouch@gmail.com` из поля `Managed by`; живой VPS -управляется `tag:device-edge-vps`. После включения проверено с самого VPS: - -- `100.109.216.21:2222` — reachable; -- `100.109.216.21:22` — blocked; -- `100.109.216.21:5001` — blocked; -- `100.114.248.4:22` — blocked; -- `100.64.19.31:22` — blocked. - -Enrollment запускается с `--accept-dns=false --accept-routes=false --ssh=false`. -Runner для backhaul/relay дополнительно требует `BackendState=Running`, online -hostname `nodedc-b2-vps` и ровно один tag `tag:device-edge-vps`. - -## 6. Runtime и файловая система VPS - -### 6.1 Root-owned source/runtime - -- runner: `/usr/local/sbin/nodedc-b2-vps-deploy`; -- live root: `/opt/nodedc-b2-vps`; -- deploy state: `/var/lib/nodedc-b2-vps-deploy`; -- inbox: `/var/lib/nodedc-b2-vps-deploy/inbox`; -- applied/failed artifacts: `applied/`, `failed/`; -- backups: `backups/`; -- journals: `state/applied.jsonl`, `state/failed.jsonl`; -- lock: `state/deploy.lock`; -- service state/trust: `/var/lib/nodedc-b2-vps`. - -### 6.2 Pinned runtimes - -- Node.js `22.23.2`; - archive SHA-256 - `d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307`. -- Tailscale `1.102.2`; - archive SHA-256 - `ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8`. - -Docker не устанавливается: для 961 MiB RAM он не нужен и добавляет лишний -daemon/state surface. - -### 6.3 systemd units - -- `nodedc-b2-tailscaled.service`: - - user `nodedc-edge`; - - userspace networking, без TUN; - - socket `/run/nodedc-b2-vps/tailscaled.sock`; - - SOCKS5 `127.0.0.1:1055`; - - `MemoryMax=160M`; - - разрешены `AF_UNIX AF_INET AF_INET6 AF_NETLINK`. -- `nodedc-b2-backhaul.service`: - - user `nodedc-backhaul`, единственный читатель backhaul private key; - - strict host key pinning; - - local forward `127.0.0.1:19921`; - - `MemoryMax=64M`. -- `nodedc-b2-relay.service`: - - user `nodedc-relay`, без credential access; - - public `0.0.0.0:9921`; - - health `127.0.0.1:18221`; - - upstream `127.0.0.1:19921`; - - `MemoryMax=192M`. - -Все units используют `NoNewPrivileges`, `ProtectSystem=strict`, -`ProtectHome=yes`, `PrivateTmp`, `PrivateDevices`, ограничение address families, -tasks и file descriptors. - -## 7. Firewall и порты - -Foundation nftables policy: - -- input: default drop; -- loopback, established/related и ICMP разрешены; -- новый TCP/22 ограничен `30/minute`, burst `60`; -- TCP/9921 отсутствует; -- forward: default drop; -- output: accept. - -Relay policy добавляет: - -- drop новых соединений на TCP/9921 сверх `300/second`; -- accept TCP/9921 после rate guard. - -Портовая матрица: - -| Endpoint | Видимость | Владелец | Стадия | -|---|---|---|---| -| `155.212.211.15:22` | public | OpenSSH | foundation | -| `127.0.0.1:1055` | loopback | tailscaled SOCKS5 | foundation | -| `127.0.0.1:19921` | loopback | SSH local forward | backhaul | -| `127.0.0.1:18221` | loopback | relay health | relay | -| `155.212.211.15:9921` | public | bounded relay | relay | -| `100.109.216.21:2222` | tailnet grant только для VPS tag | Synology target | existing | -| `127.0.0.1:9921` на Synology | loopback | device-gateway | existing | - -## 8. Relay limits - -- max concurrent sessions: `128`; -- max sessions per source IP: `16`; -- max new connections/minute/source IP: `60`; -- max tracked source IPs: `4096`; -- max bytes per direction/session: `64 MiB`; -- session timeout: `300000 ms`; -- source policy: `public-ipv4-only`; -- protocol inspection: disabled на VPS; -- command transport: disabled. - -## 9. Установленный foundation release - -Принят 2026-08-06: - -- patch: `device-edge-vps-foundation-20260806-003`; -- artifact SHA-256: - `1be852f144e9f0fea32af70bebd07a2607b6a1818825094bd4c1b4062064716a`; -- foundation-time runner SHA-256: - `3f42d23431937e70c16ce1fd346fb84a706e506ae99d89eaf11780ff1ad56c03`; -- current promoted runner SHA-256: - `5ccdc1b53ce0688e7c120976e82937842bc8491a2e05eb5f280165accfd40b6c`; -- backup: - `device-edge-vps-foundation-20260806-003-20260806-151415`; -- terminal result: `deploy-ok`; -- fresh MacBook key-only SSH acceptance: success. - -Два предыдущих ID терминальны и никогда не должны применяться повторно: - -- `...-001`: `tailscaled` заблокирован отсутствием `AF_NETLINK`; automatic - rollback `ok`; -- `...-002`: cloud-init `50-cloud-init.conf` опередил `90-*` и сохранил - `PasswordAuthentication yes`; automatic rollback `ok`; -- `...-003`: исправлены `AF_NETLINK` и ранний `00-nodedc-b2-vps.conf`; - acceptance успешна. - -Следующие exact isolation-aware artifacts стадированы в VPS inbox и не -применяются до своих predecessor barriers: - -- backhaul `device-edge-vps-backhaul-20260806-002`, SHA-256 - `830750da8f9590ca4db458ec9e90f4d48ad8d1403160d3878a968b54e9eb6913`; -- relay `device-edge-vps-relay-20260806-002`, SHA-256 - `305a6de769f24b2c6cee801426ec43b98a44d10e08cad75a96fd65d20b16b697`. - -Версии `...-001` не применялись и recoverably перемещены из inbox в -`/var/lib/nodedc-b2-vps-deploy/withdrawn/*.superseded-by-002`, потому что в них -все три процесса использовали один Unix account `nodedc-edge`. - -## 10. Synology VPS enrollment release - -Стадировано, но до авторизации Tailscale и root plan/apply не считается -применённым: - -- runner candidate: - `/volume1/docker/nodedc-deploy/runner-install/candidates/nodedc-deploy.device-plane-backhaul-vps-enrollment-20260806-011`; -- runner SHA-256: - `453228c41b411d9c925091c77dc94e501f2eb3534fc241db0d2e58f0a28e12e2`; -- artifact: - `/volume1/docker/nodedc-deploy/inbox/nodedc-device-plane-device-plane-backhaul-vps-enrollment-20260806-001.tgz`; -- artifact SHA-256: - `576dabdafde5e3b2de09c7265127928c4b463e7b40dd554fd71babc16cc70e08`; -- VPS enrollment public-key file SHA-256: - `c2718c117fd09965386524d32fa9816d9d2d9cf00b59010dcf8bcf78f29bea8c`. - -Root-переход на Synology: - -```bash -sudo sha256sum \ - /volume1/docker/nodedc-deploy/runner-install/candidates/nodedc-deploy.device-plane-backhaul-vps-enrollment-20260806-011 - -sudo install -o root -g root -m 0755 \ - /volume1/docker/nodedc-deploy/runner-install/candidates/nodedc-deploy.device-plane-backhaul-vps-enrollment-20260806-011 \ - /usr/local/sbin/nodedc-deploy - -sudo /usr/local/sbin/nodedc-deploy verify-install - -sudo /usr/local/sbin/nodedc-deploy plan \ - /volume1/docker/nodedc-deploy/inbox/nodedc-device-plane-device-plane-backhaul-vps-enrollment-20260806-001.tgz - -# Apply только после review exact plan. -sudo /usr/local/sbin/nodedc-deploy apply \ - /volume1/docker/nodedc-deploy/inbox/nodedc-device-plane-device-plane-backhaul-vps-enrollment-20260806-001.tgz -``` - -Ожидаемый plan обязан показать: - -- predecessor patch `device-plane-backhaul-target-tailnet-serve-20260804-002`; -- predecessor artifact SHA - `219408705dd4d80a962ed00eeb53a69df0b9ab6458443734d5c9cd1d1f795eba`; -- build `none`; -- recreate только `device-backhaul-target`; -- next fingerprint `SHA256:HHTi…osJcVw`; -- public ingress disabled; -- Docker port publication disabled; -- Tailscale Serve, router/NAT/firewall, PostgreSQL и Gelios unchanged; -- automatic rollback на previous key + target recreate. - -## 11. Внешняя регистрация Tailscale - -На VPS auth key не хранится. Для первой регистрации root запускает: - -```bash -/opt/nodedc-b2-vps/runtime/tailscale/tailscale \ - --socket=/run/nodedc-b2-vps/tailscaled.sock \ - up \ - --hostname=nodedc-b2-vps \ - --accept-dns=false \ - --accept-routes=false \ - --ssh=false -``` - -Одноразовый login URL не копируется в Ops. До включения service владелец tailnet -обязан сохранить policy и назначить tag из раздела 5.4. Backhaul plan требует -`BackendState=Running`, online hostname `nodedc-b2-vps` и exact service tag. - -## 12. Сборка VPS artifacts - -Из корня repository `platform`: - -```bash -NODEDC_DEVICE_EDGE_VPS_RUNTIME_DIR=/tmp \ -node infra/deploy-runner/build-device-edge-vps-artifact.mjs \ - foundation - -node infra/deploy-runner/build-device-edge-vps-artifact.mjs \ - backhaul - -node infra/deploy-runner/build-device-edge-vps-artifact.mjs \ - relay -``` - -Builder создаёт deterministic archive с `manifest.env`, `files.txt` и -`payload/`. В artifact запрещены `.env`, keys, trust, runtime, logs, uploads, -node_modules и symlinks. Foundation дополнительно проверяет pinned runtime -digests. - -Каждый release: - -1. собрать дважды и сравнить SHA-256; -2. проверить file list и отсутствие секретов; -3. скопировать exact artifact в VPS inbox; -4. выполнить свежий `plan`; -5. review границ; -6. выполнить один exact `apply`; -7. считать `deploy-ok` терминальным результатом и не запускать apply повторно. - -## 13. Rollback model - -VPS runner перед mutation создаёт backup exact partition и сохраняет текущий -nft ruleset и service enablement. При ошибке: - -- candidate services останавливаются; -- source/config возвращаются; -- nftables и SSH config восстанавливаются; -- service enablement возвращается; -- при failed foundation удаляются созданные live/runtime roots и service user; -- при failed backhaul ключ возвращается `nodedc-edge`, а созданный - `nodedc-backhaul` удаляется; -- при failed relay созданный `nodedc-relay` удаляется; -- artifact переносится в `failed/`; -- failed ID и digest становятся терминальными. - -Synology VPS enrollment отдельно сохраняет previous `authorized_keys`. При любой -ошибке он: - -- удаляет candidate marker; -- атомарно возвращает прежний restricted key; -- пересоздаёт только `device-backhaul-target`; -- проверяет target, три preserved Device Plane services, Tailscale Serve и - loopback `9921`. - -## 14. Развёртывание аналогичного VPS - -Перед клонированием нельзя просто переиспользовать текущий artifact. Нужно -создать новый descriptor/release с новыми параметрами: - -1. Получить чистый Ubuntu 24.04 LTS VPS и зафиксировать hostname, public `/32`, - gateway, CPU/RAM/disk. -2. Добавить отдельный ED25519 management key и проверить fresh connection. -3. Зафиксировать server host key fingerprint с доверенного канала. -4. Проверить active fail2ban/NTP/unattended upgrades и отсутствие listeners, - кроме TCP/22. -5. Изменить pin в runner/descriptor: - `RUNTIME_HOST`, `PUBLIC_IPV4`, management/server fingerprints и Tailscale - node name. -6. Не переносить private backhaul key: новый VPS должен сгенерировать новую - пару самостоятельно. -7. Собрать deterministic foundation с новым terminal patch ID. -8. Promoted runner проверить отдельным `verify-install`. -9. Сделать plan → review → apply. -10. Зарегистрировать новый tailnet node без сохранения auth key. -11. Скопировать только public backhaul key в новый enrollment path. -12. Выполнить отдельный Synology key rotation/grant transition. -13. Только после принятого private backhaul применять relay. -14. Только после relay acceptance перенаправлять ограниченный pilot B2. - -## 15. Acceptance checklist - -- [x] Foundation artifact deterministic и secret-free. -- [x] Foundation accepted с automatic rollback coverage. -- [x] Fresh key-only SSH с MacBook работает. -- [x] Public TCP/9921 не открыт на foundation. -- [x] Отдельный VPS backhaul public key создан и pinned. -- [x] Synology enrollment key, runner candidate и artifact стадированы. -- [ ] Tailnet node `nodedc-b2-vps` авторизован и `Running`. -- [x] Tailnet wildcard grant удалён; VPS tagged и negative-route tests пройдены. -- [x] Isolation-aware VPS runner promoted и `verify-install-ok`. -- [ ] Synology runner candidate promoted и `verify-install-ok`. -- [ ] Synology enrollment `plan` reviewed и `deploy-ok`. -- [ ] VPS backhaul artifact `deploy-ok`. -- [ ] VPS relay artifact `deploy-ok`. -- [ ] Public TCP/9921 принят внешним probe. -- [ ] Pilot B2 route согласован отдельно. -- [ ] Provider RSA-1024 recovery key удалён или принят как documented risk. - -## 16. Исходники и проверки - -Канонические файлы: - -- `infra/deploy-runner/nodedc-b2-vps-deploy`; -- `infra/deploy-runner/build-device-edge-vps-artifact.mjs`; -- `infra/deploy-runner/test_device_edge_vps_artifact.py`; -- `device-plane/vps/config/`; -- `device-plane/vps/systemd/`; -- `device-plane/deployment/device-edge-vps-*-v1.json`; -- `infra/deploy-runner/nodedc-deploy`; -- `infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs`; -- `infra/deploy-runner/test_device_plane_backhaul_vps_enrollment_artifact.py`. - -Проверки на момент документа: - -- VPS artifact tests: 9/9 OK; -- Device Plane targeted runner/artifact tests: 48/48 OK; -- полный platform `npm test`: 41/41 OK во внешнем сетевом sandbox; -- Python compilation: OK; -- systemd unit syntax: OK; отсутствие binaries до foundation было ожидаемым; -- `nft -c` foundation/relay: OK; -- effective candidate OpenSSH policy: key-only; -- `git diff --check`: OK для целевых файлов. diff --git a/device-plane/docs/TEST_MATRIX.md b/device-plane/docs/TEST_MATRIX.md deleted file mode 100644 index 27fc9df..0000000 --- a/device-plane/docs/TEST_MATRIX.md +++ /dev/null @@ -1,38 +0,0 @@ -# Device Plane baseline test matrix - -| Boundary | Required proof | -| --- | --- | -| Restricted identity | IMEI accepts exactly 15 decimal digits internally | -| Browser projection | Safe discovery view contains only a masked identifier | -| Identifier hashing | HMAC digest is deterministic and does not reveal input | -| Secret boundary | Secret-like or raw-payload keys are rejected recursively | -| Command boundary | Discovery contract rejects command-shaped input | -| Framing bound | B2 evidence inspection rejects empty and oversized buffers | -| Framing honesty | Unverified B2 bytes return `official_framing_required` | -| No identifier guessing | Embedded digit sequences are never returned as IMEI | -| Model profile | Four server routes and INTERNAL identification are recorded | -| Gelios preservation | Gelios is a parallel route, not a dependency or failover | -| Core database secret | Production Compose uses a file-backed password, not a plaintext environment value | -| Core health | Database is ready while discovery ingest and command transport remain disabled | -| Gateway health | Public ingress, TCP listener and command transport remain disabled | -| Compose exposure | Only loopback health ports `18120/18121` are published; raw `9921` is not | -| Application service scope | `files.txt` selects only affected Core/Gateway services with `--no-deps` | -| Database preservation | Ordinary application artifacts never select `device-postgres` | -| Database bootstrap | Exact descriptor selects PostgreSQL only when both container and volume are absent | -| Bootstrap rollback | Candidate container may be removed; named volume is never removed | -| Rollback predecessor | Backup records actual pre-apply services; Compose presence cannot invent Core/Gateway | -| Failed-001 evidence | Recovery requires exact failed archive, journal and backup digests | -| Partial source | Recovery accepts only DB-bootstrap source plus the observed healthy foundation runtime | -| Recovery mutation | Source is published without build, restart, recreate or service removal | -| Recovery rollback | Failed acceptance restores source only and leaves runtime unchanged | -| Artifact policy | `.env`, secrets, runtime state, tests, logs and `node_modules` are excluded | -| Artifact reproducibility | Repeated builds for the same patch id are byte-identical | -| Runner compatibility | Existing canonical Platform registry tests remain green | -| Core/Edge trust direction | Core initiates the only private channel; VPS-initiated Synology connections are forbidden | -| Core channel authentication | TLS 1.3 mutual authentication; unknown/revoked Edge identity fails closed | -| Core channel bounds | Versioned envelopes, per-session and aggregate memory limits, keepalive and reconnect bounds | -| Pilot SLO | Zero pre-Core ACK/loss, p95/p99 acceptance latency, reconnect/dead-peer ceilings and no premature availability claim | -| Tracker acknowledgement | PACKAGE ACK is emitted only after bounded Core acceptance | -| Superseded transport freeze | Old VPS backhaul/relay/enrollment builders fail closed outside explicit test-only reconstruction | -| Superseded runner freeze | VPS runner rejects old phases and Synology runner rejects a prebuilt old enrollment artifact | -| Runtime preservation in Phase 0 | VPS, Synology, DNS, B2 routes, Gelios, Engine and Foundry remain unchanged | diff --git a/device-plane/package-lock.json b/device-plane/package-lock.json deleted file mode 100644 index 092d03f..0000000 --- a/device-plane/package-lock.json +++ /dev/null @@ -1,278 +0,0 @@ -{ - "name": "@nodedc/device-plane", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@nodedc/device-plane", - "version": "0.1.0", - "workspaces": [ - "packages/*", - "services/*" - ], - "engines": { - "node": ">=20" - } - }, - "node_modules/@nodedc/arusnavi-b2-adapter": { - "resolved": "packages/arusnavi-b2-adapter", - "link": true - }, - "node_modules/@nodedc/device-adapter-catalog": { - "resolved": "packages/device-adapter-catalog", - "link": true - }, - "node_modules/@nodedc/device-adapter-runtime": { - "resolved": "packages/device-adapter-runtime", - "link": true - }, - "node_modules/@nodedc/device-control-core": { - "resolved": "services/device-control-core", - "link": true - }, - "node_modules/@nodedc/device-edge-channel": { - "resolved": "services/device-edge-channel", - "link": true - }, - "node_modules/@nodedc/device-edge-channel-contract": { - "resolved": "packages/device-edge-channel-contract", - "link": true - }, - "node_modules/@nodedc/device-edge-relay": { - "resolved": "services/device-edge-relay", - "link": true - }, - "node_modules/@nodedc/device-gateway": { - "resolved": "services/device-gateway", - "link": true - }, - "node_modules/@nodedc/device-gateway-core": { - "resolved": "services/device-gateway-core", - "link": true - }, - "node_modules/@nodedc/device-protocol-contract": { - "resolved": "packages/device-protocol-contract", - "link": true - }, - "node_modules/pg": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", - "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", - "license": "MIT", - "dependencies": { - "pg-connection-string": "^2.14.0", - "pg-pool": "^3.14.0", - "pg-protocol": "^1.15.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.4.0" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", - "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", - "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", - "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", - "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "packages/arusnavi-b2-adapter": { - "name": "@nodedc/arusnavi-b2-adapter", - "version": "0.1.0", - "engines": { - "node": ">=20" - } - }, - "packages/device-adapter-catalog": { - "name": "@nodedc/device-adapter-catalog", - "version": "0.1.0", - "engines": { - "node": ">=20" - } - }, - "packages/device-adapter-runtime": { - "name": "@nodedc/device-adapter-runtime", - "version": "0.1.0", - "engines": { - "node": ">=20" - } - }, - "packages/device-edge-channel-contract": { - "name": "@nodedc/device-edge-channel-contract", - "version": "0.1.0", - "engines": { - "node": ">=20" - } - }, - "packages/device-protocol-contract": { - "name": "@nodedc/device-protocol-contract", - "version": "0.1.0", - "engines": { - "node": ">=20" - } - }, - "services/device-control-core": { - "name": "@nodedc/device-control-core", - "version": "0.1.0", - "dependencies": { - "pg": "^8.18.0" - }, - "engines": { - "node": ">=20" - } - }, - "services/device-edge-channel": { - "name": "@nodedc/device-edge-channel", - "version": "0.1.0", - "engines": { - "node": ">=20" - } - }, - "services/device-edge-relay": { - "name": "@nodedc/device-edge-relay", - "version": "0.1.0", - "engines": { - "node": ">=20" - } - }, - "services/device-gateway": { - "name": "@nodedc/device-gateway", - "version": "0.1.0", - "engines": { - "node": ">=20" - } - }, - "services/device-gateway-core": { - "name": "@nodedc/device-gateway-core", - "version": "0.1.0", - "engines": { - "node": ">=20" - } - } - } -} diff --git a/device-plane/package.json b/device-plane/package.json deleted file mode 100644 index 8d2f45f..0000000 --- a/device-plane/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@nodedc/device-plane", - "version": "0.1.0", - "private": true, - "type": "module", - "workspaces": [ - "packages/*", - "services/*" - ], - "scripts": { - "test": "node --test packages/*/test/*.test.mjs services/*/test/*.test.mjs" - }, - "engines": { - "node": ">=20" - } -} diff --git a/device-plane/packages/arusnavi-b2-adapter/package.json b/device-plane/packages/arusnavi-b2-adapter/package.json deleted file mode 100644 index 94df5b9..0000000 --- a/device-plane/packages/arusnavi-b2-adapter/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@nodedc/arusnavi-b2-adapter", - "version": "0.1.0", - "private": true, - "type": "module", - "exports": { - ".": "./src/index.mjs" - }, - "scripts": { - "test": "node --test test/*.test.mjs" - }, - "engines": { - "node": ">=20" - } -} diff --git a/device-plane/packages/arusnavi-b2-adapter/src/index.mjs b/device-plane/packages/arusnavi-b2-adapter/src/index.mjs deleted file mode 100644 index 8fc8c19..0000000 --- a/device-plane/packages/arusnavi-b2-adapter/src/index.mjs +++ /dev/null @@ -1,331 +0,0 @@ -import { createHash } from "node:crypto"; - -import { - DEVICE_ADAPTER_CONTRACT_VERSION, - defineDeviceAdapter, -} from "../../device-adapter-runtime/src/index.mjs"; - -export const ARUSNAVI_INTERNAL_SPECIFICATION_REF = - "arusnavi.internal.protocol-sheet.gid-12.v1"; - -const HEADER2_LENGTH = 10; -const HEADER_START = 0xff; -const HEADER2_GPRS_VERSION = 0x23; -const PACKAGE_START = 0x5b; -const PACKAGE_END = 0x5d; -const SERVER_COMMAND_START = 0x7b; -const SERVER_COMMAND_END = 0x7d; -const MIN_PACKAGE_NUMBER = 0x01; -const MAX_PACKAGE_NUMBER = 0xfb; -const PACKET_FIXED_LENGTH = 8; -const MAX_PACKET_DATA_LENGTH = 32 * 1024; -const MAX_PACKAGE_LENGTH = 64 * 1024; - -export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({ - schemaVersion: "nodedc.device-model-profile.v1", - profileRef: "arusnavi.b2.internal.v1", - vendor: "ARUSNAVI", - model: "B2", - deviceType: "tracker", - protocol: "INTERNAL", - monitoringServerSlots: 4, - serverIdentity: { - kind: "imei", - source: "modem", - trust: "claimed-not-ownership-proof", - }, - bootstrap: { - operatorSurface: "ARUSNAVI_WEB_OR_LOCAL_CONFIGURATOR", - platformCredentialRequired: false, - preserveExistingRoutes: true, - }, - framing: { - status: "verified-read-only", - specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF, - headerVersion: "HEADER2_GPRS_0x23", - headerBytes: HEADER2_LENGTH, - maxBufferedBytes: MAX_PACKAGE_LENGTH, - }, - acknowledgement: { - header: "server-time-only", - package: "package-number-only", - }, - commandTransport: { - status: "typed-service-ping-v1", - exportedCommandBuilders: 1, - }, - routeCompatibility: { - gelios: "parallel-preserved", - automaticCommandFailover: false, - }, -}); - -export const ARUSNAVI_B2_ADAPTER = defineDeviceAdapter({ - contractVersion: DEVICE_ADAPTER_CONTRACT_VERSION, - adapterRef: "arusnavi-b2", - runtimePackageRef: "@nodedc/arusnavi-b2-adapter", - profiles: [ARUSNAVI_B2_MODEL_PROFILE], - createSession({ profileRef } = {}) { - if (profileRef !== ARUSNAVI_B2_MODEL_PROFILE.profileRef) { - throw new TypeError("b2_adapter_profile_unsupported"); - } - return { - parseHeader: tryParseB2Header2, - buildHeaderAcknowledgement: buildB2HeaderAcknowledgement, - parseMessage: tryParseB2Package, - buildMessageAcknowledgement(message) { - return buildB2PackageAcknowledgement(message.packageNumber); - }, - buildTypedCommand: buildB2TypedCommand, - parseTypedCommandResponse: tryParseB2TypedCommandResponse, - }; - }, -}); - -export function tryParseB2Header2(input) { - assertBuffer(input, "b2_header_buffer_required"); - if (input.length < HEADER2_LENGTH) { - return Object.freeze({ - status: "incomplete", - minimumBytes: HEADER2_LENGTH, - }); - } - if (input[0] !== HEADER_START) { - throw new TypeError("b2_header_start_invalid"); - } - if (input[1] !== HEADER2_GPRS_VERSION) { - throw new TypeError("b2_header_version_unsupported"); - } - - const identifier = input.readBigUInt64LE(2).toString(10); - if (!/^\d{15}$/.test(identifier)) { - throw new TypeError("b2_header_imei_invalid"); - } - - return Object.freeze({ - status: "complete", - bytesConsumed: HEADER2_LENGTH, - identifier: Object.freeze({ - kind: "imei", - value: identifier, - trust: "claimed-not-ownership-proof", - }), - evidence: Object.freeze({ - transport: "tcp", - bytesObserved: HEADER2_LENGTH, - framingStatus: "verified", - specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF, - }), - }); -} - -export function buildB2HeaderAcknowledgement(unixSeconds) { - const timestamp = normalizeUInt32( - unixSeconds, - "b2_header_ack_timestamp_invalid", - ); - const commandData = Buffer.allocUnsafe(4); - commandData.writeUInt32LE(timestamp); - return Buffer.from([ - SERVER_COMMAND_START, - commandData.length, - 0x00, - checksum(commandData), - ...commandData, - SERVER_COMMAND_END, - ]); -} - -export function tryParseB2Package(input) { - assertBuffer(input, "b2_package_buffer_required"); - if (input.length === 0) { - return Object.freeze({ status: "incomplete", minimumBytes: 1 }); - } - if (input[0] !== PACKAGE_START) { - throw new TypeError("b2_package_start_invalid"); - } - if (input.length < 3) { - return Object.freeze({ status: "incomplete", minimumBytes: 3 }); - } - - const packageNumber = input[1]; - if ( - packageNumber < MIN_PACKAGE_NUMBER - || packageNumber > MAX_PACKAGE_NUMBER - ) { - throw new TypeError("b2_package_number_invalid"); - } - - let offset = 2; - let packetCount = 0; - while (true) { - if (offset >= MAX_PACKAGE_LENGTH) { - throw new TypeError("b2_package_length_exceeded"); - } - if (offset >= input.length) { - return Object.freeze({ - status: "incomplete", - minimumBytes: offset + 1, - }); - } - if (input[offset] === PACKAGE_END) { - if (packetCount === 0) { - throw new TypeError("b2_package_empty"); - } - return Object.freeze({ - status: "complete", - bytesConsumed: offset + 1, - packageNumber, - packetCount, - messageType: "telemetry.package", - payloadSchemaRef: "arusnavi.internal.package-metadata.v1", - payload: Object.freeze({ - packageNumber, - packetCount, - byteLength: offset + 1, - packageDigest: `sha256:${createHash("sha256") - .update(input.subarray(0, offset + 1)) - .digest("hex")}`, - }), - }); - } - if (input.length - offset < 3) { - return Object.freeze({ - status: "incomplete", - minimumBytes: offset + 3, - }); - } - - const dataLength = input.readUInt16LE(offset + 1); - if (dataLength > MAX_PACKET_DATA_LENGTH) { - throw new TypeError("b2_packet_data_length_exceeded"); - } - const packetLength = PACKET_FIXED_LENGTH + dataLength; - const packetEnd = offset + packetLength; - if (packetEnd + 1 > MAX_PACKAGE_LENGTH) { - throw new TypeError("b2_package_length_exceeded"); - } - if (input.length < packetEnd) { - return Object.freeze({ - status: "incomplete", - minimumBytes: packetEnd, - }); - } - - const expectedChecksum = checksum( - input.subarray(offset + 3, packetEnd - 1), - ); - if (input[packetEnd - 1] !== expectedChecksum) { - throw new TypeError("b2_packet_checksum_invalid"); - } - packetCount += 1; - offset = packetEnd; - } -} - -export function buildB2PackageAcknowledgement(packageNumber) { - const normalized = Number(packageNumber); - if ( - !Number.isSafeInteger(normalized) - || normalized < MIN_PACKAGE_NUMBER - || normalized > MAX_PACKAGE_NUMBER - ) { - throw new TypeError("b2_package_ack_number_invalid"); - } - return Buffer.from([ - SERVER_COMMAND_START, - 0x00, - normalized, - SERVER_COMMAND_END, - ]); -} - -export function buildB2TypedCommand({ commandType, accessCode } = {}) { - if (commandType !== "service.ping") { - throw new TypeError("b2_typed_command_unsupported"); - } - if (typeof accessCode !== "string" || !/^\d{6}$/.test(accessCode)) { - throw new TypeError("b2_command_access_code_invalid"); - } - return Buffer.from(`${accessCode}*SERV*1.1`, "ascii"); -} - -export function tryParseB2TypedCommandResponse(input, { commandType } = {}) { - assertBuffer(input, "b2_command_response_buffer_required"); - if (commandType !== "service.ping") { - throw new TypeError("b2_typed_command_unsupported"); - } - const expected = Buffer.from("SERV OK", "ascii"); - const compared = Math.min(input.length, expected.length); - if (!input.subarray(0, compared).equals(expected.subarray(0, compared))) { - return Object.freeze({ status: "not-command" }); - } - if (input.length < expected.length) { - return Object.freeze({ status: "incomplete", minimumBytes: expected.length }); - } - return Object.freeze({ - status: "acknowledged", - bytesConsumed: expected.length, - resultCode: "serv_ok", - }); -} - -export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) { - if (profile.monitoringServerSlots !== 4) { - throw new TypeError("b2_server_slot_count_invalid"); - } - if (profile.protocol !== "INTERNAL") { - throw new TypeError("b2_protocol_invalid"); - } - if (profile.serverIdentity.kind !== "imei") { - throw new TypeError("b2_identity_kind_invalid"); - } - if (profile.framing.status !== "verified-read-only") { - throw new TypeError("b2_framing_must_be_verified"); - } - if ( - profile.framing.specificationRef - !== ARUSNAVI_INTERNAL_SPECIFICATION_REF - ) { - throw new TypeError("b2_framing_specification_invalid"); - } - if (profile.commandTransport.status !== "typed-service-ping-v1") { - throw new TypeError("b2_command_transport_profile_invalid"); - } - if (profile.routeCompatibility.gelios !== "parallel-preserved") { - throw new TypeError("b2_gelios_route_must_be_preserved"); - } - return true; -} - -function checksum(input) { - let value = 0; - for (const byte of input) value = (value + byte) & 0xff; - return value; -} - -function normalizeUInt32(value, errorCode) { - const normalized = Number(value); - if ( - !Number.isSafeInteger(normalized) - || normalized < 0 - || normalized > 0xffffffff - ) { - throw new TypeError(errorCode); - } - return normalized; -} - -function assertBuffer(input, errorCode) { - if (!Buffer.isBuffer(input)) { - throw new TypeError(errorCode); - } -} - -function deepFreeze(value) { - if (!value || typeof value !== "object" || Object.isFrozen(value)) { - return value; - } - Object.values(value).forEach(deepFreeze); - return Object.freeze(value); -} diff --git a/device-plane/packages/arusnavi-b2-adapter/test/adapter.test.mjs b/device-plane/packages/arusnavi-b2-adapter/test/adapter.test.mjs deleted file mode 100644 index 97c988d..0000000 --- a/device-plane/packages/arusnavi-b2-adapter/test/adapter.test.mjs +++ /dev/null @@ -1,164 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - ARUSNAVI_B2_MODEL_PROFILE, - ARUSNAVI_INTERNAL_SPECIFICATION_REF, - assertB2ProfileInvariant, - buildB2HeaderAcknowledgement, - buildB2PackageAcknowledgement, - buildB2TypedCommand, - tryParseB2Header2, - tryParseB2Package, - tryParseB2TypedCommandResponse, -} from "../src/index.mjs"; - -const specificationHeader = Buffer.from( - "FF23E9EF782DE7120300", - "hex", -); -const specificationPackage = Buffer.from( - "5B01010000FBDEC251EC5D", - "hex", -); - -test("records the official B2 route, framing and identity evidence", () => { - assert.equal(assertB2ProfileInvariant(), true); - assert.equal(ARUSNAVI_B2_MODEL_PROFILE.monitoringServerSlots, 4); - assert.equal(ARUSNAVI_B2_MODEL_PROFILE.protocol, "INTERNAL"); - assert.equal(ARUSNAVI_B2_MODEL_PROFILE.serverIdentity.kind, "imei"); - assert.equal( - ARUSNAVI_B2_MODEL_PROFILE.framing.specificationRef, - ARUSNAVI_INTERNAL_SPECIFICATION_REF, - ); - assert.equal( - ARUSNAVI_B2_MODEL_PROFILE.routeCompatibility.gelios, - "parallel-preserved", - ); -}); - -test("parses the official HEADER2 example as a claimed IMEI", () => { - assert.equal( - tryParseB2Header2(specificationHeader.subarray(0, 9)).status, - "incomplete", - ); - const parsed = tryParseB2Header2(specificationHeader); - assert.equal(parsed.status, "complete"); - assert.equal(parsed.bytesConsumed, 10); - assert.equal(parsed.identifier.kind, "imei"); - assert.equal(parsed.identifier.value, "865209039777769"); - assert.equal(parsed.identifier.trust, "claimed-not-ownership-proof"); - assert.deepEqual(parsed.evidence, { - transport: "tcp", - bytesObserved: 10, - framingStatus: "verified", - specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF, - }); -}); - -test("builds the official HEADER2 acknowledgement example", () => { - assert.equal( - buildB2HeaderAcknowledgement(0x52db95de).toString("hex").toUpperCase(), - "7B0400A0DE95DB527D", - ); -}); - -test("parses and acknowledges the official package example", () => { - assert.equal( - tryParseB2Package(specificationPackage.subarray(0, -1)).status, - "incomplete", - ); - const parsed = tryParseB2Package(specificationPackage); - assert.equal(parsed.status, "complete"); - assert.equal(parsed.bytesConsumed, specificationPackage.length); - assert.equal(parsed.packageNumber, 1); - assert.equal(parsed.packetCount, 1); - assert.equal(parsed.messageType, "telemetry.package"); - assert.equal( - parsed.payloadSchemaRef, - "arusnavi.internal.package-metadata.v1", - ); - assert.deepEqual(parsed.payload, { - packageNumber: 1, - packetCount: 1, - byteLength: specificationPackage.length, - packageDigest: - "sha256:bba7205d2f613ac3dfdb7bccdee292b3837e1c8d3d1254ee68bba2dda3853e11", - }); - assert.equal( - buildB2PackageAcknowledgement(1).toString("hex").toUpperCase(), - "7B00017D", - ); -}); - -test("uses packet lengths and checksum instead of scanning for 0x5D", () => { - const packetData = Buffer.from([0x5d]); - const unixTime = Buffer.from([0x01, 0x00, 0x00, 0x00]); - const checksum = (0x01 + 0x5d) & 0xff; - const packageBytes = Buffer.from([ - 0x5b, - 0x02, - 0x01, - packetData.length, - 0x00, - ...unixTime, - ...packetData, - checksum, - 0x5d, - ]); - const parsed = tryParseB2Package(packageBytes); - assert.equal(parsed.status, "complete"); - assert.equal(parsed.bytesConsumed, packageBytes.length); - assert.equal(parsed.packageNumber, 2); - assert.equal(parsed.packetCount, 1); - assert.equal(parsed.messageType, "telemetry.package"); - assert.equal(parsed.payload.byteLength, packageBytes.length); - assert.match(parsed.payload.packageDigest, /^sha256:[a-f0-9]{64}$/); -}); - -test("fails closed on unsupported headers and malformed packages", () => { - assert.throws( - () => tryParseB2Header2(Buffer.from("FE23E9EF782DE7120300", "hex")), - /b2_header_start_invalid/, - ); - assert.throws( - () => tryParseB2Header2(Buffer.from("FF24E9EF782DE7120300", "hex")), - /b2_header_version_unsupported/, - ); - const badChecksum = Buffer.from(specificationPackage); - badChecksum[badChecksum.length - 2] ^= 0xff; - assert.throws( - () => tryParseB2Package(badChecksum), - /b2_packet_checksum_invalid/, - ); -}); - -test("exports only the typed service-ping command", () => { - assert.equal( - ARUSNAVI_B2_MODEL_PROFILE.commandTransport.status, - "typed-service-ping-v1", - ); - assert.equal( - ARUSNAVI_B2_MODEL_PROFILE.commandTransport.exportedCommandBuilders, - 1, - ); - assert.equal( - buildB2TypedCommand({ commandType: "service.ping", accessCode: "123456" }) - .toString("ascii"), - "123456*SERV*1.1", - ); - assert.deepEqual( - tryParseB2TypedCommandResponse(Buffer.from("SERV OK", "ascii"), { - commandType: "service.ping", - }), - { status: "acknowledged", bytesConsumed: 7, resultCode: "serv_ok" }, - ); - assert.throws( - () => buildB2TypedCommand({ commandType: "service.ping", accessCode: "12345" }), - /b2_command_access_code_invalid/, - ); - assert.throws( - () => buildB2TypedCommand({ commandType: "firmware.update", accessCode: "123456" }), - /b2_typed_command_unsupported/, - ); -}); diff --git a/device-plane/packages/device-adapter-catalog/package.json b/device-plane/packages/device-adapter-catalog/package.json deleted file mode 100644 index c7bddf9..0000000 --- a/device-plane/packages/device-adapter-catalog/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@nodedc/device-adapter-catalog", - "version": "0.1.0", - "private": true, - "type": "module", - "exports": { - ".": "./src/index.mjs" - }, - "scripts": { - "test": "node --test test/*.test.mjs" - }, - "engines": { - "node": ">=20" - } -} diff --git a/device-plane/packages/device-adapter-catalog/src/index.mjs b/device-plane/packages/device-adapter-catalog/src/index.mjs deleted file mode 100644 index 138cf35..0000000 --- a/device-plane/packages/device-adapter-catalog/src/index.mjs +++ /dev/null @@ -1,13 +0,0 @@ -import { - createDeviceAdapterRegistry, -} from "../../device-adapter-runtime/src/index.mjs"; -import { - ARUSNAVI_B2_ADAPTER, -} from "../../arusnavi-b2-adapter/src/index.mjs"; - -export const DEVICE_ADAPTER_CATALOG = Object.freeze({ - defaultProfileRef: "arusnavi.b2.internal.v1", - registry: createDeviceAdapterRegistry({ - adapters: [ARUSNAVI_B2_ADAPTER], - }), -}); diff --git a/device-plane/packages/device-adapter-catalog/test/catalog.test.mjs b/device-plane/packages/device-adapter-catalog/test/catalog.test.mjs deleted file mode 100644 index 8e3d897..0000000 --- a/device-plane/packages/device-adapter-catalog/test/catalog.test.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { DEVICE_ADAPTER_CATALOG } from "../src/index.mjs"; - -test("allowlists ARUSNAVI B2 as the first adapter without making Gateway vendor-specific", () => { - assert.deepEqual(DEVICE_ADAPTER_CATALOG.registry.adapterRefs, ["arusnavi-b2"]); - assert.deepEqual( - DEVICE_ADAPTER_CATALOG.registry.profileRefs, - ["arusnavi.b2.internal.v1"], - ); - const registration = DEVICE_ADAPTER_CATALOG.registry.resolveProfile( - DEVICE_ADAPTER_CATALOG.defaultProfileRef, - ); - assert.equal(registration.adapter.runtimePackageRef, "@nodedc/arusnavi-b2-adapter"); - assert.equal(registration.profile.model, "B2"); -}); diff --git a/device-plane/packages/device-adapter-runtime/package.json b/device-plane/packages/device-adapter-runtime/package.json deleted file mode 100644 index eadc701..0000000 --- a/device-plane/packages/device-adapter-runtime/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@nodedc/device-adapter-runtime", - "version": "0.1.0", - "private": true, - "type": "module", - "exports": { - ".": "./src/index.mjs" - }, - "scripts": { - "test": "node --test test/*.test.mjs" - }, - "engines": { - "node": ">=20" - } -} diff --git a/device-plane/packages/device-adapter-runtime/src/index.mjs b/device-plane/packages/device-adapter-runtime/src/index.mjs deleted file mode 100644 index 9c99dc2..0000000 --- a/device-plane/packages/device-adapter-runtime/src/index.mjs +++ /dev/null @@ -1,151 +0,0 @@ -export const DEVICE_ADAPTER_CONTRACT_VERSION = - "nodedc.device-adapter.v1"; - -const ADAPTER_REF_RE = /^[a-z][a-z0-9-]{1,62}$/; -const PROFILE_REF_RE = /^[a-z][a-z0-9._-]{2,127}$/; -const RUNTIME_PACKAGE_REF_RE = /^@[a-z0-9-]+\/[a-z0-9-]+$/; - -export function defineDeviceAdapter(input) { - assertPlainObject(input, "device_adapter"); - if (input.contractVersion !== DEVICE_ADAPTER_CONTRACT_VERSION) { - throw new TypeError("device_adapter_contract_version_invalid"); - } - const adapterRef = normalizeRef( - input.adapterRef, - ADAPTER_REF_RE, - "device_adapter_ref_invalid", - ); - const runtimePackageRef = normalizeRef( - input.runtimePackageRef, - RUNTIME_PACKAGE_REF_RE, - "device_adapter_runtime_package_ref_invalid", - ); - if (!Array.isArray(input.profiles) || input.profiles.length === 0) { - throw new TypeError("device_adapter_profiles_required"); - } - const profiles = input.profiles.map((profile) => - normalizeProfile(profile, adapterRef) - ); - if (new Set(profiles.map((profile) => profile.profileRef)).size !== profiles.length) { - throw new TypeError("device_adapter_profile_duplicate"); - } - if (typeof input.createSession !== "function") { - throw new TypeError("device_adapter_session_factory_required"); - } - - return deepFreeze({ - contractVersion: DEVICE_ADAPTER_CONTRACT_VERSION, - adapterRef, - runtimePackageRef, - profiles, - createSession: input.createSession, - }); -} - -export function createDeviceAdapterRegistry({ adapters = [] } = {}) { - if (!Array.isArray(adapters)) { - throw new TypeError("device_adapter_registry_adapters_invalid"); - } - const byAdapterRef = new Map(); - const byProfileRef = new Map(); - for (const adapter of adapters) { - const normalized = defineDeviceAdapter(adapter); - if (byAdapterRef.has(normalized.adapterRef)) { - throw new TypeError("device_adapter_registry_adapter_duplicate"); - } - byAdapterRef.set(normalized.adapterRef, normalized); - for (const profile of normalized.profiles) { - if (byProfileRef.has(profile.profileRef)) { - throw new TypeError("device_adapter_registry_profile_duplicate"); - } - byProfileRef.set(profile.profileRef, Object.freeze({ - adapter: normalized, - profile, - })); - } - } - - return Object.freeze({ - adapterRefs: Object.freeze([...byAdapterRef.keys()].sort()), - profileRefs: Object.freeze([...byProfileRef.keys()].sort()), - getAdapter(adapterRef) { - const normalized = normalizeRef( - adapterRef, - ADAPTER_REF_RE, - "device_adapter_ref_invalid", - ); - const adapter = byAdapterRef.get(normalized); - if (!adapter) throw new TypeError("device_adapter_not_allowlisted"); - return adapter; - }, - resolveProfile(profileRef) { - const normalized = normalizeRef( - profileRef, - PROFILE_REF_RE, - "device_adapter_profile_ref_invalid", - ); - const registration = byProfileRef.get(normalized); - if (!registration) { - throw new TypeError("device_adapter_profile_not_allowlisted"); - } - return registration; - }, - }); -} - -export function assertDeviceAdapterSession(session) { - assertPlainObject(session, "device_adapter_session"); - for (const method of [ - "parseHeader", - "buildHeaderAcknowledgement", - "parseMessage", - "buildMessageAcknowledgement", - ]) { - if (typeof session[method] !== "function") { - throw new TypeError(`device_adapter_session_method_missing:${method}`); - } - } - return session; -} - -function normalizeProfile(profile, adapterRef) { - assertPlainObject(profile, "device_adapter_profile"); - const profileRef = normalizeRef( - profile.profileRef, - PROFILE_REF_RE, - "device_adapter_profile_ref_invalid", - ); - if (profile.adapterRef != null && profile.adapterRef !== adapterRef) { - throw new TypeError("device_adapter_profile_adapter_mismatch"); - } - const maxBufferedBytes = Number(profile?.framing?.maxBufferedBytes); - if ( - !Number.isSafeInteger(maxBufferedBytes) - || maxBufferedBytes < 1024 - || maxBufferedBytes > 256 * 1024 - ) { - throw new TypeError("device_adapter_profile_buffer_limit_invalid"); - } - return deepFreeze({ ...profile, profileRef, adapterRef }); -} - -function normalizeRef(value, pattern, errorCode) { - if (typeof value !== "string" || !pattern.test(value)) { - throw new TypeError(errorCode); - } - return value; -} - -function assertPlainObject(value, label) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new TypeError(`${label}_invalid`); - } -} - -function deepFreeze(value) { - if (!value || typeof value !== "object" || Object.isFrozen(value)) { - return value; - } - Object.values(value).forEach(deepFreeze); - return Object.freeze(value); -} diff --git a/device-plane/packages/device-adapter-runtime/test/registry.test.mjs b/device-plane/packages/device-adapter-runtime/test/registry.test.mjs deleted file mode 100644 index eef97d9..0000000 --- a/device-plane/packages/device-adapter-runtime/test/registry.test.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - DEVICE_ADAPTER_CONTRACT_VERSION, - createDeviceAdapterRegistry, - defineDeviceAdapter, -} from "../src/index.mjs"; - -function adapter(adapterRef = "generic-tracker", profileRef = "generic.tracker.v1") { - return { - contractVersion: DEVICE_ADAPTER_CONTRACT_VERSION, - adapterRef, - runtimePackageRef: `@nodedc/${adapterRef}-adapter`, - profiles: [{ - profileRef, - framing: { maxBufferedBytes: 64 * 1024 }, - }], - createSession: () => ({}), - }; -} - -test("resolves only explicitly allowlisted adapter profiles", () => { - const registry = createDeviceAdapterRegistry({ - adapters: [adapter()], - }); - assert.deepEqual(registry.adapterRefs, ["generic-tracker"]); - assert.deepEqual(registry.profileRefs, ["generic.tracker.v1"]); - assert.equal( - registry.resolveProfile("generic.tracker.v1").adapter.adapterRef, - "generic-tracker", - ); - assert.throws( - () => registry.resolveProfile("unknown.tracker.v1"), - /device_adapter_profile_not_allowlisted/, - ); -}); - -test("rejects duplicate adapters and cross-adapter profile collisions", () => { - assert.throws( - () => createDeviceAdapterRegistry({ adapters: [adapter(), adapter()] }), - /device_adapter_registry_adapter_duplicate/, - ); - assert.throws( - () => createDeviceAdapterRegistry({ - adapters: [ - adapter("generic-tracker", "shared.profile.v1"), - adapter("other-tracker", "shared.profile.v1"), - ], - }), - /device_adapter_registry_profile_duplicate/, - ); -}); - -test("freezes adapter metadata but keeps the session factory callable", () => { - const defined = defineDeviceAdapter(adapter()); - assert.equal(Object.isFrozen(defined), true); - assert.equal(Object.isFrozen(defined.profiles[0]), true); - assert.equal(typeof defined.createSession, "function"); -}); diff --git a/device-plane/packages/device-edge-channel-contract/package.json b/device-plane/packages/device-edge-channel-contract/package.json deleted file mode 100644 index 4b8c29d..0000000 --- a/device-plane/packages/device-edge-channel-contract/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@nodedc/device-edge-channel-contract", - "version": "0.1.0", - "private": true, - "type": "module", - "exports": { - ".": "./src/index.mjs" - }, - "engines": { - "node": ">=20" - } -} diff --git a/device-plane/packages/device-edge-channel-contract/src/index.mjs b/device-plane/packages/device-edge-channel-contract/src/index.mjs deleted file mode 100644 index 5a855cf..0000000 --- a/device-plane/packages/device-edge-channel-contract/src/index.mjs +++ /dev/null @@ -1,387 +0,0 @@ -export const DEVICE_EDGE_CHANNEL_SCHEMA = - "nodedc.device-edge.channel-envelope.v1"; - -export const DEVICE_EDGE_CHANNEL_PATH = - "/internal/v1/device-edge/channel"; - -export const DEVICE_EDGE_CHANNEL_LIMITS = Object.freeze({ - maxEnvelopeBytes: 1024 * 1024, - maxPendingAcceptances: 128, - keepaliveMs: 15_000, - deadPeerMs: 45_000, - reconnectMinimumMs: 1_000, - reconnectMaximumMs: 30_000, -}); - -export const EDGE_TO_CORE_MESSAGE_KINDS = Object.freeze([ - "channel.hello", - "channel.heartbeat", - "tracker.session-opened", - "tracker.session-closed", - "discovery.observed", - "adapter.message", - "delivery.acknowledged", - "command.status", - "channel.counters", -]); - -export const CORE_TO_EDGE_MESSAGE_KINDS = Object.freeze([ - "channel.accepted", - "channel.heartbeat", - "flow.window", - "session.disposition", - "event.accepted", - "event.rejected", -]); - -const OPAQUE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; -const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; -const forbiddenKeyFragments = Object.freeze([ - "password", - "secret", - "credential", - "authorization", - "privatekey", - "bearertoken", -]); - -export function normalizeChannelEnvelope(input, options = {}) { - assertPlainObject(input, "device_edge_channel_envelope"); - const direction = normalizeDirection(options.direction); - const maxEnvelopeBytes = normalizeLimit( - options.maxEnvelopeBytes, - DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes, - ); - const allowedKeys = new Set([ - "schemaVersion", - "edgeRegistrationId", - "channelGeneration", - "trackerSessionId", - "adapterProfileRef", - "sequence", - "eventAt", - "receivedAt", - "payloadBytes", - "messageKind", - "correlationId", - "payload", - ]); - rejectUnexpectedKeys(input, allowedKeys); - rejectForbiddenKeys(input); - - if (input.schemaVersion !== DEVICE_EDGE_CHANNEL_SCHEMA) { - throw new TypeError("device_edge_channel_schema_invalid"); - } - const allowedKinds = direction === "edge-to-core" - ? EDGE_TO_CORE_MESSAGE_KINDS - : CORE_TO_EDGE_MESSAGE_KINDS; - if (!allowedKinds.includes(input.messageKind)) { - throw new TypeError("device_edge_channel_message_kind_invalid"); - } - const sequence = Number(input.sequence); - if (!Number.isSafeInteger(sequence) || sequence < 1) { - throw new TypeError("device_edge_channel_sequence_invalid"); - } - assertJsonValue(input.payload, 0); - const payload = cloneJsonValue(input.payload); - const payloadBytes = Buffer.byteLength(JSON.stringify(payload), "utf8"); - if (payloadBytes > maxEnvelopeBytes) { - throw new TypeError("device_edge_channel_payload_too_large"); - } - if (Number(input.payloadBytes) !== payloadBytes) { - throw new TypeError("device_edge_channel_payload_length_mismatch"); - } - - const normalized = { - schemaVersion: DEVICE_EDGE_CHANNEL_SCHEMA, - edgeRegistrationId: normalizeRef( - input.edgeRegistrationId, - "edge_registration_id", - ), - channelGeneration: normalizeRef( - input.channelGeneration, - "channel_generation", - ), - trackerSessionId: normalizeRef( - input.trackerSessionId, - "tracker_session_id", - ), - adapterProfileRef: normalizeRef( - input.adapterProfileRef, - "adapter_profile_ref", - ), - sequence, - eventAt: normalizeTimestamp(input.eventAt, "event_at"), - receivedAt: normalizeTimestamp(input.receivedAt, "received_at"), - payloadBytes, - messageKind: input.messageKind, - correlationId: normalizeRef(input.correlationId, "correlation_id"), - payload, - }; - return deepFreeze(normalized); -} - -export function createChannelEnvelope(fields, options = {}) { - assertPlainObject(fields, "device_edge_channel_fields"); - const payload = fields.payload ?? {}; - assertJsonValue(payload, 0); - const clonedPayload = cloneJsonValue(payload); - return normalizeChannelEnvelope({ - ...fields, - schemaVersion: DEVICE_EDGE_CHANNEL_SCHEMA, - payloadBytes: Buffer.byteLength(JSON.stringify(clonedPayload), "utf8"), - payload: clonedPayload, - }, options); -} - -export function encodeChannelEnvelope(envelope, options = {}) { - const normalized = normalizeChannelEnvelope(envelope, options); - const encoded = Buffer.from(`${JSON.stringify(normalized)}\n`, "utf8"); - const maxEnvelopeBytes = normalizeLimit( - options.maxEnvelopeBytes, - DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes, - ); - if (encoded.length > maxEnvelopeBytes) { - throw new TypeError("device_edge_channel_envelope_too_large"); - } - return encoded; -} - -export function createChannelEnvelopeDecoder(options = {}) { - const direction = normalizeDirection(options.direction); - const maxEnvelopeBytes = normalizeLimit( - options.maxEnvelopeBytes, - DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes, - ); - let buffered = Buffer.alloc(0); - - return Object.freeze({ - push(chunk) { - if (!Buffer.isBuffer(chunk) && !(chunk instanceof Uint8Array)) { - throw new TypeError("device_edge_channel_chunk_invalid"); - } - let incoming = Buffer.from(chunk); - const envelopes = []; - while (incoming.length > 0) { - const newlineIndex = incoming.indexOf(0x0a); - if (newlineIndex < 0) { - if (buffered.length + incoming.length > maxEnvelopeBytes) { - throw new TypeError("device_edge_channel_envelope_too_large"); - } - buffered = buffered.length === 0 - ? incoming - : Buffer.concat([buffered, incoming]); - break; - } - if (buffered.length + newlineIndex === 0) { - throw new TypeError("device_edge_channel_envelope_empty"); - } - if (buffered.length + newlineIndex + 1 > maxEnvelopeBytes) { - throw new TypeError("device_edge_channel_envelope_too_large"); - } - const segment = incoming.subarray(0, newlineIndex); - const line = buffered.length === 0 - ? segment - : Buffer.concat([buffered, segment]); - buffered = Buffer.alloc(0); - incoming = incoming.subarray(newlineIndex + 1); - let parsed; - try { - parsed = JSON.parse(line.toString("utf8")); - } catch { - throw new TypeError("device_edge_channel_envelope_json_invalid"); - } - envelopes.push(normalizeChannelEnvelope(parsed, { - direction, - maxEnvelopeBytes, - })); - } - return envelopes; - }, - finish() { - if (buffered.length !== 0) { - throw new TypeError("device_edge_channel_envelope_truncated"); - } - }, - bufferedBytes() { - return buffered.length; - }, - }); -} - -export function normalizeCertificateFingerprint(value) { - const compact = String(value || "").replaceAll(":", "").toUpperCase(); - if (!/^[A-F0-9]{64}$/.test(compact)) { - throw new TypeError("device_edge_channel_certificate_fingerprint_invalid"); - } - return compact.match(/.{2}/g).join(":"); -} - -export function normalizeCertificateIdentities(value) { - if (!Array.isArray(value) || value.length < 1 || value.length > 2) { - throw new TypeError("device_edge_channel_certificate_identities_invalid"); - } - const generations = new Set(); - const fingerprints = new Set(); - let activeCount = 0; - const identities = value.map((identity) => { - assertPlainObject(identity, "device_edge_channel_certificate_identity"); - rejectUnexpectedKeys( - identity, - new Set(["generationRef", "fingerprint", "status"]), - ); - if (!["active", "staged"].includes(identity.status)) { - throw new TypeError("device_edge_channel_certificate_identity_status_invalid"); - } - if (identity.status === "active") activeCount += 1; - const generationRef = normalizeRef( - identity.generationRef, - "certificate_generation_ref", - ); - const fingerprint = normalizeCertificateFingerprint(identity.fingerprint); - if (generations.has(generationRef) || fingerprints.has(fingerprint)) { - throw new TypeError("device_edge_channel_certificate_identity_duplicate"); - } - generations.add(generationRef); - fingerprints.add(fingerprint); - return Object.freeze({ - generationRef, - fingerprint, - status: identity.status, - }); - }); - if (activeCount !== 1) { - throw new TypeError("device_edge_channel_active_certificate_identity_invalid"); - } - return Object.freeze(identities); -} - -export function nextReconnectDelay(attempt, options = {}) { - const normalizedAttempt = Number(attempt); - if (!Number.isSafeInteger(normalizedAttempt) || normalizedAttempt < 0) { - throw new TypeError("device_edge_channel_reconnect_attempt_invalid"); - } - const minimumMs = normalizeReconnectDuration( - options.minimumMs, - DEVICE_EDGE_CHANNEL_LIMITS.reconnectMinimumMs, - ); - const maximumMs = normalizeReconnectDuration( - options.maximumMs, - DEVICE_EDGE_CHANNEL_LIMITS.reconnectMaximumMs, - ); - if (maximumMs < minimumMs) { - throw new TypeError("device_edge_channel_reconnect_range_invalid"); - } - const random = options.random ?? Math.random; - if (typeof random !== "function") { - throw new TypeError("device_edge_channel_random_invalid"); - } - const ceiling = Math.min(maximumMs, minimumMs * (2 ** normalizedAttempt)); - const floor = Math.max(minimumMs, Math.floor(ceiling / 2)); - const sample = Number(random()); - if (!Number.isFinite(sample) || sample < 0 || sample > 1) { - throw new TypeError("device_edge_channel_random_invalid"); - } - return Math.floor(floor + ((ceiling - floor) * sample)); -} - -function normalizeReconnectDuration(value, fallback) { - const number = value == null ? fallback : Number(value); - if (!Number.isSafeInteger(number) || number < 10 || number > 120_000) { - throw new TypeError("device_edge_channel_reconnect_duration_invalid"); - } - return number; -} - -function normalizeDirection(value) { - if (!['edge-to-core', 'core-to-edge'].includes(value)) { - throw new TypeError("device_edge_channel_direction_invalid"); - } - return value; -} - -function normalizeLimit(value, fallback) { - const number = value == null ? fallback : Number(value); - if (!Number.isSafeInteger(number) || number < 256 || number > 4 * 1024 * 1024) { - throw new TypeError("device_edge_channel_limit_invalid"); - } - return number; -} - -function normalizeRef(value, field) { - if (typeof value !== "string" || !OPAQUE_REF_RE.test(value)) { - throw new TypeError(`device_edge_channel_${field}_invalid`); - } - return value; -} - -function normalizeTimestamp(value, field) { - if (typeof value !== "string" || !ISO_TIMESTAMP_RE.test(value)) { - throw new TypeError(`device_edge_channel_${field}_invalid`); - } - const date = new Date(value); - if (!Number.isFinite(date.getTime())) { - throw new TypeError(`device_edge_channel_${field}_invalid`); - } - return date.toISOString(); -} - -function assertPlainObject(value, name) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new TypeError(`${name}_invalid`); - } - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - throw new TypeError(`${name}_invalid`); - } -} - -function rejectUnexpectedKeys(value, allowedKeys) { - for (const key of Object.keys(value)) { - if (!allowedKeys.has(key)) { - throw new TypeError(`device_edge_channel_field_unexpected:${key}`); - } - } -} - -function rejectForbiddenKeys(value, depth = 0) { - if (depth > 16 || value == null || typeof value !== "object") return; - for (const [key, child] of Object.entries(value)) { - const compact = key.toLowerCase().replaceAll(/[^a-z0-9]/g, ""); - if (forbiddenKeyFragments.some((fragment) => compact.includes(fragment))) { - throw new TypeError(`device_edge_channel_forbidden_field:${key}`); - } - rejectForbiddenKeys(child, depth + 1); - } -} - -function assertJsonValue(value, depth) { - if (depth > 16) { - throw new TypeError("device_edge_channel_payload_depth_invalid"); - } - if (value == null || typeof value === "string" || typeof value === "boolean") { - return; - } - if (typeof value === "number" && Number.isFinite(value)) return; - if (Array.isArray(value)) { - for (const child of value) assertJsonValue(child, depth + 1); - return; - } - assertPlainObject(value, "device_edge_channel_payload"); - for (const child of Object.values(value)) { - assertJsonValue(child, depth + 1); - } -} - -function cloneJsonValue(value) { - return JSON.parse(JSON.stringify(value)); -} - -function deepFreeze(value) { - if (!value || typeof value !== "object" || Object.isFrozen(value)) { - return value; - } - Object.freeze(value); - for (const child of Object.values(value)) deepFreeze(child); - return value; -} diff --git a/device-plane/packages/device-edge-channel-contract/test/contract.test.mjs b/device-plane/packages/device-edge-channel-contract/test/contract.test.mjs deleted file mode 100644 index 31dc530..0000000 --- a/device-plane/packages/device-edge-channel-contract/test/contract.test.mjs +++ /dev/null @@ -1,106 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - createChannelEnvelope, - createChannelEnvelopeDecoder, - encodeChannelEnvelope, - nextReconnectDelay, - normalizeCertificateIdentities, - normalizeChannelEnvelope, -} from "../src/index.mjs"; - -const now = "2026-08-11T12:00:00.000Z"; - -function envelope(overrides = {}) { - return createChannelEnvelope({ - edgeRegistrationId: "edge:pilot-1", - channelGeneration: "generation:pilot-1", - trackerSessionId: "channel:control", - adapterProfileRef: "channel.control.v1", - sequence: 1, - eventAt: now, - receivedAt: now, - messageKind: "channel.hello", - correlationId: "correlation:hello-1", - payload: { status: "ready" }, - ...overrides, - }, { direction: "edge-to-core" }); -} - -test("round-trips a bounded versioned Edge envelope", () => { - const decoder = createChannelEnvelopeDecoder({ direction: "edge-to-core" }); - const encoded = encodeChannelEnvelope(envelope(), { - direction: "edge-to-core", - }); - const split = Math.floor(encoded.length / 2); - - assert.deepEqual(decoder.push(encoded.subarray(0, split)), []); - assert.deepEqual(decoder.push(encoded.subarray(split)), [envelope()]); - assert.equal(decoder.bufferedBytes(), 0); - decoder.finish(); -}); - -test("fails closed on unknown kinds, payload mismatches and oversized frames", () => { - const valid = envelope(); - assert.throws(() => normalizeChannelEnvelope({ - ...valid, - messageKind: "tcp.forward", - }, { direction: "edge-to-core" }), /message_kind_invalid/); - assert.throws(() => normalizeChannelEnvelope({ - ...valid, - payloadBytes: valid.payloadBytes + 1, - }, { direction: "edge-to-core" }), /payload_length_mismatch/); - - const decoder = createChannelEnvelopeDecoder({ - direction: "edge-to-core", - maxEnvelopeBytes: 256, - }); - assert.throws(() => decoder.push(Buffer.alloc(257, 0x61)), /envelope_too_large/); -}); - -test("uses bounded jittered exponential reconnect delays", () => { - assert.equal(nextReconnectDelay(0, { - minimumMs: 1000, - maximumMs: 30_000, - random: () => 1, - }), 1000); - assert.equal(nextReconnectDelay(5, { - minimumMs: 1000, - maximumMs: 30_000, - random: () => 1, - }), 30_000); - assert.equal(nextReconnectDelay(5, { - minimumMs: 1000, - maximumMs: 30_000, - random: () => 0, - }), 15_000); -}); - -test("allows exactly one active and at most one staged certificate generation", () => { - const activeFingerprint = "AA:".repeat(31) + "AA"; - const stagedFingerprint = "BB:".repeat(31) + "BB"; - const identities = normalizeCertificateIdentities([ - { - generationRef: "trust-generation:1", - fingerprint: activeFingerprint, - status: "active", - }, - { - generationRef: "trust-generation:2", - fingerprint: stagedFingerprint, - status: "staged", - }, - ]); - assert.equal(identities.length, 2); - assert.equal(identities[0].status, "active"); - assert.equal(identities[1].status, "staged"); - assert.throws(() => normalizeCertificateIdentities([ - { ...identities[0], status: "staged" }, - identities[1], - ]), /active_certificate_identity_invalid/); - assert.throws(() => normalizeCertificateIdentities([ - identities[0], - { ...identities[1], status: "active" }, - ]), /active_certificate_identity_invalid/); -}); diff --git a/device-plane/packages/device-protocol-contract/package.json b/device-plane/packages/device-protocol-contract/package.json deleted file mode 100644 index d88f5eb..0000000 --- a/device-plane/packages/device-protocol-contract/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@nodedc/device-protocol-contract", - "version": "0.1.0", - "private": true, - "type": "module", - "exports": { - ".": "./src/index.mjs" - }, - "scripts": { - "test": "node --test test/*.test.mjs" - }, - "engines": { - "node": ">=20" - } -} diff --git a/device-plane/packages/device-protocol-contract/src/index.mjs b/device-plane/packages/device-protocol-contract/src/index.mjs deleted file mode 100644 index 8c14696..0000000 --- a/device-plane/packages/device-protocol-contract/src/index.mjs +++ /dev/null @@ -1,530 +0,0 @@ -import { createHmac } from "node:crypto"; - -export const DEVICE_DISCOVERY_SIGNAL_SCHEMA = - "nodedc.device.discovery-signal.v1"; -export const DEVICE_DISCOVERY_VIEW_SCHEMA = - "nodedc.device.discovery-view.v1"; -export const DEVICE_PLANE_BINDING_SCHEMA = - "nodedc.device-plane-control.binding.v1"; -export const DEVICE_ADAPTER_MESSAGE_SCHEMA = - "nodedc.device-adapter-message.v1"; -export const DEVICE_ADAPTER_MESSAGE_VIEW_SCHEMA = - "nodedc.device-adapter-message-view.v1"; -export const DEVICE_ADAPTER_ACCEPTANCE_SCHEMA = - "nodedc.device-adapter-acceptance.v1"; - -export const DEVICE_LIFECYCLE_STATES = Object.freeze([ - "quarantine", - "claimed", - "online", - "offline", - "retired", -]); - -export const DEVICE_BINDING_CAPABILITIES = Object.freeze([ - "observe", - "inspect", - "configure", - "command", -]); - -const OPAQUE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; -const IMEI_RE = /^\d{15}$/; -const IDENTIFIER_VALUE_RE = /^[A-Za-z0-9][A-Za-z0-9._:+\/-]{3,127}$/; -const DIGEST_RE = /^hmac-sha256:[a-f0-9]{64}$/; -const SHA256_DIGEST_RE = /^sha256:[a-f0-9]{64}$/; -const IDENTIFIER_KIND_RE = /^[a-z][a-z0-9._:-]{1,63}$/; -const forbiddenKeyFragments = Object.freeze([ - "password", - "secret", - "credential", - "rawpayload", - "rawpacket", - "command", - "authorization", - "token", -]); -const safeStatusKeys = new Set([ - "commandtransport", -]); - -export function normalizeDiscoverySignal(input) { - assertPlainObject(input, "discovery_signal"); - rejectForbiddenKeys(input); - - if (input.schemaVersion !== DEVICE_DISCOVERY_SIGNAL_SCHEMA) { - throw new TypeError("discovery_signal_schema_invalid"); - } - - const sessionRef = normalizeOpaqueRef(input.sessionRef, "session_ref"); - const routeRef = input.routeRef == null - ? undefined - : normalizeEntityRef(input.routeRef, "route", "route_ref"); - const modelProfileRef = normalizeOpaqueRef( - input.modelProfileRef, - "model_profile_ref", - ); - const protocol = normalizeUpperToken(input.protocol, "protocol"); - const observedAt = normalizeTimestamp(input.observedAt, "observed_at"); - const identifier = normalizeRestrictedIdentifier(input.identifier); - const evidence = normalizeDiscoveryEvidence(input.evidence); - - return Object.freeze({ - schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA, - sessionRef, - ...(routeRef ? { routeRef } : {}), - modelProfileRef, - protocol, - observedAt, - identifier, - evidence, - lifecycleState: "quarantine", - commandTransport: "disabled", - }); -} - -export function normalizeAdapterMessage(input, { maxBytes = 1024 * 1024 } = {}) { - assertPlainObject(input, "device_adapter_message"); - const allowedKeys = new Set([ - "schemaVersion", - "edgeRef", - "adapterRef", - "protocolProfileRef", - "protocol", - "sessionRef", - "routeRef", - "messageRef", - "messageType", - "sequence", - "observedAt", - "idempotencyKey", - "identifier", - "payloadSchemaRef", - "payload", - ]); - rejectUnexpectedKeys(input, allowedKeys, "device_adapter_message_field_unexpected"); - rejectForbiddenKeys(input); - if (input.schemaVersion !== DEVICE_ADAPTER_MESSAGE_SCHEMA) { - throw new TypeError("device_adapter_message_schema_invalid"); - } - const normalizedMaxBytes = normalizeByteLimit(maxBytes); - const serializedBytes = Buffer.byteLength(JSON.stringify(input), "utf8"); - if (serializedBytes > normalizedMaxBytes) { - throw new TypeError("device_adapter_message_too_large"); - } - const sequence = Number(input.sequence); - if (!Number.isSafeInteger(sequence) || sequence < 1) { - throw new TypeError("device_adapter_message_sequence_invalid"); - } - assertPlainObject(input.payload, "device_adapter_message_payload"); - assertJsonValue(input.payload, 0); - - return deepFreeze({ - schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA, - edgeRef: normalizeOpaqueRef(input.edgeRef, "edge_ref"), - adapterRef: normalizeAdapterRef(input.adapterRef), - protocolProfileRef: normalizeOpaqueRef( - input.protocolProfileRef, - "protocol_profile_ref", - ), - protocol: normalizeUpperToken(input.protocol, "protocol"), - sessionRef: normalizeOpaqueRef(input.sessionRef, "session_ref"), - ...(input.routeRef == null - ? {} - : { routeRef: normalizeEntityRef(input.routeRef, "route", "route_ref") }), - messageRef: normalizeOpaqueRef(input.messageRef, "message_ref"), - messageType: normalizeLowerToken(input.messageType, "message_type"), - sequence, - observedAt: normalizeTimestamp(input.observedAt, "observed_at"), - idempotencyKey: normalizeSha256Digest( - input.idempotencyKey, - "idempotency_key", - ), - identifier: normalizeRestrictedIdentifier(input.identifier), - payloadSchemaRef: normalizeOpaqueRef( - input.payloadSchemaRef, - "payload_schema_ref", - ), - payload: cloneJsonValue(input.payload), - }); -} - -export function toSafeAdapterMessageView(message) { - const normalized = normalizeAdapterMessage(message); - return deepFreeze({ - schemaVersion: DEVICE_ADAPTER_MESSAGE_VIEW_SCHEMA, - edgeRef: normalized.edgeRef, - adapterRef: normalized.adapterRef, - protocolProfileRef: normalized.protocolProfileRef, - protocol: normalized.protocol, - sessionRef: normalized.sessionRef, - ...(normalized.routeRef ? { routeRef: normalized.routeRef } : {}), - messageRef: normalized.messageRef, - messageType: normalized.messageType, - sequence: normalized.sequence, - observedAt: normalized.observedAt, - idempotencyKey: normalized.idempotencyKey, - identifier: normalizeRestrictedIdentifierProjection({ - kind: normalized.identifier.kind, - masked: maskRestrictedIdentifier(normalized.identifier), - }), - payloadSchemaRef: normalized.payloadSchemaRef, - payload: normalized.payload, - }); -} - -export function normalizeAdapterAcceptance(input) { - assertPlainObject(input, "device_adapter_acceptance"); - const allowedKeys = new Set([ - "schemaVersion", - "acceptanceRef", - "idempotencyKey", - "status", - "replayed", - "acceptedAt", - ]); - rejectUnexpectedKeys(input, allowedKeys, "device_adapter_acceptance_field_unexpected"); - if (input.schemaVersion !== DEVICE_ADAPTER_ACCEPTANCE_SCHEMA) { - throw new TypeError("device_adapter_acceptance_schema_invalid"); - } - if (input.status !== "accepted") { - throw new TypeError("device_adapter_acceptance_status_invalid"); - } - if (typeof input.replayed !== "boolean") { - throw new TypeError("device_adapter_acceptance_replayed_invalid"); - } - return Object.freeze({ - schemaVersion: DEVICE_ADAPTER_ACCEPTANCE_SCHEMA, - acceptanceRef: normalizeOpaqueRef(input.acceptanceRef, "acceptance_ref"), - idempotencyKey: normalizeSha256Digest( - input.idempotencyKey, - "idempotency_key", - ), - status: "accepted", - replayed: input.replayed, - acceptedAt: normalizeTimestamp(input.acceptedAt, "accepted_at"), - }); -} - -export function toSafeDiscoveryView(signal, options = {}) { - const normalized = normalizeDiscoverySignal(signal); - const discoveryRef = options.discoveryRef - ? normalizeOpaqueRef(options.discoveryRef, "discovery_ref") - : undefined; - - return Object.freeze({ - schemaVersion: DEVICE_DISCOVERY_VIEW_SCHEMA, - ...(discoveryRef ? { discoveryRef } : {}), - ...(normalized.routeRef ? { routeRef: normalized.routeRef } : {}), - modelProfileRef: normalized.modelProfileRef, - protocol: normalized.protocol, - observedAt: normalized.observedAt, - lifecycleState: normalized.lifecycleState, - identifier: normalizeRestrictedIdentifierProjection({ - kind: normalized.identifier.kind, - masked: maskRestrictedIdentifier(normalized.identifier), - }), - evidence: normalized.evidence, - commandTransport: "disabled", - }); -} - -export function hashRestrictedIdentifier(identifier, pepper) { - const normalized = normalizeRestrictedIdentifier(identifier); - if (typeof pepper !== "string" || pepper.length < 32) { - throw new TypeError("identifier_pepper_invalid"); - } - - const digest = createHmac("sha256", pepper) - .update(`${normalized.kind}\0${normalized.value}`, "utf8") - .digest("hex"); - return `hmac-sha256:${digest}`; -} - -export function assertIdentifierDigest(value) { - if (typeof value !== "string" || !DIGEST_RE.test(value)) { - throw new TypeError("identifier_digest_invalid"); - } - return value; -} - -export function normalizeRestrictedIdentifierProjection(input) { - assertPlainObject(input, "restricted_identifier_projection"); - const allowedKeys = new Set(["kind", "masked"]); - for (const key of Object.keys(input)) { - if (!allowedKeys.has(key)) { - throw new TypeError( - `restricted_identifier_projection_field_unexpected:${key}`, - ); - } - } - if (typeof input.kind !== "string" || !IDENTIFIER_KIND_RE.test(input.kind)) { - throw new TypeError("restricted_identifier_projection_kind_invalid"); - } - if ( - typeof input.masked !== "string" - || input.masked.length < 5 - || input.masked.length > 128 - || !input.masked.includes("*") - || /\u0000|[\u0001-\u001f\u007f]/.test(input.masked) - || /\b\d{15}\b/.test(input.masked) - ) { - throw new TypeError("restricted_identifier_projection_mask_invalid"); - } - return Object.freeze({ - kind: input.kind, - masked: input.masked, - }); -} - -export function normalizeRestrictedIdentifierRecord(input) { - assertPlainObject(input, "restricted_identifier_record"); - const allowedKeys = new Set(["kind", "digest", "masked"]); - for (const key of Object.keys(input)) { - if (!allowedKeys.has(key)) { - throw new TypeError( - `restricted_identifier_record_field_unexpected:${key}`, - ); - } - } - const projection = normalizeRestrictedIdentifierProjection({ - kind: input.kind, - masked: input.masked, - }); - return Object.freeze({ - ...projection, - digest: assertIdentifierDigest(input.digest), - }); -} - -export function normalizeDevicePlaneBinding(input) { - assertPlainObject(input, "device_plane_binding"); - rejectForbiddenKeys(input); - if (input.schemaVersion !== DEVICE_PLANE_BINDING_SCHEMA) { - throw new TypeError("device_plane_binding_schema_invalid"); - } - - const allowed = new Set(DEVICE_BINDING_CAPABILITIES); - if (!Array.isArray(input.capabilities) || input.capabilities.length === 0) { - throw new TypeError("device_plane_binding_capabilities_invalid"); - } - const capabilities = [...new Set(input.capabilities.map((value) => { - if (typeof value !== "string" || !allowed.has(value)) { - throw new TypeError("device_plane_binding_capability_invalid"); - } - return value; - }))].sort(); - - return Object.freeze({ - schemaVersion: DEVICE_PLANE_BINDING_SCHEMA, - bindingRef: normalizeOpaqueRef(input.bindingRef, "binding_ref"), - contourRef: normalizeOpaqueRef(input.contourRef, "contour_ref"), - capabilities: Object.freeze(capabilities), - }); -} - -export function assertSafeProjection(value) { - assertPlainObject(value, "safe_projection"); - rejectForbiddenKeys(value); - const serialized = JSON.stringify(value); - if (/\b\d{15}\b/.test(serialized)) { - throw new TypeError("safe_projection_contains_unmasked_imei"); - } - return value; -} - -export function normalizeRestrictedIdentifier(input) { - assertPlainObject(input, "restricted_identifier"); - rejectUnexpectedKeys( - input, - new Set(["kind", "value"]), - "restricted_identifier_field_unexpected", - ); - if (typeof input.kind !== "string" || !IDENTIFIER_KIND_RE.test(input.kind)) { - throw new TypeError("restricted_identifier_kind_invalid"); - } - if (input.kind === "imei" && !IMEI_RE.test(input.value)) { - throw new TypeError("restricted_identifier_imei_invalid"); - } - if ( - typeof input.value !== "string" - || !IDENTIFIER_VALUE_RE.test(input.value) - ) { - throw new TypeError("restricted_identifier_value_invalid"); - } - return Object.freeze({ kind: input.kind, value: input.value }); -} - -export function maskRestrictedIdentifier(identifier) { - const normalized = normalizeRestrictedIdentifier(identifier); - if (normalized.kind === "imei") { - return `***********${normalized.value.slice(-4)}`; - } - const visible = normalized.value.slice(-4); - const maskedLength = Math.min( - 124, - Math.max(4, normalized.value.length - visible.length), - ); - return `${"*".repeat(maskedLength)}${visible}`; -} - -function normalizeDiscoveryEvidence(input) { - assertPlainObject(input, "discovery_evidence"); - rejectForbiddenKeys(input); - if (input.transport !== "tcp") { - throw new TypeError("discovery_evidence_transport_invalid"); - } - const bytesObserved = Number(input.bytesObserved); - if (!Number.isSafeInteger(bytesObserved) || bytesObserved < 1 || bytesObserved > 4096) { - throw new TypeError("discovery_evidence_bytes_invalid"); - } - if (input.framingStatus !== "verified") { - throw new TypeError("discovery_evidence_framing_unverified"); - } - - return Object.freeze({ - transport: "tcp", - bytesObserved, - framingStatus: "verified", - specificationRef: normalizeOpaqueRef( - input.specificationRef, - "framing_specification_ref", - ), - }); -} - -function rejectForbiddenKeys(value, path = "$") { - if (Array.isArray(value)) { - value.forEach((item, index) => rejectForbiddenKeys(item, `${path}[${index}]`)); - return; - } - if (!value || typeof value !== "object") return; - - for (const [key, child] of Object.entries(value)) { - const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, ""); - if ( - !safeStatusKeys.has(normalizedKey) - && forbiddenKeyFragments.some((fragment) => normalizedKey.includes(fragment)) - ) { - throw new TypeError(`forbidden_device_field:${path}.${key}`); - } - rejectForbiddenKeys(child, `${path}.${key}`); - } -} - -function rejectUnexpectedKeys(input, allowedKeys, errorCode) { - for (const key of Object.keys(input)) { - if (!allowedKeys.has(key)) throw new TypeError(`${errorCode}:${key}`); - } -} - -function normalizeLowerToken(value, label) { - if (typeof value !== "string" || !/^[a-z][a-z0-9._-]{1,127}$/.test(value)) { - throw new TypeError(`${label}_invalid`); - } - return value; -} - -function normalizeAdapterRef(value) { - if (typeof value !== "string" || !/^[a-z][a-z0-9-]{1,62}$/.test(value)) { - throw new TypeError("adapter_ref_invalid"); - } - return value; -} - -function normalizeSha256Digest(value, label) { - if (typeof value !== "string" || !SHA256_DIGEST_RE.test(value)) { - throw new TypeError(`${label}_invalid`); - } - return value; -} - -function normalizeByteLimit(value) { - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1024 || parsed > 1024 * 1024) { - throw new TypeError("device_adapter_message_limit_invalid"); - } - return parsed; -} - -function assertJsonValue(value, depth) { - if (depth > 12) throw new TypeError("device_adapter_message_payload_too_deep"); - if (value === null || typeof value === "boolean" || typeof value === "string") { - if (typeof value === "string" && value.length > 64 * 1024) { - throw new TypeError("device_adapter_message_payload_string_too_large"); - } - return; - } - if (typeof value === "number") { - if (!Number.isFinite(value)) { - throw new TypeError("device_adapter_message_payload_number_invalid"); - } - return; - } - if (Array.isArray(value)) { - if (value.length > 4096) { - throw new TypeError("device_adapter_message_payload_array_too_large"); - } - value.forEach((item) => assertJsonValue(item, depth + 1)); - return; - } - assertPlainObject(value, "device_adapter_message_payload"); - if (Object.keys(value).length > 1024) { - throw new TypeError("device_adapter_message_payload_object_too_large"); - } - for (const child of Object.values(value)) assertJsonValue(child, depth + 1); -} - -function cloneJsonValue(value) { - return JSON.parse(JSON.stringify(value)); -} - -function normalizeOpaqueRef(value, label) { - if (typeof value !== "string" || !OPAQUE_REF_RE.test(value)) { - throw new TypeError(`${label}_invalid`); - } - return value; -} - -function normalizeEntityRef(value, prefix, label) { - if ( - typeof value !== "string" - || !new RegExp( - `^${prefix}:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, - "i", - ).test(value) - ) { - throw new TypeError(`${label}_invalid`); - } - return value.toLowerCase(); -} - -function normalizeUpperToken(value, label) { - if (typeof value !== "string" || !/^[A-Z][A-Z0-9_]{0,31}$/.test(value)) { - throw new TypeError(`${label}_invalid`); - } - return value; -} - -function normalizeTimestamp(value, label) { - if (typeof value !== "string") throw new TypeError(`${label}_invalid`); - const date = new Date(value); - if (!Number.isFinite(date.getTime()) || date.toISOString() !== value) { - throw new TypeError(`${label}_invalid`); - } - return value; -} - -function assertPlainObject(value, label) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new TypeError(`${label}_invalid`); - } -} - -function deepFreeze(value) { - if (!value || typeof value !== "object" || Object.isFrozen(value)) { - return value; - } - Object.values(value).forEach(deepFreeze); - return Object.freeze(value); -} diff --git a/device-plane/packages/device-protocol-contract/test/contract.test.mjs b/device-plane/packages/device-protocol-contract/test/contract.test.mjs deleted file mode 100644 index 286fc9b..0000000 --- a/device-plane/packages/device-protocol-contract/test/contract.test.mjs +++ /dev/null @@ -1,265 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - DEVICE_ADAPTER_ACCEPTANCE_SCHEMA, - DEVICE_ADAPTER_MESSAGE_SCHEMA, - DEVICE_DISCOVERY_SIGNAL_SCHEMA, - DEVICE_PLANE_BINDING_SCHEMA, - assertIdentifierDigest, - assertSafeProjection, - hashRestrictedIdentifier, - maskRestrictedIdentifier, - normalizeDevicePlaneBinding, - normalizeAdapterAcceptance, - normalizeAdapterMessage, - normalizeDiscoverySignal, - normalizeRestrictedIdentifier, - normalizeRestrictedIdentifierProjection, - normalizeRestrictedIdentifierRecord, - toSafeDiscoveryView, - toSafeAdapterMessageView, -} from "../src/index.mjs"; - -const fakeImei = "000000000000001"; -const fakeSignal = { - schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA, - sessionRef: "session:test-001", - modelProfileRef: "arusnavi.b2.internal.v1", - protocol: "INTERNAL", - observedAt: "2026-07-25T00:00:00.000Z", - identifier: { - kind: "imei", - value: fakeImei, - }, - evidence: { - transport: "tcp", - bytesObserved: 128, - framingStatus: "verified", - specificationRef: "arusnavi.internal.framing.test-v1", - }, -}; - -test("normalizes a verified discovery into quarantine with commands disabled", () => { - const signal = normalizeDiscoverySignal(fakeSignal); - assert.equal(signal.lifecycleState, "quarantine"); - assert.equal(signal.commandTransport, "disabled"); - assert.equal(signal.identifier.value, fakeImei); -}); - -test("safe discovery projection masks the restricted identifier", () => { - const view = toSafeDiscoveryView(fakeSignal, { - discoveryRef: "discovery:test-001", - }); - const serialized = JSON.stringify(view); - assert.equal(view.identifier.masked, "***********0001"); - assert.equal(serialized.includes(fakeImei), false); - assertSafeProjection(view); -}); - -test("route-bound discovery preserves only a validated opaque route reference", () => { - const routeRef = "route:11111111-1111-4111-8111-111111111111"; - const signal = normalizeDiscoverySignal({ ...fakeSignal, routeRef }); - const view = toSafeDiscoveryView(signal); - - assert.equal(signal.routeRef, routeRef); - assert.equal(view.routeRef, routeRef); - assert.throws( - () => normalizeDiscoverySignal({ ...fakeSignal, routeRef: "route:generic" }), - /route_ref_invalid/, - ); -}); - -test("identifier hashing requires a strong process-only pepper", () => { - const identifier = { kind: "imei", value: fakeImei }; - assert.throws( - () => hashRestrictedIdentifier(identifier, "short"), - /identifier_pepper_invalid/, - ); - const digest = hashRestrictedIdentifier( - identifier, - "test-only-pepper-with-at-least-32-bytes", - ); - assertIdentifierDigest(digest); - assert.equal(digest.includes(fakeImei), false); - assert.equal( - digest, - hashRestrictedIdentifier( - identifier, - "test-only-pepper-with-at-least-32-bytes", - ), - ); -}); - -test("restricted identifier records keep digest internal and expose only a mask", () => { - const record = normalizeRestrictedIdentifierRecord({ - kind: "vendor.serial", - digest: `hmac-sha256:${"a".repeat(64)}`, - masked: "********ABCD", - }); - const projection = normalizeRestrictedIdentifierProjection({ - kind: record.kind, - masked: record.masked, - }); - - assert.deepEqual(projection, { - kind: "vendor.serial", - masked: "********ABCD", - }); - assert.equal("digest" in projection, false); - assertSafeProjection({ identifier: projection }); - assert.throws( - () => normalizeRestrictedIdentifierProjection({ - kind: "vendor.serial", - masked: "SERIAL-PLAINTEXT", - }), - /restricted_identifier_projection_mask_invalid/, - ); -}); - -test("restricted identifiers support future adapter-defined hardware ids", () => { - const identifier = normalizeRestrictedIdentifier({ - kind: "serial", - value: "SN-TRACKER-0001", - }); - assert.deepEqual(identifier, { - kind: "serial", - value: "SN-TRACKER-0001", - }); - assert.equal(maskRestrictedIdentifier(identifier), "***********0001"); - assert.match( - hashRestrictedIdentifier( - identifier, - "test-only-pepper-with-at-least-32-bytes", - ), - /^hmac-sha256:[a-f0-9]{64}$/, - ); -}); - -test("rejects unverified framing and command-shaped discovery input", () => { - assert.throws( - () => normalizeDiscoverySignal({ - ...fakeSignal, - evidence: { ...fakeSignal.evidence, framingStatus: "unverified" }, - }), - /discovery_evidence_framing_unverified/, - ); - assert.throws( - () => normalizeDiscoverySignal({ - ...fakeSignal, - command: { kind: "restart" }, - }), - /forbidden_device_field/, - ); -}); - -test("rejects secret-like fields recursively", () => { - assert.throws( - () => normalizeDiscoverySignal({ - ...fakeSignal, - metadata: { devicePassword: "not-a-real-password" }, - }), - /forbidden_device_field/, - ); -}); - -test("normalizes an opaque Foundry control binding without device data", () => { - const binding = normalizeDevicePlaneBinding({ - schemaVersion: DEVICE_PLANE_BINDING_SCHEMA, - bindingRef: "binding:test-001", - contourRef: "contour:robot2b-test", - capabilities: ["inspect", "observe", "observe"], - }); - assert.deepEqual(binding.capabilities, ["inspect", "observe"]); - assertSafeProjection(binding); -}); - -test("normalizes a bounded typed adapter message and masks its identity", () => { - const message = { - schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA, - edgeRef: "edge:robot2b-vps-001", - adapterRef: "arusnavi-b2", - protocolProfileRef: "arusnavi.b2.internal.v1", - protocol: "INTERNAL", - sessionRef: "session:test-001", - routeRef: "route:11111111-1111-4111-8111-111111111111", - messageRef: "package:1:abc123", - messageType: "telemetry.package", - sequence: 1, - observedAt: "2026-08-11T12:00:00.000Z", - idempotencyKey: `sha256:${"a".repeat(64)}`, - identifier: { kind: "imei", value: fakeImei }, - payloadSchemaRef: "arusnavi.internal.package-metadata.v1", - payload: { - packageNumber: 1, - packetCount: 1, - packageDigest: `sha256:${"b".repeat(64)}`, - }, - }; - const normalized = normalizeAdapterMessage(message); - const safe = toSafeAdapterMessageView(normalized); - assert.equal(normalized.identifier.value, fakeImei); - assert.equal(safe.identifier.masked, "***********0001"); - assert.equal(JSON.stringify(safe).includes(fakeImei), false); - assertSafeProjection(safe); -}); - -test("rejects oversized, untyped and secret-shaped adapter messages", () => { - const base = { - schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA, - edgeRef: "edge:test", - adapterRef: "generic-tracker", - protocolProfileRef: "generic.tracker.v1", - protocol: "GENERIC", - sessionRef: "session:test", - messageRef: "message:1", - messageType: "telemetry.sample", - sequence: 1, - observedAt: "2026-08-11T12:00:00.000Z", - idempotencyKey: `sha256:${"a".repeat(64)}`, - identifier: { kind: "imei", value: fakeImei }, - payloadSchemaRef: "generic.telemetry.v1", - payload: { value: 1 }, - }; - assert.throws( - () => normalizeAdapterMessage({ ...base, payload: "raw" }), - /device_adapter_message_payload_invalid/, - ); - assert.throws( - () => normalizeAdapterMessage({ - ...base, - payload: { devicePassword: "forbidden" }, - }), - /forbidden_device_field/, - ); - assert.throws( - () => normalizeAdapterMessage({ - ...base, - payload: { value: "x".repeat(4096) }, - }, { maxBytes: 1024 }), - /device_adapter_message_too_large/, - ); - assert.throws( - () => normalizeAdapterMessage({ - ...base, - idempotencyKey: "message-not-a-digest", - }), - /idempotency_key_invalid/, - ); -}); - -test("accepts only an explicit durable Core acceptance contract", () => { - const acceptance = normalizeAdapterAcceptance({ - schemaVersion: DEVICE_ADAPTER_ACCEPTANCE_SCHEMA, - acceptanceRef: "acceptance:test-001", - idempotencyKey: `sha256:${"a".repeat(64)}`, - status: "accepted", - replayed: false, - acceptedAt: "2026-08-11T12:00:00.000Z", - }); - assert.equal(acceptance.status, "accepted"); - assert.throws( - () => normalizeAdapterAcceptance({ ...acceptance, status: "queued" }), - /device_adapter_acceptance_status_invalid/, - ); -}); diff --git a/device-plane/packages/device-protocol-contract/test/core-edge-channel-contract.test.mjs b/device-plane/packages/device-protocol-contract/test/core-edge-channel-contract.test.mjs deleted file mode 100644 index d9f80fc..0000000 --- a/device-plane/packages/device-protocol-contract/test/core-edge-channel-contract.test.mjs +++ /dev/null @@ -1,123 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { readFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; -import test from "node:test"; - -const contractUrl = new URL( - "../../../deployment/device-edge-core-channel-v1.json", - import.meta.url, -); -const freezeUrl = new URL( - "../../../deployment/superseded-vps-initiated-transport-v1.json", - import.meta.url, -); -const sourceAcceptanceUrl = new URL( - "../../../deployment/device-edge-core-channel-source-v1.json", - import.meta.url, -); -const edgeBuilder = fileURLToPath(new URL( - "../../../../infra/deploy-runner/build-device-edge-vps-artifact.mjs", - import.meta.url, -)); -const enrollmentBuilder = fileURLToPath(new URL( - "../../../../infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs", - import.meta.url, -)); - -async function readJson(url) { - return JSON.parse(await readFile(url, "utf8")); -} - -test("pins a Core-initiated mutually authenticated Edge channel", async () => { - const contract = await readJson(contractUrl); - - assert.equal(contract.status, "accepted-design"); - assert.equal(contract.direction, "device-gateway-core-initiated"); - assert.equal(contract.transport.tls, "TLSv1.3-mutual-authentication"); - assert.equal(contract.transport.genericTcpForwarding, "forbidden"); - assert.equal(contract.networkBoundary.synologyPublicIngress, false); - assert.equal(contract.networkBoundary.vpsInitiatedSynologyConnection, false); - assert.equal(contract.networkBoundary.subnetRoutes, false); - assert.equal(contract.networkBoundary.exitNode, false); - assert.equal(contract.identity.privateKeysInArtifacts, false); -}); - -test("requires Core acceptance before acknowledging tracker packages", async () => { - const contract = await readJson(contractUrl); - - assert.equal( - contract.acknowledgement.trackerPackageAck, - "only-after-bounded-core-acceptance", - ); - assert.equal( - contract.acknowledgement.coreUnavailable, - "do-not-acknowledge-tracker-package", - ); - assert.equal(contract.acknowledgement.deliverySemantics, "at-least-once"); - assert.equal(contract.pilotLimits.durableEdgeSpool, false); - assert.ok(contract.pilotLimits.maxBufferedBytesPerTrackerSession <= 262144); - assert.ok(contract.pilotLimits.maxAggregateBufferedBytes <= 33554432); - assert.equal(contract.pilotSlo.trackerAckBeforeDurableCoreAcceptance, 0); - assert.equal(contract.pilotSlo.lossOfCoreAcceptedPackages, 0); - assert.ok( - contract.pilotSlo.edgeReceiveToCoreAcceptanceP99Milliseconds <= 5000, - ); - assert.ok(contract.pilotSlo.deadCoreDetectionHardCeilingSeconds <= 45); -}); - -test("records source acceptance without opening an Edge or tracker port", async () => { - const acceptance = await readJson(sourceAcceptanceUrl); - - assert.equal(acceptance.status, "source-accepted"); - assert.equal(acceptance.transport.initiator, "device-gateway-core"); - assert.equal(acceptance.transport.tls, "TLSv1.3-mutual-authentication"); - assert.equal(acceptance.identity.privateKeysInSource, false); - assert.equal(acceptance.identity.privateKeysInArtifact, false); - assert.equal( - acceptance.identity.rotation, - "one-active-plus-one-staged-generation", - ); - assert.equal(acceptance.identity.retiredFingerprint, "reject"); - assert.equal(acceptance.runtime.mutationInThisTransition, false); - assert.equal(acceptance.runtime.edgePort8443Published, false); - assert.equal(acceptance.runtime.trackerPort9921Published, false); - assert.equal(acceptance.runtime.synologyPublicIngress, false); - assert.equal(acceptance.runtime.commandTransport, "disabled"); - assert.equal(acceptance.runtime.gelios, "untouched"); -}); - -test("freezes the VPS-initiated Tailscale and SSH backhaul", async () => { - const freeze = await readJson(freezeUrl); - - assert.equal(freeze.status, "frozen"); - assert.equal(freeze.successor, "nodedc.device-edge.core-channel.v1"); - assert.equal(freeze.runtimeMutationInPhase0, false); - assert.ok(freeze.forbiddenForNewPlanOrApply.includes( - "nodedc.device-plane.backhaul-vps-enrollment.v1", - )); - assert.ok(freeze.forbiddenForNewPlanOrApply.includes( - "tailscale-userspace-key-only-ssh-local-forward", - )); -}); - -test("superseded artifact builders fail closed outside test-only reconstruction", () => { - const environment = { ...process.env }; - delete environment.NODEDC_ALLOW_SUPERSEDED_TRANSPORT; - - const edge = spawnSync( - process.execPath, - [edgeBuilder, "backhaul", "superseded-backhaul-unit"], - { encoding: "utf8", env: environment }, - ); - assert.notEqual(edge.status, 0); - assert.match(edge.stderr, /vps_initiated_transport_frozen:ADR-0001/); - - const enrollment = spawnSync( - process.execPath, - [enrollmentBuilder, "superseded-enrollment-unit"], - { encoding: "utf8", env: environment }, - ); - assert.notEqual(enrollment.status, 0); - assert.match(enrollment.stderr, /vps_initiated_transport_frozen:ADR-0001/); -}); diff --git a/device-plane/services/device-backhaul-target/Dockerfile b/device-plane/services/device-backhaul-target/Dockerfile deleted file mode 100644 index 799a4bc..0000000 --- a/device-plane/services/device-backhaul-target/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM alpine:3.22 - -RUN apk add --no-cache openssh-server \ - && addgroup -g 11021 device-backhaul \ - && adduser -D -H -u 11021 -G device-backhaul -s /sbin/nologin device-backhaul \ - && passwd -u device-backhaul - -COPY services/device-backhaul-target/sshd_config /etc/ssh/sshd_config - -EXPOSE 2222/tcp - -CMD ["sh", "-c", "mkdir -p /run/sshd && exec /usr/sbin/sshd -D -e -f /etc/ssh/sshd_config"] diff --git a/device-plane/services/device-backhaul-target/sshd_config b/device-plane/services/device-backhaul-target/sshd_config deleted file mode 100644 index f3907ed..0000000 --- a/device-plane/services/device-backhaul-target/sshd_config +++ /dev/null @@ -1,35 +0,0 @@ -Port 2222 -ListenAddress 127.0.0.1 -AddressFamily inet -Protocol 2 - -HostKey /run/nodedc-secrets/ssh_host_ed25519_key -AuthorizedKeysFile /run/nodedc-secrets/authorized_keys -StrictModes no - -PermitRootLogin no -PasswordAuthentication no -KbdInteractiveAuthentication no -PubkeyAuthentication yes -AuthenticationMethods publickey -AllowUsers device-backhaul - -AllowTcpForwarding local -PermitOpen 127.0.0.1:9921 -GatewayPorts no -PermitTunnel no -AllowAgentForwarding no -X11Forwarding no -PermitTTY no -PermitUserEnvironment no -ForceCommand /bin/false - -ClientAliveInterval 30 -ClientAliveCountMax 3 -LoginGraceTime 20 -MaxAuthTries 3 -MaxSessions 4 -MaxStartups 4:30:8 -UseDNS no -PrintMotd no -LogLevel VERBOSE diff --git a/device-plane/services/device-control-core/Dockerfile b/device-plane/services/device-control-core/Dockerfile deleted file mode 100644 index b194456..0000000 --- a/device-plane/services/device-control-core/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM node:22-alpine - -WORKDIR /app/services/device-control-core - -COPY services/device-control-core/package.json services/device-control-core/package-lock.json ./ - -RUN npm ci --omit=dev --ignore-scripts - -WORKDIR /app - -COPY packages/device-protocol-contract ./packages/device-protocol-contract -COPY packages/device-edge-channel-contract ./packages/device-edge-channel-contract -COPY services/device-control-core ./services/device-control-core - -USER node - -CMD ["node", "services/device-control-core/src/server.mjs"] diff --git a/device-plane/services/device-control-core/migrations/001_device_plane_foundation.sql b/device-plane/services/device-control-core/migrations/001_device_plane_foundation.sql deleted file mode 100644 index 8e5a134..0000000 --- a/device-plane/services/device-control-core/migrations/001_device_plane_foundation.sql +++ /dev/null @@ -1,99 +0,0 @@ -begin; - -create table if not exists device_model_profiles ( - profile_ref text primary key, - schema_version text not null, - vendor text not null, - model text not null, - device_type text not null, - protocol text not null, - profile jsonb not null, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() -); - -create table if not exists device_contours ( - id uuid primary key, - owner_scope text not null, - name text not null, - lifecycle_state text not null default 'active' - check (lifecycle_state in ('active', 'suspended', 'retired')), - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - unique (owner_scope, name) -); - -create table if not exists device_discoveries ( - id uuid primary key, - identifier_kind text not null, - identifier_digest text not null, - identifier_masked text not null, - model_profile_ref text not null references device_model_profiles(profile_ref), - protocol text not null, - lifecycle_state text not null default 'quarantine' - check (lifecycle_state in ('quarantine', 'claimed', 'rejected', 'expired')), - first_observed_at timestamptz not null, - last_observed_at timestamptz not null, - evidence jsonb not null, - claimed_device_id uuid, - claimed_at timestamptz, - claimed_by text, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - unique (identifier_kind, identifier_digest, model_profile_ref) -); - -create index if not exists device_discoveries_state_last_seen_idx - on device_discoveries (lifecycle_state, last_observed_at desc); - -create table if not exists device_instances ( - id uuid primary key, - contour_id uuid not null references device_contours(id), - model_profile_ref text not null references device_model_profiles(profile_ref), - display_name text not null, - identifier_kind text not null, - identifier_digest text not null, - identifier_masked text not null, - credential_ref text, - lifecycle_state text not null default 'claimed' - check (lifecycle_state in ('claimed', 'online', 'offline', 'suspended', 'retired')), - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - unique (identifier_kind, identifier_digest, model_profile_ref) -); - -alter table device_discoveries - drop constraint if exists device_discoveries_claimed_device_fk; - -alter table device_discoveries - add constraint device_discoveries_claimed_device_fk - foreign key (claimed_device_id) references device_instances(id); - -create table if not exists device_bindings ( - id uuid primary key, - contour_id uuid not null references device_contours(id), - target_kind text not null, - target_ref text not null, - capabilities text[] not null, - lifecycle_state text not null default 'active' - check (lifecycle_state in ('active', 'revoked')), - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - unique (contour_id, target_kind, target_ref) -); - -create table if not exists device_audit_events ( - id uuid primary key, - event_type text not null, - actor_ref text not null, - contour_id uuid, - device_id uuid, - discovery_id uuid, - payload jsonb not null, - occurred_at timestamptz not null default now() -); - -create index if not exists device_audit_events_device_time_idx - on device_audit_events (device_id, occurred_at desc); - -commit; diff --git a/device-plane/services/device-control-core/migrations/002_device_project_access.sql b/device-plane/services/device-control-core/migrations/002_device_project_access.sql deleted file mode 100644 index 216beb0..0000000 --- a/device-plane/services/device-control-core/migrations/002_device_project_access.sql +++ /dev/null @@ -1,113 +0,0 @@ -begin; - -create table if not exists device_owner_scopes ( - id uuid primary key, - scope_kind text not null - check (scope_kind in ('company', 'personal')), - owner_ref text not null - check (length(btrim(owner_ref)) between 3 and 256), - display_name text not null - check (length(btrim(display_name)) between 1 and 160), - lifecycle_state text not null default 'active' - check (lifecycle_state in ('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 (scope_kind, owner_ref) -); - -create table if not exists device_projects ( - id uuid primary key, - owner_scope_id uuid not null references device_owner_scopes(id), - project_key text not null - check (project_key ~ '^[a-z][a-z0-9-]{1,62}$'), - name text not null - check (length(btrim(name)) between 1 and 160), - description text, - lifecycle_state text not null default 'active' - check (lifecycle_state in ('active', 'suspended', 'archived')), - 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 (owner_scope_id, project_key) -); - -create index if not exists device_projects_owner_scope_idx - on device_projects (owner_scope_id, lifecycle_state, updated_at desc); - -alter table device_instances - add column if not exists project_id uuid references device_projects(id); - -create unique index if not exists device_instances_id_project_idx - on device_instances (id, project_id); - -create table if not exists device_collections ( - id uuid primary key, - project_id uuid not null references device_projects(id), - collection_key text not null - check (collection_key ~ '^[a-z][a-z0-9-]{1,62}$'), - name text not null - check (length(btrim(name)) between 1 and 160), - description text, - lifecycle_state text not null default 'active' - check (lifecycle_state in ('active', 'archived')), - 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, collection_key), - unique (id, project_id) -); - -create index if not exists device_collections_project_idx - on device_collections (project_id, lifecycle_state, updated_at desc); - -create table if not exists device_collection_members ( - collection_id uuid not null, - device_id uuid not null, - project_id uuid not null references device_projects(id), - added_by_ref text not null - check (length(btrim(added_by_ref)) between 3 and 256), - added_at timestamptz not null default now(), - primary key (collection_id, device_id), - foreign key (collection_id, project_id) - references device_collections(id, project_id), - foreign key (device_id, project_id) - references device_instances(id, project_id) -); - -create index if not exists device_collection_members_device_idx - on device_collection_members (device_id, collection_id); - -create table if not exists device_project_grants ( - id uuid primary key, - project_id uuid not null references device_projects(id), - principal_kind text not null - check (principal_kind in ('user', 'group')), - principal_ref text not null - check (length(btrim(principal_ref)) between 3 and 256), - project_role text not null - check (project_role in ('viewer', 'operator', 'engineer', 'admin', 'owner')), - capability_allow text[] not null default '{}', - capability_deny text[] not null default '{}', - lifecycle_state text not null default 'active' - check (lifecycle_state in ('active', 'revoked')), - 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, principal_kind, principal_ref), - check (not (capability_allow && capability_deny)) -); - -create index if not exists device_project_grants_principal_idx - on device_project_grants ( - principal_kind, - principal_ref, - lifecycle_state, - project_id - ); - -commit; diff --git a/device-plane/services/device-control-core/migrations/003_device_management_commands.sql b/device-plane/services/device-control-core/migrations/003_device_management_commands.sql deleted file mode 100644 index 48ccb5b..0000000 --- a/device-plane/services/device-control-core/migrations/003_device_management_commands.sql +++ /dev/null @@ -1,68 +0,0 @@ -begin; - -do $$ -begin - if not exists ( - select 1 - from pg_constraint - where conname = 'device_project_grants_owner_user_only' - and conrelid = 'device_project_grants'::regclass - ) then - alter table device_project_grants - add constraint device_project_grants_owner_user_only - check (project_role <> 'owner' or principal_kind = 'user'); - end if; -end -$$; - -alter table device_audit_events - add column if not exists project_id uuid references device_projects(id); - -create index if not exists device_audit_events_project_time_idx - on device_audit_events (project_id, occurred_at desc); - -create table if not exists device_management_command_receipts ( - id uuid primary key, - actor_ref text not null - check (length(btrim(actor_ref)) between 3 and 256), - command_kind text not null - check (command_kind in ( - 'owner_scope.ensure', - 'project.ensure', - 'collection.ensure', - 'project_grant.upsert' - )), - idempotency_key text not null - check (length(idempotency_key) between 8 and 256), - request_digest text not null - check (request_digest ~ '^sha256:[a-f0-9]{64}$'), - lifecycle_state text not null default 'pending' - check (lifecycle_state in ('pending', 'completed')), - response_status integer - check (response_status between 200 and 599), - response_body jsonb, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - completed_at timestamptz, - unique (actor_ref, command_kind, idempotency_key), - check ( - ( - lifecycle_state = 'pending' - and response_status is null - and response_body is null - and completed_at is null - ) - or - ( - lifecycle_state = 'completed' - and response_status is not null - and response_body is not null - and completed_at is not null - ) - ) -); - -create index if not exists device_management_receipts_created_idx - on device_management_command_receipts (created_at desc); - -commit; 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 deleted file mode 100644 index e1d5c05..0000000 --- a/device-plane/services/device-control-core/migrations/004_device_registry_foundation.sql +++ /dev/null @@ -1,208 +0,0 @@ -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/migrations/005_device_registry_commands.sql b/device-plane/services/device-control-core/migrations/005_device_registry_commands.sql deleted file mode 100644 index 062ce52..0000000 --- a/device-plane/services/device-control-core/migrations/005_device_registry_commands.sql +++ /dev/null @@ -1,21 +0,0 @@ -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' - )); - -commit; diff --git a/device-plane/services/device-control-core/migrations/006_device_lifecycle_ownership.sql b/device-plane/services/device-control-core/migrations/006_device_lifecycle_ownership.sql deleted file mode 100644 index 8076cee..0000000 --- a/device-plane/services/device-control-core/migrations/006_device_lifecycle_ownership.sql +++ /dev/null @@ -1,326 +0,0 @@ -begin; - -create unique index if not exists device_projects_id_owner_scope_idx - on device_projects (id, owner_scope_id); - -alter table device_instances - alter column contour_id drop not null, - add column if not exists owner_scope_id uuid references device_owner_scopes(id), - add column if not exists device_key text - check ( - device_key is null - or device_key ~ '^[a-z][a-z0-9-]{1,62}$' - ); - -create unique index if not exists device_instances_project_key_idx - on device_instances (project_id, device_key) - where device_key is not null; - -create unique index if not exists device_instances_id_project_owner_idx - on device_instances (id, project_id, owner_scope_id); - -do $$ -begin - if not exists ( - select 1 from pg_constraint - where conname = 'device_instances_project_owner_fk' - and conrelid = 'device_instances'::regclass - ) then - alter table device_instances - add constraint device_instances_project_owner_fk - foreign key (project_id, owner_scope_id) - references device_projects(id, owner_scope_id) - not valid; - end if; - if not exists ( - select 1 from pg_constraint - where conname = 'device_instances_ownership_mode_check' - and conrelid = 'device_instances'::regclass - ) then - alter table device_instances - add constraint device_instances_ownership_mode_check - check ( - ( - owner_scope_id is not null - and project_id is not null - ) - or - ( - owner_scope_id is null - and project_id is null - and contour_id is not null - ) - ) not valid; - end if; -end -$$; - -alter table device_discoveries - add column if not exists session_ref text - check ( - session_ref is null - or length(btrim(session_ref)) between 3 and 256 - ), - add column if not exists project_id uuid references device_projects(id), - add column if not exists route_id uuid, - add column if not exists enrollment_intent_id uuid, - add column if not exists resolution_code text - check ( - resolution_code is null - or resolution_code ~ '^[a-z][a-z0-9._-]{1,63}$' - ), - add column if not exists resolved_at timestamptz, - add column if not exists resolved_by_ref text - check ( - resolved_by_ref is null - or length(btrim(resolved_by_ref)) between 3 and 256 - ); - -create unique index if not exists device_discoveries_id_project_idx - on device_discoveries (id, project_id); - -create index if not exists device_discoveries_route_state_seen_idx - on device_discoveries (route_id, lifecycle_state, last_observed_at desc) - where route_id is not null; - -create unique index if not exists device_enrollment_intents_context_idx - on device_enrollment_intents ( - id, - project_id, - route_id, - model_profile_ref - ); - -create unique index if not exists device_enrollment_intents_active_identity_idx - on device_enrollment_intents ( - expected_identifier_kind, - expected_identifier_digest, - model_profile_ref - ) - where lifecycle_state in ('pending', 'observed', 'claimed'); - -alter table device_enrollment_intents - add column if not exists observed_discovery_id uuid, - add column if not exists observed_at timestamptz, - add column if not exists claimed_at timestamptz, - add column if not exists resolution_code text - check ( - resolution_code is null - or resolution_code ~ '^[a-z][a-z0-9._-]{1,63}$' - ), - add column if not exists resolved_at timestamptz, - add column if not exists resolved_by_ref text - check ( - resolved_by_ref is null - or length(btrim(resolved_by_ref)) between 3 and 256 - ); - -do $$ -begin - if not exists ( - select 1 from pg_constraint - where conname = 'device_discoveries_route_context_fk' - and conrelid = 'device_discoveries'::regclass - ) then - alter table device_discoveries - add constraint device_discoveries_route_context_fk - foreign key (route_id, project_id, model_profile_ref) - references device_routes(id, project_id, model_profile_ref) - not valid; - end if; - if not exists ( - select 1 from pg_constraint - where conname = 'device_discoveries_enrollment_context_fk' - and conrelid = 'device_discoveries'::regclass - ) then - alter table device_discoveries - add constraint device_discoveries_enrollment_context_fk - foreign key ( - enrollment_intent_id, - project_id, - route_id, - model_profile_ref - ) references device_enrollment_intents ( - id, - project_id, - route_id, - model_profile_ref - ) not valid; - end if; - if not exists ( - select 1 from pg_constraint - where conname = 'device_discoveries_route_context_check' - and conrelid = 'device_discoveries'::regclass - ) then - alter table device_discoveries - add constraint device_discoveries_route_context_check - check ( - (project_id is null and route_id is null) - or - (project_id is not null and route_id is not null) - ) not valid; - end if; - if not exists ( - select 1 from pg_constraint - where conname = 'device_discoveries_enrollment_context_check' - and conrelid = 'device_discoveries'::regclass - ) then - alter table device_discoveries - add constraint device_discoveries_enrollment_context_check - check ( - enrollment_intent_id is null - or (project_id is not null and route_id is not null) - ) not valid; - end if; - if not exists ( - select 1 from pg_constraint - where conname = 'device_enrollment_observed_discovery_fk' - and conrelid = 'device_enrollment_intents'::regclass - ) then - alter table device_enrollment_intents - add constraint device_enrollment_observed_discovery_fk - foreign key (observed_discovery_id, project_id) - references device_discoveries(id, project_id) - not valid; - end if; - if not exists ( - select 1 from pg_constraint - where conname = 'device_enrollment_lifecycle_evidence_check' - and conrelid = 'device_enrollment_intents'::regclass - ) then - alter table device_enrollment_intents - add constraint device_enrollment_lifecycle_evidence_check - check ( - lifecycle_state not in ('observed', 'claimed') - or (observed_discovery_id is not null and observed_at is not null) - ) not valid; - end if; -end -$$; - -alter table device_sessions - drop constraint if exists device_sessions_device_id_project_id_fkey; - -alter table device_enrollment_intents - drop constraint if exists device_enrollment_intents_claimed_device_id_project_id_fkey; - -do $$ -begin - if not exists ( - select 1 from pg_constraint - where conname = 'device_sessions_device_id_fk' - and conrelid = 'device_sessions'::regclass - ) then - alter table device_sessions - add constraint device_sessions_device_id_fk - foreign key (device_id) references device_instances(id) - not valid; - end if; - if not exists ( - select 1 from pg_constraint - where conname = 'device_enrollment_claimed_device_id_fk' - and conrelid = 'device_enrollment_intents'::regclass - ) then - alter table device_enrollment_intents - add constraint device_enrollment_claimed_device_id_fk - foreign key (claimed_device_id) references device_instances(id) - not valid; - end if; -end -$$; - -create or replace function device_assert_session_current_project() -returns trigger -language plpgsql -as $$ -begin - if new.device_id is not null and not exists ( - select 1 from device_instances di - where di.id = new.device_id - and di.project_id = new.project_id - ) then - raise foreign_key_violation using - message = 'device_session_project_mismatch'; - end if; - return new; -end -$$; - -drop trigger if exists device_sessions_current_project_guard - on device_sessions; - -create trigger device_sessions_current_project_guard -before insert or update of device_id, project_id -on device_sessions -for each row -execute function device_assert_session_current_project(); - -create or replace function device_assert_enrollment_current_project() -returns trigger -language plpgsql -as $$ -begin - if new.claimed_device_id is not null and not exists ( - select 1 from device_instances di - where di.id = new.claimed_device_id - and di.project_id = new.project_id - ) then - raise foreign_key_violation using - message = 'device_enrollment_project_mismatch'; - end if; - return new; -end -$$; - -drop trigger if exists device_enrollment_current_project_guard - on device_enrollment_intents; - -create trigger device_enrollment_current_project_guard -before insert or update of claimed_device_id, project_id -on device_enrollment_intents -for each row -execute function device_assert_enrollment_current_project(); - -create table if not exists device_ownership_transitions ( - id uuid primary key, - device_id uuid not null references device_instances(id), - transition_kind text not null - check (transition_kind in ('claim', 'transfer')), - source_owner_scope_id uuid, - source_project_id uuid, - target_owner_scope_id uuid not null, - target_project_id uuid not null, - actor_ref text not null - check (length(btrim(actor_ref)) between 3 and 256), - occurred_at timestamptz not null default now(), - foreign key (source_project_id, source_owner_scope_id) - references device_projects(id, owner_scope_id), - foreign key (target_project_id, target_owner_scope_id) - references device_projects(id, owner_scope_id), - check ( - ( - transition_kind = 'claim' - and source_owner_scope_id is null - and source_project_id is null - ) - or - ( - transition_kind = 'transfer' - and source_owner_scope_id is not null - and source_project_id is not null - and ( - source_owner_scope_id <> target_owner_scope_id - or source_project_id <> target_project_id - ) - ) - ) -); - -create unique index if not exists device_ownership_single_claim_idx - on device_ownership_transitions (device_id) - where transition_kind = 'claim'; - -create index if not exists device_ownership_device_time_idx - on device_ownership_transitions (device_id, occurred_at desc); - -commit; diff --git a/device-plane/services/device-control-core/migrations/007_device_lifecycle_commands.sql b/device-plane/services/device-control-core/migrations/007_device_lifecycle_commands.sql deleted file mode 100644 index 90881e0..0000000 --- a/device-plane/services/device-control-core/migrations/007_device_lifecycle_commands.sql +++ /dev/null @@ -1,25 +0,0 @@ -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.transfer', - 'discovery.reject', - 'discovery.expire' - )); - -commit; diff --git a/device-plane/services/device-control-core/migrations/008_device_sensitive_references.sql b/device-plane/services/device-control-core/migrations/008_device_sensitive_references.sql deleted file mode 100644 index 6fc5869..0000000 --- a/device-plane/services/device-control-core/migrations/008_device_sensitive_references.sql +++ /dev/null @@ -1,233 +0,0 @@ -begin; - -do $$ -begin - if not exists ( - select 1 from pg_constraint - where conname = 'device_instances_direct_legacy_credential_check' - and conrelid = 'device_instances'::regclass - ) then - alter table device_instances - add constraint device_instances_direct_legacy_credential_check - check (owner_scope_id is null or credential_ref is null) - not valid; - end if; -end -$$; - -create table if not exists device_restricted_identifiers ( - id uuid primary key, - device_id uuid not null references device_instances(id), - owner_scope_id uuid not null, - project_id uuid not null, - identifier_kind text not null - check (identifier_kind ~ '^[a-z][a-z0-9._:-]{1,63}$'), - identifier_digest text not null - check (identifier_digest ~ '^hmac-sha256:[a-f0-9]{64}$'), - identifier_masked text not null - check ( - length(identifier_masked) between 5 and 128 - and position('*' in identifier_masked) > 0 - and identifier_masked !~ '[[:cntrl:]]' - and identifier_masked !~ '(^|[^0-9])[0-9]{15}([^0-9]|$)' - ), - provenance_kind text not null - check (provenance_kind in ('claim', 'adapter_observation')), - is_primary boolean not null default false, - lifecycle_state text not null default 'active' - check (lifecycle_state in ('active', 'revoked')), - created_by_ref text not null - check (length(btrim(created_by_ref)) between 3 and 256), - revoked_at timestamptz, - revoked_by_ref text - check ( - revoked_by_ref is null - or length(btrim(revoked_by_ref)) between 3 and 256 - ), - revocation_code text - check ( - revocation_code is null - or revocation_code ~ '^[a-z][a-z0-9._-]{1,63}$' - ), - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - foreign key (project_id, owner_scope_id) - references device_projects(id, owner_scope_id), - check ( - (lifecycle_state = 'active' and revoked_at is null and revoked_by_ref is null and revocation_code is null) - or - (lifecycle_state = 'revoked' and revoked_at is not null and revoked_by_ref is not null and revocation_code is not null) - ) -); - -create unique index if not exists device_restricted_identifiers_active_identity_idx - on device_restricted_identifiers (identifier_kind, identifier_digest) - where lifecycle_state = 'active'; - -create unique index if not exists device_restricted_identifiers_primary_idx - on device_restricted_identifiers (device_id) - where lifecycle_state = 'active' and is_primary; - -create index if not exists device_restricted_identifiers_device_idx - on device_restricted_identifiers (device_id, lifecycle_state, created_at); - -create or replace function device_assert_identifier_current_owner() -returns trigger -language plpgsql -as $$ -begin - if new.lifecycle_state = 'active' and not exists ( - select 1 from device_instances di - where di.id = new.device_id - and di.owner_scope_id = new.owner_scope_id - and di.project_id = new.project_id - ) then - raise foreign_key_violation using - message = 'device_identifier_ownership_mismatch'; - end if; - return new; -end -$$; - -drop trigger if exists device_restricted_identifiers_owner_guard - on device_restricted_identifiers; - -create trigger device_restricted_identifiers_owner_guard -before insert or update of device_id, owner_scope_id, project_id, lifecycle_state -on device_restricted_identifiers -for each row -execute function device_assert_identifier_current_owner(); - -create or replace function device_assert_active_identifiers_follow_owner() -returns trigger -language plpgsql -as $$ -begin - if exists ( - select 1 from device_restricted_identifiers dri - where dri.device_id = new.id - and dri.lifecycle_state = 'active' - and ( - dri.owner_scope_id is distinct from new.owner_scope_id - or dri.project_id is distinct from new.project_id - ) - ) then - raise foreign_key_violation using - message = 'device_active_identifier_ownership_mismatch'; - end if; - return new; -end -$$; - -drop trigger if exists device_instances_identifier_owner_guard - on device_instances; - -create constraint trigger device_instances_identifier_owner_guard -after update -on device_instances -deferrable initially deferred -for each row -execute function device_assert_active_identifiers_follow_owner(); - -create table if not exists device_credential_bindings ( - id uuid primary key, - device_id uuid not null references device_instances(id), - owner_scope_id uuid not null, - project_id uuid not null, - purpose text not null - check (purpose ~ '^[a-z][a-z0-9._-]{1,63}$'), - credential_owner text not null - check (credential_owner = 'ndc_l2_credentials'), - credential_ref text not null - check (credential_ref ~ '^ndc-credref:[A-Za-z0-9][A-Za-z0-9._:-]{7,240}$'), - lifecycle_state text not null default 'active' - check (lifecycle_state in ('active', 'revoked')), - bound_by_ref text not null - check (length(btrim(bound_by_ref)) between 3 and 256), - revoked_at timestamptz, - revoked_by_ref text - check ( - revoked_by_ref is null - or length(btrim(revoked_by_ref)) between 3 and 256 - ), - revocation_code text - check ( - revocation_code is null - or revocation_code ~ '^[a-z][a-z0-9._-]{1,63}$' - ), - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - foreign key (project_id, owner_scope_id) - references device_projects(id, owner_scope_id), - check ( - (lifecycle_state = 'active' and revoked_at is null and revoked_by_ref is null and revocation_code is null) - or - (lifecycle_state = 'revoked' and revoked_at is not null and revoked_by_ref is not null and revocation_code is not null) - ) -); - -create unique index if not exists device_credential_bindings_active_purpose_idx - on device_credential_bindings (device_id, purpose) - where lifecycle_state = 'active'; - -create index if not exists device_credential_bindings_project_state_idx - on device_credential_bindings (project_id, lifecycle_state, updated_at desc); - -create or replace function device_assert_credential_binding_current_owner() -returns trigger -language plpgsql -as $$ -begin - if new.lifecycle_state = 'active' and not exists ( - select 1 from device_instances di - where di.id = new.device_id - and di.owner_scope_id = new.owner_scope_id - and di.project_id = new.project_id - ) then - raise foreign_key_violation using - message = 'device_credential_binding_ownership_mismatch'; - end if; - return new; -end -$$; - -drop trigger if exists device_credential_bindings_owner_guard - on device_credential_bindings; - -create trigger device_credential_bindings_owner_guard -before insert or update of device_id, owner_scope_id, project_id, lifecycle_state -on device_credential_bindings -for each row -execute function device_assert_credential_binding_current_owner(); - -create or replace function device_require_credential_revoke_before_transfer() -returns trigger -language plpgsql -as $$ -begin - if exists ( - select 1 from device_credential_bindings dcb - where dcb.device_id = old.id - and dcb.lifecycle_state = 'active' - ) then - raise check_violation using - message = 'device_transfer_active_credential_binding'; - end if; - return new; -end -$$; - -drop trigger if exists device_instances_credential_transfer_guard - on device_instances; - -create trigger device_instances_credential_transfer_guard -before update of owner_scope_id, project_id -on device_instances -for each row -when ( - old.owner_scope_id is distinct from new.owner_scope_id - or old.project_id is distinct from new.project_id -) -execute function device_require_credential_revoke_before_transfer(); - -commit; diff --git a/device-plane/services/device-control-core/migrations/009_device_sensitive_reference_commands.sql b/device-plane/services/device-control-core/migrations/009_device_sensitive_reference_commands.sql deleted file mode 100644 index 2902a15..0000000 --- a/device-plane/services/device-control-core/migrations/009_device_sensitive_reference_commands.sql +++ /dev/null @@ -1,27 +0,0 @@ -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.transfer', - 'discovery.reject', - 'discovery.expire', - 'device_credential_binding.upsert', - 'device_credential_binding.revoke' - )); - -commit; diff --git a/device-plane/services/device-control-core/migrations/010_device_control_resources.sql b/device-plane/services/device-control-core/migrations/010_device_control_resources.sql deleted file mode 100644 index 134d7ca..0000000 --- a/device-plane/services/device-control-core/migrations/010_device_control_resources.sql +++ /dev/null @@ -1,610 +0,0 @@ -begin; - -create table if not exists device_resource_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}$'), - display_name text not null - check (length(btrim(display_name)) between 1 and 160), - source_kind text not null - check (source_kind in ('device', 'collection')), - device_id uuid references device_instances(id), - collection_id uuid references device_collections(id), - target_kind text not null - check (target_kind ~ '^[a-z][a-z0-9._:-]{1,63}$'), - target_ref text not null - check (length(btrim(target_ref)) between 3 and 256), - capabilities text[] not null default '{}' - check ( - cardinality(capabilities) between 1 and 16 - and array_position(capabilities, null) is null - ), - lifecycle_state text not null default 'pending_external_approval' - check (lifecycle_state in ('pending_external_approval', 'active', 'revoked')), - source_approved_by_ref text not null - check (length(btrim(source_approved_by_ref)) between 3 and 256), - source_approved_at timestamptz not null default now(), - external_approval_ref text - check ( - external_approval_ref is null - or length(btrim(external_approval_ref)) between 3 and 256 - ), - external_approval_digest text - check ( - external_approval_digest is null - or external_approval_digest ~ '^sha256:[a-f0-9]{64}$' - ), - external_approved_at timestamptz, - revoked_at timestamptz, - revoked_by_ref text - check ( - revoked_by_ref is null - or length(btrim(revoked_by_ref)) between 3 and 256 - ), - revocation_code text - check ( - revocation_code is null - or revocation_code ~ '^[a-z][a-z0-9._-]{1,63}$' - ), - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - unique (project_id, binding_key), - foreign key (project_id, owner_scope_id) - references device_projects(id, owner_scope_id), - foreign key (collection_id, project_id) - references device_collections(id, project_id), - check ( - (source_kind = 'device' and device_id is not null and collection_id is null) - or - (source_kind = 'collection' and device_id is null and collection_id is not null) - ), - check ( - ( - lifecycle_state = 'pending_external_approval' - and external_approval_ref is null - and external_approval_digest is null - and external_approved_at is null - and revoked_at is null - and revoked_by_ref is null - and revocation_code is null - ) - or - ( - lifecycle_state = 'active' - and external_approval_ref is not null - and external_approval_digest is not null - and external_approved_at is not null - and revoked_at is null - and revoked_by_ref is null - and revocation_code is null - ) - or - ( - lifecycle_state = 'revoked' - and revoked_at is not null - and revoked_by_ref is not null - and revocation_code is not null - ) - ) -); - -create index if not exists device_resource_bindings_project_state_idx - on device_resource_bindings (project_id, lifecycle_state, updated_at desc); - -create index if not exists device_resource_bindings_device_state_idx - on device_resource_bindings (device_id, lifecycle_state, updated_at desc) - where device_id is not null; - -create or replace function device_assert_binding_source_scope() -returns trigger -language plpgsql -as $$ -begin - if new.source_kind = 'device' and not exists ( - select 1 from device_instances di - where di.id = new.device_id - and di.owner_scope_id = new.owner_scope_id - and di.project_id = new.project_id - ) then - raise foreign_key_violation using - message = 'device_binding_source_scope_mismatch'; - end if; - return new; -end -$$; - -drop trigger if exists device_resource_bindings_source_guard - on device_resource_bindings; - -create trigger device_resource_bindings_source_guard -before insert or update of owner_scope_id, project_id, source_kind, device_id, collection_id -on device_resource_bindings -for each row -execute function device_assert_binding_source_scope(); - -create table if not exists device_configuration_revisions ( - id uuid primary key, - owner_scope_id uuid not null, - project_id uuid not null, - device_id uuid not null references device_instances(id), - revision_number bigint not null check (revision_number > 0), - model_profile_ref text not null references device_model_profiles(profile_ref), - schema_artifact_ref text not null - check (length(btrim(schema_artifact_ref)) between 3 and 256), - configuration_digest text not null - check (configuration_digest ~ '^sha256:[a-f0-9]{64}$'), - configuration jsonb not null - check ( - jsonb_typeof(configuration) = 'object' - and octet_length(configuration::text) <= 65536 - ), - change_summary text - check (change_summary is null or length(change_summary) <= 1000), - created_by_ref text not null - check (length(btrim(created_by_ref)) between 3 and 256), - created_at timestamptz not null default now(), - unique (device_id, revision_number), - unique (id, device_id, project_id), - foreign key (project_id, owner_scope_id) - references device_projects(id, owner_scope_id) -); - -create index if not exists device_configuration_revisions_project_idx - on device_configuration_revisions (project_id, device_id, revision_number desc); - -create or replace function device_assert_configuration_revision_scope() -returns trigger -language plpgsql -as $$ -begin - if not exists ( - select 1 - from device_instances di - join device_model_profiles dmp - on dmp.profile_ref = di.model_profile_ref - where di.id = new.device_id - and di.owner_scope_id = new.owner_scope_id - and di.project_id = new.project_id - and di.model_profile_ref = new.model_profile_ref - and dmp.schema_artifact_ref = new.schema_artifact_ref - and dmp.lifecycle_state = 'active' - ) then - raise foreign_key_violation using - message = 'device_configuration_revision_scope_mismatch'; - end if; - return new; -end -$$; - -drop trigger if exists device_configuration_revisions_scope_guard - on device_configuration_revisions; - -create trigger device_configuration_revisions_scope_guard -before insert -on device_configuration_revisions -for each row -execute function device_assert_configuration_revision_scope(); - -create table if not exists device_configuration_state ( - device_id uuid primary key, - owner_scope_id uuid not null, - project_id uuid not null, - desired_revision_id uuid, - applied_revision_id uuid, - applied_at timestamptz, - applied_by_ref text, - updated_at timestamptz not null default now(), - foreign key (device_id, project_id, owner_scope_id) - references device_instances(id, project_id, owner_scope_id), - foreign key (desired_revision_id, device_id, project_id) - references device_configuration_revisions(id, device_id, project_id), - foreign key (applied_revision_id, device_id, project_id) - references device_configuration_revisions(id, device_id, project_id), - check (desired_revision_id is not null or applied_revision_id is not null), - check ( - (applied_revision_id is null and applied_at is null and applied_by_ref is null) - or - (applied_revision_id is not null and applied_at is not null and applied_by_ref is not null) - ) -); - -create table if not exists device_commands ( - id uuid primary key, - owner_scope_id uuid not null, - project_id uuid not null, - device_id uuid not null references device_instances(id), - command_key text not null - check (command_key ~ '^[a-z][a-z0-9-]{1,62}$'), - command_catalog_ref text not null - check (length(btrim(command_catalog_ref)) between 3 and 256), - command_type text not null - check (command_type ~ '^[a-z][a-z0-9._:-]{1,63}$'), - risk_class text not null - check (risk_class in ('low', 'moderate', 'high', 'critical')), - parameters_digest text not null - check (parameters_digest ~ '^sha256:[a-f0-9]{64}$'), - parameters_projection jsonb not null - check ( - jsonb_typeof(parameters_projection) = 'object' - and octet_length(parameters_projection::text) <= 16384 - ), - lifecycle_state text not null default 'draft' - check (lifecycle_state in ( - 'draft', - 'planned', - 'awaiting_confirmation', - 'queued', - 'dispatched', - 'acknowledged', - 'verified', - 'failed', - 'expired', - 'unknown' - )), - planned_by_ref text not null - check (length(btrim(planned_by_ref)) between 3 and 256), - planned_at timestamptz not null default now(), - expires_at timestamptz not null, - confirmed_by_ref text, - confirmed_at timestamptz, - dispatched_at timestamptz, - transport_message_ref text - check ( - transport_message_ref is null - or length(btrim(transport_message_ref)) between 3 and 256 - ), - acknowledged_at timestamptz, - terminal_at timestamptz, - terminal_reason_code text - check ( - terminal_reason_code is null - or terminal_reason_code ~ '^[a-z][a-z0-9._-]{1,63}$' - ), - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - unique (project_id, command_key), - unique (id, device_id, project_id), - foreign key (project_id, owner_scope_id) - references device_projects(id, owner_scope_id), - check (expires_at > planned_at), - check ( - (confirmed_at is null and confirmed_by_ref is null) - or - (confirmed_at is not null and confirmed_by_ref is not null) - ), - check ( - ( - lifecycle_state in ('dispatched', 'acknowledged', 'verified', 'unknown') - and dispatched_at is not null - and transport_message_ref is not null - ) - or lifecycle_state not in ('dispatched', 'acknowledged', 'verified', 'unknown') - ), - check ( - (lifecycle_state in ('acknowledged', 'verified') and acknowledged_at is not null) - or lifecycle_state not in ('acknowledged', 'verified') - ), - check ( - ( - lifecycle_state in ('verified', 'failed', 'expired', 'unknown') - and terminal_at is not null - and terminal_reason_code is not null - ) - or - ( - lifecycle_state not in ('verified', 'failed', 'expired', 'unknown') - and terminal_at is null - and terminal_reason_code is null - ) - ) -); - -create index if not exists device_commands_project_state_idx - on device_commands (project_id, lifecycle_state, updated_at desc); - -create index if not exists device_commands_device_state_idx - on device_commands (device_id, lifecycle_state, updated_at desc); - -create table if not exists device_command_events ( - id uuid primary key, - command_id uuid not null, - device_id uuid not null, - project_id uuid not null, - sequence_number bigint not null check (sequence_number > 0), - from_state text, - to_state text not null - check (to_state in ( - 'draft', - 'planned', - 'awaiting_confirmation', - 'queued', - 'dispatched', - 'acknowledged', - 'verified', - 'failed', - 'expired', - 'unknown' - )), - actor_ref text not null - check (length(btrim(actor_ref)) between 3 and 256), - reason_code text not null - check (reason_code ~ '^[a-z][a-z0-9._-]{1,63}$'), - evidence_ref text - check ( - evidence_ref is null - or length(btrim(evidence_ref)) between 3 and 256 - ), - occurred_at timestamptz not null default now(), - unique (command_id, sequence_number), - foreign key (command_id, device_id, project_id) - references device_commands(id, device_id, project_id), - check (sequence_number = 1 or from_state is not null), - check (sequence_number <> 1 or from_state is null), - check ( - from_state is null - or from_state in ( - 'draft', - 'planned', - 'awaiting_confirmation', - 'queued', - 'dispatched', - 'acknowledged', - 'verified', - 'failed', - 'expired', - 'unknown' - ) - ) -); - -create index if not exists device_command_events_command_idx - on device_command_events (command_id, sequence_number); - -create or replace function device_assert_command_scope() -returns trigger -language plpgsql -as $$ -begin - if not exists ( - select 1 from device_instances di - where di.id = new.device_id - and di.owner_scope_id = new.owner_scope_id - and di.project_id = new.project_id - ) then - raise foreign_key_violation using message = 'device_command_scope_mismatch'; - end if; - return new; -end -$$; - -drop trigger if exists device_commands_scope_guard on device_commands; - -create trigger device_commands_scope_guard -before insert -on device_commands -for each row -execute function device_assert_command_scope(); - -create or replace function device_assert_command_event_sequence() -returns trigger -language plpgsql -as $$ -declare - previous_state text; -begin - if new.sequence_number = 1 then - if new.from_state is not null or new.to_state <> 'draft' then - raise check_violation using message = 'device_command_initial_event_invalid'; - end if; - return new; - end if; - - select dce.to_state into previous_state - from device_command_events dce - where dce.command_id = new.command_id - and dce.sequence_number = new.sequence_number - 1; - - if previous_state is null or previous_state <> new.from_state then - raise check_violation using message = 'device_command_event_sequence_invalid'; - end if; - if not ( - (new.from_state = 'draft' and new.to_state in ('planned', 'expired')) - or (new.from_state = 'planned' and new.to_state in ('awaiting_confirmation', 'queued', 'expired')) - or (new.from_state = 'awaiting_confirmation' and new.to_state in ('queued', 'expired')) - or (new.from_state = 'queued' and new.to_state in ('dispatched', 'failed', 'expired')) - or (new.from_state = 'dispatched' and new.to_state in ('acknowledged', 'failed', 'unknown')) - or (new.from_state = 'acknowledged' and new.to_state in ('verified', 'failed', 'unknown')) - ) then - raise check_violation using message = 'device_command_event_transition_invalid'; - end if; - return new; -end -$$; - -drop trigger if exists device_command_events_sequence_guard - on device_command_events; - -create trigger device_command_events_sequence_guard -before insert -on device_command_events -for each row -execute function device_assert_command_event_sequence(); - -create or replace function device_assert_command_transition() -returns trigger -language plpgsql -as $$ -begin - if old.lifecycle_state = new.lifecycle_state then - return new; - end if; - if not ( - (old.lifecycle_state = 'draft' and new.lifecycle_state in ('planned', 'expired')) - or (old.lifecycle_state = 'planned' and new.lifecycle_state in ('awaiting_confirmation', 'queued', 'expired')) - or (old.lifecycle_state = 'awaiting_confirmation' and new.lifecycle_state in ('queued', 'expired')) - or (old.lifecycle_state = 'queued' and new.lifecycle_state in ('dispatched', 'failed', 'expired')) - or (old.lifecycle_state = 'dispatched' and new.lifecycle_state in ('acknowledged', 'failed', 'unknown')) - or (old.lifecycle_state = 'acknowledged' and new.lifecycle_state in ('verified', 'failed', 'unknown')) - ) then - raise check_violation using message = 'device_command_transition_invalid'; - end if; - return new; -end -$$; - -drop trigger if exists device_commands_transition_guard on device_commands; - -create trigger device_commands_transition_guard -before update of lifecycle_state -on device_commands -for each row -execute function device_assert_command_transition(); - -create or replace function device_assert_command_event_projection() -returns trigger -language plpgsql -as $$ -declare - current_state text; - latest_event_state text; -begin - select dc.lifecycle_state into current_state - from device_commands dc - where dc.id = new.id; - - select dce.to_state into latest_event_state - from device_command_events dce - where dce.command_id = new.id - order by dce.sequence_number desc - limit 1; - - if current_state is null or latest_event_state is distinct from current_state then - raise check_violation using message = 'device_command_event_projection_mismatch'; - end if; - return new; -end -$$; - -drop trigger if exists device_commands_event_projection_guard on device_commands; - -create constraint trigger device_commands_event_projection_guard -after insert or update of lifecycle_state -on device_commands -deferrable initially deferred -for each row -execute function device_assert_command_event_projection(); - -create or replace function device_assert_command_current_projection() -returns trigger -language plpgsql -as $$ -declare - current_state text; - latest_event_state text; -begin - select dc.lifecycle_state into current_state - from device_commands dc - where dc.id = new.command_id; - - select dce.to_state into latest_event_state - from device_command_events dce - where dce.command_id = new.command_id - order by dce.sequence_number desc - limit 1; - - if current_state is null or latest_event_state is distinct from current_state then - raise check_violation using message = 'device_command_current_projection_mismatch'; - end if; - return new; -end -$$; - -drop trigger if exists device_command_events_current_projection_guard - on device_command_events; - -create constraint trigger device_command_events_current_projection_guard -after insert -on device_command_events -deferrable initially deferred -for each row -execute function device_assert_command_current_projection(); - -create or replace function device_reject_immutable_mutation() -returns trigger -language plpgsql -as $$ -begin - raise check_violation using message = 'device_immutable_record_mutation_forbidden'; -end -$$; - -drop trigger if exists device_configuration_revisions_immutable_guard - on device_configuration_revisions; -create trigger device_configuration_revisions_immutable_guard -before update or delete or truncate -on device_configuration_revisions -for each statement -execute function device_reject_immutable_mutation(); - -drop trigger if exists device_command_events_immutable_guard - on device_command_events; -create trigger device_command_events_immutable_guard -before update or delete or truncate -on device_command_events -for each statement -execute function device_reject_immutable_mutation(); - -drop trigger if exists device_audit_events_immutable_guard - on device_audit_events; -create trigger device_audit_events_immutable_guard -before update or delete or truncate -on device_audit_events -for each statement -execute function device_reject_immutable_mutation(); - -create or replace function device_require_control_resources_clear_before_transfer() -returns trigger -language plpgsql -as $$ -begin - if exists ( - select 1 from device_resource_bindings drb - where drb.device_id = old.id - and drb.lifecycle_state in ('pending_external_approval', 'active') - ) then - raise check_violation using message = 'device_transfer_active_resource_binding'; - end if; - if exists ( - select 1 from device_configuration_state dcs - where dcs.device_id = old.id - and dcs.applied_revision_id is not null - ) then - raise check_violation using message = 'device_transfer_applied_configuration'; - end if; - if exists ( - select 1 from device_commands dc - where dc.device_id = old.id - and dc.lifecycle_state not in ('verified', 'failed', 'expired', 'unknown') - ) then - raise check_violation using message = 'device_transfer_nonterminal_command'; - end if; - return new; -end -$$; - -drop trigger if exists device_instances_control_resource_transfer_guard - on device_instances; - -create trigger device_instances_control_resource_transfer_guard -before update of owner_scope_id, project_id -on device_instances -for each row -when ( - old.owner_scope_id is distinct from new.owner_scope_id - or old.project_id is distinct from new.project_id -) -execute function device_require_control_resources_clear_before_transfer(); - -commit; diff --git a/device-plane/services/device-control-core/migrations/011_device_control_resource_commands.sql b/device-plane/services/device-control-core/migrations/011_device_control_resource_commands.sql deleted file mode 100644 index cc456e7..0000000 --- a/device-plane/services/device-control-core/migrations/011_device_control_resource_commands.sql +++ /dev/null @@ -1,31 +0,0 @@ -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.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' - )); - -commit; diff --git a/device-plane/services/device-control-core/migrations/012_device_gateway_message_receipts.sql b/device-plane/services/device-control-core/migrations/012_device_gateway_message_receipts.sql deleted file mode 100644 index e63184d..0000000 --- a/device-plane/services/device-control-core/migrations/012_device_gateway_message_receipts.sql +++ /dev/null @@ -1,66 +0,0 @@ -begin; - -create table if not exists device_gateway_message_receipts ( - id uuid primary key, - idempotency_key text not null - check (idempotency_key ~ '^sha256:[a-f0-9]{64}$'), - request_digest text not null - check (request_digest ~ '^sha256:[a-f0-9]{64}$'), - edge_ref text not null - check (length(btrim(edge_ref)) between 3 and 128), - adapter_ref text not null - check (adapter_ref ~ '^[a-z][a-z0-9-]{1,62}$'), - protocol_profile_ref text not null - references device_model_profiles(profile_ref), - protocol text not null - check (protocol ~ '^[A-Z][A-Z0-9_]{0,31}$'), - route_id uuid references device_routes(id), - project_id uuid references device_projects(id), - session_ref text not null - check (length(btrim(session_ref)) between 3 and 128), - message_ref text not null - check (length(btrim(message_ref)) between 3 and 128), - message_type text not null - check (message_type ~ '^[a-z][a-z0-9._-]{1,127}$'), - sequence bigint not null check (sequence > 0), - identifier_kind text not null - check (identifier_kind ~ '^[a-z][a-z0-9._:-]{1,63}$'), - identifier_digest text not null - check (identifier_digest ~ '^hmac-sha256:[a-f0-9]{64}$'), - identifier_masked text not null - check (length(identifier_masked) between 5 and 128), - payload_schema_ref text not null - check (length(btrim(payload_schema_ref)) between 3 and 128), - payload jsonb not null, - observed_at timestamptz not null, - accepted_at timestamptz not null default now(), - unique (idempotency_key), - unique (edge_ref, session_ref, message_ref), - foreign key (route_id, project_id) - references device_routes(id, project_id), - check ( - (route_id is null and project_id is null) - or (route_id is not null and project_id is not null) - ) -); - -create index if not exists device_gateway_message_receipts_route_time_idx - on device_gateway_message_receipts (route_id, accepted_at desc) - where route_id is not null; - -create index if not exists device_gateway_message_receipts_identity_time_idx - on device_gateway_message_receipts ( - identifier_kind, - identifier_digest, - accepted_at desc - ); - -drop trigger if exists device_gateway_message_receipts_immutable_guard - on device_gateway_message_receipts; -create trigger device_gateway_message_receipts_immutable_guard -before update or delete or truncate -on device_gateway_message_receipts -for each statement -execute function device_reject_immutable_mutation(); - -commit; diff --git a/device-plane/services/device-control-core/migrations/013_device_edge_channels.sql b/device-plane/services/device-control-core/migrations/013_device_edge_channels.sql deleted file mode 100644 index 1ec41d4..0000000 --- a/device-plane/services/device-control-core/migrations/013_device_edge_channels.sql +++ /dev/null @@ -1,54 +0,0 @@ -begin; - -alter table device_edges - add column if not exists channel_endpoint text, - add column if not exists channel_servername text, - add column if not exists channel_generation_ref text, - add column if not exists channel_trust_bundle_ref text, - add column if not exists channel_certificate_identities jsonb not null - default '[]'::jsonb, - add column if not exists channel_lifecycle_state text not null - default 'disabled'; - -do $$ -begin - if not exists ( - select 1 from pg_constraint - where conname = 'device_edges_channel_lifecycle_state_check' - ) then - alter table device_edges add constraint device_edges_channel_lifecycle_state_check - check (channel_lifecycle_state in ('disabled', 'active', 'revoked')); - end if; - if not exists ( - select 1 from pg_constraint - where conname = 'device_edges_channel_configuration_check' - ) then - alter table device_edges add constraint device_edges_channel_configuration_check - check ( - ( - channel_lifecycle_state = 'disabled' - and channel_endpoint is null - and channel_servername is null - and channel_generation_ref is null - and channel_trust_bundle_ref is null - and channel_certificate_identities = '[]'::jsonb - ) - or - ( - channel_lifecycle_state in ('active', 'revoked') - and length(btrim(channel_endpoint)) between 12 and 256 - and channel_servername ~ '^[A-Za-z0-9.-]{1,253}$' - and channel_generation_ref ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' - and channel_trust_bundle_ref ~ '^edge-trust:[a-z][a-z0-9-]{1,62}$' - and jsonb_typeof(channel_certificate_identities) = 'array' - and jsonb_array_length(channel_certificate_identities) between 1 and 2 - ) - ); - end if; -end $$; - -create index if not exists device_edges_active_channel_idx - on device_edges (channel_lifecycle_state, updated_at desc) - where channel_lifecycle_state = 'active'; - -commit; diff --git a/device-plane/services/device-control-core/package-lock.json b/device-plane/services/device-control-core/package-lock.json deleted file mode 100644 index 565b6a7..0000000 --- a/device-plane/services/device-control-core/package-lock.json +++ /dev/null @@ -1,164 +0,0 @@ -{ - "name": "@nodedc/device-control-core", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@nodedc/device-control-core", - "version": "0.1.0", - "dependencies": { - "pg": "8.22.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/pg": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", - "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", - "license": "MIT", - "dependencies": { - "pg-connection-string": "^2.14.0", - "pg-pool": "^3.14.0", - "pg-protocol": "^1.15.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.4.0" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", - "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", - "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", - "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", - "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - } - } -} diff --git a/device-plane/services/device-control-core/package.json b/device-plane/services/device-control-core/package.json deleted file mode 100644 index 4ba0ce5..0000000 --- a/device-plane/services/device-control-core/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@nodedc/device-control-core", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "start": "node src/server.mjs", - "test": "node --test test/*.test.mjs" - }, - "dependencies": { - "pg": "8.22.0" - }, - "engines": { - "node": ">=20" - } -} diff --git a/device-plane/services/device-control-core/src/app.mjs b/device-plane/services/device-control-core/src/app.mjs deleted file mode 100644 index 613d17a..0000000 --- a/device-plane/services/device-control-core/src/app.mjs +++ /dev/null @@ -1,504 +0,0 @@ -import { createHash, timingSafeEqual } from "node:crypto"; -import { createServer } from "node:http"; - -import { - hashRestrictedIdentifier, - maskRestrictedIdentifier, - normalizeRestrictedIdentifier, -} from "../../../packages/device-protocol-contract/src/index.mjs"; -import { createDeviceGatewayIngest } from "./gateway-ingest.mjs"; -import { - normalizeManagementActor, -} from "./project-management.mjs"; -import { normalizeDeviceManagementCommand } from "./management-command.mjs"; - -const managementRoutes = new Map([ - ["/internal/v1/management/owner-scopes:ensure", "owner_scope.ensure"], - ["/internal/v1/management/projects:ensure", "project.ensure"], - ["/internal/v1/management/collections:ensure", "collection.ensure"], - ["/internal/v1/management/project-grants:upsert", "project_grant.upsert"], - ["/internal/v1/management/adapter-packages:ensure", "adapter_package.ensure"], - ["/internal/v1/management/adapter-versions:register", "adapter_version.register"], - ["/internal/v1/management/model-profiles:register", "model_profile.register"], - ["/internal/v1/management/edges:ensure", "edge.ensure"], - ["/internal/v1/management/routes:ensure", "route.ensure"], - ["/internal/v1/management/enrollment-intents:ensure", "enrollment_intent.ensure"], - ["/internal/v1/management/devices:claim", "device.claim"], - ["/internal/v1/management/devices:transfer", "device.transfer"], - ["/internal/v1/management/discoveries:reject", "discovery.reject"], - ["/internal/v1/management/discoveries:expire", "discovery.expire"], - [ - "/internal/v1/management/device-credential-bindings:upsert", - "device_credential_binding.upsert", - ], - [ - "/internal/v1/management/device-credential-bindings:revoke", - "device_credential_binding.revoke", - ], - ["/internal/v1/management/device-bindings:ensure", "device_binding.ensure"], - ["/internal/v1/management/device-bindings:revoke", "device_binding.revoke"], - [ - "/internal/v1/management/device-configuration-revisions:create", - "device_configuration_revision.create", - ], - [ - "/internal/v1/management/device-configurations:set-desired", - "device_configuration_desired.set", - ], -]); - -export function createControlCoreApp({ - repository, - gatewayToken = "", - identifierPepper = "", - discoveryIngestEnabled = false, - managementApiEnabled = false, - managementToken = "", - gatewayIngest = null, - edgeChannelStatusProvider = null, - typedCommandRuntime = null, -} = {}) { - if (!repository || typeof repository.health !== "function") { - throw new TypeError("device_repository_required"); - } - if (discoveryIngestEnabled) { - if (typeof repository.upsertQuarantineDiscovery !== "function") { - throw new TypeError("device_discovery_repository_required"); - } - if (typeof repository.acceptAdapterMessage !== "function") { - throw new TypeError("device_gateway_message_repository_required"); - } - if (typeof gatewayToken !== "string" || gatewayToken.length < 32) { - throw new TypeError("device_gateway_token_invalid"); - } - if (typeof identifierPepper !== "string" || identifierPepper.length < 32) { - throw new TypeError("device_identifier_pepper_invalid"); - } - } - if (managementApiEnabled) { - if (typeof repository.executeManagementCommand !== "function") { - throw new TypeError("device_management_repository_required"); - } - if (typeof managementToken !== "string" || managementToken.length < 32) { - throw new TypeError("device_management_token_invalid"); - } - if (typeof identifierPepper !== "string" || identifierPepper.length < 32) { - throw new TypeError("device_identifier_pepper_invalid"); - } - } - const ingest = discoveryIngestEnabled - ? gatewayIngest ?? createDeviceGatewayIngest({ repository, identifierPepper }) - : gatewayIngest; - if ( - ingest - && ( - typeof ingest.observeDiscovery !== "function" - || typeof ingest.acceptMessage !== "function" - ) - ) { - throw new TypeError("device_gateway_ingest_invalid"); - } - if ( - typedCommandRuntime != null - && ( - typeof typedCommandRuntime.planServicePing !== "function" - || typeof typedCommandRuntime.status !== "function" - ) - ) { - throw new TypeError("device_typed_command_runtime_invalid"); - } - if ( - edgeChannelStatusProvider != null - && typeof edgeChannelStatusProvider !== "function" - ) { - throw new TypeError("device_edge_channel_status_provider_invalid"); - } - - const server = createServer(async (request, response) => { - response.setHeader("Content-Type", "application/json; charset=utf-8"); - response.setHeader("Cache-Control", "no-store"); - response.setHeader("X-Content-Type-Options", "nosniff"); - - try { - const requestUrl = new URL( - request.url || "/", - `http://${request.headers.host || "127.0.0.1"}`, - ); - - if (request.method === "GET" && requestUrl.pathname === "/healthz") { - const database = await repository.health(); - return writeJson(response, 200, { - ok: true, - service: "nodedc-device-control-core", - database, - discoveryIngest: discoveryIngestEnabled ? "enabled" : "disabled", - managementApi: managementApiEnabled ? "enabled" : "disabled", - edgeChannels: edgeChannelStatusProvider - ? edgeChannelStatusProvider() - : { enabled: false, configured: 0, accepted: 0, degraded: 0 }, - commandTransport: typedCommandRuntime - ? "typed-service-ping-v1" - : "disabled", - }); - } - - if ( - request.method === "POST" - && requestUrl.pathname === "/internal/v1/commands:service-ping" - ) { - if (!managementApiEnabled || !typedCommandRuntime) { - return writeJson(response, 404, { - ok: false, - error: "device_command_transport_disabled", - }); - } - if (!matchesBearer(request.headers.authorization, managementToken)) { - return writeJson(response, 401, { - ok: false, - error: "device_management_auth_required", - }); - } - const idempotencyKey = normalizeIdempotencyKey( - request.headers["idempotency-key"], - ); - const actor = managementActorFromHeaders(request.headers); - const input = await readJsonBody(request, 8 * 1024); - const execution = await typedCommandRuntime.planServicePing({ - idempotencyKey, - actor, - input, - }); - response.setHeader("Idempotency-Key", idempotencyKey); - response.setHeader( - "Idempotency-Replayed", - execution.replayed ? "true" : "false", - ); - return writeJson(response, 200, { - ok: true, - replayed: execution.replayed, - result: execution.command, - }); - } - - const managementCommandKind = managementRoutes.get(requestUrl.pathname); - if (request.method === "POST" && managementCommandKind) { - 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", - }); - } - - const idempotencyKey = normalizeIdempotencyKey( - request.headers["idempotency-key"], - ); - const actor = managementActorFromHeaders(request.headers); - const input = await readJsonBody(request, 64 * 1024); - const protectedInput = managementCommandKind === "enrollment_intent.ensure" - ? protectEnrollmentIdentifier(input, identifierPepper) - : input; - const command = normalizeDeviceManagementCommand( - managementCommandKind, - protectedInput, - ); - const requestDigest = managementRequestDigest({ - actor, - commandKind: managementCommandKind, - command, - }); - const execution = await repository.executeManagementCommand({ - idempotencyKey, - commandKind: managementCommandKind, - requestDigest, - actor, - command, - }); - response.setHeader("Idempotency-Key", idempotencyKey); - response.setHeader( - "Idempotency-Replayed", - execution.replayed ? "true" : "false", - ); - return writeJson(response, 200, { - ok: true, - replayed: execution.replayed, - result: execution.result, - }); - } - - if ( - request.method === "GET" - && requestUrl.pathname === "/internal/v1/query/projects" - ) { - 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.listAccessibleProjects !== "function") { - return writeJson(response, 503, { - ok: false, - error: "device_query_repository_unavailable", - }); - } - const actor = managementActorFromHeaders(request.headers); - const projects = await repository.listAccessibleProjects(actor); - return writeJson(response, 200, { ok: true, projects }); - } - - const workspaceProjectId = projectWorkspaceId(requestUrl.pathname); - if (request.method === "GET" && workspaceProjectId) { - 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.getProjectWorkspace !== "function") { - return writeJson(response, 503, { - ok: false, - error: "device_query_repository_unavailable", - }); - } - const actor = managementActorFromHeaders(request.headers); - const workspace = await repository.getProjectWorkspace( - actor, - workspaceProjectId, - { - commandTransport: typedCommandRuntime - ? "typed-service-ping-v1" - : "disabled", - }, - ); - return writeJson(response, 200, { ok: true, workspace }); - } - - if ( - request.method === "POST" - && requestUrl.pathname === "/internal/v1/device-discoveries:observe" - ) { - if (!discoveryIngestEnabled) { - return writeJson(response, 404, { - ok: false, - error: "device_discovery_ingest_disabled", - }); - } - if (!matchesBearer(request.headers.authorization, gatewayToken)) { - return writeJson(response, 401, { - ok: false, - error: "device_gateway_auth_required", - }); - } - - const input = await readJsonBody(request, 32 * 1024); - const discovery = await ingest.observeDiscovery(input); - return writeJson(response, discovery.created ? 201 : 200, { - ok: true, - created: discovery.created, - discovery: discovery.value, - }); - } - - if ( - request.method === "POST" - && requestUrl.pathname === "/internal/v1/gateway/messages:accept" - ) { - if (!discoveryIngestEnabled) { - return writeJson(response, 404, { - ok: false, - error: "device_gateway_message_ingest_disabled", - }); - } - if (!matchesBearer(request.headers.authorization, gatewayToken)) { - return writeJson(response, 401, { - ok: false, - error: "device_gateway_auth_required", - }); - } - - const input = await readJsonBody(request, 1024 * 1024); - const receipt = await ingest.acceptMessage(input); - const acceptance = receipt.value; - return writeJson(response, acceptance.replayed ? 200 : 201, { - ok: true, - acceptance, - }); - } - - return writeJson(response, 404, { - ok: false, - error: "device_control_core_route_not_found", - }); - } catch (error) { - const status = Number(error?.statusCode || 400); - return writeJson( - response, - Number.isInteger(status) && status >= 400 && status < 600 - ? status - : 500, - { - ok: false, - error: safeErrorCode(error), - }, - ); - } - }); - - return server; -} - -function protectEnrollmentIdentifier(input, identifierPepper) { - if (!input || typeof input !== "object" || Array.isArray(input)) { - throw new TypeError("device_enrollment_input_invalid"); - } - const allowedKeys = new Set([ - "projectRef", - "enrollmentKey", - "routeRef", - "modelProfileRef", - "displayName", - "identifier", - "expiresAt", - ]); - for (const key of Object.keys(input)) { - if (!allowedKeys.has(key)) { - throw new TypeError("device_enrollment_input_field_unexpected"); - } - } - const identifier = normalizeRestrictedIdentifier(input.identifier); - return Object.freeze({ - projectRef: input.projectRef, - enrollmentKey: input.enrollmentKey, - routeRef: input.routeRef, - modelProfileRef: input.modelProfileRef, - displayName: input.displayName, - identifierKind: identifier.kind, - identifierDigest: hashRestrictedIdentifier(identifier, identifierPepper), - identifierMasked: maskRestrictedIdentifier(identifier), - expiresAt: input.expiresAt, - }); -} - -function projectWorkspaceId(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})\/workspace$/i, - ); - return match?.[1]?.toLowerCase() ?? null; -} - -function managementActorFromHeaders(headers) { - return normalizeManagementActor({ - userRef: singleHeader(headers["x-nodedc-user-ref"]), - hubRole: singleHeader(headers["x-nodedc-hub-role"]), - groupRefs: commaSeparatedHeader(headers["x-nodedc-group-refs"]), - ownerScopes: ownerScopeHeader(headers["x-nodedc-owner-scopes"]), - }); -} - -function ownerScopeHeader(value) { - return commaSeparatedHeader(value).map((claim) => { - const separatorIndex = claim.indexOf("="); - if (separatorIndex < 1 || separatorIndex === claim.length - 1) { - throw new TypeError("device_actor_owner_scopes_invalid"); - } - return { - scopeKind: claim.slice(0, separatorIndex), - ownerRef: claim.slice(separatorIndex + 1), - }; - }); -} - -function commaSeparatedHeader(value) { - const header = singleHeader(value, true); - if (!header) return []; - return header.split(",").map((item) => item.trim()).filter(Boolean); -} - -function singleHeader(value, optional = false) { - if (Array.isArray(value)) throw new TypeError("device_management_header_invalid"); - if (value == null || value === "") { - if (optional) return ""; - throw new TypeError("device_management_header_required"); - } - if (typeof value !== "string" || value.length > 4096) { - throw new TypeError("device_management_header_invalid"); - } - return value.trim(); -} - -function normalizeIdempotencyKey(value) { - const key = singleHeader(value); - if (!/^[\x21-\x7e]{8,256}$/.test(key)) { - const error = new Error("device_idempotency_key_invalid"); - error.statusCode = 400; - throw error; - } - return key; -} - -function managementRequestDigest(value) { - return `sha256:${createHash("sha256") - .update(JSON.stringify(value), "utf8") - .digest("hex")}`; -} - -function matchesBearer(header, expected) { - if (typeof header !== "string" || !header.startsWith("Bearer ")) return false; - const actual = Buffer.from(header.slice("Bearer ".length), "utf8"); - const required = Buffer.from(expected, "utf8"); - return ( - actual.length === required.length - && required.length > 0 - && timingSafeEqual(actual, required) - ); -} - -async function readJsonBody(request, maxBytes) { - const chunks = []; - let size = 0; - for await (const chunk of request) { - size += chunk.length; - if (size > maxBytes) { - const error = new Error("device_request_body_too_large"); - error.statusCode = 413; - throw error; - } - chunks.push(chunk); - } - if (size === 0) throw new TypeError("device_request_body_required"); - try { - return JSON.parse(Buffer.concat(chunks).toString("utf8")); - } catch { - throw new TypeError("device_request_json_invalid"); - } -} - -function writeJson(response, status, body) { - response.statusCode = status; - return response.end(`${JSON.stringify(body)}\n`); -} - -function safeErrorCode(error) { - const value = error instanceof Error ? error.message : "device_control_error"; - return /^[a-z0-9_:-]{1,128}$/.test(value) - ? value - : "device_control_error"; -} diff --git a/device-plane/services/device-control-core/src/control-resource-management.mjs b/device-plane/services/device-control-core/src/control-resource-management.mjs deleted file mode 100644 index a4248f0..0000000 --- a/device-plane/services/device-control-core/src/control-resource-management.mjs +++ /dev/null @@ -1,284 +0,0 @@ -import { createHash } from "node:crypto"; - -import { - DEVICE_BINDING_CAPABILITIES, - assertSafeProjection, -} from "../../../packages/device-protocol-contract/src/index.mjs"; - -export const DEVICE_CONTROL_RESOURCE_COMMAND_KINDS = Object.freeze([ - "device_binding.ensure", - "device_binding.revoke", - "device_configuration_revision.create", - "device_configuration_desired.set", -]); - -const commandKindSet = new Set(DEVICE_CONTROL_RESOURCE_COMMAND_KINDS); -const bindingCapabilitySet = new Set(DEVICE_BINDING_CAPABILITIES); -const keyPattern = /^[a-z][a-z0-9-]{1,62}$/; -const tokenPattern = /^[a-z][a-z0-9._:-]{1,63}$/; -const resolutionPattern = /^[a-z][a-z0-9._-]{1,63}$/; -const targetRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{2,255}$/; -const configurationKeyPattern = /^[a-z][a-z0-9._-]{0,63}$/; -const secretReferencePattern = /^(?:ndc-credref:|(?:bearer|basic)\s)|[?&](?:token|secret|password|api[_-]?key)=/i; - -export function isControlResourceManagementCommand(kind) { - return commandKindSet.has(kind); -} - -export function normalizeControlResourceManagementCommand(kind, input) { - if (!commandKindSet.has(kind)) { - throw new TypeError("device_control_resource_command_kind_invalid"); - } - assertPlainObject(input, "device_control_resource_command_invalid"); - - if (kind === "device_binding.ensure") { - assertAllowedKeys(input, [ - "projectRef", - "bindingKey", - "displayName", - "source", - "targetKind", - "targetRef", - "capabilities", - ]); - return Object.freeze({ - projectId: normalizeEntityRef(input.projectRef, "project"), - bindingKey: normalizePattern( - input.bindingKey, - keyPattern, - "device_binding_key_invalid", - ), - displayName: normalizeDisplayText( - input.displayName, - 160, - "device_binding_name_invalid", - ), - source: normalizeBindingSource(input.source), - targetKind: normalizePattern( - input.targetKind, - tokenPattern, - "device_binding_target_kind_invalid", - ), - targetRef: normalizeTargetRef(input.targetRef), - capabilities: Object.freeze(normalizeBindingCapabilities( - input.capabilities, - )), - }); - } - - if (kind === "device_binding.revoke") { - assertAllowedKeys(input, ["projectRef", "bindingRef", "resolutionCode"]); - return Object.freeze({ - projectId: normalizeEntityRef(input.projectRef, "project"), - bindingId: normalizeEntityRef(input.bindingRef, "binding"), - resolutionCode: normalizePattern( - input.resolutionCode, - resolutionPattern, - "device_binding_resolution_code_invalid", - ), - }); - } - - if (kind === "device_configuration_revision.create") { - assertAllowedKeys(input, [ - "projectRef", - "deviceRef", - "configuration", - "changeSummary", - ]); - const configuration = normalizeDeviceConfiguration(input.configuration); - return Object.freeze({ - projectId: normalizeEntityRef(input.projectRef, "project"), - deviceId: normalizeEntityRef(input.deviceRef, "device"), - configuration, - configurationDigest: `sha256:${createHash("sha256") - .update(JSON.stringify(configuration), "utf8") - .digest("hex")}`, - changeSummary: normalizeOptionalText( - input.changeSummary, - 1000, - "device_configuration_change_summary_invalid", - ), - }); - } - - assertAllowedKeys(input, [ - "projectRef", - "deviceRef", - "configurationRevisionRef", - ]); - return Object.freeze({ - projectId: normalizeEntityRef(input.projectRef, "project"), - deviceId: normalizeEntityRef(input.deviceRef, "device"), - configurationRevisionId: normalizeEntityRef( - input.configurationRevisionRef, - "configuration-revision", - ), - }); -} - -export function normalizeDeviceConfiguration(input) { - const normalized = normalizeConfigurationValue(input, 0, "$configuration"); - if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) { - throw new TypeError("device_configuration_must_be_object"); - } - if (Object.keys(normalized).length === 0) { - throw new TypeError("device_configuration_must_not_be_empty"); - } - const serialized = JSON.stringify(normalized); - if (Buffer.byteLength(serialized, "utf8") > 32768) { - throw new TypeError("device_configuration_too_large"); - } - assertSafeProjection({ configuration: normalized }); - return deepFreeze(normalized); -} - -function normalizeBindingSource(input) { - assertPlainObject(input, "device_binding_source_invalid"); - assertAllowedKeys(input, ["kind", "ref"]); - if (input.kind === "device") { - return Object.freeze({ - kind: "device", - id: normalizeEntityRef(input.ref, "device"), - }); - } - if (input.kind === "collection") { - return Object.freeze({ - kind: "collection", - id: normalizeEntityRef(input.ref, "collection"), - }); - } - throw new TypeError("device_binding_source_kind_invalid"); -} - -function normalizeBindingCapabilities(input) { - if (!Array.isArray(input) || input.length < 1 || input.length > 16) { - throw new TypeError("device_binding_capabilities_invalid"); - } - const normalized = input.map((value) => { - if (typeof value !== "string" || !bindingCapabilitySet.has(value)) { - throw new TypeError("device_binding_capability_invalid"); - } - return value; - }); - if (new Set(normalized).size !== normalized.length) { - throw new TypeError("device_binding_capabilities_duplicate"); - } - return normalized.sort(); -} - -function normalizeTargetRef(value) { - if ( - typeof value !== "string" - || !targetRefPattern.test(value) - || secretReferencePattern.test(value) - ) { - throw new TypeError("device_binding_target_ref_invalid"); - } - assertSafeProjection({ targetRef: value }); - return value; -} - -function normalizeConfigurationValue(value, depth, path) { - if (depth > 5) throw new TypeError("device_configuration_depth_exceeded"); - if (value === null || typeof value === "boolean") return value; - if (typeof value === "number") { - if (!Number.isFinite(value)) { - throw new TypeError(`device_configuration_number_invalid:${path}`); - } - return value; - } - if (typeof value === "string") { - if ( - value.length > 1000 - || /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value) - ) { - throw new TypeError(`device_configuration_string_invalid:${path}`); - } - return value; - } - if (Array.isArray(value)) { - if (value.length > 64) { - throw new TypeError(`device_configuration_array_invalid:${path}`); - } - return value.map((item, index) => - normalizeConfigurationValue(item, depth + 1, `${path}[${index}]`) - ); - } - assertPlainObject(value, `device_configuration_object_invalid:${path}`); - const keys = Object.keys(value); - if (keys.length > 64) { - throw new TypeError(`device_configuration_object_invalid:${path}`); - } - const normalized = {}; - for (const key of keys.sort()) { - if (!configurationKeyPattern.test(key)) { - throw new TypeError(`device_configuration_key_invalid:${path}.${key}`); - } - normalized[key] = normalizeConfigurationValue( - value[key], - depth + 1, - `${path}.${key}`, - ); - } - return normalized; -} - -function normalizeEntityRef(value, prefix) { - if (typeof value !== "string") { - throw new TypeError(`device_${prefix}_ref_invalid`); - } - const match = value.match(new RegExp( - `^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`, - "i", - )); - if (!match) throw new TypeError(`device_${prefix}_ref_invalid`); - return match[1].toLowerCase(); -} - -function normalizePattern(value, pattern, code) { - if (typeof value !== "string" || !pattern.test(value)) { - throw new TypeError(code); - } - return value; -} - -function normalizeDisplayText(value, maxLength, code) { - if (typeof value !== "string") throw new TypeError(code); - const normalized = value.trim(); - if ( - normalized.length < 1 - || normalized.length > maxLength - || /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized) - ) { - throw new TypeError(code); - } - return normalized; -} - -function normalizeOptionalText(value, maxLength, code) { - if (value == null || value === "") return null; - return normalizeDisplayText(value, maxLength, code); -} - -function assertPlainObject(value, code) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new TypeError(code); - } -} - -function assertAllowedKeys(input, allowed) { - const allowedSet = new Set(allowed); - for (const key of Object.keys(input)) { - if (!allowedSet.has(key)) { - throw new TypeError(`device_management_command_field_unexpected:${key}`); - } - } -} - -function deepFreeze(value) { - if (!value || typeof value !== "object" || Object.isFrozen(value)) return value; - Object.freeze(value); - for (const child of Object.values(value)) deepFreeze(child); - return value; -} diff --git a/device-plane/services/device-control-core/src/control-resource-repository.mjs b/device-plane/services/device-control-core/src/control-resource-repository.mjs deleted file mode 100644 index 6dbb306..0000000 --- a/device-plane/services/device-control-core/src/control-resource-repository.mjs +++ /dev/null @@ -1,498 +0,0 @@ -import { randomUUID } from "node:crypto"; - -import { - isControlResourceManagementCommand, -} from "./control-resource-management.mjs"; -import { findProjectWithCapability } from "./lifecycle-repository.mjs"; -import { toProjectRef } from "./project-management.mjs"; - -export async function applyControlResourceManagementCommand( - client, - { commandKind, actor, command }, -) { - if (!isControlResourceManagementCommand(commandKind)) { - throw new TypeError("device_control_resource_command_kind_invalid"); - } - if (commandKind === "device_binding.ensure") { - return ensureBinding(client, actor, command); - } - if (commandKind === "device_binding.revoke") { - return revokeBinding(client, actor, command); - } - if (commandKind === "device_configuration_revision.create") { - return createConfigurationRevision(client, actor, command); - } - return setDesiredConfiguration(client, actor, command); -} - -export async function authorizeControlResourceManagementReplay( - client, - { commandKind, actor, command }, -) { - if (!isControlResourceManagementCommand(commandKind)) { - throw new TypeError("device_control_resource_command_kind_invalid"); - } - const capability = commandKind.startsWith("device_binding.") - ? "binding.manage" - : "configuration.manage"; - await findProjectWithCapability( - client, - actor, - command.projectId, - capability, - ); - if (command.deviceId) { - const current = await client.query( - `select project_id from device_instances where id = $1`, - [command.deviceId], - ); - const currentProjectId = current.rows[0]?.project_id; - if (!currentProjectId) throw domainError("device_not_found", 404); - if (currentProjectId !== command.projectId) { - await findProjectWithCapability( - client, - actor, - currentProjectId, - capability, - ); - } - } -} - -async function ensureBinding(client, actor, command) { - const project = await findProjectWithCapability( - client, - actor, - command.projectId, - "binding.manage", - ); - const source = await findBindingSource(client, command); - const bindingId = randomUUID(); - const result = await client.query( - `insert into device_resource_bindings ( - id, - owner_scope_id, - project_id, - binding_key, - display_name, - source_kind, - device_id, - collection_id, - target_kind, - target_ref, - capabilities, - source_approved_by_ref - ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - on conflict (project_id, binding_key) do update set - display_name = excluded.display_name, - capabilities = excluded.capabilities, - updated_at = now() - where device_resource_bindings.lifecycle_state = 'pending_external_approval' - and device_resource_bindings.source_kind = excluded.source_kind - and device_resource_bindings.device_id is not distinct from excluded.device_id - and device_resource_bindings.collection_id is not distinct from excluded.collection_id - and device_resource_bindings.target_kind = excluded.target_kind - and device_resource_bindings.target_ref = excluded.target_ref - returning id, owner_scope_id, project_id, binding_key, display_name, - source_kind, device_id, collection_id, target_kind, target_ref, - capabilities, lifecycle_state, source_approved_at, created_at, updated_at, - (xmax = 0) as created`, - [ - bindingId, - project.owner_scope_id, - project.id, - command.bindingKey, - command.displayName, - command.source.kind, - command.source.kind === "device" ? source.id : null, - command.source.kind === "collection" ? source.id : null, - command.targetKind, - command.targetRef, - command.capabilities, - actor.userRef, - ], - ); - const binding = result.rows[0]; - if (!binding) throw domainError("device_binding_identity_conflict", 409); - - await addAudit(client, { - eventType: binding.created - ? "device_binding.created" - : "device_binding.updated", - actorRef: actor.userRef, - projectId: project.id, - deviceId: binding.device_id, - payload: { - bindingRef: `binding:${binding.id}`, - projectRef: toProjectRef(project.id), - bindingKey: binding.binding_key, - sourceKind: binding.source_kind, - sourceRef: bindingSourceRef(binding), - targetKind: binding.target_kind, - targetRef: binding.target_ref, - lifecycleState: binding.lifecycle_state, - }, - }); - return { - created: binding.created === true, - binding: bindingView(binding), - }; -} - -async function revokeBinding(client, actor, command) { - const project = await findProjectWithCapability( - client, - actor, - command.projectId, - "binding.manage", - ); - const result = await client.query( - `update device_resource_bindings - set lifecycle_state = 'revoked', - revoked_at = now(), - revoked_by_ref = $3, - revocation_code = $4, - updated_at = now() - where id = $1 - and project_id = $2 - and lifecycle_state <> 'revoked' - returning id, owner_scope_id, project_id, binding_key, display_name, - source_kind, device_id, collection_id, target_kind, target_ref, - capabilities, lifecycle_state, source_approved_at, created_at, updated_at`, - [command.bindingId, project.id, actor.userRef, command.resolutionCode], - ); - const binding = result.rows[0]; - if (!binding) throw domainError("device_binding_not_found", 404); - - await addAudit(client, { - eventType: "device_binding.revoked", - actorRef: actor.userRef, - projectId: project.id, - deviceId: binding.device_id, - payload: { - bindingRef: `binding:${binding.id}`, - projectRef: toProjectRef(project.id), - sourceKind: binding.source_kind, - sourceRef: bindingSourceRef(binding), - targetKind: binding.target_kind, - targetRef: binding.target_ref, - lifecycleState: binding.lifecycle_state, - resolutionCode: command.resolutionCode, - }, - }); - return { - revoked: true, - binding: bindingView(binding), - resolutionCode: command.resolutionCode, - }; -} - -async function createConfigurationRevision(client, actor, command) { - const project = await findProjectWithCapability( - client, - actor, - command.projectId, - "configuration.manage", - ); - const device = await findDirectDeviceForUpdate(client, command); - const profileResult = await client.query( - `select profile_ref, schema_artifact_ref, lifecycle_state - from device_model_profiles - where profile_ref = $1 - for share`, - [device.model_profile_ref], - ); - const profile = profileResult.rows[0]; - if ( - !profile - || profile.lifecycle_state !== "active" - || !profile.schema_artifact_ref - ) { - throw domainError("device_configuration_profile_unavailable", 409); - } - const nextResult = await client.query( - `select coalesce(max(revision_number), 0) + 1 as next_revision - from device_configuration_revisions - where device_id = $1`, - [device.id], - ); - const revisionNumber = Number(nextResult.rows[0]?.next_revision); - if (!Number.isSafeInteger(revisionNumber) || revisionNumber < 1) { - throw domainError("device_configuration_revision_sequence_invalid", 409); - } - const revisionId = randomUUID(); - const inserted = await client.query( - `insert into device_configuration_revisions ( - id, - owner_scope_id, - project_id, - device_id, - revision_number, - model_profile_ref, - schema_artifact_ref, - configuration_digest, - configuration, - change_summary, - created_by_ref - ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11) - returning id, owner_scope_id, project_id, device_id, revision_number, - model_profile_ref, schema_artifact_ref, configuration_digest, - configuration, change_summary, created_at`, - [ - revisionId, - project.owner_scope_id, - project.id, - device.id, - revisionNumber, - profile.profile_ref, - profile.schema_artifact_ref, - command.configurationDigest, - JSON.stringify(command.configuration), - command.changeSummary, - actor.userRef, - ], - ); - const revision = inserted.rows[0]; - if (!revision) { - throw domainError("device_configuration_revision_insert_failed", 409); - } - - await addAudit(client, { - eventType: "device_configuration_revision.created", - actorRef: actor.userRef, - projectId: project.id, - deviceId: device.id, - payload: { - deviceRef: `device:${device.id}`, - projectRef: toProjectRef(project.id), - configurationRevisionRef: `configuration-revision:${revision.id}`, - revisionNumber: Number(revision.revision_number), - modelProfileRef: revision.model_profile_ref, - schemaArtifactRef: revision.schema_artifact_ref, - configurationDigest: revision.configuration_digest, - }, - }); - return { - created: true, - configurationRevision: configurationRevisionView(revision), - }; -} - -async function setDesiredConfiguration(client, actor, command) { - const project = await findProjectWithCapability( - client, - actor, - command.projectId, - "configuration.manage", - ); - const device = await findDirectDeviceForUpdate(client, command); - const revisionResult = await client.query( - `select id, project_id, device_id, revision_number, - model_profile_ref, schema_artifact_ref, configuration_digest, - configuration, change_summary, created_at - from device_configuration_revisions - where id = $1 and device_id = $2 and project_id = $3 - for share`, - [command.configurationRevisionId, device.id, project.id], - ); - const revision = revisionResult.rows[0]; - if (!revision) throw domainError("device_configuration_revision_not_found", 404); - - const currentResult = await client.query( - `select desired_revision_id, applied_revision_id - from device_configuration_state - where device_id = $1 - for update`, - [device.id], - ); - const current = currentResult.rows[0] ?? null; - if (current?.desired_revision_id === revision.id) { - return { - changed: false, - configurationState: configurationStateView({ - device_id: device.id, - project_id: project.id, - desired_revision_id: revision.id, - applied_revision_id: current.applied_revision_id, - }), - }; - } - - const stateResult = await client.query( - `insert into device_configuration_state ( - device_id, - owner_scope_id, - project_id, - desired_revision_id - ) values ($1, $2, $3, $4) - on conflict (device_id) do update set - owner_scope_id = excluded.owner_scope_id, - project_id = excluded.project_id, - desired_revision_id = excluded.desired_revision_id, - updated_at = now() - returning device_id, project_id, desired_revision_id, applied_revision_id`, - [device.id, project.owner_scope_id, project.id, revision.id], - ); - const state = stateResult.rows[0]; - if (!state) throw domainError("device_configuration_state_update_failed", 409); - - await addAudit(client, { - eventType: "device_configuration.desired_changed", - actorRef: actor.userRef, - projectId: project.id, - deviceId: device.id, - payload: { - deviceRef: `device:${device.id}`, - projectRef: toProjectRef(project.id), - configurationRevisionRef: `configuration-revision:${revision.id}`, - previousConfigurationRevisionRef: current?.desired_revision_id - ? `configuration-revision:${current.desired_revision_id}` - : null, - configurationDigest: revision.configuration_digest, - }, - }); - return { - changed: true, - configurationState: configurationStateView(state), - }; -} - -async function findBindingSource(client, command) { - if (command.source.kind === "device") { - return findDirectDeviceForUpdate(client, { - projectId: command.projectId, - deviceId: command.source.id, - }); - } - const result = await client.query( - `select id, project_id, lifecycle_state - from device_collections - where id = $1 and project_id = $2 - for share`, - [command.source.id, command.projectId], - ); - const collection = result.rows[0]; - if (!collection) throw domainError("device_collection_not_found", 404); - if (collection.lifecycle_state !== "active") { - throw domainError("device_collection_inactive", 409); - } - return collection; -} - -async function findDirectDeviceForUpdate(client, command) { - const result = await client.query( - `select id, contour_id, owner_scope_id, project_id, - model_profile_ref, lifecycle_state - from device_instances - where id = $1 - for update`, - [command.deviceId], - ); - const device = result.rows[0]; - if (!device) throw domainError("device_not_found", 404); - if ( - device.contour_id - || !device.owner_scope_id - || !device.project_id - || device.project_id !== command.projectId - ) { - throw domainError("device_control_resource_project_mismatch", 409); - } - if (device.lifecycle_state === "retired") { - throw domainError("device_control_resource_lifecycle_blocked", 409); - } - return device; -} - -async function addAudit(client, { - eventType, - actorRef, - projectId, - 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, - actorRef, - projectId, - deviceId, - JSON.stringify(payload), - ], - ); -} - -function bindingView(row) { - return { - bindingRef: `binding:${row.id}`, - projectRef: toProjectRef(row.project_id), - bindingKey: row.binding_key, - displayName: row.display_name, - source: { - kind: row.source_kind, - ref: bindingSourceRef(row), - }, - target: { - kind: row.target_kind, - ref: row.target_ref, - }, - capabilities: row.capabilities ?? [], - lifecycleState: row.lifecycle_state, - sourceApprovedAt: toIso(row.source_approved_at), - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function bindingSourceRef(row) { - return row.source_kind === "device" - ? `device:${row.device_id}` - : `collection:${row.collection_id}`; -} - -function configurationRevisionView(row) { - return { - configurationRevisionRef: `configuration-revision:${row.id}`, - deviceRef: `device:${row.device_id}`, - projectRef: toProjectRef(row.project_id), - revisionNumber: Number(row.revision_number), - modelProfileRef: row.model_profile_ref, - schemaArtifactRef: row.schema_artifact_ref, - configurationDigest: row.configuration_digest, - configuration: row.configuration, - changeSummary: row.change_summary ?? null, - createdAt: toIso(row.created_at), - }; -} - -function configurationStateView(row) { - return { - deviceRef: `device:${row.device_id}`, - projectRef: toProjectRef(row.project_id), - desiredConfigurationRevisionRef: row.desired_revision_id - ? `configuration-revision:${row.desired_revision_id}` - : null, - appliedConfigurationRevisionRef: row.applied_revision_id - ? `configuration-revision:${row.applied_revision_id}` - : null, - }; -} - -function toIso(value) { - return new Date(value).toISOString(); -} - -function domainError(code, statusCode) { - const error = new Error(code); - error.statusCode = statusCode; - return error; -} diff --git a/device-plane/services/device-control-core/src/credential-reference.mjs b/device-plane/services/device-control-core/src/credential-reference.mjs deleted file mode 100644 index 85861c3..0000000 --- a/device-plane/services/device-control-core/src/credential-reference.mjs +++ /dev/null @@ -1,29 +0,0 @@ -export const NDC_CREDENTIAL_REFERENCE_OWNER = "ndc_l2_credentials"; - -const CREDENTIAL_REFERENCE_PATTERN = - /^ndc-credref:[A-Za-z0-9][A-Za-z0-9._:-]{7,240}$/; - -export function normalizeNdcCredentialReference(input) { - if (!input || typeof input !== "object" || Array.isArray(input)) { - throw new TypeError("ndc_credential_reference_invalid"); - } - for (const key of Object.keys(input)) { - if (!new Set(["owner", "reference"]).has(key)) { - throw new TypeError(`ndc_credential_reference_field_unexpected:${key}`); - } - } - if (input.owner !== NDC_CREDENTIAL_REFERENCE_OWNER) { - throw new TypeError("ndc_credential_reference_owner_invalid"); - } - if (!isNdcCredentialReferenceValue(input.reference)) { - throw new TypeError("ndc_credential_reference_value_invalid"); - } - return Object.freeze({ - owner: NDC_CREDENTIAL_REFERENCE_OWNER, - reference: input.reference, - }); -} - -export function isNdcCredentialReferenceValue(value) { - return typeof value === "string" && CREDENTIAL_REFERENCE_PATTERN.test(value); -} diff --git a/device-plane/services/device-control-core/src/database-config.mjs b/device-plane/services/device-control-core/src/database-config.mjs deleted file mode 100644 index 859dbcd..0000000 --- a/device-plane/services/device-control-core/src/database-config.mjs +++ /dev/null @@ -1,73 +0,0 @@ -import { readFile } from "node:fs/promises"; - -export async function resolveDeviceDatabaseUrl( - environment = process.env, - readSecret = readFile, -) { - const explicit = optionalValue(environment.DEVICE_DATABASE_URL); - if (explicit) return explicit; - - const host = restrictedValue( - environment.DEVICE_DATABASE_HOST, - /^[A-Za-z0-9.-]{1,253}$/, - "device_database_host_invalid", - ); - const port = parsePort(environment.DEVICE_DATABASE_PORT, 5432); - const database = restrictedValue( - environment.DEVICE_DATABASE_NAME, - /^[A-Za-z_][A-Za-z0-9_-]{0,62}$/, - "device_database_name_invalid", - ); - const user = restrictedValue( - environment.DEVICE_DATABASE_USER, - /^[A-Za-z_][A-Za-z0-9_-]{0,62}$/, - "device_database_user_invalid", - ); - const passwordFile = requiredValue( - environment.DEVICE_DATABASE_PASSWORD_FILE, - "device_database_password_file_required", - ); - const password = (await readSecret(passwordFile, "utf8")).trim(); - if (password.length < 32 || password.length > 512) { - throw new Error("device_database_password_invalid"); - } - - return [ - "postgresql://", - encodeURIComponent(user), - ":", - encodeURIComponent(password), - "@", - host, - ":", - String(port), - "/", - encodeURIComponent(database), - "?sslmode=disable", - ].join(""); -} - -function optionalValue(value) { - if (typeof value !== "string") return ""; - return value.trim(); -} - -function requiredValue(value, errorCode) { - const normalized = optionalValue(value); - if (!normalized) throw new Error(errorCode); - return normalized; -} - -function restrictedValue(value, pattern, errorCode) { - const normalized = requiredValue(value, errorCode); - if (!pattern.test(normalized)) throw new Error(errorCode); - return normalized; -} - -function parsePort(value, fallback) { - const parsed = Number(value || fallback); - if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) { - throw new Error("device_database_port_invalid"); - } - return parsed; -} diff --git a/device-plane/services/device-control-core/src/device-gateway-core-runtime.mjs b/device-plane/services/device-control-core/src/device-gateway-core-runtime.mjs deleted file mode 100644 index 0c10bf6..0000000 --- a/device-plane/services/device-control-core/src/device-gateway-core-runtime.mjs +++ /dev/null @@ -1,741 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { connect as connectHttp2 } from "node:http2"; - -import { - DEVICE_EDGE_CHANNEL_LIMITS, - DEVICE_EDGE_CHANNEL_PATH, - createChannelEnvelope, - createChannelEnvelopeDecoder, - encodeChannelEnvelope, - nextReconnectDelay, - normalizeCertificateIdentities, - normalizeCertificateFingerprint, -} from "../../../packages/device-edge-channel-contract/src/index.mjs"; -import { - DEVICE_DISCOVERY_VIEW_SCHEMA, - assertSafeProjection, - normalizeAdapterAcceptance, - normalizeAdapterMessage, - normalizeDiscoverySignal, -} from "../../../packages/device-protocol-contract/src/index.mjs"; - -// Runtime-owned transport implementation; kept inside the deployable Core context. -const CHANNEL_TRACKER_SESSION_ID = "channel:control"; -const CHANNEL_PROFILE_REF = "channel.control.v1"; -const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; - -export function createDeviceGatewayCoreChannelClient(options = {}) { - const config = normalizeConfig(options); - const readyWaiters = new Set(); - let running = false; - let state = null; - let reconnectTimer = null; - let reconnectAttempt = 0; - let connectionSerial = 0; - let totalConnectionAttempts = 0; - let totalChannelsAccepted = 0; - let totalReconnects = 0; - let totalEventsAccepted = 0; - let totalEventsRejected = 0; - let totalProtocolFailures = 0; - let lastErrorCode = null; - - return Object.freeze({ - async start() { - if (running) return; - running = true; - void connectNow(); - }, - async stop() { - running = false; - clearTimeout(reconnectTimer); - reconnectTimer = null; - const current = state; - state = null; - if (current) closeConnection(current, false); - rejectReadyWaiters("device_gateway_core_channel_stopped"); - }, - waitForReady(timeoutMs = 5_000) { - if (state?.ready && !state.closed) return Promise.resolve(status()); - const normalizedTimeout = normalizeInteger( - timeoutMs, - 10, - 120_000, - 5_000, - "ready_timeout", - ); - return new Promise((resolve, reject) => { - const waiter = { resolve, reject, timer: null }; - waiter.timer = setTimeout(() => { - readyWaiters.delete(waiter); - reject(new Error("device_gateway_core_channel_ready_timeout")); - }, normalizedTimeout); - waiter.timer.unref?.(); - readyWaiters.add(waiter); - }); - }, - status, - disconnect() { - if (state) closeConnection(state, true); - }, - }); - - function status() { - return Object.freeze({ - running, - channel: state?.ready ? "accepted" : state ? "connecting" : "absent", - edgeRegistrationId: state?.registration?.edgeRegistrationId ?? null, - channelGeneration: state?.channelGeneration ?? null, - edgeTrustGeneration: state?.observedEdgeIdentity?.generationRef ?? null, - edgeCertificateFingerprint: - state?.observedEdgeIdentity?.fingerprint ?? null, - negotiatedCommandTransport: state?.negotiatedCommandTransport ?? null, - activeTrackerSessionChains: state?.sessionChains.size ?? 0, - connectionAttempts: totalConnectionAttempts, - channelsAccepted: totalChannelsAccepted, - reconnects: totalReconnects, - eventsAccepted: totalEventsAccepted, - eventsRejected: totalEventsRejected, - protocolFailures: totalProtocolFailures, - lastErrorCode, - trackerIngress: "remote-edge-only", - commandTransport: config.commandTransport, - }); - } - - async function connectNow() { - if (!running || state) return; - totalConnectionAttempts += 1; - const serial = ++connectionSerial; - let registration; - try { - registration = normalizeRegistration(await config.registrationProvider()); - if (registration.lifecycleState !== "active") { - throw new Error("device_gateway_core_edge_registration_inactive"); - } - } catch (error) { - lastErrorCode = safeErrorCode(error); - scheduleReconnect(); - return; - } - - const connection = { - serial, - registration, - session: null, - request: null, - decoder: createChannelEnvelopeDecoder({ - direction: "edge-to-core", - maxEnvelopeBytes: config.maxEnvelopeBytes, - }), - sessionChains: new Map(), - trackerDevices: new Map(), - channelGeneration: null, - negotiatedCommandTransport: null, - observedEdgeIdentity: null, - edgeSequence: 0, - coreSequence: 0, - lastEdgeActivityAt: config.clock(), - connectTimer: null, - heartbeatTimer: null, - ready: false, - closed: false, - }; - state = connection; - const endpoint = new URL(registration.endpoint); - const authority = `${endpoint.protocol}//${endpoint.host}`; - const session = connectHttp2(authority, { - key: config.tls.key, - cert: config.tls.cert, - ca: config.tls.ca, - minVersion: "TLSv1.3", - maxVersion: "TLSv1.3", - rejectUnauthorized: true, - servername: registration.servername, - ALPNProtocols: ["h2"], - settings: { - enablePush: false, - initialWindowSize: 1024 * 1024, - }, - }); - connection.session = session; - connection.connectTimer = setTimeout(() => { - failConnection(connection, new Error( - "device_gateway_core_channel_connect_timeout", - )); - }, config.connectTimeoutMs); - connection.connectTimer.unref?.(); - session.once("error", (error) => failConnection(connection, error)); - session.once("close", () => closeConnection(connection, true)); - session.once("connect", () => { - try { - verifyEdgePeer(connection); - openChannelStream(connection); - } catch (error) { - failConnection(connection, error); - } - }); - } - - function openChannelStream(connection) { - assertCurrent(connection); - const request = connection.session.request({ - ":method": "POST", - ":path": DEVICE_EDGE_CHANNEL_PATH, - "content-type": "application/x-ndjson", - "cache-control": "no-store", - }, { endStream: false }); - connection.request = request; - request.once("response", (headers) => { - if (Number(headers[":status"]) !== 200) { - failConnection(connection, new Error( - `device_gateway_core_channel_http_status_${headers[":status"]}`, - )); - } - }); - let processing = Promise.resolve(); - request.on("data", (chunk) => { - request.pause(); - processing = processing - .then(async () => { - const envelopes = connection.decoder.push(chunk); - for (const envelope of envelopes) { - await handleEdgeEnvelope(connection, envelope); - } - }) - .catch((error) => failConnection(connection, error)) - .finally(() => { - if (!connection.closed) request.resume(); - }); - }); - request.once("aborted", () => closeConnection(connection, true)); - request.once("close", () => closeConnection(connection, true)); - request.once("error", (error) => failConnection(connection, error)); - connection.heartbeatTimer = setInterval( - () => checkChannelHealth(connection), - config.keepaliveMs, - ); - connection.heartbeatTimer.unref?.(); - } - - async function handleEdgeEnvelope(connection, envelope) { - assertCurrent(connection); - if ( - envelope.edgeRegistrationId !== connection.registration.edgeRegistrationId - || envelope.sequence !== connection.edgeSequence + 1 - ) { - throw new Error("device_gateway_core_edge_envelope_mismatch"); - } - if ( - connection.channelGeneration - && envelope.channelGeneration !== connection.channelGeneration - ) { - throw new Error("device_gateway_core_channel_generation_mismatch"); - } - connection.edgeSequence = envelope.sequence; - connection.lastEdgeActivityAt = config.clock(); - - if (!connection.ready) { - if (envelope.messageKind !== "channel.hello") { - throw new Error("device_gateway_core_channel_hello_required"); - } - if ( - envelope.channelGeneration !== connection.registration.channelGeneration - ) { - throw new Error("device_gateway_core_channel_generation_mismatch"); - } - if ( - envelope.payload?.status !== "ready" - || envelope.payload?.transport !== "http2-mtls" - || envelope.payload?.trustGeneration - !== connection.observedEdgeIdentity?.generationRef - || !isCompatibleCommandTransport( - config.commandTransport, - envelope.payload?.commandTransport, - ) - ) { - throw new Error("device_gateway_core_channel_hello_invalid"); - } - connection.negotiatedCommandTransport = envelope.payload.commandTransport; - connection.channelGeneration = connection.registration.channelGeneration; - send(connection, "channel.accepted", { - status: "accepted", - coreIdentity: config.coreIdentity, - commandTransport: connection.negotiatedCommandTransport, - }, { - trackerSessionId: CHANNEL_TRACKER_SESSION_ID, - adapterProfileRef: CHANNEL_PROFILE_REF, - correlationId: envelope.correlationId, - }); - connection.ready = true; - clearTimeout(connection.connectTimer); - connection.connectTimer = null; - reconnectAttempt = 0; - totalChannelsAccepted += 1; - lastErrorCode = null; - resolveReadyWaiters(); - return; - } - if (envelope.messageKind === "channel.heartbeat") return; - if (["discovery.observed", "adapter.message", "command.status"].includes(envelope.messageKind)) { - scheduleTrackerEvent(connection, envelope); - return; - } - throw new Error("device_gateway_core_edge_message_unhandled"); - } - - function scheduleTrackerEvent(connection, envelope) { - if (envelope.trackerSessionId === CHANNEL_TRACKER_SESSION_ID) { - throw new Error("device_gateway_core_tracker_session_invalid"); - } - const previous = connection.sessionChains.get(envelope.trackerSessionId); - if (!previous && connection.sessionChains.size >= 128) { - throw new Error("device_gateway_core_tracker_session_limit_reached"); - } - const work = (previous ?? Promise.resolve()) - .then(() => envelope.messageKind === "discovery.observed" - ? acceptDiscovery(connection, envelope) - : envelope.messageKind === "adapter.message" - ? acceptAdapterMessage(connection, envelope) - : acceptCommandStatus(connection, envelope)) - .catch((error) => failConnection(connection, error)) - .finally(() => { - if (connection.sessionChains.get(envelope.trackerSessionId) === work) { - connection.sessionChains.delete(envelope.trackerSessionId); - } - }); - connection.sessionChains.set(envelope.trackerSessionId, work); - } - - async function acceptDiscovery(connection, envelope) { - try { - const signal = normalizeDiscoverySignal(envelope.payload?.signal); - const receipt = normalizeDiscoveryReceipt( - await config.observeDiscovery(signal), - ); - if (receipt.claimedDeviceRef) { - connection.trackerDevices.set( - envelope.trackerSessionId, - receipt.claimedDeviceRef, - ); - } else { - connection.trackerDevices.delete(envelope.trackerSessionId); - } - const commandOffer = ( - connection.negotiatedCommandTransport === "typed-service-ping-v1" - && receipt.claimedDeviceRef - ) - ? await config.offerCommand(receipt.claimedDeviceRef) - : null; - sendEventResult(connection, envelope, { - discovery: receipt.discovery, - ...(commandOffer ? { commandOffer } : {}), - }); - totalEventsAccepted += 1; - } catch (error) { - sendEventRejection(connection, envelope, error); - totalEventsRejected += 1; - } - } - - async function acceptAdapterMessage(connection, envelope) { - try { - const message = normalizeAdapterMessage(envelope.payload?.message, { - maxBytes: config.maxEnvelopeBytes, - }); - const receipt = normalizeAdapterReceipt( - await config.acceptMessage(message), - ); - if (receipt.claimedDeviceRef) { - connection.trackerDevices.set( - envelope.trackerSessionId, - receipt.claimedDeviceRef, - ); - } - const claimedDeviceRef = receipt.claimedDeviceRef - ?? connection.trackerDevices.get(envelope.trackerSessionId); - const commandOffer = ( - connection.negotiatedCommandTransport === "typed-service-ping-v1" - && claimedDeviceRef - ) - ? await config.offerCommand(claimedDeviceRef) - : null; - sendEventResult(connection, envelope, { - acceptance: receipt.acceptance, - ...(commandOffer ? { commandOffer } : {}), - }); - totalEventsAccepted += 1; - } catch (error) { - sendEventRejection(connection, envelope, error); - totalEventsRejected += 1; - } - } - - async function acceptCommandStatus(connection, envelope) { - try { - await config.recordCommandStatus(envelope.payload?.status); - sendEventResult(connection, envelope, { status: "recorded" }); - totalEventsAccepted += 1; - } catch (error) { - sendEventRejection(connection, envelope, error); - totalEventsRejected += 1; - } - } - - function sendEventResult(connection, envelope, result) { - send(connection, "event.accepted", { result }, { - trackerSessionId: envelope.trackerSessionId, - adapterProfileRef: envelope.adapterProfileRef, - correlationId: envelope.correlationId, - }); - } - - function sendEventRejection(connection, envelope, error) { - send(connection, "event.rejected", { - errorCode: safeErrorCode(error), - }, { - trackerSessionId: envelope.trackerSessionId, - adapterProfileRef: envelope.adapterProfileRef, - correlationId: envelope.correlationId, - }); - } - - function send(connection, messageKind, payload, metadata) { - assertCurrent(connection); - if (!connection.channelGeneration) { - throw new Error("device_gateway_core_channel_generation_absent"); - } - connection.coreSequence += 1; - const now = config.now(); - const envelope = createChannelEnvelope({ - edgeRegistrationId: connection.registration.edgeRegistrationId, - channelGeneration: connection.channelGeneration, - trackerSessionId: metadata.trackerSessionId, - adapterProfileRef: metadata.adapterProfileRef, - sequence: connection.coreSequence, - eventAt: metadata.eventAt ?? now, - receivedAt: now, - messageKind, - correlationId: metadata.correlationId, - payload, - }, { - direction: "core-to-edge", - maxEnvelopeBytes: config.maxEnvelopeBytes, - }); - connection.request.write(encodeChannelEnvelope(envelope, { - direction: "core-to-edge", - maxEnvelopeBytes: config.maxEnvelopeBytes, - })); - } - - function checkChannelHealth(connection) { - if (connection.closed || state !== connection) return; - if (config.clock() - connection.lastEdgeActivityAt >= config.deadPeerMs) { - failConnection(connection, new Error("device_gateway_core_edge_dead_peer")); - return; - } - if (connection.ready) { - try { - send(connection, "channel.heartbeat", { status: "alive" }, { - trackerSessionId: CHANNEL_TRACKER_SESSION_ID, - adapterProfileRef: CHANNEL_PROFILE_REF, - correlationId: `correlation:${randomUUID()}`, - }); - } catch (error) { - failConnection(connection, error); - } - } - } - - function verifyEdgePeer(connection) { - const socket = connection.session.socket; - if (!socket?.authorized || socket.alpnProtocol !== "h2") { - throw new Error("device_gateway_core_edge_tls_unauthorized"); - } - const observed = normalizeCertificateFingerprint( - socket.getPeerCertificate()?.fingerprint256, - ); - const identity = connection.registration.certificateIdentities.find( - (candidate) => candidate.fingerprint === observed, - ); - if (!identity) { - throw new Error("device_gateway_core_edge_identity_mismatch"); - } - connection.observedEdgeIdentity = identity; - } - - function failConnection(connection, error) { - if (connection.closed) return; - totalProtocolFailures += 1; - lastErrorCode = safeErrorCode(error); - closeConnection(connection, true); - } - - function closeConnection(connection, reconnect) { - if (connection.closed) return; - connection.closed = true; - clearTimeout(connection.connectTimer); - connection.connectTimer = null; - clearInterval(connection.heartbeatTimer); - connection.heartbeatTimer = null; - connection.sessionChains.clear(); - try { - connection.request?.close(); - } catch {} - try { - connection.session?.close(); - } catch {} - if (state === connection) state = null; - if (reconnect && running) scheduleReconnect(); - } - - function scheduleReconnect() { - if (!running || reconnectTimer || state) return; - const delay = nextReconnectDelay(reconnectAttempt, { - minimumMs: config.reconnectMinimumMs, - maximumMs: config.reconnectMaximumMs, - random: config.random, - }); - reconnectAttempt += 1; - totalReconnects += 1; - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - void connectNow(); - }, delay); - reconnectTimer.unref?.(); - } - - function assertCurrent(connection) { - if (!running || connection.closed || state !== connection) { - throw new Error("device_gateway_core_channel_unavailable"); - } - } - - function resolveReadyWaiters() { - const value = status(); - for (const waiter of readyWaiters) { - clearTimeout(waiter.timer); - waiter.resolve(value); - } - readyWaiters.clear(); - } - - function rejectReadyWaiters(code) { - for (const waiter of readyWaiters) { - clearTimeout(waiter.timer); - waiter.reject(new Error(code)); - } - readyWaiters.clear(); - } -} - -function normalizeDiscoveryReceipt(input) { - if ( - !input - || typeof input !== "object" - || Array.isArray(input) - || typeof input.created !== "boolean" - ) { - throw new TypeError("device_gateway_core_discovery_receipt_invalid"); - } - const discovery = assertSafeProjection(input.value); - if ( - discovery.schemaVersion !== DEVICE_DISCOVERY_VIEW_SCHEMA - || !["quarantine", "claimed"].includes(discovery.lifecycleState) - || discovery.commandTransport !== "disabled" - ) { - throw new TypeError("device_gateway_core_discovery_receipt_invalid"); - } - return Object.freeze({ - discovery, - claimedDeviceRef: input.claimedDeviceRef ?? null, - }); -} - -function normalizeAdapterReceipt(input) { - if (!input || typeof input !== "object" || Array.isArray(input)) { - throw new TypeError("device_gateway_core_adapter_receipt_invalid"); - } - const acceptanceValue = input.value ?? ( - input.schemaVersion === "nodedc.device-adapter-acceptance.v1" - ? input - : null - ); - return Object.freeze({ - acceptance: normalizeAdapterAcceptance(acceptanceValue), - claimedDeviceRef: input.claimedDeviceRef ?? null, - }); -} - -function normalizeConfig(options) { - if (typeof options.observeDiscovery !== "function") { - throw new TypeError("device_gateway_core_observe_discovery_invalid"); - } - if (typeof options.acceptMessage !== "function") { - throw new TypeError("device_gateway_core_accept_message_invalid"); - } - const commandTransport = options.commandTransport ?? "disabled"; - if (!["disabled", "typed-service-ping-v1"].includes(commandTransport)) { - throw new TypeError("device_gateway_core_command_transport_invalid"); - } - const offerCommand = options.offerCommand ?? (async () => null); - const recordCommandStatus = options.recordCommandStatus ?? (async () => undefined); - if (typeof offerCommand !== "function" || typeof recordCommandStatus !== "function") { - throw new TypeError("device_gateway_core_command_runtime_invalid"); - } - const registrationProvider = typeof options.registrationProvider === "function" - ? options.registrationProvider - : async () => options.registration; - const tls = normalizeTls(options.tls); - const keepaliveMs = normalizeInteger( - options.keepaliveMs, - 10, - 120_000, - DEVICE_EDGE_CHANNEL_LIMITS.keepaliveMs, - "keepalive", - ); - const deadPeerMs = normalizeInteger( - options.deadPeerMs, - keepaliveMs * 2, - 120_000, - DEVICE_EDGE_CHANNEL_LIMITS.deadPeerMs, - "dead_peer", - ); - const reconnectMinimumMs = normalizeInteger( - options.reconnectMinimumMs, - 10, - 120_000, - DEVICE_EDGE_CHANNEL_LIMITS.reconnectMinimumMs, - "reconnect_minimum", - ); - const reconnectMaximumMs = normalizeInteger( - options.reconnectMaximumMs, - 10, - 120_000, - DEVICE_EDGE_CHANNEL_LIMITS.reconnectMaximumMs, - "reconnect_maximum", - ); - if (reconnectMaximumMs < reconnectMinimumMs) { - throw new TypeError("device_gateway_core_reconnect_range_invalid"); - } - return Object.freeze({ - registrationProvider, - tls, - coreIdentity: normalizeRef(options.coreIdentity, "core_identity"), - observeDiscovery: options.observeDiscovery, - acceptMessage: options.acceptMessage, - commandTransport, - offerCommand, - recordCommandStatus, - keepaliveMs, - deadPeerMs, - connectTimeoutMs: normalizeInteger( - options.connectTimeoutMs, - 10, - 120_000, - DEFAULT_CONNECT_TIMEOUT_MS, - "connect_timeout", - ), - reconnectMinimumMs, - reconnectMaximumMs, - maxEnvelopeBytes: normalizeInteger( - options.maxEnvelopeBytes, - 256, - DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes, - DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes, - "max_envelope_bytes", - ), - random: typeof options.random === "function" ? options.random : Math.random, - clock: typeof options.clock === "function" ? options.clock : Date.now, - now: typeof options.now === "function" - ? () => new Date(options.now()).toISOString() - : () => new Date().toISOString(), - }); -} - -function normalizeRegistration(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new TypeError("device_gateway_core_edge_registration_invalid"); - } - let endpoint; - try { - endpoint = new URL(String(value.endpoint || "")); - } catch { - throw new TypeError("device_gateway_core_edge_endpoint_invalid"); - } - if ( - endpoint.protocol !== "https:" - || endpoint.username - || endpoint.password - || endpoint.pathname !== "/" - || endpoint.search - || endpoint.hash - ) { - throw new TypeError("device_gateway_core_edge_endpoint_invalid"); - } - if (!["active", "revoked", "disabled"].includes(value.lifecycleState)) { - throw new TypeError("device_gateway_core_edge_lifecycle_invalid"); - } - const servername = String(value.servername || ""); - if (!/^[A-Za-z0-9.-]{1,253}$/.test(servername)) { - throw new TypeError("device_gateway_core_edge_servername_invalid"); - } - return Object.freeze({ - edgeRegistrationId: normalizeRef( - value.edgeRegistrationId, - "edge_registration_id", - ), - channelGeneration: normalizeRef( - value.channelGeneration, - "channel_generation", - ), - endpoint: endpoint.toString(), - servername, - certificateIdentities: normalizeCertificateIdentities( - value.certificateIdentities, - ), - lifecycleState: value.lifecycleState, - }); -} - -function normalizeTls(value) { - if (!value || typeof value !== "object") { - throw new TypeError("device_gateway_core_channel_tls_invalid"); - } - for (const key of ["key", "cert", "ca"]) { - if (!(typeof value[key] === "string" || Buffer.isBuffer(value[key]))) { - throw new TypeError(`device_gateway_core_channel_tls_${key}_invalid`); - } - } - return Object.freeze({ key: value.key, cert: value.cert, ca: value.ca }); -} - -function safeErrorCode(error) { - const value = String(error?.message || error || "device_gateway_core_error") - .toLowerCase() - .replaceAll(/[^a-z0-9._:-]/g, "_") - .slice(0, 128); - return /^[a-z][a-z0-9._:-]{2,127}$/.test(value) - ? value - : "device_gateway_core_event_rejected"; -} - -function isCompatibleCommandTransport(configured, offered) { - if (offered === configured) return true; - return configured === "typed-service-ping-v1" && offered === "disabled"; -} - -function normalizeRef(value, field) { - if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) { - throw new TypeError(`device_gateway_core_${field}_invalid`); - } - return value; -} - -function normalizeInteger(value, minimum, maximum, fallback, field) { - const number = value == null ? fallback : Number(value); - if (!Number.isSafeInteger(number) || number < minimum || number > maximum) { - throw new TypeError(`device_gateway_core_${field}_invalid`); - } - return number; -} diff --git a/device-plane/services/device-control-core/src/discovery-repository.mjs b/device-plane/services/device-control-core/src/discovery-repository.mjs deleted file mode 100644 index dc53b16..0000000 --- a/device-plane/services/device-control-core/src/discovery-repository.mjs +++ /dev/null @@ -1,307 +0,0 @@ -import { randomUUID } from "node:crypto"; - -export async function observeQuarantineDiscovery({ - pool, - identifierDigest, - safeView, - sessionRef, - routeRef = null, -}) { - if ((safeView.routeRef ?? null) !== routeRef) { - throw new TypeError("device_discovery_route_ref_mismatch"); - } - const routeId = routeRef == null ? null : parseEntityRef(routeRef, "route"); - const client = await pool.connect(); - try { - await client.query("begin"); - const route = routeId == null - ? null - : await findActiveRoute(client, routeId, safeView); - const enrollment = route == null - ? null - : await findMatchingEnrollment(client, { - route, - identifierDigest, - safeView, - }); - const result = await client.query( - `insert into device_discoveries ( - id, - identifier_kind, - identifier_digest, - identifier_masked, - model_profile_ref, - protocol, - lifecycle_state, - first_observed_at, - last_observed_at, - evidence, - session_ref, - project_id, - route_id, - enrollment_intent_id - ) values ( - $1, $2, $3, $4, $5, $6, 'quarantine', $7, $7, $8::jsonb, - $9, $10, $11, $12 - ) - on conflict (identifier_kind, identifier_digest, model_profile_ref) - do update set - last_observed_at = case - when device_discoveries.lifecycle_state = 'claimed' - and excluded.route_id is distinct from device_discoveries.route_id - then device_discoveries.last_observed_at - else greatest( - device_discoveries.last_observed_at, - excluded.last_observed_at - ) - end, - evidence = case - when device_discoveries.lifecycle_state = 'claimed' - and excluded.route_id is distinct from device_discoveries.route_id - then device_discoveries.evidence - else excluded.evidence - end, - session_ref = case - when device_discoveries.lifecycle_state = 'claimed' - and excluded.route_id is distinct from device_discoveries.route_id - then device_discoveries.session_ref - else excluded.session_ref - end, - project_id = case - when device_discoveries.lifecycle_state = 'claimed' - then device_discoveries.project_id - when excluded.enrollment_intent_id is not null - and ( - device_discoveries.enrollment_intent_id is null - or device_discoveries.enrollment_intent_id = excluded.enrollment_intent_id - or device_discoveries.lifecycle_state in ('rejected', 'expired') - ) then excluded.project_id - when device_discoveries.project_id is null - then excluded.project_id - else device_discoveries.project_id - end, - route_id = case - when device_discoveries.lifecycle_state = 'claimed' - then device_discoveries.route_id - when excluded.enrollment_intent_id is not null - and ( - device_discoveries.enrollment_intent_id is null - or device_discoveries.enrollment_intent_id = excluded.enrollment_intent_id - or device_discoveries.lifecycle_state in ('rejected', 'expired') - ) then excluded.route_id - when device_discoveries.route_id is null - then excluded.route_id - else device_discoveries.route_id - end, - enrollment_intent_id = case - when device_discoveries.lifecycle_state = 'claimed' - then device_discoveries.enrollment_intent_id - when excluded.enrollment_intent_id is not null - and ( - device_discoveries.enrollment_intent_id is null - or device_discoveries.enrollment_intent_id = excluded.enrollment_intent_id - or device_discoveries.lifecycle_state in ('rejected', 'expired') - ) then excluded.enrollment_intent_id - else device_discoveries.enrollment_intent_id - end, - lifecycle_state = case - when device_discoveries.lifecycle_state = 'claimed' then 'claimed' - when excluded.enrollment_intent_id is not null - and device_discoveries.lifecycle_state in ('rejected', 'expired') - then 'quarantine' - else device_discoveries.lifecycle_state - end, - resolution_code = case - when excluded.enrollment_intent_id is not null - and device_discoveries.lifecycle_state in ('rejected', 'expired') - then null - else device_discoveries.resolution_code - end, - resolved_at = case - when excluded.enrollment_intent_id is not null - and device_discoveries.lifecycle_state in ('rejected', 'expired') - then null - else device_discoveries.resolved_at - end, - resolved_by_ref = case - when excluded.enrollment_intent_id is not null - and device_discoveries.lifecycle_state in ('rejected', 'expired') - then null - else device_discoveries.resolved_by_ref - end, - updated_at = now() - returning id, lifecycle_state, model_profile_ref, protocol, - identifier_kind, identifier_masked, first_observed_at, - last_observed_at, evidence, project_id, route_id, - enrollment_intent_id, claimed_device_id, (xmax = 0) as created`, - [ - randomUUID(), - safeView.identifier.kind, - identifierDigest, - safeView.identifier.masked, - safeView.modelProfileRef, - safeView.protocol, - safeView.observedAt, - JSON.stringify(safeView.evidence), - sessionRef, - route?.project_id ?? null, - route?.id ?? null, - enrollment?.id ?? null, - ], - ); - const row = result.rows[0]; - if ( - enrollment - && row.enrollment_intent_id !== enrollment.id - ) { - throw domainError("device_discovery_enrollment_conflict", 409); - } - if (enrollment && row.lifecycle_state === "quarantine") { - const observed = await client.query( - `update device_enrollment_intents - set lifecycle_state = 'observed', - observed_discovery_id = $2, - observed_at = greatest(coalesce(observed_at, $3), $3), - resolution_code = null, - resolved_at = null, - resolved_by_ref = null, - updated_at = now() - where id = $1 - and lifecycle_state in ('pending', 'observed') - returning id`, - [enrollment.id, row.id, safeView.observedAt], - ); - if (!observed.rows[0]) { - throw domainError("device_enrollment_not_observable", 409); - } - } - await client.query("commit"); - return { - created: row.created === true, - value: discoveryView(row), - claimedDeviceRef: row.claimed_device_id - ? `device:${row.claimed_device_id}` - : null, - }; - } catch (error) { - await client.query("rollback").catch(() => undefined); - throw error; - } finally { - client.release(); - } -} - -async function findActiveRoute(client, routeId, safeView) { - const result = await client.query( - `select id, project_id, model_profile_ref, protocol, lifecycle_state - from device_routes - where id = $1 - for share`, - [routeId], - ); - const route = result.rows[0]; - if (!route) throw domainError("device_discovery_route_not_found", 404); - if (route.lifecycle_state !== "active") { - throw domainError("device_discovery_route_inactive", 409); - } - if ( - route.model_profile_ref !== safeView.modelProfileRef - || route.protocol !== safeView.protocol - ) { - throw domainError("device_discovery_route_profile_mismatch", 409); - } - return route; -} - -async function findMatchingEnrollment(client, { - route, - identifierDigest, - safeView, -}) { - await client.query( - `update device_enrollment_intents - set lifecycle_state = 'expired', - resolution_code = 'deadline_elapsed', - resolved_at = $6, - updated_at = now() - where project_id = $1 - and route_id = $2 - and model_profile_ref = $3 - and expected_identifier_kind = $4 - and expected_identifier_digest = $5 - and lifecycle_state = 'pending' - and expires_at is not null - and expires_at <= $6`, - [ - route.project_id, - route.id, - safeView.modelProfileRef, - safeView.identifier.kind, - identifierDigest, - safeView.observedAt, - ], - ); - const result = await client.query( - `select id, project_id, route_id, model_profile_ref, lifecycle_state - from device_enrollment_intents - where project_id = $1 - and route_id = $2 - and model_profile_ref = $3 - and expected_identifier_kind = $4 - and expected_identifier_digest = $5 - and lifecycle_state in ('pending', 'observed') - and (expires_at is null or expires_at > $6) - for update`, - [ - route.project_id, - route.id, - safeView.modelProfileRef, - safeView.identifier.kind, - identifierDigest, - safeView.observedAt, - ], - ); - if (result.rows.length > 1) { - throw domainError("device_enrollment_identity_ambiguous", 409); - } - return result.rows[0] ?? null; -} - -function discoveryView(row) { - return { - schemaVersion: "nodedc.device.discovery-view.v1", - discoveryRef: `discovery:${row.id}`, - ...(row.route_id ? { routeRef: `route:${row.route_id}` } : {}), - ...(row.enrollment_intent_id - ? { enrollmentIntentRef: `enrollment-intent:${row.enrollment_intent_id}` } - : {}), - modelProfileRef: row.model_profile_ref, - protocol: row.protocol, - observedAt: new Date(row.last_observed_at).toISOString(), - lifecycleState: row.lifecycle_state, - identifier: { - kind: row.identifier_kind, - masked: row.identifier_masked, - }, - evidence: row.evidence, - commandTransport: "disabled", - }; -} - -function parseEntityRef(value, prefix) { - if (typeof value !== "string") { - throw new TypeError(`device_${prefix}_ref_invalid`); - } - const match = value.match(new RegExp( - `^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`, - "i", - )); - if (!match) throw new TypeError(`device_${prefix}_ref_invalid`); - return match[1].toLowerCase(); -} - -function domainError(code, statusCode) { - const error = new Error(code); - error.statusCode = statusCode; - return error; -} diff --git a/device-plane/services/device-control-core/src/edge-channel-supervisor.mjs b/device-plane/services/device-control-core/src/edge-channel-supervisor.mjs deleted file mode 100644 index 38bc08e..0000000 --- a/device-plane/services/device-control-core/src/edge-channel-supervisor.mjs +++ /dev/null @@ -1,376 +0,0 @@ -import { createHash, X509Certificate } from "node:crypto"; -import { lstat, readFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; - -import { - normalizeCertificateFingerprint, - normalizeCertificateIdentities, -} from "../../../packages/device-edge-channel-contract/src/index.mjs"; -import { - createDeviceGatewayCoreChannelClient, -} from "./device-gateway-core-runtime.mjs"; - -const DEFAULT_TRUST_ROOT = "/run/nodedc-secrets/device-edge-channel/peers"; - -export function createDeviceEdgeChannelSupervisor(options = {}) { - const config = normalizeConfiguration(options); - const clients = new Map(); - const failures = new Map(); - let running = false; - let timer = null; - let reconcilePromise = null; - let requestedCount = 0; - let reconciliationFailures = 0; - let lastErrorCode = null; - - return Object.freeze({ - async start() { - if (running) return; - running = true; - await reconcile(); - schedule(); - }, - async stop() { - running = false; - clearTimeout(timer); - timer = null; - if (reconcilePromise) await reconcilePromise.catch(() => undefined); - const stopping = [...clients.values()].map(({ client }) => client.stop()); - clients.clear(); - failures.clear(); - await Promise.allSettled(stopping); - }, - reconcile, - status, - }); - - async function reconcile() { - if (!running) return status(); - if (reconcilePromise) return reconcilePromise; - reconcilePromise = performReconcile().finally(() => { - reconcilePromise = null; - }); - return reconcilePromise; - } - - async function performReconcile() { - let registrations; - try { - registrations = await config.repository - .listActiveEdgeChannelRegistrations(config.maxEdges); - if (!Array.isArray(registrations) || registrations.length > config.maxEdges) { - throw new TypeError("device_edge_channel_registration_set_invalid"); - } - registrations = registrations.map(normalizeRegistration); - if (new Set(registrations.map((item) => item.edgeRegistrationId)).size - !== registrations.length) { - throw new TypeError("device_edge_channel_registration_set_invalid"); - } - requestedCount = registrations.length; - } catch (error) { - reconciliationFailures += 1; - lastErrorCode = safeErrorCode(error); - return status(); - } - - const desiredIds = new Set(registrations.map((item) => item.edgeRegistrationId)); - for (const [edgeRegistrationId, active] of clients) { - if (!desiredIds.has(edgeRegistrationId)) { - clients.delete(edgeRegistrationId); - await active.client.stop().catch(() => undefined); - } - } - for (const edgeRegistrationId of failures.keys()) { - if (!desiredIds.has(edgeRegistrationId)) failures.delete(edgeRegistrationId); - } - - for (const registration of registrations) { - const digest = registrationDigest(registration); - const current = clients.get(registration.edgeRegistrationId); - if (current?.digest === digest) { - failures.delete(registration.edgeRegistrationId); - continue; - } - if (current) { - clients.delete(registration.edgeRegistrationId); - await current.client.stop().catch(() => undefined); - } - try { - const ca = await config.readPeerTrust({ - registration, - trustRoot: config.trustRoot, - }); - const client = config.clientFactory({ - registration, - tls: { - key: config.coreIdentity.key, - cert: config.coreIdentity.cert, - ca, - }, - coreIdentity: config.coreIdentity.identityRef, - observeDiscovery: (signal) => config.gatewayIngest.observeDiscovery( - signal, - { authenticatedEdgeRef: registration.edgeRegistrationId }, - ), - acceptMessage: (message) => config.gatewayIngest.acceptMessage( - message, - { authenticatedEdgeRef: registration.edgeRegistrationId }, - ), - commandTransport: config.typedCommandRuntime - ? "typed-service-ping-v1" - : "disabled", - offerCommand: config.typedCommandRuntime?.offerForDevice, - recordCommandStatus: config.typedCommandRuntime?.recordStatus, - }); - assertClient(client); - clients.set(registration.edgeRegistrationId, { client, digest }); - failures.delete(registration.edgeRegistrationId); - await client.start(); - } catch (error) { - const code = safeErrorCode(error); - failures.set(registration.edgeRegistrationId, code); - lastErrorCode = code; - } - } - return status(); - } - - function schedule() { - if (!running) return; - timer = setTimeout(async () => { - timer = null; - await reconcile().catch(() => undefined); - schedule(); - }, config.reconcileIntervalMs); - timer.unref?.(); - } - - function status() { - let accepted = 0; - let connecting = 0; - let degraded = failures.size; - const edges = []; - for (const [edgeRegistrationId, { client }] of clients) { - const clientStatus = client.status(); - if (clientStatus.channel === "accepted") accepted += 1; - else connecting += 1; - if (clientStatus.lastErrorCode) degraded += 1; - edges.push(Object.freeze({ - edgeRegistrationId, - channel: clientStatus.channel, - lastErrorCode: clientStatus.lastErrorCode ?? null, - })); - } - for (const [edgeRegistrationId, code] of failures) { - edges.push(Object.freeze({ - edgeRegistrationId, - channel: "absent", - lastErrorCode: code, - })); - } - edges.sort((left, right) => - left.edgeRegistrationId.localeCompare(right.edgeRegistrationId) - ); - return Object.freeze({ - enabled: true, - running, - configured: requestedCount, - accepted, - connecting, - degraded, - reconciliationFailures, - lastErrorCode, - commandTransport: config.typedCommandRuntime - ? "typed-service-ping-v1" - : "disabled", - edges: Object.freeze(edges), - }); - } -} - -export async function readPinnedEdgeTrust({ registration, trustRoot }) { - const match = registration.trustBundleRef.match( - /^edge-trust:([a-z][a-z0-9-]{1,62})$/, - ); - if (!match) throw new TypeError("device_edge_channel_trust_bundle_ref_invalid"); - const root = resolve(trustRoot); - const path = resolve(root, `${match[1]}.pem`); - if (dirname(path) !== root) { - throw new TypeError("device_edge_channel_trust_bundle_path_invalid"); - } - const state = await lstat(path); - if (state.isSymbolicLink() || !state.isFile() || state.size < 1 || state.size > 64 * 1024) { - throw new Error("device_edge_channel_trust_bundle_file_invalid"); - } - const pem = await readFile(path); - const blocks = pem.toString("utf8").match( - /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g, - ); - if (!blocks || blocks.length < 1 || blocks.length > 2) { - throw new Error("device_edge_channel_trust_bundle_invalid"); - } - const expected = new Set( - registration.certificateIdentities.map((item) => item.fingerprint), - ); - const observed = new Set(blocks.map((block) => normalizeCertificateFingerprint( - new X509Certificate(block).fingerprint256, - ))); - if ( - observed.size !== expected.size - || [...observed].some((fingerprint) => !expected.has(fingerprint)) - ) { - throw new Error("device_edge_channel_trust_bundle_identity_mismatch"); - } - return pem; -} - -function normalizeConfiguration(options) { - if ( - !options.repository - || typeof options.repository.listActiveEdgeChannelRegistrations !== "function" - ) { - throw new TypeError("device_edge_channel_repository_required"); - } - if ( - !options.gatewayIngest - || typeof options.gatewayIngest.observeDiscovery !== "function" - || typeof options.gatewayIngest.acceptMessage !== "function" - ) { - throw new TypeError("device_edge_channel_gateway_ingest_required"); - } - const coreIdentity = options.coreIdentity; - if ( - !coreIdentity - || !(typeof coreIdentity.key === "string" || Buffer.isBuffer(coreIdentity.key)) - || !(typeof coreIdentity.cert === "string" || Buffer.isBuffer(coreIdentity.cert)) - || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(coreIdentity.identityRef) - ) { - throw new TypeError("device_edge_channel_core_identity_invalid"); - } - const maxEdges = normalizeInteger(options.maxEdges, 1, 64, 32); - const reconcileIntervalMs = normalizeInteger( - options.reconcileIntervalMs, - 1_000, - 300_000, - 15_000, - ); - const trustRoot = resolve(options.trustRoot ?? DEFAULT_TRUST_ROOT); - return Object.freeze({ - repository: options.repository, - gatewayIngest: options.gatewayIngest, - typedCommandRuntime: normalizeTypedCommandRuntime(options.typedCommandRuntime), - coreIdentity: Object.freeze({ ...coreIdentity }), - maxEdges, - reconcileIntervalMs, - trustRoot, - readPeerTrust: options.readPeerTrust ?? readPinnedEdgeTrust, - clientFactory: options.clientFactory ?? createDeviceGatewayCoreChannelClient, - }); -} - -function normalizeTypedCommandRuntime(value) { - if (value == null) return null; - if ( - typeof value.offerForDevice !== "function" - || typeof value.recordStatus !== "function" - ) { - throw new TypeError("device_edge_channel_typed_command_runtime_invalid"); - } - return value; -} - -function normalizeRegistration(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new TypeError("device_edge_channel_registration_invalid"); - } - const endpoint = new URL(String(value.endpoint || "")); - if ( - endpoint.protocol !== "https:" - || endpoint.username - || endpoint.password - || endpoint.pathname !== "/" - || endpoint.search - || endpoint.hash - || endpoint.port !== "" - || endpoint.hostname !== String(value.servername || "").toLowerCase() - || !isPublicIpv4(endpoint.hostname) - ) { - throw new TypeError("device_edge_channel_registration_endpoint_invalid"); - } - if (value.lifecycleState !== "active") { - throw new TypeError("device_edge_channel_registration_inactive"); - } - return Object.freeze({ - edgeRegistrationId: normalizeRef(value.edgeRegistrationId), - endpoint: endpoint.toString(), - servername: endpoint.hostname, - channelGeneration: normalizeRef(value.channelGeneration), - trustBundleRef: normalizeTrustRef(value.trustBundleRef), - certificateIdentities: normalizeCertificateIdentities( - value.certificateIdentities, - ), - lifecycleState: "active", - }); -} - -function normalizeRef(value) { - if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) { - throw new TypeError("device_edge_channel_registration_ref_invalid"); - } - return value; -} - -function normalizeTrustRef(value) { - if (typeof value !== "string" || !/^edge-trust:[a-z][a-z0-9-]{1,62}$/.test(value)) { - throw new TypeError("device_edge_channel_trust_bundle_ref_invalid"); - } - return value; -} - -function isPublicIpv4(value) { - const octets = value.split(".").map(Number); - if (octets.length !== 4 || octets.some((item) => - !Number.isInteger(item) || item < 0 || item > 255 - )) return false; - const [a, b, c] = octets; - return a >= 1 && a < 224 - && a !== 10 && a !== 127 - && !(a === 100 && b >= 64 && b <= 127) - && !(a === 169 && b === 254) - && !(a === 172 && b >= 16 && b <= 31) - && !(a === 192 && (b === 0 || b === 168)) - && !(a === 192 && b === 88 && c === 99) - && !(a === 198 && (b === 18 || b === 19 || b === 51)) - && !(a === 203 && b === 0 && c === 113); -} - -function registrationDigest(value) { - return createHash("sha256").update(JSON.stringify(value)).digest("hex"); -} - -function assertClient(value) { - if ( - !value - || typeof value.start !== "function" - || typeof value.stop !== "function" - || typeof value.status !== "function" - ) throw new TypeError("device_edge_channel_client_invalid"); -} - -function normalizeInteger(value, minimum, maximum, fallback) { - const parsed = Number(value ?? fallback); - if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { - throw new TypeError("device_edge_channel_integer_invalid"); - } - return parsed; -} - -function safeErrorCode(error) { - const value = String(error?.message || error || "device_edge_channel_error") - .toLowerCase() - .replaceAll(/[^a-z0-9._:-]/g, "_") - .slice(0, 128); - return /^[a-z][a-z0-9._:-]{2,127}$/.test(value) - ? value - : "device_edge_channel_error"; -} diff --git a/device-plane/services/device-control-core/src/gateway-ingest.mjs b/device-plane/services/device-control-core/src/gateway-ingest.mjs deleted file mode 100644 index 6e4e158..0000000 --- a/device-plane/services/device-control-core/src/gateway-ingest.mjs +++ /dev/null @@ -1,125 +0,0 @@ -import { createHash } from "node:crypto"; - -import { - assertSafeProjection, - hashRestrictedIdentifier, - normalizeAdapterAcceptance, - normalizeAdapterMessage, - normalizeDiscoverySignal, - toSafeAdapterMessageView, - toSafeDiscoveryView, -} from "../../../packages/device-protocol-contract/src/index.mjs"; - -export function createDeviceGatewayIngest({ repository, identifierPepper } = {}) { - if (!repository || typeof repository.upsertQuarantineDiscovery !== "function") { - throw new TypeError("device_discovery_repository_required"); - } - if (typeof repository.acceptAdapterMessage !== "function") { - throw new TypeError("device_gateway_message_repository_required"); - } - if (typeof identifierPepper !== "string" || identifierPepper.length < 32) { - throw new TypeError("device_identifier_pepper_invalid"); - } - - return Object.freeze({ - async observeDiscovery(input, context = {}) { - const receivedSignal = normalizeDiscoverySignal(input); - const identifierDigest = hashRestrictedIdentifier( - receivedSignal.identifier, - identifierPepper, - ); - const routeRef = await resolveAuthenticatedRoute(repository, { - edgeRef: context.authenticatedEdgeRef, - modelProfileRef: receivedSignal.modelProfileRef, - protocol: receivedSignal.protocol, - identifierKind: receivedSignal.identifier.kind, - identifierDigest, - observedAt: receivedSignal.observedAt, - }); - const signal = routeRef === undefined - ? receivedSignal - : normalizeDiscoverySignal({ - ...withoutKeys(receivedSignal, ["routeRef"]), - ...(routeRef ? { routeRef } : {}), - }); - const safeView = assertSafeProjection(toSafeDiscoveryView(signal)); - const discovery = await repository.upsertQuarantineDiscovery({ - identifierDigest, - safeView, - sessionRef: signal.sessionRef, - routeRef: signal.routeRef ?? null, - }); - return Object.freeze({ - created: discovery.created === true, - value: assertSafeProjection(discovery.value), - claimedDeviceRef: discovery.claimedDeviceRef ?? null, - }); - }, - - async acceptMessage(input, context = {}) { - const receivedMessage = normalizeAdapterMessage(input); - const identifierDigest = hashRestrictedIdentifier( - receivedMessage.identifier, - identifierPepper, - ); - const routeRef = await resolveAuthenticatedRoute(repository, { - edgeRef: context.authenticatedEdgeRef, - modelProfileRef: receivedMessage.protocolProfileRef, - protocol: receivedMessage.protocol, - identifierKind: receivedMessage.identifier.kind, - identifierDigest, - observedAt: receivedMessage.observedAt, - }); - const message = routeRef === undefined - ? receivedMessage - : normalizeAdapterMessage({ - ...withoutKeys(receivedMessage, ["edgeRef", "routeRef"]), - edgeRef: context.authenticatedEdgeRef, - ...(routeRef ? { routeRef } : {}), - }); - const safeView = assertSafeProjection(toSafeAdapterMessageView(message)); - const requestDigest = gatewayMessageRequestDigest({ - edgeRef: safeView.edgeRef, - adapterRef: safeView.adapterRef, - protocolProfileRef: safeView.protocolProfileRef, - protocol: safeView.protocol, - routeRef: safeView.routeRef ?? null, - idempotencyKey: safeView.idempotencyKey, - identifierKind: safeView.identifier.kind, - identifierDigest, - payloadSchemaRef: safeView.payloadSchemaRef, - payload: safeView.payload, - }); - const receipt = await repository.acceptAdapterMessage({ - identifierDigest, - requestDigest, - safeView, - }); - return Object.freeze({ - value: normalizeAdapterAcceptance(receipt.acceptance), - claimedDeviceRef: receipt.claimedDeviceRef ?? null, - }); - }, - }); -} - -async function resolveAuthenticatedRoute(repository, input) { - if (input.edgeRef == null) return undefined; - if (typeof repository.resolveInboundRoute !== "function") { - throw new TypeError("device_inbound_route_repository_required"); - } - return repository.resolveInboundRoute(input); -} - -function withoutKeys(value, keys) { - const omitted = new Set(keys); - return Object.fromEntries( - Object.entries(value).filter(([key]) => !omitted.has(key)), - ); -} - -function gatewayMessageRequestDigest(value) { - return `sha256:${createHash("sha256") - .update(JSON.stringify(value), "utf8") - .digest("hex")}`; -} diff --git a/device-plane/services/device-control-core/src/gateway-message-repository.mjs b/device-plane/services/device-control-core/src/gateway-message-repository.mjs deleted file mode 100644 index f268cef..0000000 --- a/device-plane/services/device-control-core/src/gateway-message-repository.mjs +++ /dev/null @@ -1,203 +0,0 @@ -import { randomUUID } from "node:crypto"; - -export async function acceptGatewayMessage({ - pool, - identifierDigest, - requestDigest, - safeView, -}) { - const routeId = safeView.routeRef == null - ? null - : parseEntityRef(safeView.routeRef, "route"); - const client = await pool.connect(); - try { - await client.query("begin"); - const route = routeId == null - ? null - : await findActiveRoute(client, routeId, safeView); - const claimedDeviceRef = route == null - ? null - : await findClaimedDeviceRef(client, { - identifierDigest, - route, - safeView, - }); - const id = randomUUID(); - const inserted = await client.query( - `insert into device_gateway_message_receipts ( - id, - idempotency_key, - request_digest, - edge_ref, - adapter_ref, - protocol_profile_ref, - protocol, - route_id, - project_id, - session_ref, - message_ref, - message_type, - sequence, - identifier_kind, - identifier_digest, - identifier_masked, - payload_schema_ref, - payload, - observed_at - ) values ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, - $11, $12, $13, $14, $15, $16, $17, $18::jsonb, $19 - ) - on conflict (idempotency_key) do nothing - returning id, idempotency_key, accepted_at`, - [ - id, - safeView.idempotencyKey, - requestDigest, - safeView.edgeRef, - safeView.adapterRef, - safeView.protocolProfileRef, - safeView.protocol, - route?.id ?? null, - route?.project_id ?? null, - safeView.sessionRef, - safeView.messageRef, - safeView.messageType, - safeView.sequence, - safeView.identifier.kind, - identifierDigest, - safeView.identifier.masked, - safeView.payloadSchemaRef, - JSON.stringify(safeView.payload), - safeView.observedAt, - ], - ); - if (inserted.rows[0]) { - await client.query("commit"); - return receiptView(inserted.rows[0], false, claimedDeviceRef); - } - - const existing = await client.query( - `select id, idempotency_key, request_digest, accepted_at - from device_gateway_message_receipts - where idempotency_key = $1 - for share`, - [safeView.idempotencyKey], - ); - const row = existing.rows[0]; - if (!row) throw domainError("device_gateway_receipt_missing", 409); - if (row.request_digest !== requestDigest) { - throw domainError("device_gateway_idempotency_conflict", 409); - } - await client.query("commit"); - return receiptView(row, true, claimedDeviceRef); - } catch (error) { - await client.query("rollback").catch(() => undefined); - throw error; - } finally { - client.release(); - } -} - -async function findClaimedDeviceRef(client, { - identifierDigest, - route, - safeView, -}) { - const result = await client.query( - `select claimed_device_id - from device_discoveries - where identifier_kind = $1 - and identifier_digest = $2 - and model_profile_ref = $3 - and lifecycle_state = 'claimed' - and project_id = $4 - and route_id = $5 - and claimed_device_id is not null - for share`, - [ - safeView.identifier.kind, - identifierDigest, - safeView.protocolProfileRef, - route.project_id, - route.id, - ], - ); - const row = result.rows[0]; - return row?.claimed_device_id - ? `device:${row.claimed_device_id}` - : null; -} - -async function findActiveRoute(client, routeId, safeView) { - const edgeId = parseEntityRef(safeView.edgeRef, "edge"); - const result = await client.query( - `select r.id, r.project_id, r.edge_id, r.model_profile_ref, - r.protocol, r.lifecycle_state, - e.lifecycle_state as edge_lifecycle_state, - p.lifecycle_state as profile_lifecycle_state, - ap.package_key as adapter_ref, - ap.lifecycle_state as adapter_lifecycle_state, - av.lifecycle_state as adapter_version_lifecycle_state - from device_routes r - join device_edges e on e.id = r.edge_id - join device_model_profiles p on p.profile_ref = r.model_profile_ref - join device_adapter_versions av on av.id = p.adapter_version_id - join device_adapter_packages ap on ap.id = av.adapter_package_id - where r.id = $1 - for share`, - [routeId], - ); - const route = result.rows[0]; - if (!route) throw domainError("device_gateway_route_not_found", 404); - if ( - route.lifecycle_state !== "active" - || route.edge_lifecycle_state !== "active" - || route.profile_lifecycle_state !== "active" - || route.adapter_lifecycle_state !== "active" - || route.adapter_version_lifecycle_state !== "active" - ) { - throw domainError("device_gateway_route_not_active", 409); - } - if ( - route.edge_id !== edgeId - || route.model_profile_ref !== safeView.protocolProfileRef - || route.protocol !== safeView.protocol - || route.adapter_ref !== safeView.adapterRef - ) { - throw domainError("device_gateway_route_contract_mismatch", 409); - } - return route; -} - -function receiptView(row, replayed, claimedDeviceRef) { - return { - acceptance: { - schemaVersion: "nodedc.device-adapter-acceptance.v1", - acceptanceRef: `acceptance:${row.id}`, - idempotencyKey: row.idempotency_key, - status: "accepted", - replayed, - acceptedAt: new Date(row.accepted_at).toISOString(), - }, - claimedDeviceRef, - }; -} - -function parseEntityRef(value, prefix) { - if (typeof value !== "string") { - throw new TypeError(`device_${prefix}_ref_invalid`); - } - const match = value.match(new RegExp( - `^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`, - "i", - )); - if (!match) throw new TypeError(`device_${prefix}_ref_invalid`); - return match[1].toLowerCase(); -} - -function domainError(code, statusCode) { - const error = new Error(code); - error.statusCode = statusCode; - return error; -} diff --git a/device-plane/services/device-control-core/src/inbound-route-repository.mjs b/device-plane/services/device-control-core/src/inbound-route-repository.mjs deleted file mode 100644 index 029477b..0000000 --- a/device-plane/services/device-control-core/src/inbound-route-repository.mjs +++ /dev/null @@ -1,111 +0,0 @@ -export async function resolveInboundRoute(client, input = {}) { - if (!client || typeof client.query !== "function") { - throw new TypeError("device_inbound_route_client_required"); - } - const edgeId = parseEntityRef(input.edgeRef, "edge"); - const modelProfileRef = normalizeOpaqueRef( - input.modelProfileRef, - "model_profile_ref", - ); - const protocol = normalizeUpperToken(input.protocol, "protocol"); - const identifierKind = normalizeLowerToken( - input.identifierKind, - "identifier_kind", - ); - const identifierDigest = normalizeIdentifierDigest(input.identifierDigest); - const observedAt = normalizeTimestamp(input.observedAt, "observed_at"); - - const result = await client.query( - `select r.id - from device_enrollment_intents ei - join device_routes r - on r.id = ei.route_id - and r.project_id = ei.project_id - and r.model_profile_ref = ei.model_profile_ref - join device_edges e on e.id = r.edge_id - where r.edge_id = $1 - and r.model_profile_ref = $2 - and r.protocol = $3 - and r.lifecycle_state = 'active' - and e.lifecycle_state = 'active' - and e.channel_lifecycle_state = 'active' - and ei.expected_identifier_kind = $4 - and ei.expected_identifier_digest = $5 - and ei.lifecycle_state in ('pending', 'observed', 'claimed') - and ( - ei.lifecycle_state = 'claimed' - or ei.expires_at is null - or ei.expires_at > $6 - ) - order by r.id - limit 2`, - [ - edgeId, - modelProfileRef, - protocol, - identifierKind, - identifierDigest, - observedAt, - ], - ); - if (result.rows.length > 1) { - throw domainError("device_inbound_route_ambiguous", 409); - } - return result.rows[0]?.id ? `route:${result.rows[0].id}` : null; -} - -function parseEntityRef(value, prefix) { - if (typeof value !== "string") { - throw new TypeError(`device_${prefix}_ref_invalid`); - } - const match = value.match(new RegExp( - `^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`, - "i", - )); - if (!match) throw new TypeError(`device_${prefix}_ref_invalid`); - return match[1].toLowerCase(); -} - -function normalizeOpaqueRef(value, name) { - if ( - typeof value !== "string" - || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value) - ) { - throw new TypeError(`device_inbound_route_${name}_invalid`); - } - return value; -} - -function normalizeUpperToken(value, name) { - if (typeof value !== "string" || !/^[A-Z][A-Z0-9_]{0,31}$/.test(value)) { - throw new TypeError(`device_inbound_route_${name}_invalid`); - } - return value; -} - -function normalizeLowerToken(value, name) { - if (typeof value !== "string" || !/^[a-z][a-z0-9._:-]{1,63}$/.test(value)) { - throw new TypeError(`device_inbound_route_${name}_invalid`); - } - return value; -} - -function normalizeIdentifierDigest(value) { - if (typeof value !== "string" || !/^hmac-sha256:[a-f0-9]{64}$/.test(value)) { - throw new TypeError("device_inbound_route_identifier_digest_invalid"); - } - return value; -} - -function normalizeTimestamp(value, name) { - if (typeof value !== "string" || Number.isNaN(Date.parse(value))) { - throw new TypeError(`device_inbound_route_${name}_invalid`); - } - return value; -} - -function domainError(code, statusCode) { - const error = new Error(code); - error.statusCode = statusCode; - return error; -} diff --git a/device-plane/services/device-control-core/src/infrastructure-management.mjs b/device-plane/services/device-control-core/src/infrastructure-management.mjs deleted file mode 100644 index 8310f2d..0000000 --- a/device-plane/services/device-control-core/src/infrastructure-management.mjs +++ /dev/null @@ -1,505 +0,0 @@ -import { - assertIdentifierDigest, - assertSafeProjection, -} from "../../../packages/device-protocol-contract/src/index.mjs"; -import { - normalizeCertificateIdentities, -} from "../../../packages/device-edge-channel-contract/src/index.mjs"; -import { normalizeManagementActor } from "./project-management.mjs"; - -export const DEVICE_INFRASTRUCTURE_COMMAND_KINDS = Object.freeze([ - "adapter_package.ensure", - "adapter_version.register", - "model_profile.register", - "edge.ensure", - "route.ensure", - "enrollment_intent.ensure", -]); - -const commandKindSet = new Set(DEVICE_INFRASTRUCTURE_COMMAND_KINDS); -const keyPattern = /^[a-z][a-z0-9-]{1,62}$/; -const opaqueRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{2,255}$/; -const profileRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$/; -const protocolPattern = /^[A-Z][A-Z0-9_]{0,31}$/; -const capabilityPattern = /^[a-z][a-z0-9._-]{1,63}$/; -const semverPattern = /^[0-9]+\.[0-9]+\.[0-9]+(?:[+-][A-Za-z0-9.-]+)?$/; -const digestPattern = /^sha256:[a-f0-9]{64}$/; -const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; - -export function isInfrastructureManagementCommand(kind) { - return commandKindSet.has(kind); -} - -export function normalizeInfrastructureManagementCommand(kind, input) { - if (!commandKindSet.has(kind)) { - throw new TypeError("device_infrastructure_command_kind_invalid"); - } - assertPlainObject(input, "device_infrastructure_command_invalid"); - - if (kind === "adapter_package.ensure") { - assertAllowedKeys(input, [ - "packageKey", - "displayName", - "publisherRef", - "lifecycleState", - ]); - return Object.freeze({ - packageKey: normalizeKey(input.packageKey, "device_adapter_package_key_invalid"), - displayName: normalizeDisplayText( - input.displayName, - 160, - "device_adapter_package_name_invalid", - ), - publisherRef: normalizeOpaqueRef( - input.publisherRef, - "device_adapter_publisher_ref_invalid", - ), - lifecycleState: normalizeEnum( - input.lifecycleState ?? "active", - new Set(["active", "retired"]), - "device_adapter_package_state_invalid", - ), - }); - } - - if (kind === "adapter_version.register") { - assertAllowedKeys(input, [ - "adapterPackageRef", - "version", - "runtimePackageRef", - "contentDigest", - "contractVersion", - "capabilities", - "lifecycleState", - ]); - return Object.freeze({ - adapterPackageId: normalizeEntityRef( - input.adapterPackageRef, - "adapter-package", - "device_adapter_package_ref_invalid", - ), - version: normalizePattern( - input.version, - semverPattern, - "device_adapter_version_invalid", - ), - runtimePackageRef: normalizeOpaqueRef( - input.runtimePackageRef, - "device_adapter_runtime_package_ref_invalid", - ), - contentDigest: normalizePattern( - input.contentDigest, - digestPattern, - "device_adapter_content_digest_invalid", - ), - contractVersion: normalizeProfileRef( - input.contractVersion, - "device_adapter_contract_version_invalid", - ), - capabilities: Object.freeze(normalizeCapabilities(input.capabilities ?? [])), - lifecycleState: normalizeEnum( - input.lifecycleState ?? "draft", - new Set(["draft", "active", "retired"]), - "device_adapter_version_state_invalid", - ), - }); - } - - if (kind === "model_profile.register") { - assertAllowedKeys(input, [ - "adapterVersionRef", - "profileRef", - "schemaVersion", - "vendor", - "model", - "deviceType", - "protocol", - "schemaArtifactRef", - "profileDigest", - "capabilities", - "lifecycleState", - ]); - return Object.freeze({ - adapterVersionId: normalizeEntityRef( - input.adapterVersionRef, - "adapter-version", - "device_adapter_version_ref_invalid", - ), - profileRef: normalizeProfileRef( - input.profileRef, - "device_model_profile_ref_invalid", - ), - schemaVersion: normalizeProfileRef( - input.schemaVersion, - "device_model_profile_schema_version_invalid", - ), - vendor: normalizeDisplayText(input.vendor, 120, "device_model_vendor_invalid"), - model: normalizeDisplayText(input.model, 120, "device_model_name_invalid"), - deviceType: normalizePattern( - input.deviceType, - capabilityPattern, - "device_model_type_invalid", - ), - protocol: normalizePattern( - input.protocol, - protocolPattern, - "device_model_protocol_invalid", - ), - schemaArtifactRef: normalizeOpaqueRef( - input.schemaArtifactRef, - "device_model_schema_artifact_ref_invalid", - ), - profileDigest: normalizePattern( - input.profileDigest, - digestPattern, - "device_model_profile_digest_invalid", - ), - capabilities: Object.freeze(normalizeCapabilities(input.capabilities ?? [])), - lifecycleState: normalizeEnum( - input.lifecycleState ?? "draft", - new Set(["draft", "active", "retired"]), - "device_model_profile_state_invalid", - ), - }); - } - - if (kind === "edge.ensure") { - assertAllowedKeys(input, [ - "edgeKey", - "displayName", - "deploymentRef", - "lifecycleState", - "channel", - ]); - const normalized = { - edgeKey: normalizeKey(input.edgeKey, "device_edge_key_invalid"), - displayName: normalizeDisplayText( - input.displayName, - 160, - "device_edge_name_invalid", - ), - deploymentRef: normalizeOptionalOpaqueRef( - input.deploymentRef, - "device_edge_deployment_ref_invalid", - ), - lifecycleState: normalizeEnum( - input.lifecycleState ?? "provisioning", - new Set(["provisioning", "active", "suspended", "retired"]), - "device_edge_state_invalid", - ), - }; - if (input.channel !== undefined) { - normalized.channel = normalizeEdgeChannel(input.channel); - } - return Object.freeze(normalized); - } - - if (kind === "route.ensure") { - assertAllowedKeys(input, [ - "projectRef", - "routeKey", - "displayName", - "edgeRef", - "modelProfileRef", - "listenerRef", - "protocol", - "direction", - "lifecycleState", - ]); - return Object.freeze({ - projectId: normalizeEntityRef( - input.projectRef, - "project", - "device_project_ref_invalid", - ), - routeKey: normalizeKey(input.routeKey, "device_route_key_invalid"), - displayName: normalizeDisplayText( - input.displayName, - 160, - "device_route_name_invalid", - ), - edgeId: normalizeEntityRef( - input.edgeRef, - "edge", - "device_edge_ref_invalid", - ), - modelProfileRef: normalizeProfileRef( - input.modelProfileRef, - "device_model_profile_ref_invalid", - ), - listenerRef: normalizeOpaqueRef( - input.listenerRef, - "device_route_listener_ref_invalid", - ), - protocol: normalizePattern( - input.protocol, - protocolPattern, - "device_route_protocol_invalid", - ), - direction: normalizeEnum( - input.direction ?? "telemetry", - new Set(["telemetry", "bidirectional"]), - "device_route_direction_invalid", - ), - lifecycleState: normalizeEnum( - input.lifecycleState ?? "draft", - new Set(["draft", "active", "suspended", "retired"]), - "device_route_state_invalid", - ), - }); - } - - assertAllowedKeys(input, [ - "projectRef", - "enrollmentKey", - "routeRef", - "modelProfileRef", - "displayName", - "identifierKind", - "identifierDigest", - "identifierMasked", - "expiresAt", - ]); - const identifierMasked = normalizeDisplayText( - input.identifierMasked, - 64, - "device_enrollment_identifier_masked_invalid", - ); - assertSafeProjection({ identifierMasked }); - return Object.freeze({ - projectId: normalizeEntityRef( - input.projectRef, - "project", - "device_project_ref_invalid", - ), - enrollmentKey: normalizeKey( - input.enrollmentKey, - "device_enrollment_key_invalid", - ), - routeId: normalizeEntityRef( - input.routeRef, - "route", - "device_route_ref_invalid", - ), - modelProfileRef: normalizeProfileRef( - input.modelProfileRef, - "device_model_profile_ref_invalid", - ), - displayName: normalizeDisplayText( - input.displayName, - 160, - "device_enrollment_name_invalid", - ), - identifierKind: normalizePattern( - input.identifierKind, - /^[a-z][a-z0-9._-]{1,31}$/, - "device_enrollment_identifier_kind_invalid", - ), - identifierDigest: assertIdentifierDigest(input.identifierDigest), - identifierMasked, - expiresAt: normalizeOptionalTimestamp(input.expiresAt), - }); -} - -export function assertPlatformCatalogAuthority(actorInput) { - const actor = normalizeManagementActor(actorInput); - if (actor.hubRole !== "owner") { - throw domainError("device_platform_catalog_access_denied", 403); - } - return actor; -} - -function normalizeCapabilities(input) { - if (!Array.isArray(input) || input.length > 64) { - throw new TypeError("device_adapter_capabilities_invalid"); - } - return [...new Set(input.map((capability) => normalizePattern( - capability, - capabilityPattern, - "device_adapter_capability_invalid", - )))].sort(); -} - -function normalizeEntityRef(value, prefix, code) { - if (typeof value !== "string") throw new TypeError(code); - const match = value.match(new RegExp( - `^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`, - "i", - )); - if (!match) throw new TypeError(code); - return match[1].toLowerCase(); -} - -function normalizeKey(value, code) { - return normalizePattern(value, keyPattern, code); -} - -function normalizeProfileRef(value, code) { - return normalizePattern(value, profileRefPattern, code); -} - -function normalizeOpaqueRef(value, code) { - return normalizePattern(value, opaqueRefPattern, code); -} - -function normalizeOptionalOpaqueRef(value, code) { - if (value == null || value === "") return null; - return normalizeOpaqueRef(value, code); -} - -function normalizeEdgeChannel(input) { - assertPlainObject(input, "device_edge_channel_invalid"); - assertAllowedKeys(input, [ - "endpoint", - "servername", - "generationRef", - "trustBundleRef", - "certificateIdentities", - "lifecycleState", - ]); - const lifecycleState = normalizeEnum( - input.lifecycleState ?? "disabled", - new Set(["disabled", "active", "revoked"]), - "device_edge_channel_state_invalid", - ); - if (lifecycleState === "disabled") { - if (Object.keys(input).some((key) => key !== "lifecycleState")) { - throw new TypeError("device_edge_channel_disabled_configuration_invalid"); - } - return Object.freeze({ - endpoint: null, - servername: null, - generationRef: null, - trustBundleRef: null, - certificateIdentities: Object.freeze([]), - lifecycleState, - }); - } - - const endpoint = normalizeEdgeEndpoint(input.endpoint); - const servername = normalizePattern( - input.servername, - /^[A-Za-z0-9.-]{1,253}$/, - "device_edge_channel_servername_invalid", - ).toLowerCase(); - if (servername !== endpoint.hostname) { - throw new TypeError("device_edge_channel_servername_mismatch"); - } - return Object.freeze({ - endpoint: endpoint.toString(), - servername, - generationRef: normalizeProfileRef( - input.generationRef, - "device_edge_channel_generation_invalid", - ), - trustBundleRef: normalizePattern( - input.trustBundleRef, - /^edge-trust:[a-z][a-z0-9-]{1,62}$/, - "device_edge_channel_trust_bundle_ref_invalid", - ), - certificateIdentities: normalizeCertificateIdentities( - input.certificateIdentities, - ), - lifecycleState, - }); -} - -function normalizeEdgeEndpoint(value) { - let endpoint; - try { - endpoint = new URL(String(value || "")); - } catch { - throw new TypeError("device_edge_channel_endpoint_invalid"); - } - if ( - endpoint.protocol !== "https:" - || endpoint.username - || endpoint.password - || endpoint.pathname !== "/" - || endpoint.search - || endpoint.hash - || endpoint.port !== "" - || !isPublicIpv4(endpoint.hostname) - ) { - throw new TypeError("device_edge_channel_endpoint_invalid"); - } - return endpoint; -} - -function isPublicIpv4(value) { - const octets = value.split(".").map(Number); - if ( - octets.length !== 4 - || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255) - ) return false; - const [a, b, c] = octets; - if (a < 1 || a >= 224) return false; - if (a === 10 || a === 127) return false; - if (a === 100 && b >= 64 && b <= 127) return false; - if (a === 169 && b === 254) return false; - if (a === 172 && b >= 16 && b <= 31) return false; - if (a === 192 && (b === 0 || b === 168)) return false; - if (a === 192 && b === 88 && c === 99) return false; - if (a === 198 && (b === 18 || b === 19 || b === 51)) return false; - if (a === 203 && b === 0 && c === 113) return false; - return true; -} - -function normalizePattern(value, pattern, code) { - if (typeof value !== "string" || !pattern.test(value)) { - throw new TypeError(code); - } - return value; -} - -function normalizeDisplayText(value, maxLength, code) { - if (typeof value !== "string") throw new TypeError(code); - const normalized = value.trim(); - if (normalized.length < 1 || normalized.length > maxLength) { - throw new TypeError(code); - } - if (/\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized)) { - throw new TypeError(code); - } - return normalized; -} - -function normalizeOptionalTimestamp(value) { - if (value == null || value === "") return null; - if (typeof value !== "string" || !isoTimestampPattern.test(value)) { - throw new TypeError("device_enrollment_expires_at_invalid"); - } - const parsed = new Date(value); - if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== value) { - throw new TypeError("device_enrollment_expires_at_invalid"); - } - return value; -} - -function normalizeEnum(value, allowed, code) { - if (typeof value !== "string" || !allowed.has(value)) { - throw new TypeError(code); - } - return value; -} - -function assertPlainObject(value, code) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new TypeError(code); - } -} - -function assertAllowedKeys(input, allowed) { - const allowedSet = new Set(allowed); - for (const key of Object.keys(input)) { - if (!allowedSet.has(key)) { - throw new TypeError(`device_management_command_field_unexpected:${key}`); - } - } -} - -function domainError(code, statusCode) { - const error = new Error(code); - error.statusCode = statusCode; - return error; -} diff --git a/device-plane/services/device-control-core/src/infrastructure-repository.mjs b/device-plane/services/device-control-core/src/infrastructure-repository.mjs deleted file mode 100644 index 5828883..0000000 --- a/device-plane/services/device-control-core/src/infrastructure-repository.mjs +++ /dev/null @@ -1,964 +0,0 @@ -import { randomUUID } from "node:crypto"; - -import { - assertPlatformCatalogAuthority, - isInfrastructureManagementCommand, -} from "./infrastructure-management.mjs"; -import { - assertProjectCapability, - toProjectRef, -} from "./project-management.mjs"; - -export async function applyInfrastructureManagementCommand( - client, - { commandKind, actor, command }, -) { - if (!isInfrastructureManagementCommand(commandKind)) { - throw new TypeError("device_infrastructure_command_kind_invalid"); - } - if (commandKind === "adapter_package.ensure") { - return ensureAdapterPackage(client, actor, command); - } - if (commandKind === "adapter_version.register") { - return registerAdapterVersion(client, actor, command); - } - if (commandKind === "model_profile.register") { - return registerModelProfile(client, actor, command); - } - if (commandKind === "edge.ensure") { - return ensureEdge(client, actor, command); - } - if (commandKind === "route.ensure") { - return ensureRoute(client, actor, command); - } - return ensureEnrollmentIntent(client, actor, command); -} - -export async function authorizeInfrastructureManagementReplay( - client, - { commandKind, actor, command }, -) { - if (!isInfrastructureManagementCommand(commandKind)) { - throw new TypeError("device_infrastructure_command_kind_invalid"); - } - if ( - commandKind === "adapter_package.ensure" - || commandKind === "adapter_version.register" - || commandKind === "model_profile.register" - || commandKind === "edge.ensure" - ) { - assertPlatformCatalogAuthority(actor); - return; - } - - const capability = commandKind === "route.ensure" - ? "route.manage" - : "device.enroll"; - await assertCurrentProjectCapability(client, actor, command.projectId, capability); -} - -async function ensureAdapterPackage(client, actor, command) { - assertPlatformCatalogAuthority(actor); - const result = await client.query( - `insert into device_adapter_packages ( - id, - package_key, - display_name, - publisher_ref, - lifecycle_state, - created_by_ref - ) values ($1, $2, $3, $4, $5, $6) - on conflict (package_key) do update set - display_name = excluded.display_name, - lifecycle_state = excluded.lifecycle_state, - updated_at = now() - where device_adapter_packages.publisher_ref = excluded.publisher_ref - and ( - device_adapter_packages.lifecycle_state = excluded.lifecycle_state - or ( - device_adapter_packages.lifecycle_state = 'active' - and excluded.lifecycle_state = 'retired' - ) - ) - returning id, package_key, display_name, publisher_ref, lifecycle_state, - created_at, updated_at, (xmax = 0) as created`, - [ - randomUUID(), - command.packageKey, - command.displayName, - command.publisherRef, - command.lifecycleState, - actor.userRef, - ], - ); - const row = requireMutationRow( - result, - "device_adapter_package_identity_conflict", - ); - await addAudit(client, { - eventType: row.created - ? "adapter_package.created" - : "adapter_package.updated", - actorRef: actor.userRef, - payload: { - adapterPackageRef: `adapter-package:${row.id}`, - packageKey: row.package_key, - publisherRef: row.publisher_ref, - lifecycleState: row.lifecycle_state, - }, - }); - return { - created: row.created === true, - adapterPackage: adapterPackageView(row), - }; -} - -async function registerAdapterVersion(client, actor, command) { - assertPlatformCatalogAuthority(actor); - const adapterPackage = await findAdapterPackage( - client, - command.adapterPackageId, - ); - if (adapterPackage.lifecycle_state !== "active") { - throw domainError("device_adapter_package_inactive", 409); - } - - const result = await client.query( - `insert into device_adapter_versions ( - id, - adapter_package_id, - version, - runtime_package_ref, - content_digest, - contract_version, - capabilities, - lifecycle_state, - registered_by_ref - ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9) - on conflict (adapter_package_id, version) do update set - lifecycle_state = excluded.lifecycle_state, - updated_at = now() - where device_adapter_versions.runtime_package_ref = excluded.runtime_package_ref - and device_adapter_versions.content_digest = excluded.content_digest - and device_adapter_versions.contract_version = excluded.contract_version - and device_adapter_versions.capabilities = excluded.capabilities - and ( - device_adapter_versions.lifecycle_state = excluded.lifecycle_state - or ( - device_adapter_versions.lifecycle_state = 'draft' - and excluded.lifecycle_state in ('active', 'retired') - ) - or ( - device_adapter_versions.lifecycle_state = 'active' - and excluded.lifecycle_state = 'retired' - ) - ) - returning id, adapter_package_id, version, runtime_package_ref, - content_digest, contract_version, capabilities, lifecycle_state, - created_at, updated_at, (xmax = 0) as created`, - [ - randomUUID(), - command.adapterPackageId, - command.version, - command.runtimePackageRef, - command.contentDigest, - command.contractVersion, - command.capabilities, - command.lifecycleState, - actor.userRef, - ], - ); - const row = requireMutationRow( - result, - "device_adapter_version_identity_conflict", - ); - await addAudit(client, { - eventType: row.created - ? "adapter_version.registered" - : "adapter_version.lifecycle_updated", - actorRef: actor.userRef, - payload: { - adapterPackageRef: `adapter-package:${row.adapter_package_id}`, - adapterVersionRef: `adapter-version:${row.id}`, - version: row.version, - contentDigest: row.content_digest, - lifecycleState: row.lifecycle_state, - }, - }); - return { - created: row.created === true, - adapterPackage: adapterPackageView(adapterPackage), - adapterVersion: adapterVersionView(row), - }; -} - -async function registerModelProfile(client, actor, command) { - assertPlatformCatalogAuthority(actor); - const adapterVersion = await findAdapterVersion( - client, - command.adapterVersionId, - ); - if ( - adapterVersion.package_lifecycle_state !== "active" - || adapterVersion.lifecycle_state === "retired" - ) { - throw domainError("device_adapter_version_inactive", 409); - } - if ( - command.lifecycleState === "active" - && adapterVersion.lifecycle_state !== "active" - ) { - throw domainError("device_model_profile_adapter_not_active", 409); - } - - const profile = { - schemaVersion: command.schemaVersion, - profileRef: command.profileRef, - vendor: command.vendor, - model: command.model, - deviceType: command.deviceType, - protocol: command.protocol, - schemaArtifactRef: command.schemaArtifactRef, - capabilities: command.capabilities, - }; - const existingProfile = await findOptionalModelProfileRegistration( - client, - command.profileRef, - ); - const adoptsLegacyProfile = isLegacyMetadataOnlyProfile(existingProfile); - const result = await client.query( - `insert into device_model_profiles ( - profile_ref, - schema_version, - vendor, - model, - device_type, - protocol, - profile, - adapter_version_id, - schema_artifact_ref, - profile_digest, - capabilities, - lifecycle_state - ) values ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, $11, $12) - on conflict (profile_ref) do update set - adapter_version_id = case - when device_model_profiles.adapter_version_id is null - then excluded.adapter_version_id - else device_model_profiles.adapter_version_id - end, - schema_artifact_ref = case - when device_model_profiles.schema_artifact_ref is null - then excluded.schema_artifact_ref - else device_model_profiles.schema_artifact_ref - end, - profile_digest = case - when device_model_profiles.profile_digest is null - then excluded.profile_digest - else device_model_profiles.profile_digest - end, - capabilities = case - when cardinality(device_model_profiles.capabilities) = 0 - then excluded.capabilities - else device_model_profiles.capabilities - end, - lifecycle_state = excluded.lifecycle_state, - updated_at = now() - where device_model_profiles.schema_version = excluded.schema_version - and device_model_profiles.vendor = excluded.vendor - and device_model_profiles.model = excluded.model - and device_model_profiles.device_type = excluded.device_type - and device_model_profiles.protocol = excluded.protocol - and ( - ( - ( - device_model_profiles.profile = excluded.profile - or ( - jsonb_typeof(device_model_profiles.profile) = 'object' - and device_model_profiles.profile ->> 'schemaVersion' = excluded.schema_version - and device_model_profiles.profile ->> 'profileRef' = excluded.profile_ref - and device_model_profiles.profile ->> 'vendor' = excluded.vendor - and device_model_profiles.profile ->> 'model' = excluded.model - and device_model_profiles.profile ->> 'deviceType' = excluded.device_type - and device_model_profiles.profile ->> 'protocol' = excluded.protocol - ) - ) - and device_model_profiles.adapter_version_id = excluded.adapter_version_id - and device_model_profiles.schema_artifact_ref = excluded.schema_artifact_ref - and device_model_profiles.profile_digest = excluded.profile_digest - and device_model_profiles.capabilities = excluded.capabilities - and ( - device_model_profiles.lifecycle_state = excluded.lifecycle_state - or ( - device_model_profiles.lifecycle_state = 'draft' - and excluded.lifecycle_state in ('active', 'retired') - ) - or ( - device_model_profiles.lifecycle_state = 'active' - and excluded.lifecycle_state = 'retired' - ) - ) - ) - or ( - jsonb_typeof(device_model_profiles.profile) = 'object' - and device_model_profiles.profile ->> 'schemaVersion' = excluded.schema_version - and device_model_profiles.profile ->> 'profileRef' = excluded.profile_ref - and device_model_profiles.profile ->> 'vendor' = excluded.vendor - and device_model_profiles.profile ->> 'model' = excluded.model - and device_model_profiles.profile ->> 'deviceType' = excluded.device_type - and device_model_profiles.profile ->> 'protocol' = excluded.protocol - and device_model_profiles.adapter_version_id is null - and device_model_profiles.schema_artifact_ref is null - and device_model_profiles.profile_digest is null - and cardinality(device_model_profiles.capabilities) = 0 - and device_model_profiles.lifecycle_state = 'active' - and excluded.lifecycle_state = 'draft' - ) - ) - returning profile_ref, schema_version, vendor, model, device_type, - protocol, adapter_version_id, schema_artifact_ref, profile_digest, - capabilities, lifecycle_state, created_at, updated_at, - (xmax = 0) as created`, - [ - command.profileRef, - command.schemaVersion, - command.vendor, - command.model, - command.deviceType, - command.protocol, - JSON.stringify(profile), - command.adapterVersionId, - command.schemaArtifactRef, - command.profileDigest, - command.capabilities, - command.lifecycleState, - ], - ); - const row = requireMutationRow( - result, - "device_model_profile_identity_conflict", - ); - await addAudit(client, { - eventType: row.created - ? "model_profile.registered" - : adoptsLegacyProfile - ? "model_profile.registry_adopted" - : "model_profile.lifecycle_updated", - actorRef: actor.userRef, - payload: { - adapterVersionRef: `adapter-version:${row.adapter_version_id}`, - modelProfileRef: row.profile_ref, - profileDigest: row.profile_digest, - lifecycleState: row.lifecycle_state, - }, - }); - return { - created: row.created === true, - adapterVersion: adapterVersionView(adapterVersion), - modelProfile: modelProfileView(row), - }; -} - -async function findOptionalModelProfileRegistration(client, profileRef) { - const result = await client.query( - `select profile_ref, adapter_version_id, schema_artifact_ref, - profile_digest, capabilities, lifecycle_state - from device_model_profiles - where profile_ref = $1 - for update`, - [profileRef], - ); - return result.rows[0] ?? null; -} - -function isLegacyMetadataOnlyProfile(profile) { - return profile != null - && profile.adapter_version_id == null - && profile.schema_artifact_ref == null - && profile.profile_digest == null - && Array.isArray(profile.capabilities) - && profile.capabilities.length === 0 - && profile.lifecycle_state === "active"; -} - -async function ensureEdge(client, actor, command) { - assertPlatformCatalogAuthority(actor); - const channelProvided = command.channel !== undefined; - const channel = command.channel ?? { - endpoint: null, - servername: null, - generationRef: null, - trustBundleRef: null, - certificateIdentities: [], - lifecycleState: "disabled", - }; - const result = await client.query( - `insert into device_edges ( - id, - edge_key, - display_name, - deployment_ref, - lifecycle_state, - channel_endpoint, - channel_servername, - channel_generation_ref, - channel_trust_bundle_ref, - channel_certificate_identities, - channel_lifecycle_state, - created_by_ref - ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11, $13) - on conflict (edge_key) do update set - display_name = excluded.display_name, - deployment_ref = excluded.deployment_ref, - lifecycle_state = excluded.lifecycle_state, - channel_endpoint = case when $12 then excluded.channel_endpoint - else device_edges.channel_endpoint end, - channel_servername = case when $12 then excluded.channel_servername - else device_edges.channel_servername end, - channel_generation_ref = case when $12 then excluded.channel_generation_ref - else device_edges.channel_generation_ref end, - channel_trust_bundle_ref = case when $12 then excluded.channel_trust_bundle_ref - else device_edges.channel_trust_bundle_ref end, - channel_certificate_identities = case when $12 - then excluded.channel_certificate_identities - else device_edges.channel_certificate_identities end, - channel_lifecycle_state = case when $12 - then excluded.channel_lifecycle_state - else device_edges.channel_lifecycle_state end, - updated_at = now() - where ( - device_edges.lifecycle_state = excluded.lifecycle_state - or ( - device_edges.lifecycle_state = 'provisioning' - and excluded.lifecycle_state in ('active', 'retired') - ) - or ( - device_edges.lifecycle_state = 'active' - and excluded.lifecycle_state in ('suspended', 'retired') - ) - or ( - device_edges.lifecycle_state = 'suspended' - and excluded.lifecycle_state in ('active', 'retired') - ) - ) - and ( - not $12 - or device_edges.channel_lifecycle_state = excluded.channel_lifecycle_state - or ( - device_edges.channel_lifecycle_state = 'disabled' - and excluded.channel_lifecycle_state = 'active' - ) - or ( - device_edges.channel_lifecycle_state = 'active' - and excluded.channel_lifecycle_state in ('disabled', 'revoked') - ) - ) - returning id, edge_key, display_name, deployment_ref, lifecycle_state, - channel_endpoint, channel_servername, channel_generation_ref, - channel_trust_bundle_ref, channel_certificate_identities, - channel_lifecycle_state, created_at, updated_at, - (xmax = 0) as created`, - [ - randomUUID(), - command.edgeKey, - command.displayName, - command.deploymentRef, - command.lifecycleState, - channel.endpoint, - channel.servername, - channel.generationRef, - channel.trustBundleRef, - JSON.stringify(channel.certificateIdentities), - channel.lifecycleState, - channelProvided, - actor.userRef, - ], - ); - const row = requireMutationRow(result, "device_edge_identity_conflict"); - await addAudit(client, { - eventType: row.created ? "edge.created" : "edge.updated", - actorRef: actor.userRef, - payload: { - edgeRef: `edge:${row.id}`, - edgeKey: row.edge_key, - deploymentRef: row.deployment_ref, - lifecycleState: row.lifecycle_state, - channelLifecycleState: row.channel_lifecycle_state, - channelGenerationRef: row.channel_generation_ref, - channelTrustBundleRef: row.channel_trust_bundle_ref, - }, - }); - return { - created: row.created === true, - edge: edgeView(row), - }; -} - -async function ensureRoute(client, actor, command) { - await assertCurrentProjectCapability( - client, - actor, - command.projectId, - "route.manage", - ); - const edge = await findEdge(client, command.edgeId); - const profile = await findModelProfile(client, command.modelProfileRef); - assertRouteDependencies(command, edge, profile); - - const result = await client.query( - `insert into device_routes ( - id, - project_id, - route_key, - display_name, - edge_id, - model_profile_ref, - listener_ref, - protocol, - direction, - lifecycle_state, - created_by_ref - ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - on conflict (project_id, route_key) do update set - display_name = excluded.display_name, - edge_id = excluded.edge_id, - model_profile_ref = excluded.model_profile_ref, - listener_ref = excluded.listener_ref, - protocol = excluded.protocol, - direction = excluded.direction, - lifecycle_state = excluded.lifecycle_state, - updated_at = now() - where - device_routes.lifecycle_state = excluded.lifecycle_state - or ( - device_routes.lifecycle_state = 'draft' - and excluded.lifecycle_state in ('active', 'retired') - ) - or ( - device_routes.lifecycle_state = 'active' - and excluded.lifecycle_state in ('suspended', 'retired') - ) - or ( - device_routes.lifecycle_state = 'suspended' - and excluded.lifecycle_state in ('active', 'retired') - ) - returning id, project_id, route_key, display_name, edge_id, - model_profile_ref, listener_ref, protocol, direction, lifecycle_state, - created_at, updated_at, (xmax = 0) as created`, - [ - randomUUID(), - command.projectId, - command.routeKey, - command.displayName, - command.edgeId, - command.modelProfileRef, - command.listenerRef, - command.protocol, - command.direction, - command.lifecycleState, - actor.userRef, - ], - ); - const row = requireMutationRow(result, "device_route_identity_conflict"); - await addAudit(client, { - eventType: row.created ? "route.created" : "route.updated", - actorRef: actor.userRef, - projectId: command.projectId, - payload: { - projectRef: toProjectRef(command.projectId), - routeRef: `route:${row.id}`, - routeKey: row.route_key, - edgeRef: `edge:${row.edge_id}`, - modelProfileRef: row.model_profile_ref, - listenerRef: row.listener_ref, - direction: row.direction, - lifecycleState: row.lifecycle_state, - }, - }); - return { - created: row.created === true, - route: routeView(row), - }; -} - -async function ensureEnrollmentIntent(client, actor, command) { - await assertCurrentProjectCapability( - client, - actor, - command.projectId, - "device.enroll", - ); - const route = await findProjectRoute( - client, - command.projectId, - command.routeId, - ); - if (route.lifecycle_state !== "active") { - throw domainError("device_enrollment_route_inactive", 409); - } - if (route.model_profile_ref !== command.modelProfileRef) { - throw domainError("device_enrollment_profile_mismatch", 409); - } - - const result = await client.query( - `insert into device_enrollment_intents ( - id, - project_id, - enrollment_key, - route_id, - model_profile_ref, - display_name, - expected_identifier_kind, - expected_identifier_digest, - expected_identifier_masked, - expires_at, - created_by_ref - ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - on conflict (project_id, enrollment_key) do update set - display_name = excluded.display_name, - expires_at = excluded.expires_at, - updated_at = now() - where device_enrollment_intents.route_id = excluded.route_id - and device_enrollment_intents.model_profile_ref = excluded.model_profile_ref - and device_enrollment_intents.expected_identifier_kind = excluded.expected_identifier_kind - and device_enrollment_intents.expected_identifier_digest = excluded.expected_identifier_digest - and device_enrollment_intents.expected_identifier_masked = excluded.expected_identifier_masked - and device_enrollment_intents.lifecycle_state = 'pending' - returning id, project_id, enrollment_key, route_id, model_profile_ref, - display_name, expected_identifier_kind, expected_identifier_masked, - lifecycle_state, expires_at, claimed_device_id, created_at, updated_at, - (xmax = 0) as created`, - [ - randomUUID(), - command.projectId, - command.enrollmentKey, - command.routeId, - command.modelProfileRef, - command.displayName, - command.identifierKind, - command.identifierDigest, - command.identifierMasked, - command.expiresAt, - actor.userRef, - ], - ); - const row = requireMutationRow( - result, - "device_enrollment_intent_identity_conflict", - ); - await addAudit(client, { - eventType: row.created - ? "enrollment_intent.created" - : "enrollment_intent.updated", - actorRef: actor.userRef, - projectId: command.projectId, - payload: { - projectRef: toProjectRef(command.projectId), - enrollmentIntentRef: `enrollment-intent:${row.id}`, - enrollmentKey: row.enrollment_key, - routeRef: `route:${row.route_id}`, - modelProfileRef: row.model_profile_ref, - identifier: { - kind: row.expected_identifier_kind, - masked: row.expected_identifier_masked, - }, - lifecycleState: row.lifecycle_state, - }, - }); - return { - created: row.created === true, - enrollmentIntent: enrollmentIntentView(row), - }; -} - -async function assertCurrentProjectCapability( - client, - actor, - projectId, - capability, -) { - const project = await client.query( - `select p.id, p.lifecycle_state, os.lifecycle_state as owner_lifecycle_state - from device_projects p - join device_owner_scopes os on os.id = p.owner_scope_id - where p.id = $1 - for share of p, os`, - [projectId], - ); - const row = project.rows[0]; - if (!row) throw domainError("device_project_not_found", 404); - if (row.owner_lifecycle_state !== "active") { - throw domainError("device_owner_scope_inactive", 409); - } - if (row.lifecycle_state !== "active") { - throw domainError("device_project_inactive", 409); - } - const grants = await client.query( - `select id, principal_kind, principal_ref, project_role, - capability_allow, capability_deny, lifecycle_state - from device_project_grants - where project_id = $1 - order by created_at, id - for share`, - [projectId], - ); - assertProjectCapability( - actor, - grants.rows.map((grant) => ({ - grantRef: `grant:${grant.id}`, - principalKind: grant.principal_kind, - principalRef: grant.principal_ref, - projectRole: grant.project_role, - capabilityAllow: grant.capability_allow ?? [], - capabilityDeny: grant.capability_deny ?? [], - lifecycleState: grant.lifecycle_state, - })), - capability, - ); -} - -async function findAdapterPackage(client, adapterPackageId) { - const result = await client.query( - `select id, package_key, display_name, publisher_ref, lifecycle_state, - created_at, updated_at - from device_adapter_packages - where id = $1 - for share`, - [adapterPackageId], - ); - if (!result.rows[0]) { - throw domainError("device_adapter_package_not_found", 404); - } - return result.rows[0]; -} - -async function findAdapterVersion(client, adapterVersionId) { - const result = await client.query( - `select av.id, av.adapter_package_id, av.version, - av.runtime_package_ref, av.content_digest, av.contract_version, - av.capabilities, av.lifecycle_state, av.created_at, av.updated_at, - ap.lifecycle_state as package_lifecycle_state - from device_adapter_versions av - join device_adapter_packages ap on ap.id = av.adapter_package_id - where av.id = $1 - for share of av, ap`, - [adapterVersionId], - ); - if (!result.rows[0]) { - throw domainError("device_adapter_version_not_found", 404); - } - return result.rows[0]; -} - -async function findEdge(client, edgeId) { - const result = await client.query( - `select id, edge_key, display_name, deployment_ref, lifecycle_state, - created_at, updated_at - from device_edges - where id = $1 - for share`, - [edgeId], - ); - if (!result.rows[0]) throw domainError("device_edge_not_found", 404); - return result.rows[0]; -} - -async function findModelProfile(client, profileRef) { - const result = await client.query( - `select mp.profile_ref, mp.schema_version, mp.vendor, mp.model, - mp.device_type, mp.protocol, mp.adapter_version_id, - mp.schema_artifact_ref, mp.profile_digest, mp.capabilities, - mp.lifecycle_state, mp.created_at, mp.updated_at, - av.lifecycle_state as adapter_lifecycle_state, - ap.lifecycle_state as package_lifecycle_state - from device_model_profiles mp - left join device_adapter_versions av on av.id = mp.adapter_version_id - left join device_adapter_packages ap on ap.id = av.adapter_package_id - where mp.profile_ref = $1 - for share of mp`, - [profileRef], - ); - if (!result.rows[0]) { - throw domainError("device_model_profile_not_found", 404); - } - return result.rows[0]; -} - -async function findProjectRoute(client, projectId, routeId) { - const result = await client.query( - `select id, project_id, route_key, display_name, edge_id, - model_profile_ref, listener_ref, protocol, direction, lifecycle_state, - created_at, updated_at - from device_routes - where id = $1 and project_id = $2 - for share`, - [routeId, projectId], - ); - if (!result.rows[0]) throw domainError("device_route_not_found", 404); - return result.rows[0]; -} - -function assertRouteDependencies(command, edge, profile) { - if (profile.adapter_version_id == null) { - throw domainError("device_model_profile_unregistered", 409); - } - if (profile.protocol !== command.protocol) { - throw domainError("device_route_protocol_mismatch", 409); - } - if ( - edge.lifecycle_state === "retired" - || profile.lifecycle_state === "retired" - || profile.adapter_lifecycle_state === "retired" - || profile.package_lifecycle_state === "retired" - ) { - throw domainError("device_route_dependency_inactive", 409); - } - if ( - command.lifecycleState === "active" - && ( - edge.lifecycle_state !== "active" - || profile.lifecycle_state !== "active" - || profile.adapter_lifecycle_state !== "active" - || profile.package_lifecycle_state !== "active" - ) - ) { - throw domainError("device_route_dependency_not_active", 409); - } -} - -async function addAudit(client, { - eventType, - actorRef, - projectId = null, - payload, -}) { - await client.query( - `insert into device_audit_events ( - id, - event_type, - actor_ref, - project_id, - payload - ) values ($1, $2, $3, $4, $5::jsonb)`, - [randomUUID(), eventType, actorRef, projectId, JSON.stringify(payload)], - ); -} - -function requireMutationRow(result, code) { - if (!result.rows[0]) throw domainError(code, 409); - return result.rows[0]; -} - -function adapterPackageView(row) { - return { - adapterPackageRef: `adapter-package:${row.id}`, - packageKey: row.package_key, - displayName: row.display_name, - publisherRef: row.publisher_ref, - lifecycleState: row.lifecycle_state, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function adapterVersionView(row) { - return { - adapterVersionRef: `adapter-version:${row.id}`, - adapterPackageRef: `adapter-package:${row.adapter_package_id}`, - version: row.version, - runtimePackageRef: row.runtime_package_ref, - contentDigest: row.content_digest, - contractVersion: row.contract_version, - capabilities: [...(row.capabilities ?? [])].sort(), - lifecycleState: row.lifecycle_state, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function modelProfileView(row) { - return { - modelProfileRef: row.profile_ref, - adapterVersionRef: `adapter-version:${row.adapter_version_id}`, - schemaVersion: row.schema_version, - vendor: row.vendor, - model: row.model, - deviceType: row.device_type, - protocol: row.protocol, - schemaArtifactRef: row.schema_artifact_ref, - profileDigest: row.profile_digest, - capabilities: [...(row.capabilities ?? [])].sort(), - lifecycleState: row.lifecycle_state, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function edgeView(row) { - return { - edgeRef: `edge:${row.id}`, - edgeKey: row.edge_key, - displayName: row.display_name, - deploymentRef: row.deployment_ref ?? null, - lifecycleState: row.lifecycle_state, - channel: { - lifecycleState: row.channel_lifecycle_state ?? "disabled", - endpoint: row.channel_endpoint ?? null, - servername: row.channel_servername ?? null, - generationRef: row.channel_generation_ref ?? null, - trustBundleRef: row.channel_trust_bundle_ref ?? null, - certificateIdentities: [...(row.channel_certificate_identities ?? [])], - }, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function routeView(row) { - return { - routeRef: `route:${row.id}`, - projectRef: toProjectRef(row.project_id), - routeKey: row.route_key, - displayName: row.display_name, - edgeRef: `edge:${row.edge_id}`, - modelProfileRef: row.model_profile_ref, - listenerRef: row.listener_ref, - protocol: row.protocol, - direction: row.direction, - lifecycleState: row.lifecycle_state, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function enrollmentIntentView(row) { - return { - enrollmentIntentRef: `enrollment-intent:${row.id}`, - projectRef: toProjectRef(row.project_id), - enrollmentKey: row.enrollment_key, - routeRef: `route:${row.route_id}`, - modelProfileRef: row.model_profile_ref, - displayName: row.display_name, - identifier: { - kind: row.expected_identifier_kind, - masked: row.expected_identifier_masked, - }, - lifecycleState: row.lifecycle_state, - expiresAt: toIso(row.expires_at), - claimedDeviceRef: row.claimed_device_id - ? `device:${row.claimed_device_id}` - : null, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -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; -} diff --git a/device-plane/services/device-control-core/src/lifecycle-management.mjs b/device-plane/services/device-control-core/src/lifecycle-management.mjs deleted file mode 100644 index 9c8be20..0000000 --- a/device-plane/services/device-control-core/src/lifecycle-management.mjs +++ /dev/null @@ -1,139 +0,0 @@ -export const DEVICE_LIFECYCLE_COMMAND_KINDS = Object.freeze([ - "device.claim", - "device.transfer", - "discovery.reject", - "discovery.expire", -]); - -const commandKindSet = new Set(DEVICE_LIFECYCLE_COMMAND_KINDS); -const keyPattern = /^[a-z][a-z0-9-]{1,62}$/; -const resolutionPattern = /^[a-z][a-z0-9._-]{1,63}$/; - -export function isLifecycleManagementCommand(kind) { - return commandKindSet.has(kind); -} - -export function normalizeLifecycleManagementCommand(kind, input) { - if (!commandKindSet.has(kind)) { - throw new TypeError("device_lifecycle_command_kind_invalid"); - } - assertPlainObject(input); - - if (kind === "device.claim") { - assertAllowedKeys(input, [ - "projectRef", - "enrollmentIntentRef", - "discoveryRef", - "deviceKey", - "displayName", - ]); - return Object.freeze({ - projectId: normalizeEntityRef(input.projectRef, "project"), - enrollmentIntentId: normalizeEntityRef( - input.enrollmentIntentRef, - "enrollment-intent", - ), - discoveryId: normalizeEntityRef(input.discoveryRef, "discovery"), - deviceKey: normalizePattern( - input.deviceKey, - keyPattern, - "device_key_invalid", - ), - displayName: normalizeDisplayText(input.displayName, 160), - }); - } - - if (kind === "device.transfer") { - assertAllowedKeys(input, [ - "deviceRef", - "sourceProjectRef", - "targetProjectRef", - "targetDeviceKey", - ]); - const sourceProjectId = normalizeEntityRef( - input.sourceProjectRef, - "project", - ); - const targetProjectId = normalizeEntityRef( - input.targetProjectRef, - "project", - ); - if (sourceProjectId === targetProjectId) { - throw new TypeError("device_transfer_target_same_as_source"); - } - return Object.freeze({ - deviceId: normalizeEntityRef(input.deviceRef, "device"), - sourceProjectId, - targetProjectId, - targetDeviceKey: normalizePattern( - input.targetDeviceKey, - keyPattern, - "device_transfer_target_key_invalid", - ), - }); - } - - assertAllowedKeys(input, [ - "projectRef", - "discoveryRef", - "resolutionCode", - ]); - return Object.freeze({ - projectId: normalizeEntityRef(input.projectRef, "project"), - discoveryId: normalizeEntityRef(input.discoveryRef, "discovery"), - resolutionCode: normalizePattern( - input.resolutionCode, - resolutionPattern, - "device_discovery_resolution_code_invalid", - ), - }); -} - -function normalizeEntityRef(value, prefix) { - if (typeof value !== "string") { - throw new TypeError(`device_${prefix}_ref_invalid`); - } - const match = value.match(new RegExp( - `^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`, - "i", - )); - if (!match) throw new TypeError(`device_${prefix}_ref_invalid`); - return match[1].toLowerCase(); -} - -function normalizePattern(value, pattern, code) { - if (typeof value !== "string" || !pattern.test(value)) { - throw new TypeError(code); - } - return value; -} - -function normalizeDisplayText(value, maxLength) { - if (typeof value !== "string") { - throw new TypeError("device_display_name_invalid"); - } - const normalized = value.trim(); - if ( - normalized.length < 1 - || normalized.length > maxLength - || /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized) - ) { - throw new TypeError("device_display_name_invalid"); - } - return normalized; -} - -function assertPlainObject(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new TypeError("device_lifecycle_command_invalid"); - } -} - -function assertAllowedKeys(input, allowed) { - const allowedSet = new Set(allowed); - for (const key of Object.keys(input)) { - if (!allowedSet.has(key)) { - throw new TypeError(`device_management_command_field_unexpected:${key}`); - } - } -} diff --git a/device-plane/services/device-control-core/src/lifecycle-repository.mjs b/device-plane/services/device-control-core/src/lifecycle-repository.mjs deleted file mode 100644 index 41f1473..0000000 --- a/device-plane/services/device-control-core/src/lifecycle-repository.mjs +++ /dev/null @@ -1,682 +0,0 @@ -import { randomUUID } from "node:crypto"; - -import { - normalizeRestrictedIdentifierProjection, -} from "../../../packages/device-protocol-contract/src/index.mjs"; -import { isLifecycleManagementCommand } from "./lifecycle-management.mjs"; -import { - assertProjectCapability, - toProjectRef, -} from "./project-management.mjs"; - -export async function applyLifecycleManagementCommand( - client, - { commandKind, actor, command }, -) { - if (!isLifecycleManagementCommand(commandKind)) { - throw new TypeError("device_lifecycle_command_kind_invalid"); - } - if (commandKind === "device.claim") { - return claimDevice(client, actor, command); - } - if (commandKind === "device.transfer") { - return transferDevice(client, actor, command); - } - return resolveDiscovery(client, actor, command, commandKind); -} - -export async function authorizeLifecycleManagementReplay( - client, - { commandKind, actor, command }, -) { - if (!isLifecycleManagementCommand(commandKind)) { - throw new TypeError("device_lifecycle_command_kind_invalid"); - } - if (commandKind === "device.transfer") { - await findProjectWithCapability( - client, - actor, - command.sourceProjectId, - "device.transfer", - ); - await findProjectWithCapability( - client, - actor, - command.targetProjectId, - "device.transfer", - ); - return; - } - - await findProjectWithCapability( - client, - actor, - command.projectId, - "device.claim", - ); - if (commandKind === "device.claim") { - const current = await client.query( - `select di.project_id - from device_discoveries dd - join device_instances di on di.id = dd.claimed_device_id - where dd.id = $1`, - [command.discoveryId], - ); - const currentProjectId = current.rows[0]?.project_id; - if (currentProjectId && currentProjectId !== command.projectId) { - await findProjectWithCapability( - client, - actor, - currentProjectId, - "device.claim", - ); - } - } -} - -async function claimDevice(client, actor, command) { - const project = await findProjectWithCapability( - client, - actor, - command.projectId, - "device.claim", - ); - const enrollment = await findEnrollmentForUpdate( - client, - command.projectId, - command.enrollmentIntentId, - ); - const discovery = await findDiscoveryForUpdate( - client, - command.projectId, - command.discoveryId, - ); - assertClaimEvidence(command, enrollment, discovery); - - const deviceId = randomUUID(); - const inserted = await client.query( - `insert into device_instances ( - id, - contour_id, - owner_scope_id, - project_id, - device_key, - model_profile_ref, - display_name, - identifier_kind, - identifier_digest, - identifier_masked, - lifecycle_state - ) values ($1, null, $2, $3, $4, $5, $6, $7, $8, $9, 'claimed') - returning id, owner_scope_id, project_id, device_key, - model_profile_ref, display_name, identifier_kind, - identifier_masked, lifecycle_state, created_at, updated_at`, - [ - deviceId, - project.owner_scope_id, - project.id, - command.deviceKey, - discovery.model_profile_ref, - command.displayName, - discovery.identifier_kind, - discovery.identifier_digest, - discovery.identifier_masked, - ], - ); - const device = inserted.rows[0]; - if (!device) throw domainError("device_claim_insert_failed", 409); - - const identifierId = randomUUID(); - await client.query( - `insert into device_restricted_identifiers ( - id, - device_id, - owner_scope_id, - project_id, - identifier_kind, - identifier_digest, - identifier_masked, - provenance_kind, - is_primary, - created_by_ref - ) values ($1, $2, $3, $4, $5, $6, $7, 'claim', true, $8)`, - [ - identifierId, - device.id, - project.owner_scope_id, - project.id, - discovery.identifier_kind, - discovery.identifier_digest, - discovery.identifier_masked, - actor.userRef, - ], - ); - - const claimedDiscovery = await client.query( - `update device_discoveries - set lifecycle_state = 'claimed', - claimed_device_id = $2, - claimed_at = now(), - claimed_by = $3, - resolution_code = 'claimed', - resolved_at = now(), - resolved_by_ref = $3, - updated_at = now() - where id = $1 - and lifecycle_state = 'quarantine' - and enrollment_intent_id = $4 - returning id`, - [discovery.id, device.id, actor.userRef, enrollment.id], - ); - if (!claimedDiscovery.rows[0]) { - throw domainError("device_discovery_not_claimable", 409); - } - - const claimedEnrollment = await client.query( - `update device_enrollment_intents - set lifecycle_state = 'claimed', - claimed_device_id = $2, - claimed_at = now(), - resolution_code = 'claimed', - resolved_at = now(), - resolved_by_ref = $3, - updated_at = now() - where id = $1 - and lifecycle_state = 'observed' - and observed_discovery_id = $4 - and (expires_at is null or expires_at > now()) - returning id`, - [enrollment.id, device.id, actor.userRef, discovery.id], - ); - if (!claimedEnrollment.rows[0]) { - throw domainError("device_enrollment_not_claimable", 409); - } - - const transitionId = randomUUID(); - await client.query( - `insert into device_ownership_transitions ( - id, - device_id, - transition_kind, - target_owner_scope_id, - target_project_id, - actor_ref - ) values ($1, $2, 'claim', $3, $4, $5)`, - [ - transitionId, - device.id, - project.owner_scope_id, - project.id, - actor.userRef, - ], - ); - await addAudit(client, { - eventType: "device.claimed", - actorRef: actor.userRef, - projectId: project.id, - deviceId: device.id, - discoveryId: discovery.id, - payload: { - deviceRef: `device:${device.id}`, - projectRef: toProjectRef(project.id), - enrollmentIntentRef: `enrollment-intent:${enrollment.id}`, - discoveryRef: `discovery:${discovery.id}`, - ownershipTransitionRef: `ownership-transition:${transitionId}`, - identifierRef: `identifier:${identifierId}`, - modelProfileRef: device.model_profile_ref, - }, - }); - return { - created: true, - device: deviceView(device, project), - enrollmentIntentRef: `enrollment-intent:${enrollment.id}`, - discoveryRef: `discovery:${discovery.id}`, - ownershipTransitionRef: `ownership-transition:${transitionId}`, - identifierRef: `identifier:${identifierId}`, - }; -} - -async function resolveDiscovery(client, actor, command, commandKind) { - const project = await findProjectWithCapability( - client, - actor, - command.projectId, - "device.claim", - ); - const discovery = await findDiscoveryForUpdate( - client, - command.projectId, - command.discoveryId, - ); - if ( - discovery.lifecycle_state !== "quarantine" - || !discovery.enrollment_intent_id - ) { - throw domainError("device_discovery_not_resolvable", 409); - } - const enrollment = await findEnrollmentForUpdate( - client, - command.projectId, - discovery.enrollment_intent_id, - ); - if ( - enrollment.lifecycle_state !== "observed" - || enrollment.observed_discovery_id !== discovery.id - ) { - throw domainError("device_enrollment_not_resolvable", 409); - } - - const discoveryState = commandKind === "discovery.reject" - ? "rejected" - : "expired"; - const enrollmentState = commandKind === "discovery.reject" - ? "cancelled" - : "expired"; - await client.query( - `update device_discoveries - set lifecycle_state = $2, - resolution_code = $3, - resolved_at = now(), - resolved_by_ref = $4, - updated_at = now() - where id = $1 and lifecycle_state = 'quarantine'`, - [discovery.id, discoveryState, command.resolutionCode, actor.userRef], - ); - await client.query( - `update device_enrollment_intents - set lifecycle_state = $2, - resolution_code = $3, - resolved_at = now(), - resolved_by_ref = $4, - updated_at = now() - where id = $1 and lifecycle_state = 'observed'`, - [enrollment.id, enrollmentState, command.resolutionCode, actor.userRef], - ); - await addAudit(client, { - eventType: `discovery.${discoveryState}`, - actorRef: actor.userRef, - projectId: project.id, - discoveryId: discovery.id, - payload: { - projectRef: toProjectRef(project.id), - discoveryRef: `discovery:${discovery.id}`, - enrollmentIntentRef: `enrollment-intent:${enrollment.id}`, - lifecycleState: discoveryState, - resolutionCode: command.resolutionCode, - }, - }); - return { - discovery: { - discoveryRef: `discovery:${discovery.id}`, - projectRef: toProjectRef(project.id), - enrollmentIntentRef: `enrollment-intent:${enrollment.id}`, - lifecycleState: discoveryState, - identifier: { - kind: discovery.identifier_kind, - masked: discovery.identifier_masked, - }, - resolutionCode: command.resolutionCode, - }, - }; -} - -async function transferDevice(client, actor, command) { - const device = await findDeviceForUpdate(client, command.deviceId); - if (device.project_id !== command.sourceProjectId) { - throw domainError("device_transfer_source_mismatch", 409); - } - if (!device.owner_scope_id || !device.project_id || device.contour_id) { - throw domainError("device_transfer_legacy_ownership_unsupported", 409); - } - if (["online", "retired"].includes(device.lifecycle_state)) { - throw domainError("device_transfer_lifecycle_blocked", 409); - } - - const sourceProject = await findProjectWithCapability( - client, - actor, - command.sourceProjectId, - "device.transfer", - ); - const targetProject = await findProjectWithCapability( - client, - actor, - command.targetProjectId, - "device.transfer", - ); - if (sourceProject.owner_scope_id !== device.owner_scope_id) { - throw domainError("device_transfer_owner_mismatch", 409); - } - - const activeSessions = await client.query( - `select exists ( - select 1 from device_sessions - where device_id = $1 - and lifecycle_state in ('connecting', 'online', 'closing') - ) as active`, - [device.id], - ); - if (activeSessions.rows[0]?.active === true) { - throw domainError("device_transfer_active_session", 409); - } - - const activeCredentialBindings = await client.query( - `select exists ( - select 1 from device_credential_bindings - where device_id = $1 and lifecycle_state = 'active' - ) as active`, - [device.id], - ); - if (activeCredentialBindings.rows[0]?.active === true) { - throw domainError("device_transfer_active_credential_binding", 409); - } - - const activeResourceBindings = await client.query( - `select exists ( - select 1 from device_resource_bindings - where device_id = $1 - and lifecycle_state in ('pending_external_approval', 'active') - ) as active`, - [device.id], - ); - if (activeResourceBindings.rows[0]?.active === true) { - throw domainError("device_transfer_active_resource_binding", 409); - } - - const configurationState = await client.query( - `select desired_revision_id, applied_revision_id - from device_configuration_state - where device_id = $1 - for update`, - [device.id], - ); - if (configurationState.rows[0]?.applied_revision_id) { - throw domainError("device_transfer_applied_configuration", 409); - } - - const nonterminalCommands = await client.query( - `select exists ( - select 1 from device_commands - where device_id = $1 - and lifecycle_state not in ('verified', 'failed', 'expired', 'unknown') - ) as active`, - [device.id], - ); - if (nonterminalCommands.rows[0]?.active === true) { - throw domainError("device_transfer_nonterminal_command", 409); - } - - const clearedConfiguration = await client.query( - `delete from device_configuration_state - where device_id = $1 and applied_revision_id is null`, - [device.id], - ); - - const detached = await client.query( - `delete from device_collection_members - where device_id = $1 and project_id = $2`, - [device.id, sourceProject.id], - ); - const updated = await client.query( - `update device_instances - set owner_scope_id = $2, - project_id = $3, - device_key = $4, - updated_at = now() - where id = $1 - returning id, contour_id, owner_scope_id, project_id, device_key, - model_profile_ref, display_name, identifier_kind, - identifier_masked, lifecycle_state, created_at, updated_at`, - [ - device.id, - targetProject.owner_scope_id, - targetProject.id, - command.targetDeviceKey, - ], - ); - const moved = updated.rows[0]; - if (!moved) throw domainError("device_transfer_update_failed", 409); - - const movedIdentifiers = await client.query( - `update device_restricted_identifiers - set owner_scope_id = $2, - project_id = $3, - updated_at = now() - where device_id = $1 and lifecycle_state = 'active'`, - [device.id, targetProject.owner_scope_id, targetProject.id], - ); - if (Number(movedIdentifiers.rowCount || 0) < 1) { - throw domainError("device_identifier_projection_missing", 409); - } - - const transitionId = randomUUID(); - await client.query( - `insert into device_ownership_transitions ( - id, - device_id, - transition_kind, - source_owner_scope_id, - source_project_id, - target_owner_scope_id, - target_project_id, - actor_ref - ) values ($1, $2, 'transfer', $3, $4, $5, $6, $7)`, - [ - transitionId, - device.id, - sourceProject.owner_scope_id, - sourceProject.id, - targetProject.owner_scope_id, - targetProject.id, - actor.userRef, - ], - ); - const auditPayload = { - deviceRef: `device:${device.id}`, - ownershipTransitionRef: `ownership-transition:${transitionId}`, - sourceProjectRef: toProjectRef(sourceProject.id), - targetProjectRef: toProjectRef(targetProject.id), - detachedCollectionCount: Number(detached.rowCount || 0), - transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0), - clearedDesiredConfiguration: Number(clearedConfiguration.rowCount || 0) > 0, - }; - await addAudit(client, { - eventType: "device.transferred_out", - actorRef: actor.userRef, - projectId: sourceProject.id, - deviceId: device.id, - payload: auditPayload, - }); - await addAudit(client, { - eventType: "device.transferred_in", - actorRef: actor.userRef, - projectId: targetProject.id, - deviceId: device.id, - payload: auditPayload, - }); - return { - transferred: true, - device: deviceView(moved, targetProject), - sourceProjectRef: toProjectRef(sourceProject.id), - ownershipTransitionRef: `ownership-transition:${transitionId}`, - detachedCollectionCount: Number(detached.rowCount || 0), - transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0), - clearedDesiredConfiguration: Number(clearedConfiguration.rowCount || 0) > 0, - }; -} - -export async function findProjectWithCapability( - client, - actor, - projectId, - capability, - { lock = true } = {}, -) { - const projectLockClause = lock ? "for share of p, os" : ""; - const result = await client.query( - `select p.id, p.owner_scope_id, p.project_key, p.name, p.description, - p.lifecycle_state, p.created_at, p.updated_at, - os.scope_kind, os.owner_ref, os.display_name as owner_display_name, - os.lifecycle_state as owner_lifecycle_state - from device_projects p - join device_owner_scopes os on os.id = p.owner_scope_id - where p.id = $1 - ${projectLockClause}`, - [projectId], - ); - const project = result.rows[0]; - if (!project) throw domainError("device_project_not_found", 404); - if (project.owner_lifecycle_state !== "active") { - throw domainError("device_owner_scope_inactive", 409); - } - if (project.lifecycle_state !== "active") { - throw domainError("device_project_inactive", 409); - } - const grants = await client.query( - `select id, principal_kind, principal_ref, project_role, - capability_allow, capability_deny, lifecycle_state - from device_project_grants - where project_id = $1 - order by created_at, id - ${lock ? "for share" : ""}`, - [projectId], - ); - assertProjectCapability( - actor, - grants.rows.map((grant) => ({ - grantRef: `grant:${grant.id}`, - principalKind: grant.principal_kind, - principalRef: grant.principal_ref, - projectRole: grant.project_role, - capabilityAllow: grant.capability_allow ?? [], - capabilityDeny: grant.capability_deny ?? [], - lifecycleState: grant.lifecycle_state, - })), - capability, - ); - return project; -} - -async function findEnrollmentForUpdate(client, projectId, enrollmentId) { - const result = await client.query( - `select id, project_id, route_id, model_profile_ref, - expected_identifier_kind, expected_identifier_digest, - expected_identifier_masked, lifecycle_state, - observed_discovery_id, claimed_device_id, expires_at - from device_enrollment_intents - where id = $1 and project_id = $2 - for update`, - [enrollmentId, projectId], - ); - if (!result.rows[0]) { - throw domainError("device_enrollment_intent_not_found", 404); - } - return result.rows[0]; -} - -async function findDiscoveryForUpdate(client, projectId, discoveryId) { - const result = await client.query( - `select id, project_id, route_id, enrollment_intent_id, - model_profile_ref, protocol, identifier_kind, identifier_digest, - identifier_masked, lifecycle_state, claimed_device_id - from device_discoveries - where id = $1 and project_id = $2 - for update`, - [discoveryId, projectId], - ); - if (!result.rows[0]) throw domainError("device_discovery_not_found", 404); - return result.rows[0]; -} - -async function findDeviceForUpdate(client, deviceId) { - const result = await client.query( - `select id, contour_id, owner_scope_id, project_id, device_key, - model_profile_ref, display_name, identifier_kind, - identifier_masked, lifecycle_state, created_at, updated_at - from device_instances - where id = $1 - for update`, - [deviceId], - ); - if (!result.rows[0]) throw domainError("device_not_found", 404); - return result.rows[0]; -} - -function assertClaimEvidence(command, enrollment, discovery) { - if ( - enrollment.lifecycle_state !== "observed" - || enrollment.observed_discovery_id !== discovery.id - || discovery.lifecycle_state !== "quarantine" - || discovery.enrollment_intent_id !== enrollment.id - || discovery.project_id !== command.projectId - || discovery.route_id !== enrollment.route_id - || discovery.model_profile_ref !== enrollment.model_profile_ref - || discovery.identifier_kind !== enrollment.expected_identifier_kind - || discovery.identifier_digest !== enrollment.expected_identifier_digest - || discovery.identifier_masked !== enrollment.expected_identifier_masked - ) { - throw domainError("device_claim_evidence_mismatch", 409); - } -} - -async function addAudit(client, { - eventType, - actorRef, - projectId, - deviceId = null, - discoveryId = null, - payload, -}) { - await client.query( - `insert into device_audit_events ( - id, - event_type, - actor_ref, - project_id, - device_id, - discovery_id, - payload - ) values ($1, $2, $3, $4, $5, $6, $7::jsonb)`, - [ - randomUUID(), - eventType, - actorRef, - projectId, - deviceId, - discoveryId, - JSON.stringify(payload), - ], - ); -} - -function deviceView(row, project) { - return { - deviceRef: `device:${row.id}`, - deviceKey: row.device_key, - projectRef: toProjectRef(row.project_id), - ownerScope: { - ownerScopeRef: `owner-scope:${row.owner_scope_id}`, - scopeKind: project.scope_kind, - ownerRef: project.owner_ref, - }, - modelProfileRef: row.model_profile_ref, - displayName: row.display_name, - identifier: normalizeRestrictedIdentifierProjection({ - kind: row.identifier_kind, - masked: row.identifier_masked, - }), - lifecycleState: row.lifecycle_state, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function toIso(value) { - return new Date(value).toISOString(); -} - -function domainError(code, statusCode) { - const error = new Error(code); - error.statusCode = statusCode; - return error; -} diff --git a/device-plane/services/device-control-core/src/management-command.mjs b/device-plane/services/device-control-core/src/management-command.mjs deleted file mode 100644 index 9dc8072..0000000 --- a/device-plane/services/device-control-core/src/management-command.mjs +++ /dev/null @@ -1,48 +0,0 @@ -import { - DEVICE_INFRASTRUCTURE_COMMAND_KINDS, - isInfrastructureManagementCommand, - normalizeInfrastructureManagementCommand, -} from "./infrastructure-management.mjs"; -import { - DEVICE_CONTROL_RESOURCE_COMMAND_KINDS, - isControlResourceManagementCommand, - normalizeControlResourceManagementCommand, -} from "./control-resource-management.mjs"; -import { - DEVICE_LIFECYCLE_COMMAND_KINDS, - isLifecycleManagementCommand, - normalizeLifecycleManagementCommand, -} from "./lifecycle-management.mjs"; -import { - DEVICE_MANAGEMENT_COMMAND_KINDS, - normalizeManagementCommand, -} from "./project-management.mjs"; -import { - DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS, - isSensitiveReferenceManagementCommand, - normalizeSensitiveReferenceManagementCommand, -} from "./sensitive-reference-management.mjs"; - -export const ALL_DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([ - ...DEVICE_MANAGEMENT_COMMAND_KINDS, - ...DEVICE_INFRASTRUCTURE_COMMAND_KINDS, - ...DEVICE_LIFECYCLE_COMMAND_KINDS, - ...DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS, - ...DEVICE_CONTROL_RESOURCE_COMMAND_KINDS, -]); - -export function normalizeDeviceManagementCommand(kind, input) { - if (isControlResourceManagementCommand(kind)) { - return normalizeControlResourceManagementCommand(kind, input); - } - if (isSensitiveReferenceManagementCommand(kind)) { - return normalizeSensitiveReferenceManagementCommand(kind, input); - } - if (isLifecycleManagementCommand(kind)) { - return normalizeLifecycleManagementCommand(kind, input); - } - if (isInfrastructureManagementCommand(kind)) { - return normalizeInfrastructureManagementCommand(kind, input); - } - return normalizeManagementCommand(kind, input); -} diff --git a/device-plane/services/device-control-core/src/postgres-repository.mjs b/device-plane/services/device-control-core/src/postgres-repository.mjs deleted file mode 100644 index c4fcf75..0000000 --- a/device-plane/services/device-control-core/src/postgres-repository.mjs +++ /dev/null @@ -1,914 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { readFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -import pg from "pg"; - -import { observeQuarantineDiscovery } from "./discovery-repository.mjs"; -import { acceptGatewayMessage } from "./gateway-message-repository.mjs"; -import { resolveInboundRoute } from "./inbound-route-repository.mjs"; -import { - applyControlResourceManagementCommand, - authorizeControlResourceManagementReplay, -} from "./control-resource-repository.mjs"; -import { - isControlResourceManagementCommand, -} from "./control-resource-management.mjs"; -import { - getDeviceProjectWorkspace, - listAccessibleDeviceProjects, -} from "./project-query-repository.mjs"; -import { - applyInfrastructureManagementCommand, - authorizeInfrastructureManagementReplay, -} from "./infrastructure-repository.mjs"; -import { isInfrastructureManagementCommand } from "./infrastructure-management.mjs"; -import { isLifecycleManagementCommand } from "./lifecycle-management.mjs"; -import { - applyLifecycleManagementCommand, - authorizeLifecycleManagementReplay, -} from "./lifecycle-repository.mjs"; -import { - applySensitiveReferenceManagementCommand, - authorizeSensitiveReferenceManagementReplay, -} from "./sensitive-reference-repository.mjs"; -import { - isSensitiveReferenceManagementCommand, -} from "./sensitive-reference-management.mjs"; -import { - assertActorCanManageOwnerScope, - assertGrantMutationAllowed, - assertProjectCapability, - toProjectRef, -} from "./project-management.mjs"; -import { - dispatchTypedCommand, - planTypedServicePing, - recordTypedCommandStatus, -} from "./typed-command-repository.mjs"; - -const { Pool } = pg; -const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const migrationFiles = [ - "001_device_plane_foundation.sql", - "002_device_project_access.sql", - "003_device_management_commands.sql", - "004_device_registry_foundation.sql", - "005_device_registry_commands.sql", - "006_device_lifecycle_ownership.sql", - "007_device_lifecycle_commands.sql", - "008_device_sensitive_references.sql", - "009_device_sensitive_reference_commands.sql", - "010_device_control_resources.sql", - "011_device_control_resource_commands.sql", - "012_device_gateway_message_receipts.sql", - "013_device_edge_channels.sql", -]; - -export class PostgresDeviceRepository { - constructor({ databaseUrl, poolSize = 10, pool = null } = {}) { - if (pool) { - if ( - typeof pool.query !== "function" || - typeof pool.connect !== "function" || - typeof pool.end !== "function" - ) { - throw new TypeError("device_database_pool_invalid"); - } - this.pool = pool; - return; - } - if (typeof databaseUrl !== "string" || databaseUrl.trim() === "") { - throw new TypeError("device_database_url_required"); - } - this.pool = new Pool({ - connectionString: databaseUrl, - max: normalizePoolSize(poolSize), - }); - } - - async migrate() { - for (const migrationFile of migrationFiles) { - const sql = await readFile( - resolve(serviceRoot, "migrations", migrationFile), - "utf8", - ); - await this.pool.query(sql); - } - } - - async health() { - await this.pool.query("select 1"); - return "ready"; - } - - async upsertQuarantineDiscovery(input) { - return observeQuarantineDiscovery({ - pool: this.pool, - ...input, - }); - } - - async acceptAdapterMessage(input) { - return acceptGatewayMessage({ - pool: this.pool, - ...input, - }); - } - - async resolveInboundRoute(input) { - return this.#executeRead((client) => resolveInboundRoute(client, input)); - } - - async executeManagementCommand({ - idempotencyKey, - commandKind, - requestDigest, - actor, - command, - }) { - const client = await this.pool.connect(); - try { - await client.query("begin"); - const receipt = await claimManagementReceipt(client, { - idempotencyKey, - commandKind, - requestDigest, - actorRef: actor.userRef, - }); - - if (receipt.replayed) { - await authorizeManagementReplay(client, { - commandKind, - actor, - command, - }); - await client.query("commit"); - return { - replayed: true, - result: receipt.responseBody, - }; - } - - const result = await applyManagementCommand(client, { - commandKind, - actor, - command, - }); - await completeManagementReceipt(client, receipt.id, result); - await client.query("commit"); - return { replayed: false, result }; - } catch (error) { - await client.query("rollback").catch(() => undefined); - throw mapPostgresError(error); - } finally { - client.release(); - } - } - - async listAccessibleProjects(actor) { - return this.#executeRead((client) => - listAccessibleDeviceProjects(client, actor) - ); - } - - async getProjectWorkspace(actor, projectId, options) { - return this.#executeRead((client) => - getDeviceProjectWorkspace(client, actor, projectId, options) - ); - } - - async listActiveEdgeChannelRegistrations(limit = 64) { - if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) { - throw new TypeError("device_edge_channel_registration_limit_invalid"); - } - return this.#executeRead(async (client) => { - const result = await client.query( - `select id, channel_endpoint, channel_servername, - channel_generation_ref, channel_trust_bundle_ref, - channel_certificate_identities, channel_lifecycle_state - from device_edges - where lifecycle_state = 'active' - and channel_lifecycle_state = 'active' - order by id - limit $1`, - [limit], - ); - return result.rows.map((row) => Object.freeze({ - edgeRegistrationId: `edge:${row.id}`, - endpoint: row.channel_endpoint, - servername: row.channel_servername, - channelGeneration: row.channel_generation_ref, - trustBundleRef: row.channel_trust_bundle_ref, - certificateIdentities: Object.freeze( - [...(row.channel_certificate_identities ?? [])], - ), - lifecycleState: row.channel_lifecycle_state, - })); - }); - } - - async planTypedServicePing(input) { - return this.#executeWrite((client) => planTypedServicePing(client, input)); - } - - async dispatchTypedCommand(input) { - return this.#executeWrite((client) => dispatchTypedCommand(client, input)); - } - - async recordTypedCommandStatus(input) { - return this.#executeWrite((client) => recordTypedCommandStatus(client, input)); - } - - async #executeWrite(operation) { - const client = await this.pool.connect(); - try { - await client.query("begin"); - const result = await operation(client); - await client.query("commit"); - return result; - } catch (error) { - await client.query("rollback").catch(() => undefined); - throw mapPostgresError(error); - } finally { - client.release(); - } - } - - async #executeRead(operation) { - const client = await this.pool.connect(); - try { - await client.query("begin transaction read only"); - const result = await operation(client); - await client.query("commit"); - return result; - } catch (error) { - await client.query("rollback").catch(() => undefined); - throw mapPostgresError(error); - } finally { - client.release(); - } - } - - async close() { - await this.pool.end(); - } -} - -async function claimManagementReceipt(client, { - idempotencyKey, - commandKind, - requestDigest, - actorRef, -}) { - const id = randomUUID(); - const inserted = await client.query( - `insert into device_management_command_receipts ( - id, - actor_ref, - command_kind, - idempotency_key, - request_digest - ) values ($1, $2, $3, $4, $5) - on conflict (actor_ref, command_kind, idempotency_key) do nothing - returning id`, - [id, actorRef, commandKind, idempotencyKey, requestDigest], - ); - - if (inserted.rows.length === 1) { - return { id, replayed: false, responseBody: null }; - } - - const existing = await client.query( - `select id, request_digest, lifecycle_state, response_body - from device_management_command_receipts - where actor_ref = $1 - and command_kind = $2 - and idempotency_key = $3 - for update`, - [actorRef, commandKind, idempotencyKey], - ); - const row = existing.rows[0]; - if (!row) throw domainError("device_idempotency_receipt_missing", 409); - if (row.request_digest !== requestDigest) { - throw domainError("device_idempotency_key_conflict", 409); - } - if (row.lifecycle_state !== "completed" || !row.response_body) { - throw domainError("device_idempotency_command_in_progress", 409); - } - return { - id: row.id, - replayed: true, - responseBody: row.response_body, - }; -} - -async function completeManagementReceipt(client, receiptId, result) { - await client.query( - `update device_management_command_receipts - set lifecycle_state = 'completed', - response_status = 200, - response_body = $2::jsonb, - completed_at = now(), - updated_at = now() - where id = $1`, - [receiptId, JSON.stringify(result)], - ); -} - -async function applyManagementCommand(client, { commandKind, actor, command }) { - if (isControlResourceManagementCommand(commandKind)) { - return applyControlResourceManagementCommand(client, { - commandKind, - actor, - command, - }); - } - if (isSensitiveReferenceManagementCommand(commandKind)) { - return applySensitiveReferenceManagementCommand(client, { - commandKind, - actor, - command, - }); - } - if (isLifecycleManagementCommand(commandKind)) { - return applyLifecycleManagementCommand(client, { - commandKind, - actor, - command, - }); - } - if (isInfrastructureManagementCommand(commandKind)) { - return applyInfrastructureManagementCommand(client, { - commandKind, - actor, - command, - }); - } - if (commandKind === "owner_scope.ensure") { - return ensureOwnerScope(client, actor, command); - } - if (commandKind === "project.ensure") { - return ensureProject(client, actor, command); - } - if (commandKind === "collection.ensure") { - return ensureCollection(client, actor, command); - } - if (commandKind === "project_grant.upsert") { - return upsertProjectGrant(client, actor, command); - } - throw new TypeError("device_management_command_kind_invalid"); -} - -async function authorizeManagementReplay(client, { commandKind, actor, command }) { - if (isControlResourceManagementCommand(commandKind)) { - return authorizeControlResourceManagementReplay(client, { - commandKind, - actor, - command, - }); - } - if (isSensitiveReferenceManagementCommand(commandKind)) { - return authorizeSensitiveReferenceManagementReplay(client, { - commandKind, - actor, - command, - }); - } - if (isLifecycleManagementCommand(commandKind)) { - return authorizeLifecycleManagementReplay(client, { - commandKind, - actor, - command, - }); - } - if (isInfrastructureManagementCommand(commandKind)) { - return authorizeInfrastructureManagementReplay(client, { - commandKind, - actor, - command, - }); - } - if (commandKind === "owner_scope.ensure") { - assertActorCanManageOwnerScope(actor, command); - const ownerScope = await findOwnerScope( - client, - command.scopeKind, - command.ownerRef, - ); - assertOwnerScopeActive(ownerScope); - return; - } - if (commandKind === "project.ensure") { - const ownerScope = await findOwnerScope( - client, - command.scopeKind, - command.ownerRef, - ); - assertOwnerScopeActive(ownerScope); - const project = await findProjectByOwnerAndKey( - client, - ownerScope.id, - command.projectKey, - false, - ); - assertProjectActive(project); - const grants = await listProjectGrants(client, project.id); - assertProjectCapability(actor, grants, "project.manage"); - return; - } - - const lockForGrantMutation = commandKind === "project_grant.upsert"; - const { project } = await findProjectContext( - client, - command.projectId, - lockForGrantMutation, - ); - assertProjectActive(project); - const grants = await listProjectGrants(client, project.id); - if (commandKind === "collection.ensure") { - assertProjectCapability(actor, grants, "collection.manage"); - return; - } - if (commandKind === "project_grant.upsert") { - const existing = grants.find( - (grant) => - grant.principalKind === command.principalKind && - grant.principalRef === command.principalRef, - ) ?? null; - assertGrantMutationAllowed(actor, grants, command, existing); - return; - } - throw new TypeError("device_management_command_kind_invalid"); -} - -async function ensureOwnerScope(client, actor, command) { - assertActorCanManageOwnerScope(actor, command); - const result = await client.query( - `insert into device_owner_scopes ( - id, - scope_kind, - owner_ref, - display_name, - created_by_ref - ) values ($1, $2, $3, $4, $5) - on conflict (scope_kind, owner_ref) do update set - display_name = excluded.display_name, - updated_at = now() - returning id, scope_kind, owner_ref, display_name, lifecycle_state, - created_at, updated_at, (xmax = 0) as created`, - [randomUUID(), command.scopeKind, command.ownerRef, command.displayName, actor.userRef], - ); - const row = result.rows[0]; - assertOwnerScopeActive(row); - await addManagementAudit(client, { - eventType: row.created ? "owner_scope.created" : "owner_scope.updated", - actorRef: actor.userRef, - payload: { - scopeKind: row.scope_kind, - ownerRef: row.owner_ref, - lifecycleState: row.lifecycle_state, - }, - }); - return { - created: row.created === true, - ownerScope: projectOwnerScopeView(row), - }; -} - -async function ensureProject(client, actor, command) { - const ownerScope = await findOwnerScope(client, command.scopeKind, command.ownerRef); - assertOwnerScopeActive(ownerScope); - const inserted = await client.query( - `insert into device_projects ( - id, - owner_scope_id, - project_key, - name, - description, - created_by_ref - ) values ($1, $2, $3, $4, $5, $6) - on conflict (owner_scope_id, project_key) do nothing - returning id, owner_scope_id, project_key, name, description, - lifecycle_state, created_at, updated_at`, - [ - randomUUID(), - ownerScope.id, - command.projectKey, - command.name, - command.description, - actor.userRef, - ], - ); - - let row = inserted.rows[0]; - const created = Boolean(row); - if (created) { - assertActorCanManageOwnerScope(actor, command); - await client.query( - `insert into device_project_grants ( - id, - project_id, - principal_kind, - principal_ref, - project_role, - capability_allow, - capability_deny, - lifecycle_state, - created_by_ref - ) values ($1, $2, 'user', $3, 'owner', '{}', '{}', 'active', $3)`, - [randomUUID(), row.id, actor.userRef], - ); - } else { - row = await findProjectByOwnerAndKey( - client, - ownerScope.id, - command.projectKey, - true, - ); - assertProjectActive(row); - const grants = await listProjectGrants(client, row.id); - assertProjectCapability(actor, grants, "project.manage"); - const updated = await client.query( - `update device_projects - set name = $2, - description = $3, - updated_at = now() - where id = $1 - returning id, owner_scope_id, project_key, name, description, - lifecycle_state, created_at, updated_at`, - [row.id, command.name, command.description], - ); - row = updated.rows[0]; - } - - await addManagementAudit(client, { - eventType: created ? "project.created" : "project.updated", - actorRef: actor.userRef, - projectId: row.id, - payload: { - projectRef: toProjectRef(row.id), - projectKey: row.project_key, - ownerScope: { - scopeKind: ownerScope.scope_kind, - ownerRef: ownerScope.owner_ref, - }, - }, - }); - return { - created, - project: projectView(row, ownerScope), - ...(created - ? { - initialGrant: { - principalKind: "user", - principalRef: actor.userRef, - projectRole: "owner", - lifecycleState: "active", - }, - } - : {}), - }; -} - -async function ensureCollection(client, actor, command) { - const { project, ownerScope } = await findProjectContext(client, command.projectId); - assertProjectActive(project); - const grants = await listProjectGrants(client, project.id); - assertProjectCapability(actor, grants, "collection.manage"); - const result = await client.query( - `insert into device_collections ( - id, - project_id, - collection_key, - name, - description, - created_by_ref - ) values ($1, $2, $3, $4, $5, $6) - on conflict (project_id, collection_key) do update set - name = excluded.name, - description = excluded.description, - updated_at = now() - returning id, project_id, collection_key, name, description, - lifecycle_state, created_at, updated_at, (xmax = 0) as created`, - [ - randomUUID(), - project.id, - command.collectionKey, - command.name, - command.description, - actor.userRef, - ], - ); - const row = result.rows[0]; - assertCollectionActive(row); - await addManagementAudit(client, { - eventType: row.created ? "collection.created" : "collection.updated", - actorRef: actor.userRef, - projectId: project.id, - payload: { - projectRef: toProjectRef(project.id), - collectionRef: `collection:${row.id}`, - collectionKey: row.collection_key, - }, - }); - return { - created: row.created === true, - project: projectView(project, ownerScope), - collection: collectionView(row), - }; -} - -async function upsertProjectGrant(client, actor, command) { - const { project, ownerScope } = await findProjectContext( - client, - command.projectId, - true, - ); - assertProjectActive(project); - const grants = await listProjectGrants(client, project.id); - const existing = grants.find( - (grant) => - grant.principalKind === command.principalKind && - grant.principalRef === command.principalRef, - ) ?? null; - assertGrantMutationAllowed(actor, grants, command, existing); - - const removesActiveOwner = - existing?.projectRole === "owner" && - existing.lifecycleState === "active" && - (command.projectRole !== "owner" || command.lifecycleState !== "active"); - if (removesActiveOwner) { - const remaining = grants.filter( - (grant) => - grant.grantRef !== existing.grantRef && - grant.projectRole === "owner" && - grant.lifecycleState === "active" && - grant.principalKind === "user", - ); - if (remaining.length === 0) { - throw domainError("device_project_last_owner_required", 409); - } - } - - const result = await client.query( - `insert into device_project_grants ( - id, - project_id, - principal_kind, - principal_ref, - project_role, - capability_allow, - capability_deny, - lifecycle_state, - created_by_ref - ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9) - on conflict (project_id, principal_kind, principal_ref) do update set - project_role = excluded.project_role, - capability_allow = excluded.capability_allow, - capability_deny = excluded.capability_deny, - lifecycle_state = excluded.lifecycle_state, - updated_at = now() - returning id, principal_kind, principal_ref, project_role, - capability_allow, capability_deny, lifecycle_state, created_at, - updated_at, (xmax = 0) as created`, - [ - randomUUID(), - project.id, - command.principalKind, - command.principalRef, - command.projectRole, - command.capabilityAllow, - command.capabilityDeny, - command.lifecycleState, - actor.userRef, - ], - ); - const row = result.rows[0]; - await addManagementAudit(client, { - eventType: row.created ? "project_grant.created" : "project_grant.updated", - actorRef: actor.userRef, - projectId: project.id, - payload: { - projectRef: toProjectRef(project.id), - grantRef: `grant:${row.id}`, - principalKind: row.principal_kind, - principalRef: row.principal_ref, - projectRole: row.project_role, - lifecycleState: row.lifecycle_state, - }, - }); - return { - created: row.created === true, - project: projectView(project, ownerScope), - grant: grantView(row), - }; -} - -async function findOwnerScope(client, scopeKind, ownerRef) { - const result = await client.query( - `select id, scope_kind, owner_ref, display_name, lifecycle_state, - created_at, updated_at - from device_owner_scopes - where scope_kind = $1 and owner_ref = $2 - for share`, - [scopeKind, ownerRef], - ); - if (!result.rows[0]) throw domainError("device_owner_scope_not_found", 404); - return result.rows[0]; -} - -async function findProjectByOwnerAndKey( - client, - ownerScopeId, - projectKey, - forUpdate = false, -) { - const lockClause = forUpdate ? "for update" : "for share"; - const result = await client.query( - `select id, owner_scope_id, project_key, name, description, - lifecycle_state, created_at, updated_at - from device_projects - where owner_scope_id = $1 and project_key = $2 - ${lockClause}`, - [ownerScopeId, projectKey], - ); - if (!result.rows[0]) throw domainError("device_project_not_found", 404); - return result.rows[0]; -} - -async function findProjectContext(client, projectId, forUpdate = false) { - const lockClause = forUpdate ? "for update of p" : "for share of p, os"; - const result = await client.query( - `select - p.id, - p.owner_scope_id, - p.project_key, - p.name, - p.description, - p.lifecycle_state, - p.created_at, - p.updated_at, - os.scope_kind, - os.owner_ref, - os.display_name as owner_display_name, - os.lifecycle_state as owner_lifecycle_state, - os.created_at as owner_created_at, - os.updated_at as owner_updated_at - from device_projects p - join device_owner_scopes os on os.id = p.owner_scope_id - where p.id = $1 - ${lockClause}`, - [projectId], - ); - const row = result.rows[0]; - if (!row) throw domainError("device_project_not_found", 404); - assertOwnerScopeActive({ lifecycle_state: row.owner_lifecycle_state }); - return { - project: row, - ownerScope: { - id: row.owner_scope_id, - scope_kind: row.scope_kind, - owner_ref: row.owner_ref, - display_name: row.owner_display_name, - lifecycle_state: row.owner_lifecycle_state, - created_at: row.owner_created_at, - updated_at: row.owner_updated_at, - }, - }; -} - -async function listProjectGrants(client, projectId) { - const result = await client.query( - `select id, principal_kind, principal_ref, project_role, - capability_allow, capability_deny, lifecycle_state, - created_at, updated_at - from device_project_grants - where project_id = $1 - order by created_at, id - for share`, - [projectId], - ); - return result.rows.map(grantView); -} - -async function addManagementAudit(client, { - eventType, - actorRef, - projectId = null, - payload, -}) { - await client.query( - `insert into device_audit_events ( - id, - event_type, - actor_ref, - project_id, - payload - ) values ($1, $2, $3, $4, $5::jsonb)`, - [randomUUID(), eventType, actorRef, projectId, JSON.stringify(payload)], - ); -} - -function projectOwnerScopeView(row) { - return { - ownerScopeRef: `owner-scope:${row.id}`, - scopeKind: row.scope_kind, - ownerRef: row.owner_ref, - displayName: row.display_name, - lifecycleState: row.lifecycle_state, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function projectView(row, ownerScope) { - return { - projectRef: toProjectRef(row.id), - ownerScope: projectOwnerScopeView(ownerScope), - projectKey: row.project_key, - name: row.name, - description: row.description ?? null, - lifecycleState: row.lifecycle_state, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function collectionView(row) { - return { - collectionRef: `collection:${row.id}`, - projectRef: toProjectRef(row.project_id), - collectionKey: row.collection_key, - name: row.name, - description: row.description ?? null, - lifecycleState: row.lifecycle_state, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function grantView(row) { - return { - grantRef: `grant:${row.id}`, - principalKind: row.principal_kind, - principalRef: row.principal_ref, - projectRole: row.project_role, - capabilityAllow: [...(row.capability_allow ?? [])].sort(), - capabilityDeny: [...(row.capability_deny ?? [])].sort(), - lifecycleState: row.lifecycle_state, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function assertOwnerScopeActive(ownerScope) { - if (ownerScope.lifecycle_state !== "active") { - throw domainError("device_owner_scope_inactive", 409); - } -} - -function assertProjectActive(project) { - if (project.lifecycle_state !== "active") { - throw domainError("device_project_inactive", 409); - } -} - -function assertCollectionActive(collection) { - if (collection.lifecycle_state !== "active") { - throw domainError("device_collection_inactive", 409); - } -} - -function toIso(value) { - return new Date(value).toISOString(); -} - -function mapPostgresError(error) { - if (error?.statusCode) return error; - if (error?.code === "23503") { - return domainError("device_management_reference_invalid", 409); - } - if (error?.code === "23505") { - return domainError("device_management_identity_conflict", 409); - } - if (error?.code === "23514") { - return domainError("device_management_constraint_failed", 400); - } - return error; -} - -function domainError(code, statusCode) { - const error = new Error(code); - error.statusCode = statusCode; - return error; -} - -function normalizePoolSize(value) { - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 50) { - throw new TypeError("device_database_pool_size_invalid"); - } - return parsed; -} diff --git a/device-plane/services/device-control-core/src/project-management.mjs b/device-plane/services/device-control-core/src/project-management.mjs deleted file mode 100644 index b9862ca..0000000 --- a/device-plane/services/device-control-core/src/project-management.mjs +++ /dev/null @@ -1,538 +0,0 @@ -export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([ - "project.read", - "project.manage", - "access.manage", - "inventory.read", - "device.enroll", - "device.claim", - "device.transfer", - "collection.manage", - "route.manage", - "binding.manage", - "telemetry.observe", - "configuration.read", - "configuration.manage", - "command.plan", - "command.confirm", - "command.dispatch", - "credential.manage", - "audit.read", -]); - -export const DEVICE_PROJECT_ROLES = Object.freeze([ - "viewer", - "operator", - "engineer", - "admin", - "owner", -]); - -export const DEVICE_HUB_ROLES = Object.freeze([ - "viewer", - "member", - "admin", - "owner", -]); - -export const DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([ - "owner_scope.ensure", - "project.ensure", - "collection.ensure", - "project_grant.upsert", -]); - -const capabilitySet = new Set(DEVICE_PROJECT_CAPABILITIES); -const projectRoleSet = new Set(DEVICE_PROJECT_ROLES); -const hubRoleSet = new Set(DEVICE_HUB_ROLES); -const commandKindSet = new Set(DEVICE_MANAGEMENT_COMMAND_KINDS); -const opaqueRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,255}$/; -const keyPattern = /^[a-z][a-z0-9-]{1,62}$/; -const projectRefPattern = /^project:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i; - -const roleCapabilities = Object.freeze({ - viewer: Object.freeze([ - "project.read", - "inventory.read", - "telemetry.observe", - "configuration.read", - "audit.read", - ]), - operator: Object.freeze([ - "project.read", - "inventory.read", - "telemetry.observe", - "configuration.read", - "command.plan", - "command.confirm", - "command.dispatch", - "audit.read", - ]), - engineer: Object.freeze([ - "project.read", - "inventory.read", - "device.enroll", - "device.claim", - "collection.manage", - "route.manage", - "binding.manage", - "telemetry.observe", - "configuration.read", - "configuration.manage", - "command.plan", - "audit.read", - ]), - admin: Object.freeze([ - "project.read", - "project.manage", - "access.manage", - "inventory.read", - "device.enroll", - "device.claim", - "collection.manage", - "route.manage", - "binding.manage", - "telemetry.observe", - "configuration.read", - "configuration.manage", - "command.plan", - "command.confirm", - "command.dispatch", - "credential.manage", - "audit.read", - ]), - owner: DEVICE_PROJECT_CAPABILITIES, -}); - -const hubRoleCeilings = Object.freeze({ - viewer: roleCapabilities.viewer, - member: Object.freeze([ - ...new Set([ - ...roleCapabilities.viewer, - ...roleCapabilities.operator, - ...roleCapabilities.engineer, - ]), - ]), - admin: roleCapabilities.admin, - owner: DEVICE_PROJECT_CAPABILITIES, -}); - -const projectRoleWeight = Object.freeze({ - viewer: 10, - operator: 20, - engineer: 30, - admin: 40, - owner: 50, -}); - -export function normalizeManagementActor(input) { - assertPlainObject(input, "device_management_actor_invalid"); - assertAllowedKeys( - input, - ["userRef", "hubRole", "groupRefs", "ownerScopes"], - "device_management_actor_field_unexpected", - ); - - const userRef = normalizeOpaqueRef(input.userRef, "device_actor_user_ref_invalid"); - const hubRole = normalizeEnum(input.hubRole, hubRoleSet, "device_actor_hub_role_invalid"); - const groupRefs = normalizeOpaqueRefArray( - input.groupRefs ?? [], - "device_actor_group_refs_invalid", - ); - const ownerScopes = normalizeOwnerScopeClaims(input.ownerScopes ?? []); - - return Object.freeze({ - userRef, - hubRole, - groupRefs: Object.freeze(groupRefs), - ownerScopes: Object.freeze(ownerScopes), - }); -} - -export function normalizeManagementCommand(kind, input) { - const normalizedKind = normalizeEnum( - kind, - commandKindSet, - "device_management_command_kind_invalid", - ); - assertPlainObject(input, "device_management_command_invalid"); - - if (normalizedKind === "owner_scope.ensure") { - assertAllowedKeys( - input, - ["scopeKind", "ownerRef", "displayName"], - "device_management_command_field_unexpected", - ); - return Object.freeze({ - scopeKind: normalizeScopeKind(input.scopeKind), - ownerRef: normalizeOpaqueRef(input.ownerRef, "device_owner_ref_invalid"), - displayName: normalizeDisplayText(input.displayName, 160, "device_owner_name_invalid"), - }); - } - - if (normalizedKind === "project.ensure") { - assertAllowedKeys( - input, - ["scopeKind", "ownerRef", "projectKey", "name", "description"], - "device_management_command_field_unexpected", - ); - return Object.freeze({ - scopeKind: normalizeScopeKind(input.scopeKind), - ownerRef: normalizeOpaqueRef(input.ownerRef, "device_owner_ref_invalid"), - projectKey: normalizeKey(input.projectKey, "device_project_key_invalid"), - name: normalizeDisplayText(input.name, 160, "device_project_name_invalid"), - description: normalizeOptionalText( - input.description, - 2000, - "device_project_description_invalid", - ), - }); - } - - if (normalizedKind === "collection.ensure") { - assertAllowedKeys( - input, - ["projectRef", "collectionKey", "name", "description"], - "device_management_command_field_unexpected", - ); - return Object.freeze({ - projectId: normalizeProjectRef(input.projectRef), - collectionKey: normalizeKey( - input.collectionKey, - "device_collection_key_invalid", - ), - name: normalizeDisplayText(input.name, 160, "device_collection_name_invalid"), - description: normalizeOptionalText( - input.description, - 2000, - "device_collection_description_invalid", - ), - }); - } - - assertAllowedKeys( - input, - [ - "projectRef", - "principalKind", - "principalRef", - "projectRole", - "capabilityAllow", - "capabilityDeny", - "lifecycleState", - ], - "device_management_command_field_unexpected", - ); - const principalKind = normalizeEnum( - input.principalKind, - new Set(["user", "group"]), - "device_project_principal_kind_invalid", - ); - const projectRole = normalizeEnum( - input.projectRole, - projectRoleSet, - "device_project_role_invalid", - ); - if (projectRole === "owner" && principalKind !== "user") { - throw domainError("device_project_owner_must_be_user", 400); - } - const capabilityAllow = normalizeCapabilities(input.capabilityAllow ?? []); - const capabilityDeny = normalizeCapabilities(input.capabilityDeny ?? []); - if (capabilityAllow.some((capability) => capabilityDeny.includes(capability))) { - throw domainError("device_project_capability_overlap", 400); - } - - return Object.freeze({ - projectId: normalizeProjectRef(input.projectRef), - principalKind, - principalRef: normalizeOpaqueRef( - input.principalRef, - "device_project_principal_ref_invalid", - ), - projectRole, - capabilityAllow: Object.freeze(capabilityAllow), - capabilityDeny: Object.freeze(capabilityDeny), - lifecycleState: normalizeEnum( - input.lifecycleState ?? "active", - new Set(["active", "revoked"]), - "device_project_grant_state_invalid", - ), - }); -} - -export function assertActorCanManageOwnerScope(actorInput, scopeInput) { - const actor = normalizeManagementActor(actorInput); - const scope = { - scopeKind: normalizeScopeKind(scopeInput?.scopeKind), - ownerRef: normalizeOpaqueRef(scopeInput?.ownerRef, "device_owner_ref_invalid"), - }; - - if (scope.scopeKind === "personal") { - if ( - actor.userRef !== scope.ownerRef || - !["admin", "owner"].includes(actor.hubRole) - ) { - throw domainError("device_owner_scope_access_denied", 403); - } - return actor; - } - - const hasClaim = actor.ownerScopes.some( - (claim) => claim.scopeKind === "company" && claim.ownerRef === scope.ownerRef, - ); - if (!hasClaim || !["admin", "owner"].includes(actor.hubRole)) { - throw domainError("device_owner_scope_access_denied", 403); - } - return actor; -} - -export function resolveProjectAccess({ actor: actorInput, grants = [] }) { - const actor = normalizeManagementActor(actorInput); - if (!Array.isArray(grants)) { - throw new TypeError("device_project_grants_invalid"); - } - - const active = grants - .map(normalizeStoredGrant) - .filter((grant) => grant.lifecycleState === "active"); - const direct = active.find( - (grant) => grant.principalKind === "user" && grant.principalRef === actor.userRef, - ); - const matching = direct - ? [direct] - : active - .filter( - (grant) => - grant.principalKind === "group" && - actor.groupRefs.includes(grant.principalRef), - ) - .sort(compareGrantPriority); - - if (matching.length === 0) { - return Object.freeze({ - allowed: false, - projectRole: null, - capabilities: Object.freeze([]), - sourceRefs: Object.freeze([]), - }); - } - - const primary = matching[0]; - const allowed = new Set(); - const denied = new Set(); - for (const grant of matching) { - for (const capability of roleCapabilities[grant.projectRole]) { - allowed.add(capability); - } - for (const capability of grant.capabilityAllow) allowed.add(capability); - for (const capability of grant.capabilityDeny) denied.add(capability); - } - for (const capability of denied) allowed.delete(capability); - - const hubCeiling = new Set(hubRoleCeilings[actor.hubRole]); - const capabilities = [...allowed] - .filter((capability) => hubCeiling.has(capability)) - .sort(); - - if (!capabilities.includes("project.read")) { - return Object.freeze({ - allowed: false, - projectRole: primary.projectRole, - capabilities: Object.freeze([]), - sourceRefs: Object.freeze(matching.map((grant) => grant.grantRef)), - }); - } - - return Object.freeze({ - allowed: true, - projectRole: primary.projectRole, - capabilities: Object.freeze(capabilities), - sourceRefs: Object.freeze(matching.map((grant) => grant.grantRef)), - }); -} - -export function assertProjectCapability(actor, grants, capability) { - if (!capabilitySet.has(capability)) { - throw new TypeError("device_project_capability_invalid"); - } - const access = resolveProjectAccess({ actor, grants }); - if (!access.capabilities.includes(capability)) { - throw domainError("device_project_capability_denied", 403); - } - return access; -} - -export function assertGrantMutationAllowed(actor, grants, command, existingGrant = null) { - const access = assertProjectCapability(actor, grants, "access.manage"); - if ( - command.projectRole === "owner" || - existingGrant?.projectRole === "owner" - ) { - if (!access.capabilities.includes("device.transfer")) { - throw domainError("device_project_owner_transfer_denied", 403); - } - } - return access; -} - -export function toProjectRef(projectId) { - if (typeof projectId !== "string" || !projectRefPattern.test(`project:${projectId}`)) { - throw new TypeError("device_project_id_invalid"); - } - return `project:${projectId.toLowerCase()}`; -} - -function normalizeStoredGrant(input) { - assertPlainObject(input, "device_project_grant_invalid"); - const grant = { - grantRef: normalizeOpaqueRef(input.grantRef, "device_project_grant_ref_invalid"), - principalKind: normalizeEnum( - input.principalKind, - new Set(["user", "group"]), - "device_project_principal_kind_invalid", - ), - principalRef: normalizeOpaqueRef( - input.principalRef, - "device_project_principal_ref_invalid", - ), - projectRole: normalizeEnum( - input.projectRole, - projectRoleSet, - "device_project_role_invalid", - ), - capabilityAllow: normalizeCapabilities(input.capabilityAllow ?? []), - capabilityDeny: normalizeCapabilities(input.capabilityDeny ?? []), - lifecycleState: normalizeEnum( - input.lifecycleState, - new Set(["active", "revoked"]), - "device_project_grant_state_invalid", - ), - }; - if (grant.projectRole === "owner" && grant.principalKind !== "user") { - throw new TypeError("device_project_owner_must_be_user"); - } - return grant; -} - -function compareGrantPriority(left, right) { - return ( - projectRoleWeight[right.projectRole] - projectRoleWeight[left.projectRole] || - left.principalRef.localeCompare(right.principalRef) - ); -} - -function normalizeOwnerScopeClaims(input) { - if (!Array.isArray(input) || input.length > 128) { - throw new TypeError("device_actor_owner_scopes_invalid"); - } - const claims = input.map((claim) => { - assertPlainObject(claim, "device_actor_owner_scope_invalid"); - assertAllowedKeys( - claim, - ["scopeKind", "ownerRef"], - "device_actor_owner_scope_field_unexpected", - ); - return { - scopeKind: normalizeScopeKind(claim.scopeKind), - ownerRef: normalizeOpaqueRef(claim.ownerRef, "device_owner_ref_invalid"), - }; - }); - const byKey = new Map( - claims.map((claim) => [`${claim.scopeKind}\0${claim.ownerRef}`, claim]), - ); - return [...byKey.values()].sort((left, right) => - `${left.scopeKind}:${left.ownerRef}`.localeCompare( - `${right.scopeKind}:${right.ownerRef}`, - ), - ); -} - -function normalizeCapabilities(input) { - if (!Array.isArray(input) || input.length > DEVICE_PROJECT_CAPABILITIES.length) { - throw new TypeError("device_project_capabilities_invalid"); - } - const normalized = input.map((capability) => - normalizeEnum( - capability, - capabilitySet, - "device_project_capability_invalid", - ), - ); - return [...new Set(normalized)].sort(); -} - -function normalizeOpaqueRefArray(input, code) { - if (!Array.isArray(input) || input.length > 128) throw new TypeError(code); - return [...new Set(input.map((value) => normalizeOpaqueRef(value, code)))].sort(); -} - -function normalizeProjectRef(value) { - if (typeof value !== "string") throw new TypeError("device_project_ref_invalid"); - const match = value.match(projectRefPattern); - if (!match) throw new TypeError("device_project_ref_invalid"); - return match[1].toLowerCase(); -} - -function normalizeScopeKind(value) { - return normalizeEnum( - value, - new Set(["company", "personal"]), - "device_owner_scope_kind_invalid", - ); -} - -function normalizeKey(value, code) { - if (typeof value !== "string" || !keyPattern.test(value)) { - throw new TypeError(code); - } - return value; -} - -function normalizeOpaqueRef(value, code) { - if (typeof value !== "string" || !opaqueRefPattern.test(value)) { - throw new TypeError(code); - } - return value; -} - -function normalizeDisplayText(value, maxLength, code) { - if (typeof value !== "string") throw new TypeError(code); - const normalized = value.trim(); - if (normalized.length < 1 || normalized.length > maxLength) { - throw new TypeError(code); - } - if (/\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized)) { - throw new TypeError(code); - } - return normalized; -} - -function normalizeOptionalText(value, maxLength, code) { - if (value == null || value === "") return null; - return normalizeDisplayText(value, maxLength, code); -} - -function normalizeEnum(value, allowed, code) { - if (typeof value !== "string" || !allowed.has(value)) { - throw new TypeError(code); - } - return value; -} - -function assertAllowedKeys(input, allowed, code) { - const allowedSet = new Set(allowed); - for (const key of Object.keys(input)) { - if (!allowedSet.has(key)) throw new TypeError(`${code}:${key}`); - } -} - -function assertPlainObject(value, code) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new TypeError(code); - } -} - -function domainError(code, statusCode) { - const error = new Error(code); - error.statusCode = statusCode; - return error; -} diff --git a/device-plane/services/device-control-core/src/project-query-repository.mjs b/device-plane/services/device-control-core/src/project-query-repository.mjs deleted file mode 100644 index 4373529..0000000 --- a/device-plane/services/device-control-core/src/project-query-repository.mjs +++ /dev/null @@ -1,661 +0,0 @@ -import { - resolveProjectAccess, - toProjectRef, -} from "./project-management.mjs"; -import { findProjectWithCapability } from "./lifecycle-repository.mjs"; - -export async function listAccessibleDeviceProjects(client, actor) { - const result = await client.query( - `select p.id, p.project_key, p.name, p.description, - p.lifecycle_state, p.created_at, p.updated_at, - os.id as owner_scope_id, os.scope_kind, os.owner_ref, - os.display_name as owner_display_name, - g.id as grant_id, g.principal_kind, g.principal_ref, - g.project_role, g.capability_allow, g.capability_deny, - g.lifecycle_state as grant_lifecycle_state, - (select count(*)::bigint from device_instances di - where di.project_id = p.id) as device_count, - (select count(*)::bigint from device_collections dc - where dc.project_id = p.id and dc.lifecycle_state = 'active') as collection_count, - (select count(*)::bigint from device_discoveries dd - where dd.project_id = p.id and dd.lifecycle_state = 'quarantine') as discovery_count - from device_projects p - join device_owner_scopes os on os.id = p.owner_scope_id - join device_project_grants g on g.project_id = p.id - where p.lifecycle_state <> 'archived' - and os.lifecycle_state = 'active' - and g.lifecycle_state = 'active' - and ( - (g.principal_kind = 'user' and g.principal_ref = $1) - or (g.principal_kind = 'group' and g.principal_ref = any($2::text[])) - ) - order by os.display_name, p.name, g.created_at, g.id`, - [actor.userRef, actor.groupRefs], - ); - - const projects = new Map(); - for (const row of result.rows) { - const entry = projects.get(row.id) ?? { row, grants: [] }; - entry.grants.push(grantView(row)); - projects.set(row.id, entry); - } - - return [...projects.values()].flatMap(({ row, grants }) => { - const access = resolveProjectAccess({ actor, grants }); - return access.allowed ? [projectSummaryView(row, access)] : []; - }); -} - -export async function getDeviceProjectWorkspace( - client, - actor, - projectId, - { commandTransport = "disabled" } = {}, -) { - const project = await findProjectWithCapability( - client, - actor, - projectId, - "project.read", - { lock: false }, - ); - const grantsResult = await client.query( - `select id, principal_kind, principal_ref, project_role, - capability_allow, capability_deny, lifecycle_state - from device_project_grants - where project_id = $1 - order by created_at, id`, - [projectId], - ); - const access = resolveProjectAccess({ - actor, - grants: grantsResult.rows.map(storedGrantView), - }); - - const devices = await client.query( - `select di.id, di.device_key, di.display_name, di.model_profile_ref, - di.lifecycle_state, di.created_at, di.updated_at, - identifier.identifier_kind, identifier.identifier_masked, - session.lifecycle_state as session_state, - session.last_seen_at - from device_instances di - left join lateral ( - select dri.identifier_kind, dri.identifier_masked - from device_restricted_identifiers dri - where dri.device_id = di.id - and dri.lifecycle_state = 'active' - order by dri.is_primary desc, dri.created_at - limit 1 - ) identifier on true - left join lateral ( - select ds.lifecycle_state, ds.last_seen_at - from device_sessions ds - where ds.device_id = di.id - and ds.lifecycle_state in ('connecting', 'online') - order by ds.last_seen_at desc - limit 1 - ) session on true - where di.project_id = $1 - order by di.display_name, di.id`, - [projectId], - ); - const collections = await client.query( - `select dc.id, dc.collection_key, dc.name, dc.description, - dc.lifecycle_state, dc.created_at, dc.updated_at, - count(dcm.device_id)::bigint as member_count - from device_collections dc - left join device_collection_members dcm - on dcm.collection_id = dc.id and dcm.project_id = dc.project_id - where dc.project_id = $1 - group by dc.id - order by dc.name, dc.id`, - [projectId], - ); - const discoveries = await client.query( - `select dd.id, dd.identifier_kind, dd.identifier_masked, - dd.model_profile_ref, dd.protocol, dd.lifecycle_state, - dd.first_observed_at, dd.last_observed_at, - dd.enrollment_intent_id, dd.claimed_device_id - from device_discoveries dd - where dd.project_id = $1 - order by dd.last_observed_at desc, dd.id`, - [projectId], - ); - const enrollments = await client.query( - `select dei.id, dei.enrollment_key, dei.display_name, - dei.model_profile_ref, dei.expected_identifier_kind, - dei.expected_identifier_masked, dei.lifecycle_state, - dei.observed_discovery_id, dei.claimed_device_id, - dei.expires_at, dei.created_at, dei.updated_at - from device_enrollment_intents dei - where dei.project_id = $1 - order by dei.updated_at desc, dei.id`, - [projectId], - ); - - const capabilities = new Set(access.capabilities); - const mayManageRoutes = capabilities.has("route.manage"); - const catalogParameters = [projectId, mayManageRoutes]; - const adapterPackages = await client.query( - `select ap.id, ap.package_key, ap.display_name, ap.publisher_ref, - ap.lifecycle_state, ap.created_at, ap.updated_at - from device_adapter_packages ap - where $2::boolean - or exists ( - select 1 - from device_adapter_versions av - join device_model_profiles dmp on dmp.adapter_version_id = av.id - where av.adapter_package_id = ap.id - and ( - exists ( - select 1 from device_routes dr - where dr.project_id = $1 and dr.model_profile_ref = dmp.profile_ref - ) - or exists ( - select 1 from device_instances di - where di.project_id = $1 and di.model_profile_ref = dmp.profile_ref - ) - ) - ) - order by ap.display_name, ap.id`, - catalogParameters, - ); - const adapterVersions = await client.query( - `select av.id, av.adapter_package_id, av.version, - av.runtime_package_ref, av.content_digest, av.contract_version, - av.capabilities, av.lifecycle_state, av.created_at, av.updated_at - from device_adapter_versions av - where $2::boolean - or exists ( - select 1 from device_model_profiles dmp - where dmp.adapter_version_id = av.id - and ( - exists ( - select 1 from device_routes dr - where dr.project_id = $1 and dr.model_profile_ref = dmp.profile_ref - ) - or exists ( - select 1 from device_instances di - where di.project_id = $1 and di.model_profile_ref = dmp.profile_ref - ) - ) - ) - order by av.adapter_package_id, av.created_at, av.id`, - catalogParameters, - ); - const modelProfiles = await client.query( - `select dmp.profile_ref, dmp.adapter_version_id, dmp.schema_version, - dmp.vendor, dmp.model, dmp.device_type, dmp.protocol, - dmp.schema_artifact_ref, dmp.profile_digest, dmp.capabilities, - dmp.lifecycle_state, dmp.created_at, dmp.updated_at - from device_model_profiles dmp - where $2::boolean - or exists ( - select 1 from device_routes dr - where dr.project_id = $1 and dr.model_profile_ref = dmp.profile_ref - ) - or exists ( - select 1 from device_instances di - where di.project_id = $1 and di.model_profile_ref = dmp.profile_ref - ) - order by dmp.vendor, dmp.model, dmp.profile_ref`, - catalogParameters, - ); - const edges = await client.query( - `select de.id, de.edge_key, de.display_name, de.deployment_ref, - de.lifecycle_state, de.created_at, de.updated_at - from device_edges de - where $2::boolean - or exists ( - select 1 from device_routes dr - where dr.project_id = $1 and dr.edge_id = de.id - ) - order by de.display_name, de.id`, - catalogParameters, - ); - const routes = await client.query( - `select dr.id, dr.route_key, dr.display_name, dr.edge_id, - de.display_name as edge_name, dr.model_profile_ref, - dmp.vendor as profile_vendor, dmp.model as profile_model, - dr.listener_ref, dr.protocol, dr.direction, dr.lifecycle_state, - dr.created_at, dr.updated_at, - count(ds.id)::bigint as session_count, - (count(ds.id) filter ( - where ds.lifecycle_state in ('connecting', 'online') - ))::bigint as active_session_count - from device_routes dr - join device_edges de on de.id = dr.edge_id - join device_model_profiles dmp on dmp.profile_ref = dr.model_profile_ref - left join device_sessions ds on ds.route_id = dr.id - where dr.project_id = $1 - group by dr.id, de.display_name, dmp.vendor, dmp.model - order by dr.display_name, dr.id`, - [projectId], - ); - const sessions = capabilities.has("telemetry.observe") - ? await client.query( - `select ds.id, ds.route_id, dr.display_name as route_name, - ds.device_id, di.display_name as device_name, - ds.protocol, ds.lifecycle_state, ds.connected_at, - ds.last_seen_at, ds.disconnected_at, ds.close_reason_code, - ds.frame_count, ds.byte_count - from device_sessions ds - join device_routes dr on dr.id = ds.route_id - left join device_instances di on di.id = ds.device_id - where ds.project_id = $1 - order by ds.last_seen_at desc, ds.id - limit 200`, - [projectId], - ) - : { rows: [] }; - const bindings = capabilities.has("binding.manage") - ? await client.query( - `select drb.id, drb.binding_key, drb.display_name, - drb.source_kind, drb.device_id, drb.collection_id, - coalesce(di.display_name, dc.name) as source_name, - drb.target_kind, drb.target_ref, drb.capabilities, - drb.lifecycle_state, drb.source_approved_at, - drb.created_at, drb.updated_at - from device_resource_bindings drb - left join device_instances di on di.id = drb.device_id - left join device_collections dc on dc.id = drb.collection_id - where drb.project_id = $1 - order by drb.updated_at desc, drb.id`, - [projectId], - ) - : { rows: [] }; - const configurationRevisions = capabilities.has("configuration.read") - ? await client.query( - `select dcr.id, dcr.device_id, di.display_name as device_name, - dcr.revision_number, dcr.model_profile_ref, - dcr.schema_artifact_ref, dcr.configuration_digest, - dcr.change_summary, dcr.created_at - from device_configuration_revisions dcr - join device_instances di on di.id = dcr.device_id - where dcr.project_id = $1 - order by dcr.created_at desc, dcr.id - limit 200`, - [projectId], - ) - : { rows: [] }; - const configurationStates = capabilities.has("configuration.read") - ? await client.query( - `select dcs.device_id, di.display_name as device_name, - dcs.desired_revision_id, dcs.applied_revision_id, - dcs.applied_at, dcs.updated_at - from device_configuration_state dcs - join device_instances di on di.id = dcs.device_id - where dcs.project_id = $1 - order by di.display_name, dcs.device_id`, - [projectId], - ) - : { rows: [] }; - const mayReadCommands = ["command.plan", "command.confirm", "command.dispatch"] - .some((capability) => capabilities.has(capability)); - const commands = mayReadCommands - ? await client.query( - `select dc.id, dc.device_id, di.display_name as device_name, - dc.command_key, dc.command_catalog_ref, dc.command_type, - dc.risk_class, dc.lifecycle_state, dc.planned_at, dc.expires_at, - dc.confirmed_at, dc.dispatched_at, dc.acknowledged_at, - dc.terminal_at, dc.terminal_reason_code, - dc.created_at, dc.updated_at - from device_commands dc - join device_instances di on di.id = dc.device_id - where dc.project_id = $1 - order by dc.updated_at desc, dc.id - limit 200`, - [projectId], - ) - : { rows: [] }; - const auditEvents = capabilities.has("audit.read") - ? await client.query( - `select dae.id, dae.event_type, dae.actor_ref, - dae.device_id, dae.discovery_id, dae.occurred_at - from device_audit_events dae - where dae.project_id = $1 - order by dae.occurred_at desc, dae.id - limit 300`, - [projectId], - ) - : { rows: [] }; - const projectGrants = capabilities.has("access.manage") - ? grantsResult - : { rows: [] }; - - return { - project: projectSummaryView(project, access), - devices: devices.rows.map(deviceView), - collections: collections.rows.map(collectionView), - discoveries: discoveries.rows.map(discoveryView), - enrollments: enrollments.rows.map(enrollmentView), - adapterPackages: adapterPackages.rows.map(adapterPackageView), - adapterVersions: adapterVersions.rows.map(adapterVersionView), - modelProfiles: modelProfiles.rows.map(modelProfileView), - edges: edges.rows.map(edgeView), - routes: routes.rows.map(routeView), - sessions: sessions.rows.map(sessionView), - bindings: bindings.rows.map(bindingView), - configurationRevisions: configurationRevisions.rows.map( - configurationRevisionView, - ), - configurationStates: configurationStates.rows.map(configurationStateView), - commands: commands.rows.map(commandView), - auditEvents: auditEvents.rows.map(auditEventView), - grants: projectGrants.rows.map(storedGrantView), - policies: { - commandTransport, - commandPlanningApi: commandTransport === "disabled" ? "disabled" : "enabled", - identifierProjection: "masked-only", - auditPayloadProjection: "metadata-only", - }, - }; -} - -function projectSummaryView(row, access) { - return { - projectRef: toProjectRef(row.id), - projectKey: row.project_key, - name: row.name, - description: row.description ?? null, - lifecycleState: row.lifecycle_state, - ownerScope: { - ownerScopeRef: `owner-scope:${row.owner_scope_id}`, - scopeKind: row.scope_kind, - ownerRef: row.owner_ref, - displayName: row.owner_display_name, - }, - access: { - projectRole: access.projectRole, - capabilities: access.capabilities, - }, - counts: { - devices: numericCount(row.device_count), - collections: numericCount(row.collection_count), - discoveries: numericCount(row.discovery_count), - }, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function deviceView(row) { - return { - deviceRef: `device:${row.id}`, - deviceKey: row.device_key, - displayName: row.display_name, - modelProfileRef: row.model_profile_ref, - lifecycleState: row.lifecycle_state, - identifier: row.identifier_masked - ? { kind: row.identifier_kind, masked: row.identifier_masked } - : null, - session: row.session_state - ? { state: row.session_state, lastSeenAt: toIso(row.last_seen_at) } - : null, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function collectionView(row) { - return { - collectionRef: `collection:${row.id}`, - collectionKey: row.collection_key, - name: row.name, - description: row.description ?? null, - lifecycleState: row.lifecycle_state, - memberCount: numericCount(row.member_count), - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function discoveryView(row) { - return { - discoveryRef: `discovery:${row.id}`, - identifier: { kind: row.identifier_kind, masked: row.identifier_masked }, - modelProfileRef: row.model_profile_ref, - protocol: row.protocol, - lifecycleState: row.lifecycle_state, - enrollmentIntentRef: row.enrollment_intent_id - ? `enrollment-intent:${row.enrollment_intent_id}` - : null, - claimedDeviceRef: row.claimed_device_id ? `device:${row.claimed_device_id}` : null, - firstObservedAt: toIso(row.first_observed_at), - lastObservedAt: toIso(row.last_observed_at), - }; -} - -function enrollmentView(row) { - return { - enrollmentIntentRef: `enrollment-intent:${row.id}`, - enrollmentKey: row.enrollment_key, - displayName: row.display_name, - modelProfileRef: row.model_profile_ref, - expectedIdentifier: { - kind: row.expected_identifier_kind, - masked: row.expected_identifier_masked, - }, - lifecycleState: row.lifecycle_state, - observedDiscoveryRef: row.observed_discovery_id - ? `discovery:${row.observed_discovery_id}` - : null, - claimedDeviceRef: row.claimed_device_id ? `device:${row.claimed_device_id}` : null, - expiresAt: toIso(row.expires_at), - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function adapterPackageView(row) { - return { - adapterPackageRef: `adapter-package:${row.id}`, - packageKey: row.package_key, - displayName: row.display_name, - publisherRef: row.publisher_ref, - lifecycleState: row.lifecycle_state, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function adapterVersionView(row) { - return { - adapterVersionRef: `adapter-version:${row.id}`, - adapterPackageRef: `adapter-package:${row.adapter_package_id}`, - version: row.version, - runtimePackageRef: row.runtime_package_ref, - contentDigest: row.content_digest, - contractVersion: row.contract_version, - capabilities: [...(row.capabilities ?? [])].sort(), - lifecycleState: row.lifecycle_state, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function modelProfileView(row) { - return { - modelProfileRef: row.profile_ref, - adapterVersionRef: row.adapter_version_id - ? `adapter-version:${row.adapter_version_id}` - : null, - schemaVersion: row.schema_version, - vendor: row.vendor, - model: row.model, - deviceType: row.device_type, - protocol: row.protocol, - schemaArtifactRef: row.schema_artifact_ref ?? null, - profileDigest: row.profile_digest ?? null, - capabilities: [...(row.capabilities ?? [])].sort(), - lifecycleState: row.lifecycle_state, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function edgeView(row) { - return { - edgeRef: `edge:${row.id}`, - edgeKey: row.edge_key, - displayName: row.display_name, - deploymentRef: row.deployment_ref ?? null, - lifecycleState: row.lifecycle_state, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function routeView(row) { - return { - routeRef: `route:${row.id}`, - routeKey: row.route_key, - displayName: row.display_name, - edgeRef: `edge:${row.edge_id}`, - edgeName: row.edge_name, - modelProfileRef: row.model_profile_ref, - profileName: `${row.profile_vendor} ${row.profile_model}`.trim(), - listenerRef: row.listener_ref, - protocol: row.protocol, - direction: row.direction, - lifecycleState: row.lifecycle_state, - sessionCount: numericCount(row.session_count), - activeSessionCount: numericCount(row.active_session_count), - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function sessionView(row) { - return { - sessionRef: `session:${row.id}`, - routeRef: `route:${row.route_id}`, - routeName: row.route_name, - deviceRef: row.device_id ? `device:${row.device_id}` : null, - deviceName: row.device_name ?? null, - protocol: row.protocol, - lifecycleState: row.lifecycle_state, - connectedAt: toIso(row.connected_at), - lastSeenAt: toIso(row.last_seen_at), - disconnectedAt: toIso(row.disconnected_at), - closeReasonCode: row.close_reason_code ?? null, - frameCount: numericCount(row.frame_count), - byteCount: numericCount(row.byte_count), - }; -} - -function bindingView(row) { - return { - bindingRef: `binding:${row.id}`, - bindingKey: row.binding_key, - displayName: row.display_name, - source: { - kind: row.source_kind, - ref: row.source_kind === "device" - ? `device:${row.device_id}` - : `collection:${row.collection_id}`, - displayName: row.source_name, - }, - target: { kind: row.target_kind, ref: row.target_ref }, - capabilities: [...(row.capabilities ?? [])].sort(), - lifecycleState: row.lifecycle_state, - sourceApprovedAt: toIso(row.source_approved_at), - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function configurationRevisionView(row) { - return { - configurationRevisionRef: `configuration-revision:${row.id}`, - deviceRef: `device:${row.device_id}`, - deviceName: row.device_name, - revisionNumber: numericCount(row.revision_number), - modelProfileRef: row.model_profile_ref, - schemaArtifactRef: row.schema_artifact_ref, - configurationDigest: row.configuration_digest, - changeSummary: row.change_summary ?? null, - createdAt: toIso(row.created_at), - }; -} - -function configurationStateView(row) { - return { - deviceRef: `device:${row.device_id}`, - deviceName: row.device_name, - desiredConfigurationRevisionRef: row.desired_revision_id - ? `configuration-revision:${row.desired_revision_id}` - : null, - appliedConfigurationRevisionRef: row.applied_revision_id - ? `configuration-revision:${row.applied_revision_id}` - : null, - appliedAt: toIso(row.applied_at), - updatedAt: toIso(row.updated_at), - }; -} - -function commandView(row) { - return { - commandRef: `command:${row.id}`, - deviceRef: `device:${row.device_id}`, - deviceName: row.device_name, - commandKey: row.command_key, - commandCatalogRef: row.command_catalog_ref, - commandType: row.command_type, - riskClass: row.risk_class, - lifecycleState: row.lifecycle_state, - plannedAt: toIso(row.planned_at), - expiresAt: toIso(row.expires_at), - confirmedAt: toIso(row.confirmed_at), - dispatchedAt: toIso(row.dispatched_at), - acknowledgedAt: toIso(row.acknowledged_at), - terminalAt: toIso(row.terminal_at), - terminalReasonCode: row.terminal_reason_code ?? null, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function auditEventView(row) { - return { - auditEventRef: `audit-event:${row.id}`, - eventType: row.event_type, - actorRef: row.actor_ref, - deviceRef: row.device_id ? `device:${row.device_id}` : null, - discoveryRef: row.discovery_id ? `discovery:${row.discovery_id}` : null, - occurredAt: toIso(row.occurred_at), - }; -} - -function grantView(row) { - return storedGrantView({ - id: row.grant_id, - principal_kind: row.principal_kind, - principal_ref: row.principal_ref, - project_role: row.project_role, - capability_allow: row.capability_allow, - capability_deny: row.capability_deny, - lifecycle_state: row.grant_lifecycle_state, - }); -} - -function storedGrantView(row) { - return { - grantRef: `grant:${row.id}`, - principalKind: row.principal_kind, - principalRef: row.principal_ref, - projectRole: row.project_role, - capabilityAllow: row.capability_allow ?? [], - capabilityDeny: row.capability_deny ?? [], - lifecycleState: row.lifecycle_state, - }; -} - -function numericCount(value) { - const count = Number(value ?? 0); - return Number.isSafeInteger(count) && count >= 0 ? count : 0; -} - -function toIso(value) { - return value == null ? null : new Date(value).toISOString(); -} diff --git a/device-plane/services/device-control-core/src/sensitive-reference-management.mjs b/device-plane/services/device-control-core/src/sensitive-reference-management.mjs deleted file mode 100644 index 97bd246..0000000 --- a/device-plane/services/device-control-core/src/sensitive-reference-management.mjs +++ /dev/null @@ -1,97 +0,0 @@ -import { - normalizeNdcCredentialReference, -} from "./credential-reference.mjs"; - -export const DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS = Object.freeze([ - "device_credential_binding.upsert", - "device_credential_binding.revoke", -]); - -const commandKindSet = new Set(DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS); -const purposePattern = /^[a-z][a-z0-9._-]{1,63}$/; -const resolutionPattern = /^[a-z][a-z0-9._-]{1,63}$/; - -export function isSensitiveReferenceManagementCommand(kind) { - return commandKindSet.has(kind); -} - -export function normalizeSensitiveReferenceManagementCommand(kind, input) { - if (!commandKindSet.has(kind)) { - throw new TypeError("device_sensitive_reference_command_kind_invalid"); - } - assertPlainObject(input); - - if (kind === "device_credential_binding.upsert") { - assertAllowedKeys(input, [ - "projectRef", - "deviceRef", - "purpose", - "credentialRef", - ]); - return Object.freeze({ - projectId: normalizeEntityRef(input.projectRef, "project"), - deviceId: normalizeEntityRef(input.deviceRef, "device"), - purpose: normalizePattern( - input.purpose, - purposePattern, - "device_credential_purpose_invalid", - ), - credentialRef: normalizeNdcCredentialReference(input.credentialRef), - }); - } - - assertAllowedKeys(input, [ - "projectRef", - "deviceRef", - "purpose", - "resolutionCode", - ]); - return Object.freeze({ - projectId: normalizeEntityRef(input.projectRef, "project"), - deviceId: normalizeEntityRef(input.deviceRef, "device"), - purpose: normalizePattern( - input.purpose, - purposePattern, - "device_credential_purpose_invalid", - ), - resolutionCode: normalizePattern( - input.resolutionCode, - resolutionPattern, - "device_credential_resolution_code_invalid", - ), - }); -} - -function normalizeEntityRef(value, prefix) { - if (typeof value !== "string") { - throw new TypeError(`device_${prefix}_ref_invalid`); - } - const match = value.match(new RegExp( - `^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`, - "i", - )); - if (!match) throw new TypeError(`device_${prefix}_ref_invalid`); - return match[1].toLowerCase(); -} - -function normalizePattern(value, pattern, code) { - if (typeof value !== "string" || !pattern.test(value)) { - throw new TypeError(code); - } - return value; -} - -function assertPlainObject(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new TypeError("device_sensitive_reference_command_invalid"); - } -} - -function assertAllowedKeys(input, allowed) { - const allowedSet = new Set(allowed); - for (const key of Object.keys(input)) { - if (!allowedSet.has(key)) { - throw new TypeError(`device_management_command_field_unexpected:${key}`); - } - } -} diff --git a/device-plane/services/device-control-core/src/sensitive-reference-repository.mjs b/device-plane/services/device-control-core/src/sensitive-reference-repository.mjs deleted file mode 100644 index 038da0c..0000000 --- a/device-plane/services/device-control-core/src/sensitive-reference-repository.mjs +++ /dev/null @@ -1,269 +0,0 @@ -import { randomUUID } from "node:crypto"; - -import { - findProjectWithCapability, -} from "./lifecycle-repository.mjs"; -import { - isSensitiveReferenceManagementCommand, -} from "./sensitive-reference-management.mjs"; -import { toProjectRef } from "./project-management.mjs"; - -export async function applySensitiveReferenceManagementCommand( - client, - { commandKind, actor, command }, -) { - if (!isSensitiveReferenceManagementCommand(commandKind)) { - throw new TypeError("device_sensitive_reference_command_kind_invalid"); - } - if (commandKind === "device_credential_binding.upsert") { - return upsertCredentialBinding(client, actor, command); - } - return revokeCredentialBinding(client, actor, command); -} - -export async function authorizeSensitiveReferenceManagementReplay( - client, - { commandKind, actor, command }, -) { - if (!isSensitiveReferenceManagementCommand(commandKind)) { - throw new TypeError("device_sensitive_reference_command_kind_invalid"); - } - await findProjectWithCapability( - client, - actor, - command.projectId, - "credential.manage", - ); - const current = await client.query( - `select project_id - from device_instances - where id = $1`, - [command.deviceId], - ); - const currentProjectId = current.rows[0]?.project_id; - if (!currentProjectId) throw domainError("device_not_found", 404); - if (currentProjectId !== command.projectId) { - await findProjectWithCapability( - client, - actor, - currentProjectId, - "credential.manage", - ); - } -} - -async function upsertCredentialBinding(client, actor, command) { - const project = await findProjectWithCapability( - client, - actor, - command.projectId, - "credential.manage", - ); - const device = await findDirectDeviceForUpdate(client, command); - const currentResult = await client.query( - `select id, device_id, owner_scope_id, project_id, purpose, - credential_owner, credential_ref, lifecycle_state, - created_at, updated_at - from device_credential_bindings - where device_id = $1 - and purpose = $2 - and lifecycle_state = 'active' - for update`, - [device.id, command.purpose], - ); - const current = currentResult.rows[0] ?? null; - if ( - current - && current.credential_owner === command.credentialRef.owner - && current.credential_ref === command.credentialRef.reference - ) { - return { - created: false, - rotated: false, - credentialBinding: credentialBindingView(current), - }; - } - - if (current) { - await client.query( - `update device_credential_bindings - set lifecycle_state = 'revoked', - revoked_at = now(), - revoked_by_ref = $2, - revocation_code = 'credential_rotation', - updated_at = now() - where id = $1 and lifecycle_state = 'active'`, - [current.id, actor.userRef], - ); - } - - const inserted = await client.query( - `insert into device_credential_bindings ( - id, - device_id, - owner_scope_id, - project_id, - purpose, - credential_owner, - credential_ref, - bound_by_ref - ) values ($1, $2, $3, $4, $5, $6, $7, $8) - returning id, device_id, owner_scope_id, project_id, purpose, - credential_owner, lifecycle_state, created_at, updated_at`, - [ - randomUUID(), - device.id, - project.owner_scope_id, - project.id, - command.purpose, - command.credentialRef.owner, - command.credentialRef.reference, - actor.userRef, - ], - ); - const binding = inserted.rows[0]; - if (!binding) throw domainError("device_credential_binding_insert_failed", 409); - - await addAudit(client, { - eventType: current - ? "device_credential_binding.rotated" - : "device_credential_binding.created", - actorRef: actor.userRef, - projectId: project.id, - deviceId: device.id, - payload: { - deviceRef: `device:${device.id}`, - projectRef: toProjectRef(project.id), - credentialBindingRef: `credential-binding:${binding.id}`, - ...(current - ? { rotatedCredentialBindingRef: `credential-binding:${current.id}` } - : {}), - purpose: binding.purpose, - credentialOwner: binding.credential_owner, - }, - }); - return { - created: true, - rotated: Boolean(current), - credentialBinding: credentialBindingView(binding), - }; -} - -async function revokeCredentialBinding(client, actor, command) { - const project = await findProjectWithCapability( - client, - actor, - command.projectId, - "credential.manage", - ); - const device = await findDirectDeviceForUpdate(client, command); - const revoked = await client.query( - `update device_credential_bindings - set lifecycle_state = 'revoked', - revoked_at = now(), - revoked_by_ref = $4, - revocation_code = $3, - updated_at = now() - where device_id = $1 - and purpose = $2 - and lifecycle_state = 'active' - returning id, device_id, owner_scope_id, project_id, purpose, - credential_owner, lifecycle_state, created_at, updated_at`, - [device.id, command.purpose, command.resolutionCode, actor.userRef], - ); - const binding = revoked.rows[0]; - if (!binding) throw domainError("device_credential_binding_not_found", 404); - - await addAudit(client, { - eventType: "device_credential_binding.revoked", - actorRef: actor.userRef, - projectId: project.id, - deviceId: device.id, - payload: { - deviceRef: `device:${device.id}`, - projectRef: toProjectRef(project.id), - credentialBindingRef: `credential-binding:${binding.id}`, - purpose: binding.purpose, - credentialOwner: binding.credential_owner, - resolutionCode: command.resolutionCode, - }, - }); - return { - revoked: true, - credentialBinding: credentialBindingView(binding), - resolutionCode: command.resolutionCode, - }; -} - -async function findDirectDeviceForUpdate(client, command) { - const result = await client.query( - `select id, owner_scope_id, project_id, lifecycle_state - from device_instances - where id = $1 - for update`, - [command.deviceId], - ); - const device = result.rows[0]; - if (!device) throw domainError("device_not_found", 404); - if ( - !device.owner_scope_id - || !device.project_id - || device.project_id !== command.projectId - ) { - throw domainError("device_credential_binding_project_mismatch", 409); - } - if (device.lifecycle_state === "retired") { - throw domainError("device_credential_binding_lifecycle_blocked", 409); - } - return device; -} - -async function addAudit(client, { - eventType, - actorRef, - projectId, - deviceId, - 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, - actorRef, - projectId, - deviceId, - JSON.stringify(payload), - ], - ); -} - -function credentialBindingView(row) { - return { - credentialBindingRef: `credential-binding:${row.id}`, - deviceRef: `device:${row.device_id}`, - projectRef: toProjectRef(row.project_id), - purpose: row.purpose, - credentialOwner: row.credential_owner, - lifecycleState: row.lifecycle_state, - createdAt: toIso(row.created_at), - updatedAt: toIso(row.updated_at), - }; -} - -function toIso(value) { - return new Date(value).toISOString(); -} - -function domainError(code, statusCode) { - const error = new Error(code); - error.statusCode = statusCode; - return error; -} diff --git a/device-plane/services/device-control-core/src/server.mjs b/device-plane/services/device-control-core/src/server.mjs deleted file mode 100644 index 8874250..0000000 --- a/device-plane/services/device-control-core/src/server.mjs +++ /dev/null @@ -1,255 +0,0 @@ -import { - createPrivateKey, - createPublicKey, - timingSafeEqual, - X509Certificate, -} from "node:crypto"; -import { lstat, readFile } from "node:fs/promises"; - -import { createControlCoreApp } from "./app.mjs"; -import { resolveDeviceDatabaseUrl } from "./database-config.mjs"; -import { - createDeviceEdgeChannelSupervisor, -} from "./edge-channel-supervisor.mjs"; -import { createDeviceGatewayIngest } from "./gateway-ingest.mjs"; -import { PostgresDeviceRepository } from "./postgres-repository.mjs"; -import { createTypedCommandRuntime } from "./typed-command-runtime.mjs"; - -const config = await readConfig(); -const repository = new PostgresDeviceRepository({ - databaseUrl: config.databaseUrl, - poolSize: config.databasePoolSize, -}); - -await repository.migrate(); -const typedCommandRuntime = config.managementApiEnabled && config.edgeChannelEnabled - ? createTypedCommandRuntime({ repository }) - : null; - -const gatewayIngest = config.discoveryIngestEnabled || config.edgeChannelEnabled - ? createDeviceGatewayIngest({ - repository, - identifierPepper: config.identifierPepper, - }) - : null; -const edgeChannelSupervisor = config.edgeChannelEnabled - ? createDeviceEdgeChannelSupervisor({ - repository, - gatewayIngest, - coreIdentity: config.edgeChannelCoreIdentity, - trustRoot: config.edgeChannelTrustRoot, - maxEdges: config.edgeChannelMaxEdges, - reconcileIntervalMs: config.edgeChannelReconcileIntervalMs, - typedCommandRuntime, - }) - : null; -await edgeChannelSupervisor?.start(); - -const server = createControlCoreApp({ - repository, - gatewayToken: config.gatewayToken, - identifierPepper: config.identifierPepper, - discoveryIngestEnabled: config.discoveryIngestEnabled, - managementApiEnabled: config.managementApiEnabled, - managementToken: config.managementToken, - gatewayIngest, - edgeChannelStatusProvider: edgeChannelSupervisor - ? () => edgeChannelSupervisor.status() - : null, - typedCommandRuntime, -}); - -server.listen(config.port, config.host, () => { - console.log(JSON.stringify({ - event: "device_control_core_started", - host: config.host, - port: config.port, - discoveryIngest: config.discoveryIngestEnabled, - managementApi: config.managementApiEnabled, - edgeChannels: config.edgeChannelEnabled, - commandTransport: typedCommandRuntime ? "typed-service-ping-v1" : "disabled", - })); -}); - -process.on("SIGTERM", shutdown); -process.on("SIGINT", shutdown); - -async function shutdown() { - server.close(async () => { - await edgeChannelSupervisor?.stop(); - await repository.close(); - process.exit(0); - }); -} - -async function readConfig() { - const discoveryIngestEnabled = parseBoolean( - process.env.DEVICE_DISCOVERY_INGEST_ENABLED, - false, - ); - const managementApiEnabled = parseBoolean( - process.env.DEVICE_MANAGEMENT_API_ENABLED, - false, - ); - const edgeChannelEnabled = parseBoolean( - process.env.DEVICE_EDGE_CHANNEL_ENABLED, - false, - ); - const edgeChannelCoreIdentity = edgeChannelEnabled - ? await readCoreIdentity(process.env) - : null; - return { - host: String(process.env.HOST || "127.0.0.1").trim(), - port: parsePort(process.env.PORT, 18120), - databaseUrl: await resolveDeviceDatabaseUrl(process.env), - databasePoolSize: parsePositiveInt( - process.env.DEVICE_DATABASE_POOL_SIZE, - 10, - ), - discoveryIngestEnabled, - managementApiEnabled, - edgeChannelEnabled, - gatewayToken: discoveryIngestEnabled - ? await readRequiredSecretFile( - process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE, - "device_gateway_core_token_file_required", - ) - : "", - identifierPepper: - discoveryIngestEnabled || managementApiEnabled || edgeChannelEnabled - ? await readRequiredSecretFile( - process.env.DEVICE_IDENTIFIER_PEPPER_FILE, - "device_identifier_pepper_file_required", - ) - : "", - managementToken: managementApiEnabled - ? await readRequiredSecretFile( - process.env.DEVICE_MANAGEMENT_CORE_TOKEN_FILE, - "device_management_core_token_file_required", - ) - : "", - edgeChannelCoreIdentity, - edgeChannelTrustRoot: edgeChannelEnabled - ? await readRequiredDirectory( - process.env.DEVICE_EDGE_CHANNEL_TRUST_ROOT, - "device_edge_channel_trust_root_required", - ) - : "", - edgeChannelMaxEdges: parseBoundedInt( - process.env.DEVICE_EDGE_CHANNEL_MAX_EDGES, - 32, - 1, - 64, - ), - edgeChannelReconcileIntervalMs: parseBoundedInt( - process.env.DEVICE_EDGE_CHANNEL_RECONCILE_INTERVAL_MS, - 15_000, - 1_000, - 300_000, - ), - }; -} - -async function readCoreIdentity(environment) { - const keyPath = requiredValue( - environment.DEVICE_EDGE_CHANNEL_CORE_KEY_FILE, - "device_edge_channel_core_key_file_required", - ); - const certificatePath = requiredValue( - environment.DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE, - "device_edge_channel_core_certificate_file_required", - ); - const [key, cert] = await Promise.all([ - readBoundedRegularFile(keyPath, 32 * 1024), - readBoundedRegularFile(certificatePath, 32 * 1024), - ]); - const privatePublic = createPublicKey(createPrivateKey(key)).export({ - type: "spki", - format: "der", - }); - const certificatePublic = new X509Certificate(cert).publicKey.export({ - type: "spki", - format: "der", - }); - if ( - privatePublic.length !== certificatePublic.length - || !timingSafeEqual(privatePublic, certificatePublic) - ) { - throw new Error("device_edge_channel_core_identity_mismatch"); - } - return Object.freeze({ - identityRef: "workload:device-control-core", - key, - cert, - }); -} - -async function readBoundedRegularFile(path, maximumBytes) { - const state = await lstat(path); - if ( - state.isSymbolicLink() - || !state.isFile() - || state.size < 1 - || state.size > maximumBytes - ) { - throw new Error("device_edge_channel_core_identity_file_invalid"); - } - return readFile(path); -} - -async function readRequiredDirectory(path, errorCode) { - const normalized = requiredValue(path, errorCode); - const state = await lstat(normalized); - if (state.isSymbolicLink() || !state.isDirectory()) throw new Error(errorCode); - return normalized; -} - -async function readRequiredSecretFile(path, errorCode) { - const normalized = requiredValue(path, errorCode); - const value = (await readFile(normalized, "utf8")).trim(); - if (value.length < 32) throw new Error(errorCode); - return value; -} - -function requiredValue(value, errorCode) { - if (typeof value !== "string" || value.trim() === "") { - throw new Error(errorCode); - } - return value.trim(); -} - -function parsePort(value, fallback) { - const parsed = Number(value || fallback); - if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) { - throw new Error("device_control_port_invalid"); - } - return parsed; -} - -function parsePositiveInt(value, fallback) { - const parsed = Number(value || fallback); - if (!Number.isSafeInteger(parsed) || parsed < 1) { - throw new Error("device_positive_integer_invalid"); - } - return parsed; -} - -function parseBoundedInt(value, fallback, minimum, maximum) { - const parsed = Number(value ?? fallback); - if ( - !Number.isSafeInteger(parsed) - || parsed < minimum - || parsed > maximum - ) { - throw new Error("device_bounded_integer_invalid"); - } - return parsed; -} - -function parseBoolean(value, fallback) { - if (value === undefined || value === null || value === "") return fallback; - const normalized = String(value).trim().toLowerCase(); - if (["1", "true", "yes", "on"].includes(normalized)) return true; - if (["0", "false", "no", "off"].includes(normalized)) return false; - throw new Error("device_boolean_invalid"); -} diff --git a/device-plane/services/device-control-core/src/typed-command-repository.mjs b/device-plane/services/device-control-core/src/typed-command-repository.mjs deleted file mode 100644 index f93c2b8..0000000 --- a/device-plane/services/device-control-core/src/typed-command-repository.mjs +++ /dev/null @@ -1,299 +0,0 @@ -import { createHash, randomUUID } from "node:crypto"; - -import { assertProjectCapability } from "./project-management.mjs"; - -const SERVICE_PING_CATALOG = "arusnavi.b2.internal.v1:service-ping"; - -export async function planTypedServicePing(client, { - idempotencyKey, - requestDigest, - actor, - projectId, - deviceId, - expiresAt, -}) { - const commandKey = `service-ping-${createHash("sha256") - .update(`${actor.userRef}\0${idempotencyKey}`, "utf8") - .digest("hex").slice(0, 32)}`; - const project = await findProject(client, projectId); - const grants = await projectGrants(client, projectId); - assertProjectCapability(actor, grants, "command.plan"); - assertProjectCapability(actor, grants, "command.dispatch"); - const device = await findCommandableDevice(client, projectId, deviceId); - - const existing = await client.query( - `select dc.*, di.display_name as device_name - from device_commands dc - join device_instances di on di.id = dc.device_id - where dc.project_id = $1 and dc.command_key = $2 - for update of dc`, - [projectId, commandKey], - ); - if (existing.rows[0]) { - const row = existing.rows[0]; - if ( - row.device_id !== deviceId - || row.command_catalog_ref !== SERVICE_PING_CATALOG - || row.command_type !== "service.ping" - || row.parameters_digest !== requestDigest - ) { - throw domainError("device_command_idempotency_conflict", 409); - } - return { replayed: true, commandId: row.id, command: commandView(row) }; - } - - const commandId = randomUUID(); - const plannedAt = new Date(); - await client.query( - `insert into device_commands ( - id, owner_scope_id, project_id, device_id, command_key, - command_catalog_ref, command_type, risk_class, - parameters_digest, parameters_projection, lifecycle_state, - planned_by_ref, planned_at, expires_at - ) values ( - $1, $2, $3, $4, $5, $6, 'service.ping', 'low', - $7, $8::jsonb, 'queued', $9, $10, $11 - )`, - [ - commandId, - project.owner_scope_id, - projectId, - deviceId, - commandKey, - SERVICE_PING_CATALOG, - requestDigest, - JSON.stringify({ operation: "service.ping", profileRef: device.model_profile_ref }), - actor.userRef, - plannedAt, - expiresAt, - ], - ); - await insertEvent(client, commandId, deviceId, projectId, 1, null, "draft", actor.userRef, "operator_requested"); - await insertEvent(client, commandId, deviceId, projectId, 2, "draft", "planned", actor.userRef, "typed_policy_approved"); - await insertEvent(client, commandId, deviceId, projectId, 3, "planned", "queued", actor.userRef, "awaiting_tracker_session"); - await audit(client, { - eventType: "command.queued", - actorRef: actor.userRef, - projectId, - deviceId, - payload: { commandRef: `command:${commandId}`, commandType: "service.ping", riskClass: "low" }, - }); - return { - replayed: false, - commandId, - command: commandView({ - id: commandId, - device_id: deviceId, - device_name: device.display_name, - command_key: commandKey, - command_catalog_ref: SERVICE_PING_CATALOG, - command_type: "service.ping", - risk_class: "low", - lifecycle_state: "queued", - planned_at: plannedAt, - expires_at: expiresAt, - created_at: plannedAt, - updated_at: plannedAt, - }), - }; -} - -export async function dispatchTypedCommand(client, { - commandId, - transportMessageRef, - now, -}) { - const result = await client.query( - `select dc.*, di.display_name as device_name - from device_commands dc - join device_instances di on di.id = dc.device_id - where dc.id = $1 - for update of dc`, - [commandId], - ); - const row = result.rows[0]; - if (!row) throw domainError("device_command_not_found", 404); - if (row.lifecycle_state !== "queued") return null; - if (new Date(row.expires_at).getTime() <= now.getTime()) { - await client.query( - `update device_commands set lifecycle_state = 'expired', - terminal_at = $2, terminal_reason_code = 'ttl_elapsed', updated_at = $2 - where id = $1`, - [commandId, now], - ); - await insertEvent(client, commandId, row.device_id, row.project_id, 4, "queued", "expired", "workload:device-control-core", "ttl_elapsed"); - return null; - } - await client.query( - `update device_commands set lifecycle_state = 'dispatched', - dispatched_at = $2, transport_message_ref = $3, updated_at = $2 - where id = $1`, - [commandId, now, transportMessageRef], - ); - await insertEvent(client, commandId, row.device_id, row.project_id, 4, "queued", "dispatched", "workload:device-control-core", "tracker_session_allocated", transportMessageRef); - await audit(client, { - eventType: "command.dispatched", - actorRef: "workload:device-control-core", - projectId: row.project_id, - deviceId: row.device_id, - payload: { commandRef: `command:${commandId}`, commandType: row.command_type }, - }); - return commandView({ ...row, lifecycle_state: "dispatched", dispatched_at: now, transport_message_ref: transportMessageRef, updated_at: now }); -} - -export async function recordTypedCommandStatus(client, { - commandId, - transportMessageRef, - lifecycleState, - resultCode, - observedAt, -}) { - const result = await client.query( - `select * from device_commands where id = $1 for update`, - [commandId], - ); - const row = result.rows[0]; - if (!row) throw domainError("device_command_not_found", 404); - if (row.transport_message_ref !== transportMessageRef) { - throw domainError("device_command_transport_mismatch", 409); - } - if (["verified", "failed", "expired", "unknown"].includes(row.lifecycle_state)) { - return; - } - if (lifecycleState === "acknowledged" && row.lifecycle_state === "dispatched") { - await client.query( - `update device_commands set lifecycle_state = 'acknowledged', - acknowledged_at = $2, updated_at = $2 where id = $1`, - [commandId, observedAt], - ); - await insertEvent(client, commandId, row.device_id, row.project_id, 5, "dispatched", "acknowledged", "workload:device-edge", "protocol_reply_received", `result:${resultCode}`); - await client.query( - `update device_commands set lifecycle_state = 'verified', - terminal_at = $2, terminal_reason_code = 'protocol_reply_serv_ok', - updated_at = $2 where id = $1`, - [commandId, observedAt], - ); - await insertEvent(client, commandId, row.device_id, row.project_id, 6, "acknowledged", "verified", "workload:device-control-core", "protocol_reply_serv_ok", `result:${resultCode}`); - await audit(client, { - eventType: "command.verified", - actorRef: "workload:device-control-core", - projectId: row.project_id, - deviceId: row.device_id, - payload: { commandRef: `command:${commandId}`, commandType: row.command_type, resultCode }, - }); - return; - } - if (lifecycleState === "unknown" && row.lifecycle_state === "dispatched") { - await client.query( - `update device_commands set lifecycle_state = 'unknown', terminal_at = $2, - terminal_reason_code = $3, updated_at = $2 where id = $1`, - [commandId, observedAt, resultCode], - ); - await insertEvent(client, commandId, row.device_id, row.project_id, 5, "dispatched", "unknown", "workload:device-edge", resultCode); - } -} - -async function findProject(client, projectId) { - const result = await client.query( - `select id, owner_scope_id, lifecycle_state from device_projects where id = $1 for share`, - [projectId], - ); - const row = result.rows[0]; - if (!row) throw domainError("device_project_not_found", 404); - if (row.lifecycle_state !== "active") throw domainError("device_project_inactive", 409); - return row; -} - -async function findCommandableDevice(client, projectId, deviceId) { - const result = await client.query( - `select di.id, di.display_name, di.model_profile_ref, di.lifecycle_state - from device_instances di - where di.project_id = $1 and di.id = $2 - and exists ( - select 1 from device_routes dr - where dr.project_id = di.project_id - and dr.model_profile_ref = di.model_profile_ref - and dr.direction = 'bidirectional' - and dr.lifecycle_state = 'active' - ) - for share`, - [projectId, deviceId], - ); - const row = result.rows[0]; - if (!row) throw domainError("device_command_route_unavailable", 409); - if (row.model_profile_ref !== "arusnavi.b2.internal.v1") { - throw domainError("device_command_profile_unsupported", 409); - } - if (["suspended", "retired"].includes(row.lifecycle_state)) { - throw domainError("device_command_device_inactive", 409); - } - return row; -} - -async function projectGrants(client, projectId) { - const result = await client.query( - `select id, principal_kind, principal_ref, project_role, - capability_allow, capability_deny, lifecycle_state - from device_project_grants where project_id = $1 for share`, - [projectId], - ); - return result.rows.map((row) => ({ - grantRef: `grant:${row.id}`, - principalKind: row.principal_kind, - principalRef: row.principal_ref, - projectRole: row.project_role, - capabilityAllow: row.capability_allow ?? [], - capabilityDeny: row.capability_deny ?? [], - lifecycleState: row.lifecycle_state, - })); -} - -async function insertEvent(client, commandId, deviceId, projectId, sequence, from, to, actor, reason, evidence = null) { - await client.query( - `insert into device_command_events ( - id, command_id, device_id, project_id, sequence_number, - from_state, to_state, actor_ref, reason_code, evidence_ref - ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, - [randomUUID(), commandId, deviceId, projectId, sequence, from, to, actor, reason, evidence], - ); -} - -async function audit(client, { eventType, actorRef, projectId, deviceId, 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, actorRef, projectId, deviceId, JSON.stringify(payload)], - ); -} - -function commandView(row) { - return Object.freeze({ - commandRef: `command:${row.id}`, - deviceRef: `device:${row.device_id}`, - deviceName: row.device_name, - commandKey: row.command_key, - commandCatalogRef: row.command_catalog_ref, - commandType: row.command_type, - riskClass: row.risk_class, - lifecycleState: row.lifecycle_state, - plannedAt: iso(row.planned_at), - expiresAt: iso(row.expires_at), - dispatchedAt: iso(row.dispatched_at), - acknowledgedAt: iso(row.acknowledged_at), - terminalAt: iso(row.terminal_at), - terminalReasonCode: row.terminal_reason_code ?? null, - createdAt: iso(row.created_at), - updatedAt: iso(row.updated_at), - }); -} - -function iso(value) { - return value == null ? null : new Date(value).toISOString(); -} - -function domainError(code, statusCode) { - const error = new Error(code); - error.statusCode = statusCode; - return error; -} diff --git a/device-plane/services/device-control-core/src/typed-command-runtime.mjs b/device-plane/services/device-control-core/src/typed-command-runtime.mjs deleted file mode 100644 index 3e60bfd..0000000 --- a/device-plane/services/device-control-core/src/typed-command-runtime.mjs +++ /dev/null @@ -1,149 +0,0 @@ -import { createHash, randomUUID } from "node:crypto"; - -export function createTypedCommandRuntime({ repository, now = () => new Date() } = {}) { - if ( - !repository - || typeof repository.planTypedServicePing !== "function" - || typeof repository.dispatchTypedCommand !== "function" - || typeof repository.recordTypedCommandStatus !== "function" - ) { - throw new TypeError("device_typed_command_repository_required"); - } - const credentials = new Map(); - - return Object.freeze({ - async planServicePing({ idempotencyKey, actor, input }) { - const normalized = normalizeInput(input); - const expiresAt = new Date(now().getTime() + normalized.expiresInSeconds * 1000); - const requestDigest = `sha256:${createHash("sha256").update(JSON.stringify({ - actorRef: actor.userRef, - projectId: normalized.projectId, - deviceId: normalized.deviceId, - operation: "service.ping", - expiresInSeconds: normalized.expiresInSeconds, - }), "utf8").digest("hex")}`; - const execution = await repository.planTypedServicePing({ - idempotencyKey, - requestDigest, - actor, - projectId: normalized.projectId, - deviceId: normalized.deviceId, - expiresAt, - }); - if (!execution.replayed && execution.command.lifecycleState === "queued") { - credentials.set(execution.commandId, Object.freeze({ - deviceId: normalized.deviceId, - accessCode: normalized.accessCode, - expiresAt: new Date(execution.command.expiresAt).getTime(), - })); - } - return { replayed: execution.replayed, command: execution.command }; - }, - - async offerForDevice(deviceRef) { - const deviceId = entityId(deviceRef, "device"); - const at = now(); - for (const [commandId, secret] of credentials) { - if (secret.expiresAt <= at.getTime()) { - await repository.dispatchTypedCommand({ - commandId, - transportMessageRef: `edge-command:${randomUUID()}`, - now: at, - }); - credentials.delete(commandId); - continue; - } - if (secret.deviceId !== deviceId) continue; - const transportMessageRef = `edge-command:${randomUUID()}`; - const dispatched = await repository.dispatchTypedCommand({ - commandId, - transportMessageRef, - now: at, - }); - if (!dispatched) { - credentials.delete(commandId); - continue; - } - return Object.freeze({ - commandRef: `command:${commandId}`, - commandType: "service.ping", - accessCode: secret.accessCode, - transportMessageRef, - }); - } - return null; - }, - - async recordStatus(status) { - const commandId = entityId(status?.commandRef, "command"); - const normalized = normalizeStatus(status); - await repository.recordTypedCommandStatus({ commandId, ...normalized }); - credentials.delete(commandId); - }, - - status() { - return Object.freeze({ - commandTransport: "typed-service-ping-v1", - transientAuthorizations: credentials.size, - }); - }, - }); -} - -function normalizeInput(input) { - if (!input || typeof input !== "object" || Array.isArray(input)) { - throw domainError("device_service_ping_input_invalid", 400); - } - const keys = Object.keys(input).sort().join(","); - if (keys !== "accessCode,deviceRef,expiresInSeconds,projectRef") { - throw domainError("device_service_ping_input_invalid", 400); - } - if (typeof input.accessCode !== "string" || !/^\d{6}$/.test(input.accessCode)) { - throw domainError("device_service_ping_access_code_invalid", 400); - } - const expiresInSeconds = Number(input.expiresInSeconds); - if (!Number.isSafeInteger(expiresInSeconds) || expiresInSeconds < 30 || expiresInSeconds > 1800) { - throw domainError("device_service_ping_ttl_invalid", 400); - } - return { - projectId: entityId(input.projectRef, "project"), - deviceId: entityId(input.deviceRef, "device"), - accessCode: input.accessCode, - expiresInSeconds, - }; -} - -function normalizeStatus(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new TypeError("device_command_status_invalid"); - } - if (!['acknowledged', 'unknown'].includes(value.lifecycleState)) { - throw new TypeError("device_command_status_invalid"); - } - if (typeof value.transportMessageRef !== "string" || !/^edge-command:[0-9a-f-]{36}$/i.test(value.transportMessageRef)) { - throw new TypeError("device_command_status_invalid"); - } - if (typeof value.resultCode !== "string" || !/^[a-z][a-z0-9._-]{1,63}$/.test(value.resultCode)) { - throw new TypeError("device_command_status_invalid"); - } - const observedAt = new Date(value.observedAt); - if (Number.isNaN(observedAt.getTime())) throw new TypeError("device_command_status_invalid"); - return { - transportMessageRef: value.transportMessageRef.toLowerCase(), - lifecycleState: value.lifecycleState, - resultCode: value.resultCode, - observedAt, - }; -} - -function entityId(value, prefix) { - const match = String(value || "").match(new RegExp(`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`, "i")); - if (!match) throw domainError(`device_${prefix}_ref_invalid`, 400); - return match[1].toLowerCase(); -} - -function domainError(code, statusCode) { - const error = new Error(code); - error.statusCode = statusCode; - return error; -} diff --git a/device-plane/services/device-control-core/test/app.test.mjs b/device-plane/services/device-control-core/test/app.test.mjs deleted file mode 100644 index 9f7f6d0..0000000 --- a/device-plane/services/device-control-core/test/app.test.mjs +++ /dev/null @@ -1,589 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - DEVICE_ADAPTER_MESSAGE_SCHEMA, - DEVICE_DISCOVERY_SIGNAL_SCHEMA, -} from "../../../packages/device-protocol-contract/src/index.mjs"; -import { createControlCoreApp } from "../src/app.mjs"; - -const gatewayToken = "test-only-gateway-token-with-32-bytes"; -const identifierPepper = "test-only-identifier-pepper-with-32-bytes"; -const managementToken = "test-only-management-token-with-32-bytes"; -const fakeImei = "000000000000001"; - -test("health reports database readiness and disabled command transport", async () => { - const runtime = await startTestServer({ - repository: { - health: async () => "ready", - }, - }); - try { - const response = await fetch(`${runtime.baseUrl}/healthz`); - assert.equal(response.status, 200); - assert.deepEqual(await response.json(), { - ok: true, - service: "nodedc-device-control-core", - database: "ready", - discoveryIngest: "disabled", - managementApi: "disabled", - edgeChannels: { - enabled: false, - configured: 0, - accepted: 0, - degraded: 0, - }, - commandTransport: "disabled", - }); - } finally { - await runtime.close(); - } -}); - -test("management API is closed by default", async () => { - const runtime = await startTestServer({ - repository: { - health: async () => "ready", - }, - }); - try { - const response = await fetch( - `${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{}", - }, - ); - assert.equal(response.status, 404); - assert.equal((await response.json()).error, "device_management_api_disabled"); - } finally { - await runtime.close(); - } -}); - -test("management API cannot start without its repository boundary and strong token", () => { - assert.throws( - () => createControlCoreApp({ - managementApiEnabled: true, - managementToken, - repository: { health: async () => "ready" }, - }), - /device_management_repository_required/, - ); - assert.throws( - () => createControlCoreApp({ - managementApiEnabled: true, - managementToken: "short", - repository: { - health: async () => "ready", - executeManagementCommand: async () => ({}), - }, - }), - /device_management_token_invalid/, - ); - assert.throws( - () => createControlCoreApp({ - managementApiEnabled: true, - managementToken, - repository: { - health: async () => "ready", - executeManagementCommand: async () => ({}), - }, - }), - /device_identifier_pepper_invalid/, - ); -}); - -test("management API requires service auth and an idempotency key", async () => { - const runtime = await startTestServer({ - managementApiEnabled: true, - managementToken, - repository: { - health: async () => "ready", - executeManagementCommand: async () => { - throw new Error("must_not_execute"); - }, - }, - }); - try { - const unauthorized = await fetch( - `${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{}", - }, - ); - assert.equal(unauthorized.status, 401); - - const missingIdempotency = await fetch( - `${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`, - { - method: "POST", - headers: managementHeaders({ includeIdempotency: false }), - body: JSON.stringify(ownerScopeCommand()), - }, - ); - assert.equal(missingIdempotency.status, 400); - assert.equal( - (await missingIdempotency.json()).error, - "device_management_header_required", - ); - } finally { - await runtime.close(); - } -}); - -test("management API forwards only normalized actor and command data", async () => { - let executed; - const runtime = await startTestServer({ - managementApiEnabled: true, - managementToken, - repository: { - health: async () => "ready", - executeManagementCommand: async (value) => { - executed = value; - return { - replayed: false, - result: { - created: true, - ownerScope: { - ownerScopeRef: "owner-scope:11111111-1111-4111-8111-111111111111", - scopeKind: "company", - ownerRef: "client:example", - displayName: "Example Company", - lifecycleState: "active", - }, - }, - }; - }, - }, - }); - try { - const response = await fetch( - `${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`, - { - method: "POST", - headers: managementHeaders(), - body: JSON.stringify(ownerScopeCommand()), - }, - ); - assert.equal(response.status, 200); - assert.equal(response.headers.get("idempotency-key"), "phase2-test-0001"); - assert.equal(response.headers.get("idempotency-replayed"), "false"); - assert.match(executed.requestDigest, /^sha256:[a-f0-9]{64}$/); - assert.equal(executed.commandKind, "owner_scope.ensure"); - assert.deepEqual(executed.command, ownerScopeCommand()); - assert.deepEqual(executed.actor, { - userRef: "user:engineer", - hubRole: "admin", - groupRefs: ["group:engineers", "group:operators"], - ownerScopes: [{ scopeKind: "company", ownerRef: "client:example" }], - }); - assert.equal((await response.json()).result.created, true); - } finally { - await runtime.close(); - } -}); - -test("management API rejects unexpected fields before repository execution", async () => { - let executions = 0; - const runtime = await startTestServer({ - managementApiEnabled: true, - managementToken, - repository: { - health: async () => "ready", - executeManagementCommand: async () => { - executions += 1; - return { replayed: false, result: {} }; - }, - }, - }); - try { - const response = await fetch( - `${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`, - { - method: "POST", - headers: managementHeaders(), - body: JSON.stringify({ - ...ownerScopeCommand(), - credential: "must-never-cross-boundary", - }), - }, - ); - assert.equal(response.status, 400); - assert.match( - (await response.json()).error, - /device_management_command_field_unexpected:credential/, - ); - assert.equal(executions, 0); - } finally { - await runtime.close(); - } -}); - -test("management API exposes a repository idempotency conflict without retrying", async () => { - let executions = 0; - const runtime = await startTestServer({ - managementApiEnabled: true, - managementToken, - repository: { - health: async () => "ready", - executeManagementCommand: async () => { - executions += 1; - const error = new Error("device_idempotency_key_conflict"); - error.statusCode = 409; - throw error; - }, - }, - }); - try { - const response = await fetch( - `${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`, - { - method: "POST", - headers: managementHeaders(), - body: JSON.stringify(ownerScopeCommand()), - }, - ); - assert.equal(response.status, 409); - assert.equal((await response.json()).error, "device_idempotency_key_conflict"); - assert.equal(executions, 1); - } finally { - await runtime.close(); - } -}); - -test("typed service ping accepts a transient access code and never echoes it", async () => { - const projectRef = "project:11111111-1111-4111-8111-111111111111"; - const deviceRef = "device:22222222-2222-4222-8222-222222222222"; - let planned; - const runtime = await startTestServer({ - managementApiEnabled: true, - managementToken, - repository: { - health: async () => "ready", - executeManagementCommand: async () => ({ replayed: false, result: {} }), - }, - typedCommandRuntime: { - status: () => ({ commandTransport: "typed-service-ping-v1" }), - planServicePing: async (value) => { - planned = value; - return { - replayed: false, - command: { - commandRef: "command:33333333-3333-4333-8333-333333333333", - deviceRef, - commandType: "service.ping", - lifecycleState: "queued", - }, - }; - }, - }, - }); - try { - const response = await fetch( - `${runtime.baseUrl}/internal/v1/commands:service-ping`, - { - method: "POST", - headers: managementHeaders(), - body: JSON.stringify({ - projectRef, - deviceRef, - accessCode: "654321", - expiresInSeconds: 300, - }), - }, - ); - assert.equal(response.status, 200); - const body = await response.json(); - assert.equal(body.result.lifecycleState, "queued"); - assert.equal(JSON.stringify(body).includes("654321"), false); - assert.equal(planned.input.accessCode, "654321"); - assert.equal(planned.idempotencyKey, "phase2-test-0001"); - } finally { - await runtime.close(); - } -}); - -test("project query is service-authenticated and forwards only the trusted actor", async () => { - let queriedActor; - const runtime = await startTestServer({ - managementApiEnabled: true, - managementToken, - repository: { - health: async () => "ready", - executeManagementCommand: async () => ({ replayed: false, result: {} }), - listAccessibleProjects: async (actor) => { - queriedActor = actor; - return [{ projectRef: "project:11111111-1111-4111-8111-111111111111" }]; - }, - }, - }); - try { - const unauthorized = await fetch( - `${runtime.baseUrl}/internal/v1/query/projects`, - ); - assert.equal(unauthorized.status, 401); - - const response = await fetch( - `${runtime.baseUrl}/internal/v1/query/projects`, - { headers: managementHeaders() }, - ); - assert.equal(response.status, 200); - assert.equal((await response.json()).projects.length, 1); - assert.deepEqual(queriedActor, { - userRef: "user:engineer", - hubRole: "admin", - groupRefs: ["group:engineers", "group:operators"], - ownerScopes: [{ scopeKind: "company", ownerRef: "client:example" }], - }); - } finally { - await runtime.close(); - } -}); - -test("project workspace query accepts only a canonical project path", async () => { - const projectId = "11111111-1111-4111-8111-111111111111"; - let queried; - const runtime = await startTestServer({ - managementApiEnabled: true, - managementToken, - repository: { - health: async () => "ready", - executeManagementCommand: async () => ({ replayed: false, result: {} }), - getProjectWorkspace: async (actor, id) => { - queried = { actor, id }; - return { project: { projectRef: `project:${id}` }, devices: [] }; - }, - }, - }); - try { - const response = await fetch( - `${runtime.baseUrl}/internal/v1/query/projects/${projectId}/workspace`, - { headers: managementHeaders() }, - ); - assert.equal(response.status, 200); - assert.equal((await response.json()).workspace.devices.length, 0); - assert.equal(queried.id, projectId); - - const invalid = await fetch( - `${runtime.baseUrl}/internal/v1/query/projects/not-a-project/workspace`, - { headers: managementHeaders() }, - ); - assert.equal(invalid.status, 404); - } finally { - await runtime.close(); - } -}); - -test("discovery ingest is closed by default", async () => { - const runtime = await startTestServer({ - repository: { - health: async () => "ready", - }, - }); - try { - const response = await fetch( - `${runtime.baseUrl}/internal/v1/device-discoveries:observe`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{}", - }, - ); - assert.equal(response.status, 404); - assert.equal( - (await response.json()).error, - "device_discovery_ingest_disabled", - ); - } finally { - await runtime.close(); - } -}); - -test("authenticated ingest stores only digest and returns a masked view", async () => { - let stored; - const runtime = await startTestServer({ - discoveryIngestEnabled: true, - gatewayToken, - identifierPepper, - repository: { - health: async () => "ready", - upsertQuarantineDiscovery: async (value) => { - stored = value; - return { - created: true, - value: { - ...value.safeView, - discoveryRef: "discovery:test-001", - }, - }; - }, - acceptAdapterMessage: async () => { - throw new Error("must_not_accept_message"); - }, - }, - }); - try { - const unauthorized = await fetch( - `${runtime.baseUrl}/internal/v1/device-discoveries:observe`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(fakeSignal()), - }, - ); - assert.equal(unauthorized.status, 401); - - const response = await fetch( - `${runtime.baseUrl}/internal/v1/device-discoveries:observe`, - { - method: "POST", - headers: { - Authorization: `Bearer ${gatewayToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(fakeSignal()), - }, - ); - assert.equal(response.status, 201); - const body = await response.json(); - const serialized = JSON.stringify(body); - assert.equal(serialized.includes(fakeImei), false); - assert.equal(body.discovery.identifier.masked, "***********0001"); - assert.match(stored.identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/); - assert.equal(stored.sessionRef, "session:test-001"); - assert.equal(stored.routeRef, null); - assert.equal(JSON.stringify(stored).includes(fakeImei), false); - } finally { - await runtime.close(); - } -}); - -test("gateway message endpoint returns acceptance only after repository commit", async () => { - let stored; - const runtime = await startTestServer({ - discoveryIngestEnabled: true, - gatewayToken, - identifierPepper, - repository: { - health: async () => "ready", - upsertQuarantineDiscovery: async () => { - throw new Error("must_not_observe_discovery"); - }, - acceptAdapterMessage: async (value) => { - stored = value; - return { - acceptance: { - schemaVersion: "nodedc.device-adapter-acceptance.v1", - acceptanceRef: "acceptance:test-001", - idempotencyKey: value.safeView.idempotencyKey, - status: "accepted", - replayed: false, - acceptedAt: "2026-08-11T12:00:00.000Z", - }, - claimedDeviceRef: "device:11111111-1111-4111-8111-111111111111", - }; - }, - }, - }); - try { - const response = await fetch( - `${runtime.baseUrl}/internal/v1/gateway/messages:accept`, - { - method: "POST", - headers: { - Authorization: `Bearer ${gatewayToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(fakeAdapterMessage()), - }, - ); - assert.equal(response.status, 201); - const body = await response.json(); - assert.equal(body.acceptance.status, "accepted"); - assert.match(stored.identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/); - assert.match(stored.requestDigest, /^sha256:[a-f0-9]{64}$/); - assert.equal(stored.safeView.identifier.masked, "***********0001"); - assert.equal(JSON.stringify(stored).includes(fakeImei), false); - } finally { - await runtime.close(); - } -}); - -function fakeSignal() { - return { - schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA, - sessionRef: "session:test-001", - modelProfileRef: "arusnavi.b2.internal.v1", - protocol: "INTERNAL", - observedAt: "2026-07-25T00:00:00.000Z", - identifier: { kind: "imei", value: fakeImei }, - evidence: { - transport: "tcp", - bytesObserved: 128, - framingStatus: "verified", - specificationRef: "arusnavi.internal.framing.test-v1", - }, - }; -} - -function fakeAdapterMessage() { - return { - schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA, - edgeRef: "edge:test-001", - adapterRef: "arusnavi-b2", - protocolProfileRef: "arusnavi.b2.internal.v1", - protocol: "INTERNAL", - sessionRef: "session:test-001", - messageRef: "package:1:test", - messageType: "telemetry.package", - sequence: 1, - observedAt: "2026-08-11T12:00:00.000Z", - idempotencyKey: `sha256:${"a".repeat(64)}`, - identifier: { kind: "imei", value: fakeImei }, - payloadSchemaRef: "arusnavi.internal.package-metadata.v1", - payload: { - packageNumber: 1, - packetCount: 1, - packageDigest: `sha256:${"b".repeat(64)}`, - }, - }; -} - -function managementHeaders({ includeIdempotency = true } = {}) { - return { - Authorization: `Bearer ${managementToken}`, - "Content-Type": "application/json", - ...(includeIdempotency ? { "Idempotency-Key": "phase2-test-0001" } : {}), - "X-NODEDC-User-Ref": "user:engineer", - "X-NODEDC-Hub-Role": "admin", - "X-NODEDC-Group-Refs": "group:operators,group:engineers", - "X-NODEDC-Owner-Scopes": "company=client:example", - }; -} - -function ownerScopeCommand() { - return { - scopeKind: "company", - ownerRef: "client:example", - displayName: "Example Company", - }; -} - -async function startTestServer(options) { - const server = createControlCoreApp({ identifierPepper, ...options }); - 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())); - }), - }; -} diff --git a/device-plane/services/device-control-core/test/control-resource-management.test.mjs b/device-plane/services/device-control-core/test/control-resource-management.test.mjs deleted file mode 100644 index e71445c..0000000 --- a/device-plane/services/device-control-core/test/control-resource-management.test.mjs +++ /dev/null @@ -1,158 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - DEVICE_CONTROL_RESOURCE_COMMAND_KINDS, - normalizeControlResourceManagementCommand, -} from "../src/control-resource-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 collectionRef = "collection:33333333-3333-4333-8333-333333333333"; -const bindingRef = "binding:44444444-4444-4444-8444-444444444444"; -const revisionRef = - "configuration-revision:55555555-5555-4555-8555-555555555555"; - -test("control resource commands join the strict idempotent surface", () => { - for (const kind of DEVICE_CONTROL_RESOURCE_COMMAND_KINDS) { - assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes(kind), true); - } - assert.equal( - normalizeDeviceManagementCommand( - "device_binding.ensure", - bindingInput(), - ).projectId, - projectRef.slice("project:".length), - ); -}); - -test("binding input is source-scoped and cannot claim external approval", () => { - const command = normalizeControlResourceManagementCommand( - "device_binding.ensure", - bindingInput(), - ); - - assert.deepEqual(command.source, { - kind: "collection", - id: collectionRef.slice("collection:".length), - }); - assert.deepEqual(command.capabilities, ["inspect", "observe"]); - assert.equal("lifecycleState" in command, false); - assert.equal("externalApprovalRef" in command, false); - assert.throws( - () => normalizeControlResourceManagementCommand( - "device_binding.ensure", - { ...bindingInput(), externalApprovalRef: "approval:forged" }, - ), - /device_management_command_field_unexpected:externalApprovalRef/, - ); - assert.throws( - () => normalizeControlResourceManagementCommand( - "device_binding.ensure", - { ...bindingInput(), targetRef: "ndc-credref:must-not-be-a-target" }, - ), - /device_binding_target_ref_invalid/, - ); -}); - -test("binding revoke uses only project, binding and bounded reason refs", () => { - const command = normalizeControlResourceManagementCommand( - "device_binding.revoke", - { - projectRef, - bindingRef, - resolutionCode: "operator.unbound", - }, - ); - - assert.equal(command.bindingId, bindingRef.slice("binding:".length)); - assert.equal(command.resolutionCode, "operator.unbound"); -}); - -test("configuration is canonical, bounded and secret-free before hashing", () => { - const first = normalizeControlResourceManagementCommand( - "device_configuration_revision.create", - { - projectRef, - deviceRef, - configuration: { - reporting_interval_seconds: 15, - motion: { enabled: true, threshold: 3.5 }, - channels: ["gps", "voltage"], - }, - changeSummary: "Pilot reporting profile", - }, - ); - const reordered = normalizeControlResourceManagementCommand( - "device_configuration_revision.create", - { - projectRef, - deviceRef, - configuration: { - channels: ["gps", "voltage"], - motion: { threshold: 3.5, enabled: true }, - reporting_interval_seconds: 15, - }, - changeSummary: "Pilot reporting profile", - }, - ); - - assert.equal(first.configurationDigest, reordered.configurationDigest); - assert.equal(Object.isFrozen(first.configuration.motion), true); - assert.throws( - () => normalizeControlResourceManagementCommand( - "device_configuration_revision.create", - { - projectRef, - deviceRef, - configuration: { api_token: "forbidden" }, - }, - ), - /forbidden_device_field/, - ); - assert.throws( - () => normalizeControlResourceManagementCommand( - "device_configuration_revision.create", - { - projectRef, - deviceRef, - configuration: { tracker_imei: "000000000000001" }, - }, - ), - /safe_projection_contains_unmasked_imei/, - ); -}); - -test("desired configuration binds one exact immutable revision", () => { - const command = normalizeControlResourceManagementCommand( - "device_configuration_desired.set", - { - projectRef, - deviceRef, - configurationRevisionRef: revisionRef, - }, - ); - - assert.equal(command.deviceId, deviceRef.slice("device:".length)); - assert.equal( - command.configurationRevisionId, - revisionRef.slice("configuration-revision:".length), - ); - assert.equal("applied" in command, false); -}); - -function bindingInput() { - return { - projectRef, - bindingKey: "robot2b-map", - displayName: "Robot2B map binding", - source: { kind: "collection", ref: collectionRef }, - targetKind: "foundry.application", - targetRef: "foundry-application:robot2b-test", - capabilities: ["observe", "inspect"], - }; -} diff --git a/device-plane/services/device-control-core/test/control-resource-migration.test.mjs b/device-plane/services/device-control-core/test/control-resource-migration.test.mjs deleted file mode 100644 index 950672e..0000000 --- a/device-plane/services/device-control-core/test/control-resource-migration.test.mjs +++ /dev/null @@ -1,104 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const schemaUrl = new URL( - "../migrations/010_device_control_resources.sql", - import.meta.url, -); -const commandsUrl = new URL( - "../migrations/011_device_control_resource_commands.sql", - import.meta.url, -); -const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); -const appUrl = new URL("../src/app.mjs", import.meta.url); - -test("control resource schema separates bindings, revisions and current state", async () => { - const sql = await readFile(schemaUrl, "utf8"); - - assert.match(sql, /create table if not exists device_resource_bindings/); - assert.match(sql, /pending_external_approval/); - assert.match(sql, /external_approval_digest/); - assert.match(sql, /device_binding_source_scope_mismatch/); - assert.match(sql, /create table if not exists device_configuration_revisions/); - assert.match(sql, /create table if not exists device_configuration_state/); - assert.match(sql, /device_configuration_revision_scope_mismatch/); - assert.match(sql, /unique \(device_id, revision_number\)/); -}); - -test("command ledger has honest ordered states without a transport API", async () => { - const sql = await readFile(schemaUrl, "utf8"); - const app = await readFile(appUrl, "utf8"); - - assert.match(sql, /create table if not exists device_commands/); - assert.match(sql, /create table if not exists device_command_events/); - for (const state of [ - "draft", - "planned", - "awaiting_confirmation", - "queued", - "dispatched", - "acknowledged", - "verified", - "failed", - "expired", - "unknown", - ]) { - assert.match(sql, new RegExp(`'${state}'`)); - } - assert.match(sql, /device_command_initial_event_invalid/); - assert.match(sql, /device_command_event_sequence_invalid/); - assert.match(sql, /device_command_event_transition_invalid/); - assert.match(sql, /device_command_event_projection_mismatch/); - assert.match(sql, /device_command_events_current_projection_guard/); - assert.match(sql, /device_command_current_projection_mismatch/); - assert.doesNotMatch(app, /device-commands:(?:plan|confirm|dispatch)/); -}); - -test("configuration, command history and audit are append-only", async () => { - const sql = await readFile(schemaUrl, "utf8"); - - for (const table of [ - "device_configuration_revisions", - "device_command_events", - "device_audit_events", - ]) { - assert.match( - sql, - new RegExp(`${table}_immutable_guard[\\s\\S]*before update or delete or truncate`), - ); - } - assert.match(sql, /device_immutable_record_mutation_forbidden/); - assert.match(sql, /device_transfer_active_resource_binding/); - assert.match(sql, /device_transfer_applied_configuration/); - assert.match(sql, /device_transfer_nonterminal_command/); -}); - -test("control resource schema contains no seeded device or raw secret material", async () => { - const sql = await readFile(schemaUrl, "utf8"); - - assert.doesNotMatch(sql, /insert\s+into/i); - assert.doesNotMatch(sql, /dcctouch|arusnavi|gelios|\bb2\b|imei/i); - assert.doesNotMatch(sql, /password\s+text|token\s+text|secret\s+text|raw_command|raw_packet/i); -}); - -test("commands extend receipts only after their schema", async () => { - const commands = await readFile(commandsUrl, "utf8"); - const repository = await readFile(repositoryUrl, "utf8"); - - for (const kind of [ - "device_binding.ensure", - "device_binding.revoke", - "device_configuration_revision.create", - "device_configuration_desired.set", - ]) { - assert.match(commands, new RegExp(`'${kind.replace(".", "\\.")}'`)); - } - const schemaIndex = repository.indexOf("010_device_control_resources.sql"); - const commandsIndex = repository.indexOf( - "011_device_control_resource_commands.sql", - ); - assert.notEqual(schemaIndex, -1); - assert.notEqual(commandsIndex, -1); - assert.ok(schemaIndex < commandsIndex); -}); diff --git a/device-plane/services/device-control-core/test/control-resource-repository.test.mjs b/device-plane/services/device-control-core/test/control-resource-repository.test.mjs deleted file mode 100644 index 2835e1e..0000000 --- a/device-plane/services/device-control-core/test/control-resource-repository.test.mjs +++ /dev/null @@ -1,313 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { assertSafeProjection } from "../../../packages/device-protocol-contract/src/index.mjs"; -import { normalizeDeviceManagementCommand } from "../src/management-command.mjs"; -import { PostgresDeviceRepository } from "../src/postgres-repository.mjs"; -import { normalizeManagementActor } from "../src/project-management.mjs"; - -const now = new Date("2026-08-10T00:00:00.000Z"); -const projectId = "11111111-1111-4111-8111-111111111111"; -const ownerId = "22222222-2222-4222-8222-222222222222"; -const deviceId = "33333333-3333-4333-8333-333333333333"; -const collectionId = "44444444-4444-4444-8444-444444444444"; -const bindingId = "55555555-5555-4555-8555-555555555555"; -const revisionId = "66666666-6666-4666-8666-666666666666"; - -test("creates only a pending collection binding owned by the source project", async () => { - const actor = managementActor(); - const command = normalizeDeviceManagementCommand("device_binding.ensure", { - projectRef: `project:${projectId}`, - bindingKey: "robot2b-map", - displayName: "Robot2B map binding", - source: { kind: "collection", ref: `collection:${collectionId}` }, - targetKind: "foundry.application", - targetRef: "foundry-application:robot2b-test", - capabilities: ["observe", "inspect"], - }); - const client = scriptedClient([ - step("begin"), - receiptStep("receipt-binding"), - projectStep(), - grantsStep(actor), - step("from device_collections", { - rows: [{ id: collectionId, project_id: projectId, lifecycle_state: "active" }], - }), - step("insert into device_resource_bindings", { - rows: [bindingRow({ source_kind: "collection", device_id: null })], - }), - step("insert into device_audit_events"), - step("update device_management_command_receipts"), - step("commit"), - ]); - const repository = repositoryWithClient(client); - - const result = await repository.executeManagementCommand(commandInput({ - actor, - commandKind: "device_binding.ensure", - command, - digestCharacter: "a", - })); - - assert.equal(result.result.binding.lifecycleState, "pending_external_approval"); - assert.equal(result.result.binding.source.ref, `collection:${collectionId}`); - assert.equal("externalApprovalRef" in result.result.binding, false); - assertSafeProjection(result.result); - assert.equal(client.remaining(), 0); -}); - -test("creates an immutable configuration revision from the active profile schema", async () => { - const actor = managementActor(); - const command = createConfigurationCommand(); - const client = scriptedClient([ - step("begin"), - receiptStep("receipt-configuration-revision"), - projectStep(), - grantsStep(actor), - step("from device_instances", { rows: [deviceRow()] }), - step("from device_model_profiles", { - rows: [{ - profile_ref: "vendor.model.protocol.v1", - schema_artifact_ref: "schema:vendor.model.protocol.v1", - lifecycle_state: "active", - }], - }), - step("from device_configuration_revisions", { - rows: [{ next_revision: "1" }], - }), - step("insert into device_configuration_revisions", { - rows: [configurationRevisionRow({ - configuration_digest: command.configurationDigest, - configuration: command.configuration, - })], - }), - step("insert into device_audit_events"), - step("update device_management_command_receipts"), - step("commit"), - ]); - const repository = repositoryWithClient(client); - - const result = await repository.executeManagementCommand(commandInput({ - actor, - commandKind: "device_configuration_revision.create", - command, - digestCharacter: "b", - })); - - assert.equal(result.result.configurationRevision.revisionNumber, 1); - assert.deepEqual(result.result.configurationRevision.configuration, { - reporting_interval_seconds: 15, - }); - assert.equal( - result.result.configurationRevision.schemaArtifactRef, - "schema:vendor.model.protocol.v1", - ); - assertSafeProjection(result.result); - assert.equal(client.remaining(), 0); -}); - -test("sets desired configuration without claiming runtime apply", async () => { - const actor = managementActor(); - const command = normalizeDeviceManagementCommand( - "device_configuration_desired.set", - { - projectRef: `project:${projectId}`, - deviceRef: `device:${deviceId}`, - configurationRevisionRef: `configuration-revision:${revisionId}`, - }, - ); - const client = scriptedClient([ - step("begin"), - receiptStep("receipt-configuration-desired"), - projectStep(), - grantsStep(actor), - step("from device_instances", { rows: [deviceRow()] }), - step("from device_configuration_revisions", { - rows: [configurationRevisionRow()], - }), - step("from device_configuration_state", { rows: [] }), - step("insert into device_configuration_state", { - rows: [{ - device_id: deviceId, - project_id: projectId, - desired_revision_id: revisionId, - applied_revision_id: null, - }], - }), - step("insert into device_audit_events"), - step("update device_management_command_receipts"), - step("commit"), - ]); - const repository = repositoryWithClient(client); - - const result = await repository.executeManagementCommand(commandInput({ - actor, - commandKind: "device_configuration_desired.set", - command, - digestCharacter: "c", - })); - - assert.equal(result.result.changed, true); - assert.equal( - result.result.configurationState.desiredConfigurationRevisionRef, - `configuration-revision:${revisionId}`, - ); - assert.equal( - result.result.configurationState.appliedConfigurationRevisionRef, - null, - ); - assert.equal("applied" in result.result, false); - assert.equal(client.remaining(), 0); -}); - -function createConfigurationCommand() { - return normalizeDeviceManagementCommand( - "device_configuration_revision.create", - { - projectRef: `project:${projectId}`, - deviceRef: `device:${deviceId}`, - configuration: { reporting_interval_seconds: 15 }, - changeSummary: "Pilot reporting profile", - }, - ); -} - -function managementActor() { - return normalizeManagementActor({ - userRef: "user:device-engineer", - hubRole: "member", - groupRefs: [], - ownerScopes: [], - }); -} - -function projectStep() { - return step("from device_projects p", { - rows: [{ - id: projectId, - owner_scope_id: ownerId, - lifecycle_state: "active", - scope_kind: "company", - owner_ref: "client:example-company", - owner_display_name: "Example Company", - owner_lifecycle_state: "active", - }], - }); -} - -function grantsStep(actor) { - return step("from device_project_grants", { - rows: [{ - id: "77777777-7777-4777-8777-777777777777", - principal_kind: "user", - principal_ref: actor.userRef, - project_role: "engineer", - capability_allow: [], - capability_deny: [], - lifecycle_state: "active", - }], - }); -} - -function deviceRow() { - return { - id: deviceId, - contour_id: null, - owner_scope_id: ownerId, - project_id: projectId, - model_profile_ref: "vendor.model.protocol.v1", - lifecycle_state: "claimed", - }; -} - -function bindingRow(overrides = {}) { - return { - id: bindingId, - owner_scope_id: ownerId, - project_id: projectId, - binding_key: "robot2b-map", - display_name: "Robot2B map binding", - source_kind: "device", - device_id: deviceId, - collection_id: collectionId, - target_kind: "foundry.application", - target_ref: "foundry-application:robot2b-test", - capabilities: ["inspect", "observe"], - lifecycle_state: "pending_external_approval", - source_approved_at: now, - created_at: now, - updated_at: now, - created: true, - ...overrides, - }; -} - -function configurationRevisionRow(overrides = {}) { - return { - id: revisionId, - owner_scope_id: ownerId, - project_id: projectId, - device_id: deviceId, - revision_number: "1", - model_profile_ref: "vendor.model.protocol.v1", - schema_artifact_ref: "schema:vendor.model.protocol.v1", - configuration_digest: `sha256:${"d".repeat(64)}`, - configuration: { reporting_interval_seconds: 15 }, - change_summary: "Pilot reporting profile", - created_at: now, - ...overrides, - }; -} - -function receiptStep(id) { - return step("insert into device_management_command_receipts", { - rows: [{ id }], - }); -} - -function commandInput({ actor, commandKind, command, digestCharacter }) { - return { - idempotencyKey: `phase25-${commandKind.replaceAll(".", "-")}-0001`, - commandKind, - requestDigest: `sha256:${digestCharacter.repeat(64)}`, - actor, - command, - }; -} - -function repositoryWithClient(client) { - return new PostgresDeviceRepository({ - pool: { - query: async () => ({ rows: [] }), - connect: async () => client, - end: async () => undefined, - }, - }); -} - -function step(includes, result = { rows: [] }) { - return { includes, result }; -} - -function scriptedClient(steps) { - const queue = [...steps]; - return { - released: false, - async query(sql) { - const next = queue.shift(); - assert.ok(next, `Unexpected query: ${sql}`); - assert.match(String(sql), new RegExp(escapeRegExp(next.includes), "i")); - return next.result; - }, - release() { - this.released = true; - }, - remaining() { - return queue.length; - }, - }; -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/device-plane/services/device-control-core/test/database-config.test.mjs b/device-plane/services/device-control-core/test/database-config.test.mjs deleted file mode 100644 index 0cdff09..0000000 --- a/device-plane/services/device-control-core/test/database-config.test.mjs +++ /dev/null @@ -1,50 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { resolveDeviceDatabaseUrl } from "../src/database-config.mjs"; - -test("builds the database URL from a file-backed password", async () => { - const password = "test-only-database-password-with-32-bytes"; - const url = await resolveDeviceDatabaseUrl( - { - DEVICE_DATABASE_HOST: "device-postgres", - DEVICE_DATABASE_PORT: "5432", - DEVICE_DATABASE_NAME: "device_plane", - DEVICE_DATABASE_USER: "device_plane", - DEVICE_DATABASE_PASSWORD_FILE: "/run/test/postgres-password", - }, - async (path, encoding) => { - assert.equal(path, "/run/test/postgres-password"); - assert.equal(encoding, "utf8"); - return `${password}\n`; - }, - ); - - assert.equal( - url, - `postgresql://device_plane:${encodeURIComponent(password)}@device-postgres:5432/device_plane?sslmode=disable`, - ); -}); - -test("rejects a short file-backed database password", async () => { - await assert.rejects( - resolveDeviceDatabaseUrl( - { - DEVICE_DATABASE_HOST: "device-postgres", - DEVICE_DATABASE_NAME: "device_plane", - DEVICE_DATABASE_USER: "device_plane", - DEVICE_DATABASE_PASSWORD_FILE: "/run/test/postgres-password", - }, - async () => "too-short", - ), - /device_database_password_invalid/, - ); -}); - -test("keeps an explicit database URL as a compatibility-only boundary", async () => { - const explicit = "postgresql://local:test@127.0.0.1:5432/device_plane"; - assert.equal( - await resolveDeviceDatabaseUrl({ DEVICE_DATABASE_URL: explicit }), - explicit, - ); -}); diff --git a/device-plane/services/device-control-core/test/device-edge-channel-migration.test.mjs b/device-plane/services/device-control-core/test/device-edge-channel-migration.test.mjs deleted file mode 100644 index b82f561..0000000 --- a/device-plane/services/device-control-core/test/device-edge-channel-migration.test.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const migrationUrl = new URL( - "../migrations/013_device_edge_channels.sql", - import.meta.url, -); - -test("Edge channel migration extends the canonical Edge without storing keys", async () => { - const sql = await readFile(migrationUrl, "utf8"); - - assert.match(sql, /alter table device_edges/); - assert.match(sql, /channel_endpoint text/); - assert.match(sql, /channel_generation_ref text/); - assert.match(sql, /channel_trust_bundle_ref text/); - assert.match(sql, /channel_certificate_identities jsonb/); - assert.match(sql, /channel_lifecycle_state in \('disabled', 'active', 'revoked'\)/); - assert.match(sql, /where channel_lifecycle_state = 'active'/); - assert.doesNotMatch(sql, /private[_ ]?key/i); - assert.doesNotMatch(sql, /password/i); -}); diff --git a/device-plane/services/device-control-core/test/device-lifecycle-command-migration.test.mjs b/device-plane/services/device-control-core/test/device-lifecycle-command-migration.test.mjs deleted file mode 100644 index 2a1bdca..0000000 --- a/device-plane/services/device-control-core/test/device-lifecycle-command-migration.test.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const migrationUrl = new URL( - "../migrations/007_device_lifecycle_commands.sql", - import.meta.url, -); -const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); - -test("lifecycle command migration extends the durable receipt allowlist", async () => { - const sql = await readFile(migrationUrl, "utf8"); - - for (const kind of [ - "device.claim", - "device.transfer", - "discovery.reject", - "discovery.expire", - ]) { - assert.match(sql, new RegExp(`'${kind.replace(".", "\\.")}'`)); - } -}); - -test("lifecycle command migration contains no runtime entity or secret", 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, /password|secret|credential|private_key/i); -}); - -test("repository applies lifecycle commands after ownership schema", async () => { - const source = await readFile(repositoryUrl, "utf8"); - const lifecycleIndex = source.indexOf("006_device_lifecycle_ownership.sql"); - const commandsIndex = source.indexOf("007_device_lifecycle_commands.sql"); - - assert.notEqual(lifecycleIndex, -1); - assert.notEqual(commandsIndex, -1); - assert.ok(lifecycleIndex < commandsIndex); -}); diff --git a/device-plane/services/device-control-core/test/device-lifecycle-migration.test.mjs b/device-plane/services/device-control-core/test/device-lifecycle-migration.test.mjs deleted file mode 100644 index 912207f..0000000 --- a/device-plane/services/device-control-core/test/device-lifecycle-migration.test.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const migrationUrl = new URL( - "../migrations/006_device_lifecycle_ownership.sql", - import.meta.url, -); -const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); - -test("lifecycle migration separates direct ownership from legacy contours", async () => { - const sql = await readFile(migrationUrl, "utf8"); - - assert.match(sql, /alter column contour_id drop not null/); - assert.match(sql, /add column if not exists owner_scope_id uuid/); - assert.match(sql, /device_instances_project_owner_fk/); - assert.match(sql, /device_instances_ownership_mode_check/); - assert.match(sql, /references device_projects\(id, owner_scope_id\)/); - assert.match(sql, /\) not valid;/); -}); - -test("route-bound discovery and enrollment evidence are DB constrained", async () => { - const sql = await readFile(migrationUrl, "utf8"); - - assert.match(sql, /device_discoveries_route_context_fk/); - assert.match(sql, /device_discoveries_enrollment_context_fk/); - assert.match(sql, /device_enrollment_observed_discovery_fk/); - assert.match(sql, /device_enrollment_intents_active_identity_idx/); - assert.match(sql, /where lifecycle_state in \('pending', 'observed', 'claimed'\)/); -}); - -test("ownership history supports transfer without rewriting session provenance", async () => { - const sql = await readFile(migrationUrl, "utf8"); - - assert.match(sql, /create table if not exists device_ownership_transitions/); - assert.match(sql, /transition_kind in \('claim', 'transfer'\)/); - assert.match(sql, /device_ownership_single_claim_idx/); - assert.match(sql, /device_assert_session_current_project/); - assert.match(sql, /device_assert_enrollment_current_project/); - assert.match(sql, /drop constraint if exists device_sessions_device_id_project_id_fkey/); -}); - -test("lifecycle migration contains no tenant, device, route 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 lifecycle migration after registry commands", async () => { - const source = await readFile(repositoryUrl, "utf8"); - const commandsIndex = source.indexOf("005_device_registry_commands.sql"); - const lifecycleIndex = source.indexOf("006_device_lifecycle_ownership.sql"); - - assert.notEqual(commandsIndex, -1); - assert.notEqual(lifecycleIndex, -1); - assert.ok(commandsIndex < lifecycleIndex); -}); diff --git a/device-plane/services/device-control-core/test/device-registry-command-migration.test.mjs b/device-plane/services/device-control-core/test/device-registry-command-migration.test.mjs deleted file mode 100644 index ab9ecd9..0000000 --- a/device-plane/services/device-control-core/test/device-registry-command-migration.test.mjs +++ /dev/null @@ -1,43 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const migrationUrl = new URL( - "../migrations/005_device_registry_commands.sql", - import.meta.url, -); -const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); - -test("registry command migration extends the durable receipt allowlist", async () => { - const sql = await readFile(migrationUrl, "utf8"); - - for (const kind of [ - "adapter_package.ensure", - "adapter_version.register", - "model_profile.register", - "edge.ensure", - "route.ensure", - "enrollment_intent.ensure", - ]) { - assert.match(sql, new RegExp(`'${kind.replace(".", "\\.")}'`)); - } - assert.doesNotMatch(sql, /session\.(ensure|create|upsert)/); -}); - -test("registry command migration contains no environment or device data", 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, /password|secret|credential|private_key/i); -}); - -test("repository applies registry commands after registry schema", async () => { - const source = await readFile(repositoryUrl, "utf8"); - const schemaIndex = source.indexOf("004_device_registry_foundation.sql"); - const commandsIndex = source.indexOf("005_device_registry_commands.sql"); - - assert.notEqual(schemaIndex, -1); - assert.notEqual(commandsIndex, -1); - assert.ok(schemaIndex < commandsIndex); -}); 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 deleted file mode 100644 index 34734da..0000000 --- a/device-plane/services/device-control-core/test/device-registry-migration.test.mjs +++ /dev/null @@ -1,67 +0,0 @@ -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); -}); diff --git a/device-plane/services/device-control-core/test/discovery-repository.test.mjs b/device-plane/services/device-control-core/test/discovery-repository.test.mjs deleted file mode 100644 index 68c5c21..0000000 --- a/device-plane/services/device-control-core/test/discovery-repository.test.mjs +++ /dev/null @@ -1,199 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -import { assertSafeProjection } from "../../../packages/device-protocol-contract/src/index.mjs"; -import { observeQuarantineDiscovery } from "../src/discovery-repository.mjs"; - -const observedAt = "2026-08-10T00:00:00.000Z"; -const projectId = "11111111-1111-4111-8111-111111111111"; -const routeId = "22222222-2222-4222-8222-222222222222"; -const enrollmentId = "33333333-3333-4333-8333-333333333333"; -const discoveryId = "44444444-4444-4444-8444-444444444444"; -const identifierDigest = `hmac-sha256:${"a".repeat(64)}`; - -test("observation expires stale intents before matching an identity", async () => { - const source = await readFile( - new URL("../src/discovery-repository.mjs", import.meta.url), - "utf8", - ); - - assert.match(source, /expires_at <= \$6/); - assert.match(source, /expires_at is null or expires_at > \$6/); - assert.match(source, /resolution_code = 'deadline_elapsed'/); -}); - -test("legacy discovery remains unbound quarantine without a route reference", async () => { - const client = scriptedClient([ - step("begin"), - step("insert into device_discoveries", { - rows: [discoveryRow({ project_id: null, route_id: null })], - }), - step("commit"), - ]); - - const result = await observeQuarantineDiscovery({ - pool: poolWithClient(client), - identifierDigest, - safeView: safeView(), - sessionRef: "session:legacy-test", - }); - - assert.equal(result.created, true); - assert.equal("routeRef" in result.value, false); - assert.equal("enrollmentIntentRef" in result.value, false); - assertSafeProjection(result.value); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("route-bound discovery atomically observes only its matching enrollment", async () => { - const client = scriptedClient([ - step("begin"), - step("from device_routes", { - rows: [{ - id: routeId, - project_id: projectId, - model_profile_ref: "vendor.model.protocol.v1", - protocol: "GENERIC_TCP", - lifecycle_state: "active", - }], - }), - step("update device_enrollment_intents"), - step("from device_enrollment_intents", { - rows: [{ - id: enrollmentId, - project_id: projectId, - route_id: routeId, - model_profile_ref: "vendor.model.protocol.v1", - lifecycle_state: "pending", - }], - }), - step("insert into device_discoveries", { - rows: [discoveryRow({ - project_id: projectId, - route_id: routeId, - enrollment_intent_id: enrollmentId, - })], - }), - step("update device_enrollment_intents", { - rows: [{ id: enrollmentId }], - }), - step("commit"), - ]); - - const result = await observeQuarantineDiscovery({ - pool: poolWithClient(client), - identifierDigest, - safeView: safeView(`route:${routeId}`), - sessionRef: "session:route-test", - routeRef: `route:${routeId}`, - }); - - assert.equal(result.value.routeRef, `route:${routeId}`); - assert.equal( - result.value.enrollmentIntentRef, - `enrollment-intent:${enrollmentId}`, - ); - assertSafeProjection(result.value); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("inactive or mismatched routes fail before a discovery is stored", async () => { - const client = scriptedClient([ - step("begin"), - step("from device_routes", { - rows: [{ - id: routeId, - project_id: projectId, - model_profile_ref: "other.profile.v1", - protocol: "OTHER_TCP", - lifecycle_state: "active", - }], - }), - step("rollback"), - ]); - - await assert.rejects( - observeQuarantineDiscovery({ - pool: poolWithClient(client), - identifierDigest, - safeView: safeView(`route:${routeId}`), - sessionRef: "session:mismatch-test", - routeRef: `route:${routeId}`, - }), - /device_discovery_route_profile_mismatch/, - ); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -function safeView(routeRef = null) { - return { - schemaVersion: "nodedc.device.discovery-view.v1", - ...(routeRef ? { routeRef } : {}), - modelProfileRef: "vendor.model.protocol.v1", - protocol: "GENERIC_TCP", - observedAt, - lifecycleState: "quarantine", - identifier: { kind: "serial", masked: "********0001" }, - evidence: { - transport: "tcp", - bytesObserved: 32, - framingStatus: "verified", - specificationRef: "vendor.protocol.v1", - }, - commandTransport: "disabled", - }; -} - -function discoveryRow(overrides = {}) { - return { - id: discoveryId, - lifecycle_state: "quarantine", - model_profile_ref: "vendor.model.protocol.v1", - protocol: "GENERIC_TCP", - identifier_kind: "serial", - identifier_masked: "********0001", - first_observed_at: new Date(observedAt), - last_observed_at: new Date(observedAt), - evidence: safeView().evidence, - project_id: null, - route_id: null, - enrollment_intent_id: null, - created: true, - ...overrides, - }; -} - -function poolWithClient(client) { - return { connect: async () => client }; -} - -function step(includes, result = { rows: [] }) { - return { includes, result }; -} - -function scriptedClient(steps) { - const queue = [...steps]; - return { - released: false, - async query(sql) { - const next = queue.shift(); - assert.ok(next, `Unexpected query: ${sql}`); - assert.match(String(sql), new RegExp(escapeRegExp(next.includes), "i")); - return next.result; - }, - release() { - this.released = true; - }, - remaining() { - return queue.length; - }, - }; -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/device-plane/services/device-control-core/test/edge-channel-supervisor.test.mjs b/device-plane/services/device-control-core/test/edge-channel-supervisor.test.mjs deleted file mode 100644 index e970c4f..0000000 --- a/device-plane/services/device-control-core/test/edge-channel-supervisor.test.mjs +++ /dev/null @@ -1,130 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - createDeviceEdgeChannelSupervisor, -} from "../src/edge-channel-supervisor.mjs"; - -test("supervisor reconciles one in-process client per active Edge", async () => { - let registrations = [registration("channel-generation:1")]; - const clients = []; - const ingestCalls = []; - const supervisor = createDeviceEdgeChannelSupervisor({ - repository: { - listActiveEdgeChannelRegistrations: async () => registrations, - }, - gatewayIngest: { - observeDiscovery: async (...args) => ingestCalls.push(["discovery", ...args]), - acceptMessage: async (...args) => ingestCalls.push(["message", ...args]), - }, - coreIdentity: { - identityRef: "workload:device-control-core", - key: "test-key", - cert: "test-cert", - }, - readPeerTrust: async () => "test-edge-certificate", - clientFactory: (options) => { - const state = { started: 0, stopped: 0, options }; - clients.push(state); - return { - async start() { state.started += 1; }, - async stop() { state.stopped += 1; }, - status: () => ({ channel: "accepted", lastErrorCode: null }), - }; - }, - reconcileIntervalMs: 300_000, - }); - - await supervisor.start(); - assert.equal(clients.length, 1); - assert.equal(supervisor.status().accepted, 1); - assert.equal(clients[0].options.registration.channelGeneration, "channel-generation:1"); - await clients[0].options.observeDiscovery({ signal: true }); - await clients[0].options.acceptMessage({ message: true }); - assert.deepEqual(ingestCalls, [ - [ - "discovery", - { signal: true }, - { authenticatedEdgeRef: "edge:pilot" }, - ], - [ - "message", - { message: true }, - { authenticatedEdgeRef: "edge:pilot" }, - ], - ]); - - await supervisor.reconcile(); - assert.equal(clients.length, 1); - - registrations = [registration("channel-generation:2")]; - await supervisor.reconcile(); - assert.equal(clients.length, 2); - assert.equal(clients[0].stopped, 1); - assert.equal(clients[1].started, 1); - - registrations = []; - await supervisor.reconcile(); - assert.equal(clients[1].stopped, 1); - assert.equal(supervisor.status().configured, 0); - assert.equal(JSON.stringify(supervisor.status()).includes("155.212"), false); - await supervisor.stop(); -}); - -test("supervisor keeps a failed trust enrollment isolated from other Edges", async () => { - const supervisor = createDeviceEdgeChannelSupervisor({ - repository: { - listActiveEdgeChannelRegistrations: async () => [ - registration("channel-generation:1", "edge:good"), - registration("channel-generation:1", "edge:bad"), - ], - }, - gatewayIngest: { - observeDiscovery: async () => ({}), - acceptMessage: async () => ({}), - }, - coreIdentity: { - identityRef: "workload:device-control-core", - key: "test-key", - cert: "test-cert", - }, - readPeerTrust: async ({ registration: value }) => { - if (value.edgeRegistrationId === "edge:bad") { - throw new Error("device_edge_channel_trust_bundle_identity_mismatch"); - } - return "test-edge-certificate"; - }, - clientFactory: () => ({ - start: async () => undefined, - stop: async () => undefined, - status: () => ({ channel: "accepted", lastErrorCode: null }), - }), - reconcileIntervalMs: 300_000, - }); - - await supervisor.start(); - const status = supervisor.status(); - assert.equal(status.configured, 2); - assert.equal(status.accepted, 1); - assert.equal(status.degraded, 1); - assert.equal(status.commandTransport, "disabled"); - assert.equal(status.edges.find((item) => item.edgeRegistrationId === "edge:bad") - .lastErrorCode, "device_edge_channel_trust_bundle_identity_mismatch"); - await supervisor.stop(); -}); - -function registration(channelGeneration, edgeRegistrationId = "edge:pilot") { - return { - edgeRegistrationId, - endpoint: "https://155.212.211.15/", - servername: "155.212.211.15", - channelGeneration, - trustBundleRef: "edge-trust:moscow-edge", - certificateIdentities: [{ - generationRef: "edge-identity:1", - fingerprint: "AA:".repeat(31) + "AA", - status: "active", - }], - lifecycleState: "active", - }; -} diff --git a/device-plane/services/device-control-core/test/gateway-ingest.test.mjs b/device-plane/services/device-control-core/test/gateway-ingest.test.mjs deleted file mode 100644 index c851b1c..0000000 --- a/device-plane/services/device-control-core/test/gateway-ingest.test.mjs +++ /dev/null @@ -1,143 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - DEVICE_ADAPTER_MESSAGE_SCHEMA, - DEVICE_DISCOVERY_SIGNAL_SCHEMA, -} from "../../../packages/device-protocol-contract/src/index.mjs"; -import { createDeviceGatewayIngest } from "../src/gateway-ingest.mjs"; - -const identifierPepper = "test-only-identifier-pepper-with-32-bytes"; -const rawImei = "000000000000001"; - -test("shared gateway ingest masks identifiers for HTTP and Edge callers", async () => { - const stored = []; - const ingest = createDeviceGatewayIngest({ - identifierPepper, - repository: { - async upsertQuarantineDiscovery(value) { - stored.push(value); - return { - created: true, - value: { ...value.safeView, discoveryRef: "discovery:test" }, - }; - }, - async acceptAdapterMessage(value) { - stored.push(value); - return { - acceptance: { - schemaVersion: "nodedc.device-adapter-acceptance.v1", - acceptanceRef: "acceptance:test", - idempotencyKey: value.safeView.idempotencyKey, - status: "accepted", - replayed: false, - acceptedAt: "2026-08-11T12:00:00.000Z", - }, - claimedDeviceRef: "device:11111111-1111-4111-8111-111111111111", - }; - }, - }, - }); - - const discovery = await ingest.observeDiscovery(discoverySignal()); - const acceptance = await ingest.acceptMessage(adapterMessage()); - - assert.equal(discovery.value.identifier.masked, "***********0001"); - assert.equal(acceptance.value.status, "accepted"); - assert.equal( - acceptance.claimedDeviceRef, - "device:11111111-1111-4111-8111-111111111111", - ); - assert.match(stored[0].identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/); - assert.match(stored[1].requestDigest, /^sha256:[a-f0-9]{64}$/); - assert.equal(JSON.stringify(stored).includes(rawImei), false); -}); - -test("authenticated Edge identity resolves the allowlisted project route", async () => { - const stored = []; - const resolutions = []; - const authenticatedEdgeRef = "edge:11111111-1111-4111-8111-111111111111"; - const routeRef = "route:22222222-2222-4222-8222-222222222222"; - const ingest = createDeviceGatewayIngest({ - identifierPepper, - repository: { - async resolveInboundRoute(value) { - resolutions.push(value); - return routeRef; - }, - async upsertQuarantineDiscovery(value) { - stored.push(value); - return { - created: false, - value: { ...value.safeView, discoveryRef: "discovery:test" }, - }; - }, - async acceptAdapterMessage(value) { - stored.push(value); - return { - acceptance: { - schemaVersion: "nodedc.device-adapter-acceptance.v1", - acceptanceRef: "acceptance:test", - idempotencyKey: value.safeView.idempotencyKey, - status: "accepted", - replayed: false, - acceptedAt: "2026-08-11T12:00:00.000Z", - }, - claimedDeviceRef: null, - }; - }, - }, - }); - - await ingest.observeDiscovery(discoverySignal(), { authenticatedEdgeRef }); - await ingest.acceptMessage(adapterMessage(), { authenticatedEdgeRef }); - - assert.equal(resolutions.length, 2); - assert.equal(resolutions[0].edgeRef, authenticatedEdgeRef); - assert.match(resolutions[0].identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/); - assert.equal(stored[0].safeView.routeRef, routeRef); - assert.equal(stored[1].safeView.routeRef, routeRef); - assert.equal(stored[1].safeView.edgeRef, authenticatedEdgeRef); - assert.notEqual(stored[1].safeView.edgeRef, adapterMessage().edgeRef); - assert.equal(JSON.stringify(resolutions).includes(rawImei), false); -}); - -function discoverySignal() { - return { - schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA, - sessionRef: "session:test", - modelProfileRef: "arusnavi.b2.internal.v1", - protocol: "INTERNAL", - observedAt: "2026-08-11T12:00:00.000Z", - identifier: { kind: "imei", value: rawImei }, - evidence: { - transport: "tcp", - bytesObserved: 16, - framingStatus: "verified", - specificationRef: "arusnavi.internal.framing.test-v1", - }, - }; -} - -function adapterMessage() { - return { - schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA, - edgeRef: "edge:test", - adapterRef: "arusnavi-b2", - protocolProfileRef: "arusnavi.b2.internal.v1", - protocol: "INTERNAL", - sessionRef: "session:test", - messageRef: "package:1:test", - messageType: "telemetry.package", - sequence: 1, - observedAt: "2026-08-11T12:00:00.000Z", - idempotencyKey: `sha256:${"a".repeat(64)}`, - identifier: { kind: "imei", value: rawImei }, - payloadSchemaRef: "arusnavi.internal.package-metadata.v1", - payload: { - packageNumber: 1, - packetCount: 1, - packageDigest: `sha256:${"b".repeat(64)}`, - }, - }; -} diff --git a/device-plane/services/device-control-core/test/gateway-message-migration.test.mjs b/device-plane/services/device-control-core/test/gateway-message-migration.test.mjs deleted file mode 100644 index b6736b5..0000000 --- a/device-plane/services/device-control-core/test/gateway-message-migration.test.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const migrationUrl = new URL( - "../migrations/012_device_gateway_message_receipts.sql", - import.meta.url, -); -const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); - -test("gateway receipts persist only typed bounded Core acceptance evidence", async () => { - const sql = await readFile(migrationUrl, "utf8"); - - assert.match(sql, /create table if not exists device_gateway_message_receipts/); - assert.match(sql, /unique \(idempotency_key\)/); - assert.match(sql, /unique \(edge_ref, session_ref, message_ref\)/); - assert.match(sql, /identifier_digest text not null/); - assert.match(sql, /identifier_masked text not null/); - assert.match(sql, /payload_schema_ref text not null/); - assert.match(sql, /payload jsonb not null/); - assert.match(sql, /device_gateway_message_receipts_immutable_guard/); - assert.match(sql, /execute function device_reject_immutable_mutation\(\)/); - assert.doesNotMatch(sql, /execute function reject_device_immutable_record_mutation\(\)/); - assert.doesNotMatch(sql, /raw_packet|raw_identifier|password|token|secret/i); - assert.doesNotMatch(sql, /insert\s+into|arusnavi|gelios|\bb2\b|imei/i); -}); - -test("gateway receipt migration follows the generic control resource schema", async () => { - const repository = await readFile(repositoryUrl, "utf8"); - const controlResourceIndex = repository.indexOf( - "011_device_control_resource_commands.sql", - ); - const gatewayReceiptIndex = repository.indexOf( - "012_device_gateway_message_receipts.sql", - ); - - assert.notEqual(controlResourceIndex, -1); - assert.notEqual(gatewayReceiptIndex, -1); - assert.ok(controlResourceIndex < gatewayReceiptIndex); - assert.doesNotMatch(repository, /arusnavi-b2-adapter/); -}); diff --git a/device-plane/services/device-control-core/test/gateway-message-repository.test.mjs b/device-plane/services/device-control-core/test/gateway-message-repository.test.mjs deleted file mode 100644 index 6f7ddf6..0000000 --- a/device-plane/services/device-control-core/test/gateway-message-repository.test.mjs +++ /dev/null @@ -1,166 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { acceptGatewayMessage } from "../src/gateway-message-repository.mjs"; - -const acceptedAt = new Date("2026-08-11T12:00:00.000Z"); -const idempotencyKey = `sha256:${"a".repeat(64)}`; -const requestDigest = `sha256:${"b".repeat(64)}`; - -test("commits a gateway receipt before returning Core acceptance", async () => { - const client = scriptedClient([ - step("begin"), - step("insert into device_gateway_message_receipts", { - rows: [{ - id: "11111111-1111-4111-8111-111111111111", - idempotency_key: idempotencyKey, - accepted_at: acceptedAt, - }], - }), - step("commit"), - ]); - - const result = await acceptGatewayMessage(messageInput(client)); - - assert.equal(result.acceptance.status, "accepted"); - assert.equal(result.acceptance.replayed, false); - assert.equal(result.acceptance.idempotencyKey, idempotencyKey); - assert.equal(result.acceptance.acceptedAt, acceptedAt.toISOString()); - assert.equal(result.claimedDeviceRef, null); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("replays one durable receipt for the same normalized request", async () => { - const client = scriptedClient([ - step("begin"), - step("insert into device_gateway_message_receipts", { rows: [] }), - step("from device_gateway_message_receipts", { - rows: [{ - id: "11111111-1111-4111-8111-111111111111", - idempotency_key: idempotencyKey, - request_digest: requestDigest, - accepted_at: acceptedAt, - }], - }), - step("commit"), - ]); - - const result = await acceptGatewayMessage(messageInput(client)); - - assert.equal(result.acceptance.status, "accepted"); - assert.equal(result.acceptance.replayed, true); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("rejects idempotency reuse with different content", async () => { - const client = scriptedClient([ - step("begin"), - step("insert into device_gateway_message_receipts", { rows: [] }), - step("from device_gateway_message_receipts", { - rows: [{ - id: "11111111-1111-4111-8111-111111111111", - idempotency_key: idempotencyKey, - request_digest: `sha256:${"c".repeat(64)}`, - accepted_at: acceptedAt, - }], - }), - step("rollback"), - ]); - - await assert.rejects( - acceptGatewayMessage(messageInput(client)), - /device_gateway_idempotency_conflict/, - ); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("fails closed when a route does not match its Edge contract", async () => { - const routeId = "22222222-2222-4222-8222-222222222222"; - const client = scriptedClient([ - step("begin"), - step("from device_routes r", { - rows: [{ - id: routeId, - project_id: "33333333-3333-4333-8333-333333333333", - edge_id: "44444444-4444-4444-8444-444444444444", - model_profile_ref: "generic.model.protocol.v1", - protocol: "GENERIC_TCP", - lifecycle_state: "active", - edge_lifecycle_state: "active", - profile_lifecycle_state: "active", - adapter_ref: "generic-adapter", - adapter_lifecycle_state: "active", - adapter_version_lifecycle_state: "active", - }], - }), - step("rollback"), - ]); - const input = messageInput(client); - input.safeView.routeRef = `route:${routeId}`; - input.safeView.edgeRef = "edge:55555555-5555-4555-8555-555555555555"; - - await assert.rejects( - acceptGatewayMessage(input), - /device_gateway_route_contract_mismatch/, - ); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -function messageInput(client) { - return { - pool: { - connect: async () => client, - }, - identifierDigest: `hmac-sha256:${"d".repeat(64)}`, - requestDigest, - safeView: { - edgeRef: "edge:test-001", - adapterRef: "generic-adapter", - protocolProfileRef: "generic.model.protocol.v1", - protocol: "GENERIC_TCP", - sessionRef: "session:test-001", - messageRef: "message:test-001", - messageType: "telemetry.sample", - sequence: 1, - idempotencyKey, - identifier: { - kind: "serial", - masked: "********0001", - }, - payloadSchemaRef: "generic.telemetry.v1", - payload: { value: 1 }, - observedAt: "2026-08-11T12:00:00.000Z", - }, - }; -} - -function step(includes, result = { rows: [] }) { - return { includes, result }; -} - -function scriptedClient(steps) { - const queue = [...steps]; - return { - released: false, - async query(sql) { - const next = queue.shift(); - assert.ok(next, `Unexpected query: ${sql}`); - assert.match(String(sql), new RegExp(escapeRegExp(next.includes), "i")); - return next.result; - }, - release() { - this.released = true; - }, - remaining() { - return queue.length; - }, - }; -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/device-plane/services/device-control-core/test/inbound-route-repository.test.mjs b/device-plane/services/device-control-core/test/inbound-route-repository.test.mjs deleted file mode 100644 index 1d73a07..0000000 --- a/device-plane/services/device-control-core/test/inbound-route-repository.test.mjs +++ /dev/null @@ -1,69 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - resolveInboundRoute, -} from "../src/inbound-route-repository.mjs"; - -const edgeRef = "edge:11111111-1111-4111-8111-111111111111"; -const routeId = "22222222-2222-4222-8222-222222222222"; -const identifierDigest = `hmac-sha256:${"a".repeat(64)}`; - -test("inbound route resolution is scoped by authenticated Edge and enrollment", async () => { - const queries = []; - const client = { - async query(sql, values) { - queries.push({ sql, values }); - return { rows: [{ id: routeId }] }; - }, - }; - - const result = await resolveInboundRoute(client, input()); - - assert.equal(result, `route:${routeId}`); - assert.equal(queries.length, 1); - assert.match(queries[0].sql, /device_enrollment_intents/); - assert.match(queries[0].sql, /r\.edge_id = \$1/); - assert.match(queries[0].sql, /ei\.expected_identifier_digest = \$5/); - assert.deepEqual(queries[0].values, [ - edgeRef.slice("edge:".length), - "arusnavi.b2.internal.v1", - "INTERNAL", - "imei", - identifierDigest, - "2026-08-13T09:00:00.000Z", - ]); -}); - -test("inbound route resolution leaves unknown identifiers quarantined", async () => { - const result = await resolveInboundRoute( - { query: async () => ({ rows: [] }) }, - input(), - ); - assert.equal(result, null); -}); - -test("inbound route resolution fails closed on ambiguous ownership", async () => { - await assert.rejects( - () => resolveInboundRoute( - { query: async () => ({ rows: [{ id: routeId }, { id: routeId }] }) }, - input(), - ), - (error) => { - assert.equal(error.message, "device_inbound_route_ambiguous"); - assert.equal(error.statusCode, 409); - return true; - }, - ); -}); - -function input() { - return { - edgeRef, - modelProfileRef: "arusnavi.b2.internal.v1", - protocol: "INTERNAL", - identifierKind: "imei", - identifierDigest, - observedAt: "2026-08-13T09:00:00.000Z", - }; -} diff --git a/device-plane/services/device-control-core/test/infrastructure-app.test.mjs b/device-plane/services/device-control-core/test/infrastructure-app.test.mjs deleted file mode 100644 index 717b724..0000000 --- a/device-plane/services/device-control-core/test/infrastructure-app.test.mjs +++ /dev/null @@ -1,322 +0,0 @@ -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"; - -test("management API forwards a normalized generic Edge registration", async () => { - let executed; - const runtime = await startServer({ - managementApiEnabled: true, - managementToken, - repository: { - health: async () => "ready", - executeManagementCommand: async (input) => { - executed = input; - return { replayed: false, result: { created: true } }; - }, - }, - }); - try { - const response = await fetch( - `${runtime.baseUrl}/internal/v1/management/edges:ensure`, - { - method: "POST", - headers: managementHeaders(), - body: JSON.stringify({ - edgeKey: "generic-edge", - displayName: "Generic Edge", - deploymentRef: "deployment:device-edge/pilot", - }), - }, - ); - - assert.equal(response.status, 200); - assert.equal(executed.commandKind, "edge.ensure"); - assert.deepEqual(executed.command, { - edgeKey: "generic-edge", - displayName: "Generic Edge", - deploymentRef: "deployment:device-edge/pilot", - lifecycleState: "provisioning", - }); - assert.equal(executed.actor.hubRole, "owner"); - } finally { - await runtime.close(); - } -}); - -test("management API exposes no user-owned session mutation", async () => { - let executions = 0; - const runtime = await startServer({ - managementApiEnabled: true, - managementToken, - repository: { - health: async () => "ready", - executeManagementCommand: async () => { - executions += 1; - return { replayed: false, result: {} }; - }, - }, - }); - try { - const response = await fetch( - `${runtime.baseUrl}/internal/v1/management/sessions:ensure`, - { - method: "POST", - headers: managementHeaders(), - body: "{}", - }, - ); - - assert.equal(response.status, 404); - assert.equal(executions, 0); - } finally { - await runtime.close(); - } -}); - -test("management API forwards claim as evidence references without identity input", async () => { - let executed; - const runtime = await startServer({ - managementApiEnabled: true, - managementToken, - repository: { - health: async () => "ready", - executeManagementCommand: async (input) => { - executed = input; - return { replayed: false, result: { created: true } }; - }, - }, - }); - try { - const response = await fetch( - `${runtime.baseUrl}/internal/v1/management/devices:claim`, - { - method: "POST", - headers: managementHeaders(), - body: JSON.stringify({ - projectRef: "project:11111111-1111-4111-8111-111111111111", - enrollmentIntentRef: - "enrollment-intent:22222222-2222-4222-8222-222222222222", - discoveryRef: "discovery:33333333-3333-4333-8333-333333333333", - deviceKey: "pilot-device", - displayName: "Pilot device", - }), - }, - ); - - assert.equal(response.status, 200); - assert.equal(executed.commandKind, "device.claim"); - assert.equal(executed.command.deviceKey, "pilot-device"); - assert.equal("identifier" in executed.command, false); - assert.equal("credentialRef" in executed.command, false); - } finally { - await runtime.close(); - } -}); - -test("management API derives enrollment identity inside Core and never forwards raw IMEI", async () => { - let executed; - const runtime = await startServer({ - managementApiEnabled: true, - managementToken, - repository: { - health: async () => "ready", - executeManagementCommand: async (input) => { - executed = input; - return { - replayed: false, - result: { - enrollmentIntent: { - identifier: { - kind: input.command.identifierKind, - masked: input.command.identifierMasked, - }, - }, - }, - }; - }, - }, - }); - try { - const rawImei = "123456789012345"; - const response = await fetch( - `${runtime.baseUrl}/internal/v1/management/enrollment-intents:ensure`, - { - method: "POST", - headers: { - ...managementHeaders(), - "Idempotency-Key": "phase25-enrollment-0001", - }, - body: JSON.stringify({ - projectRef: "project:11111111-1111-4111-8111-111111111111", - enrollmentKey: "pilot-device", - routeRef: "route:22222222-2222-4222-8222-222222222222", - modelProfileRef: "arusnavi.b2.v1", - displayName: "Pilot device", - identifier: { kind: "imei", value: rawImei }, - expiresAt: null, - }), - }, - ); - - const body = await response.json(); - assert.equal(response.status, 200); - assert.equal(executed.command.identifierKind, "imei"); - assert.equal(executed.command.identifierMasked, "***********2345"); - assert.match(executed.command.identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/); - assert.equal(JSON.stringify(executed).includes(rawImei), false); - assert.equal(JSON.stringify(body).includes(rawImei), false); - } finally { - await runtime.close(); - } -}); - -test("management API rejects client-supplied enrollment digests", async () => { - let executions = 0; - const runtime = await startServer({ - managementApiEnabled: true, - managementToken, - repository: { - health: async () => "ready", - executeManagementCommand: async () => { - executions += 1; - return { replayed: false, result: {} }; - }, - }, - }); - try { - const response = await fetch( - `${runtime.baseUrl}/internal/v1/management/enrollment-intents:ensure`, - { - method: "POST", - headers: { - ...managementHeaders(), - "Idempotency-Key": "phase25-enrollment-reject-0001", - }, - body: JSON.stringify({ - projectRef: "project:11111111-1111-4111-8111-111111111111", - enrollmentKey: "pilot-device", - routeRef: "route:22222222-2222-4222-8222-222222222222", - modelProfileRef: "arusnavi.b2.v1", - displayName: "Pilot device", - identifier: { kind: "imei", value: "123456789012345" }, - identifierDigest: `hmac-sha256:${"a".repeat(64)}`, - }), - }, - ); - assert.equal(response.status, 400); - assert.equal( - (await response.json()).error, - "device_enrollment_input_field_unexpected", - ); - assert.equal(executions, 0); - } finally { - await runtime.close(); - } -}); - -test("management API accepts only a canonical credential reference", async () => { - let executed; - const runtime = await startServer({ - managementApiEnabled: true, - managementToken, - repository: { - health: async () => "ready", - executeManagementCommand: async (input) => { - executed = input; - return { - replayed: false, - result: { - credentialBinding: { - credentialBindingRef: - "credential-binding:44444444-4444-4444-8444-444444444444", - }, - }, - }; - }, - }, - }); - try { - const response = await fetch( - `${runtime.baseUrl}/internal/v1/management/device-credential-bindings:upsert`, - { - method: "POST", - headers: managementHeaders(), - body: JSON.stringify({ - projectRef: "project:11111111-1111-4111-8111-111111111111", - deviceRef: "device:22222222-2222-4222-8222-222222222222", - purpose: "tracker.command", - credentialRef: { - owner: "ndc_l2_credentials", - reference: "ndc-credref:pilot-command-0001", - }, - }), - }, - ); - - assert.equal(response.status, 200); - assert.equal( - executed.commandKind, - "device_credential_binding.upsert", - ); - assert.deepEqual(executed.command.credentialRef, { - owner: "ndc_l2_credentials", - reference: "ndc-credref:pilot-command-0001", - }); - - const rejected = await fetch( - `${runtime.baseUrl}/internal/v1/management/device-credential-bindings:upsert`, - { - method: "POST", - headers: { - ...managementHeaders(), - "Idempotency-Key": "phase24-credential-invalid-0001", - }, - body: JSON.stringify({ - projectRef: "project:11111111-1111-4111-8111-111111111111", - deviceRef: "device:22222222-2222-4222-8222-222222222222", - purpose: "tracker.command", - credentialRef: { - owner: "device_core", - reference: "ndc-credref:pilot-command-0001", - }, - }), - }, - ); - assert.equal(rejected.status, 400); - assert.equal( - (await rejected.json()).error, - "ndc_credential_reference_owner_invalid", - ); - } finally { - await runtime.close(); - } -}); - -async function startServer(options) { - const server = createControlCoreApp({ identifierPepper, ...options }); - 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() { - return { - Authorization: `Bearer ${managementToken}`, - "Content-Type": "application/json", - "Idempotency-Key": "phase23-edge-0001", - "X-NODEDC-User-Ref": "user:platform-owner", - "X-NODEDC-Hub-Role": "owner", - }; -} diff --git a/device-plane/services/device-control-core/test/infrastructure-lifecycle-contract.test.mjs b/device-plane/services/device-control-core/test/infrastructure-lifecycle-contract.test.mjs deleted file mode 100644 index 38f97eb..0000000 --- a/device-plane/services/device-control-core/test/infrastructure-lifecycle-contract.test.mjs +++ /dev/null @@ -1,43 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const repositorySource = new URL( - "../src/infrastructure-repository.mjs", - import.meta.url, -); - -test("catalog, Edge and route upserts enforce irreversible lifecycle transitions", async () => { - const source = await readFile(repositorySource, "utf8"); - - assert.match(source, /device_adapter_versions\.lifecycle_state = 'draft'[\s\S]*excluded\.lifecycle_state in \('active', 'retired'\)/); - assert.match(source, /device_model_profiles\.lifecycle_state = 'active'[\s\S]*excluded\.lifecycle_state = 'retired'/); - assert.match(source, /device_edges\.lifecycle_state = 'suspended'[\s\S]*excluded\.lifecycle_state in \('active', 'retired'\)/); - assert.match(source, /device_routes\.lifecycle_state = 'draft'[\s\S]*excluded\.lifecycle_state in \('active', 'retired'\)/); - assert.doesNotMatch(source, /device_(?:adapter_versions|model_profiles|edges|routes)\.lifecycle_state = 'retired'[\s\S]{0,160}excluded\.lifecycle_state = 'active'/); -}); - -test("legacy model adoption is a one-way exact-identity registry transition", async () => { - const source = await readFile( - new URL("../src/infrastructure-repository.mjs", import.meta.url), - "utf8", - ); - - assert.match(source, /profile ->> 'schemaVersion' = excluded\.schema_version/); - assert.match(source, /profile ->> 'profileRef' = excluded\.profile_ref/); - assert.match(source, /profile ->> 'vendor' = excluded\.vendor/); - assert.match(source, /profile ->> 'model' = excluded\.model/); - assert.match(source, /profile ->> 'deviceType' = excluded\.device_type/); - assert.match(source, /profile ->> 'protocol' = excluded\.protocol/); - assert.match(source, /adapter_version_id is null[\s\S]*schema_artifact_ref is null[\s\S]*profile_digest is null[\s\S]*cardinality\(device_model_profiles\.capabilities\) = 0[\s\S]*lifecycle_state = 'active'[\s\S]*excluded\.lifecycle_state = 'draft'/); -}); - -test("adopted rich profile lifecycle changes preserve exact registry identity", async () => { - const source = await readFile( - new URL("../src/infrastructure-repository.mjs", import.meta.url), - "utf8", - ); - - assert.match(source, /profile = excluded\.profile[\s\S]*or \([\s\S]*profile ->> 'schemaVersion' = excluded\.schema_version[\s\S]*profile ->> 'profileRef' = excluded\.profile_ref[\s\S]*profile ->> 'vendor' = excluded\.vendor[\s\S]*profile ->> 'model' = excluded\.model[\s\S]*profile ->> 'deviceType' = excluded\.device_type[\s\S]*profile ->> 'protocol' = excluded\.protocol/); - assert.match(source, /adapter_version_id = excluded\.adapter_version_id[\s\S]*schema_artifact_ref = excluded\.schema_artifact_ref[\s\S]*profile_digest = excluded\.profile_digest[\s\S]*capabilities = excluded\.capabilities/); -}); diff --git a/device-plane/services/device-control-core/test/infrastructure-management.test.mjs b/device-plane/services/device-control-core/test/infrastructure-management.test.mjs deleted file mode 100644 index 8b12b60..0000000 --- a/device-plane/services/device-control-core/test/infrastructure-management.test.mjs +++ /dev/null @@ -1,210 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - assertPlatformCatalogAuthority, - DEVICE_INFRASTRUCTURE_COMMAND_KINDS, - normalizeInfrastructureManagementCommand, -} from "../src/infrastructure-management.mjs"; -import { - ALL_DEVICE_MANAGEMENT_COMMAND_KINDS, - normalizeDeviceManagementCommand, -} from "../src/management-command.mjs"; -import { normalizeManagementActor } from "../src/project-management.mjs"; - -const projectRef = "project:11111111-1111-4111-8111-111111111111"; -const packageRef = "adapter-package:22222222-2222-4222-8222-222222222222"; -const versionRef = "adapter-version:33333333-3333-4333-8333-333333333333"; -const edgeRef = "edge:44444444-4444-4444-8444-444444444444"; -const routeRef = "route:55555555-5555-4555-8555-555555555555"; -const digest = `sha256:${"a".repeat(64)}`; -const identifierDigest = `hmac-sha256:${"b".repeat(64)}`; - -test("aggregates project and infrastructure commands without a session mutation", () => { - for (const kind of DEVICE_INFRASTRUCTURE_COMMAND_KINDS) { - assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes(kind), true); - } - assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes("project.ensure"), true); - assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes("session.ensure"), false); - assert.throws( - () => normalizeDeviceManagementCommand("session.ensure", {}), - /device_management_command_kind_invalid/, - ); -}); - -test("normalizes immutable adapter version metadata and sorted capabilities", () => { - const command = normalizeInfrastructureManagementCommand( - "adapter_version.register", - { - adapterPackageRef: packageRef, - version: "1.2.3", - runtimePackageRef: "artifact:device-adapters/generic-1.2.3", - contentDigest: digest, - contractVersion: "nodedc.device-adapter.v1", - capabilities: ["telemetry.observe", "command.typed", "telemetry.observe"], - lifecycleState: "active", - }, - ); - - assert.equal(command.adapterPackageId, packageRef.slice("adapter-package:".length)); - assert.deepEqual(command.capabilities, ["command.typed", "telemetry.observe"]); - assert.equal(command.contentDigest, digest); -}); - -test("normalizes a generic model profile as artifact metadata, not executable payload", () => { - const command = normalizeInfrastructureManagementCommand( - "model_profile.register", - { - adapterVersionRef: versionRef, - profileRef: "vendor.model.protocol.v1", - schemaVersion: "nodedc.device-model-profile.v1", - vendor: "Example Vendor", - model: "Model One", - deviceType: "tracker", - protocol: "GENERIC_TCP", - schemaArtifactRef: "artifact:model-profiles/vendor-model-v1", - profileDigest: digest, - capabilities: ["telemetry.observe"], - }, - ); - - assert.equal(command.adapterVersionId, versionRef.slice("adapter-version:".length)); - assert.equal(command.protocol, "GENERIC_TCP"); - assert.equal(command.lifecycleState, "draft"); - assert.equal("profile" in command, false); - assert.equal("source" in command, false); -}); - -test("route and enrollment commands resolve only scoped references", () => { - const route = normalizeInfrastructureManagementCommand("route.ensure", { - projectRef, - routeKey: "primary-ingress", - displayName: "Primary ingress", - edgeRef, - modelProfileRef: "vendor.model.protocol.v1", - listenerRef: "listener:generic-tcp-primary", - protocol: "GENERIC_TCP", - direction: "bidirectional", - }); - const enrollment = normalizeInfrastructureManagementCommand( - "enrollment_intent.ensure", - { - projectRef, - enrollmentKey: "pilot-device", - routeRef, - modelProfileRef: "vendor.model.protocol.v1", - displayName: "Pilot device", - identifierKind: "serial", - identifierDigest, - identifierMasked: "********0001", - expiresAt: "2026-09-01T00:00:00.000Z", - }, - ); - - assert.equal(route.projectId, projectRef.slice("project:".length)); - assert.equal(route.edgeId, edgeRef.slice("edge:".length)); - assert.equal(enrollment.routeId, routeRef.slice("route:".length)); - assert.equal(enrollment.identifierDigest, identifierDigest); -}); - -test("enrollment contract rejects raw identifiers and credential-shaped fields", () => { - const base = { - projectRef, - enrollmentKey: "pilot-device", - routeRef, - modelProfileRef: "vendor.model.protocol.v1", - displayName: "Pilot device", - identifierKind: "imei", - identifierDigest, - identifierMasked: "***********0001", - }; - - assert.throws( - () => normalizeInfrastructureManagementCommand( - "enrollment_intent.ensure", - { ...base, identifierMasked: "000000000000001" }, - ), - /safe_projection_contains_unmasked_imei/, - ); - assert.throws( - () => normalizeInfrastructureManagementCommand( - "enrollment_intent.ensure", - { ...base, credential: "forbidden" }, - ), - /device_management_command_field_unexpected:credential/, - ); -}); - -test("shared catalog and Edge authority requires the Hub owner ceiling", () => { - assert.doesNotThrow(() => assertPlatformCatalogAuthority(actor("owner"))); - assert.throws( - () => assertPlatformCatalogAuthority(actor("admin")), - /device_platform_catalog_access_denied/, - ); -}); - -test("normalizes only a pinned Core-initiated public Edge channel", () => { - const command = normalizeInfrastructureManagementCommand("edge.ensure", { - edgeKey: "moscow-edge", - displayName: "Moscow Edge", - deploymentRef: "deployment:device-edge/moscow-1", - lifecycleState: "active", - channel: { - endpoint: "https://155.212.211.15/", - servername: "155.212.211.15", - generationRef: "channel-generation:1", - trustBundleRef: "edge-trust:moscow-edge", - certificateIdentities: [{ - generationRef: "edge-identity:1", - fingerprint: "AA:".repeat(31) + "AA", - status: "active", - }], - lifecycleState: "active", - }, - }); - - assert.equal(command.channel.endpoint, "https://155.212.211.15/"); - assert.equal(command.channel.lifecycleState, "active"); - assert.equal(command.channel.certificateIdentities.length, 1); - for (const endpoint of [ - "https://127.0.0.1/", - "https://192.168.1.1/", - "https://155.212.211.15:8443/", - "https://155.212.211.15:9921/", - "http://155.212.211.15/", - ]) { - assert.throws( - () => normalizeInfrastructureManagementCommand("edge.ensure", { - edgeKey: "bad-edge", - displayName: "Bad Edge", - channel: { ...command.channel, endpoint }, - }), - /device_edge_channel_endpoint_invalid/, - ); - } - assert.throws( - () => normalizeInfrastructureManagementCommand("edge.ensure", { - edgeKey: "bad-edge", - displayName: "Bad Edge", - channel: { ...command.channel, servername: "example.invalid" }, - }), - /device_edge_channel_servername_mismatch/, - ); - assert.throws( - () => normalizeInfrastructureManagementCommand("edge.ensure", { - edgeKey: "bad-edge", - displayName: "Bad Edge", - channel: { lifecycleState: "disabled", endpoint: command.channel.endpoint }, - }), - /device_edge_channel_disabled_configuration_invalid/, - ); -}); - -function actor(hubRole) { - return normalizeManagementActor({ - userRef: "user:platform-admin", - hubRole, - groupRefs: [], - ownerScopes: [], - }); -} diff --git a/device-plane/services/device-control-core/test/infrastructure-repository.test.mjs b/device-plane/services/device-control-core/test/infrastructure-repository.test.mjs deleted file mode 100644 index 2fd60c9..0000000 --- a/device-plane/services/device-control-core/test/infrastructure-repository.test.mjs +++ /dev/null @@ -1,516 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { assertSafeProjection } from "../../../packages/device-protocol-contract/src/index.mjs"; -import { normalizeDeviceManagementCommand } from "../src/management-command.mjs"; -import { PostgresDeviceRepository } from "../src/postgres-repository.mjs"; -import { normalizeManagementActor } from "../src/project-management.mjs"; - -const now = new Date("2026-08-10T00:00:00.000Z"); -const projectId = "11111111-1111-4111-8111-111111111111"; -const edgeId = "22222222-2222-4222-8222-222222222222"; -const routeId = "33333333-3333-4333-8333-333333333333"; -const adapterPackageId = "44444444-4444-4444-8444-444444444444"; -const adapterVersionId = "55555555-5555-4555-8555-555555555555"; - -test("commits an owner-authorized generic Edge registration", async () => { - const actor = managementActor("owner"); - const command = normalizeDeviceManagementCommand("edge.ensure", { - edgeKey: "generic-edge", - displayName: "Generic Edge", - deploymentRef: "deployment:device-edge/pilot", - }); - const client = scriptedClient([ - step("begin"), - step("insert into device_management_command_receipts", { - rows: [{ id: "receipt-edge" }], - }), - step("insert into device_edges", { - rows: [{ - id: edgeId, - edge_key: command.edgeKey, - display_name: command.displayName, - deployment_ref: command.deploymentRef, - lifecycle_state: command.lifecycleState, - created_at: now, - updated_at: now, - created: true, - }], - }), - step("insert into device_audit_events"), - step("update device_management_command_receipts"), - step("commit"), - ]); - const repository = repositoryWithClient(client); - - const result = await repository.executeManagementCommand(commandInput({ - actor, - commandKind: "edge.ensure", - command, - digestCharacter: "a", - })); - - assert.equal(result.replayed, false); - assert.equal(result.result.edge.edgeRef, `edge:${edgeId}`); - assert.equal(result.result.edge.lifecycleState, "provisioning"); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("adopts a legacy metadata-only model profile into the versioned registry", async () => { - const actor = managementActor("owner"); - const command = normalizeDeviceManagementCommand("model_profile.register", { - adapterVersionRef: `adapter-version:${adapterVersionId}`, - profileRef: "vendor.model.protocol.v1", - schemaVersion: "nodedc.device-model-profile.v1", - vendor: "Example Vendor", - model: "Model One", - deviceType: "tracker", - protocol: "GENERIC_TCP", - schemaArtifactRef: "artifact:model-profiles/vendor-model-v1", - profileDigest: `sha256:${"e".repeat(64)}`, - capabilities: ["telemetry.observe"], - }); - const client = scriptedClient([ - step("begin"), - step("insert into device_management_command_receipts", { - rows: [{ id: "receipt-profile" }], - }), - step("from device_adapter_versions av", { - rows: [{ - id: adapterVersionId, - adapter_package_id: adapterPackageId, - version: "1.0.0", - runtime_package_ref: "artifact:device-adapters/vendor-model-1.0.0", - content_digest: `sha256:${"f".repeat(64)}`, - contract_version: "nodedc.device-adapter.v1", - capabilities: ["telemetry.observe"], - lifecycle_state: "active", - package_lifecycle_state: "active", - created_at: now, - updated_at: now, - }], - }), - step("from device_model_profiles", { - rows: [{ - profile_ref: command.profileRef, - adapter_version_id: null, - schema_artifact_ref: null, - profile_digest: null, - capabilities: [], - lifecycle_state: "active", - }], - }), - step("insert into device_model_profiles", { - rows: [{ - profile_ref: command.profileRef, - schema_version: command.schemaVersion, - vendor: command.vendor, - model: command.model, - device_type: command.deviceType, - protocol: command.protocol, - adapter_version_id: adapterVersionId, - schema_artifact_ref: command.schemaArtifactRef, - profile_digest: command.profileDigest, - capabilities: command.capabilities, - lifecycle_state: "draft", - created_at: now, - updated_at: now, - created: false, - }], - }), - step("insert into device_audit_events"), - step("update device_management_command_receipts"), - step("commit"), - ]); - const repository = repositoryWithClient(client); - - const result = await repository.executeManagementCommand(commandInput({ - actor, - commandKind: "model_profile.register", - command, - digestCharacter: "e", - })); - - assert.equal(result.replayed, false); - assert.equal(result.result.created, false); - assert.equal( - result.result.modelProfile.adapterVersionRef, - `adapter-version:${adapterVersionId}`, - ); - assert.equal(result.result.modelProfile.lifecycleState, "draft"); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("keeps non-legacy model profile identity conflicts fail-closed", async () => { - const actor = managementActor("owner"); - const command = normalizeDeviceManagementCommand("model_profile.register", { - adapterVersionRef: `adapter-version:${adapterVersionId}`, - profileRef: "vendor.model.protocol.v1", - schemaVersion: "nodedc.device-model-profile.v1", - vendor: "Example Vendor", - model: "Model One", - deviceType: "tracker", - protocol: "GENERIC_TCP", - schemaArtifactRef: "artifact:model-profiles/vendor-model-v1", - profileDigest: `sha256:${"e".repeat(64)}`, - capabilities: ["telemetry.observe"], - }); - const client = scriptedClient([ - step("begin"), - step("insert into device_management_command_receipts", { - rows: [{ id: "receipt-profile-conflict" }], - }), - step("from device_adapter_versions av", { - rows: [{ - id: adapterVersionId, - adapter_package_id: adapterPackageId, - lifecycle_state: "active", - package_lifecycle_state: "active", - }], - }), - step("from device_model_profiles", { - rows: [{ - profile_ref: command.profileRef, - adapter_version_id: null, - schema_artifact_ref: "artifact:legacy-but-partial", - profile_digest: null, - capabilities: [], - lifecycle_state: "active", - }], - }), - step("insert into device_model_profiles"), - step("rollback"), - ]); - const repository = repositoryWithClient(client); - - await assert.rejects( - repository.executeManagementCommand(commandInput({ - actor, - commandKind: "model_profile.register", - command, - digestCharacter: "f", - })), - /device_model_profile_identity_conflict/, - ); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("activates an adopted rich legacy profile without replacing its immutable JSON", async () => { - const actor = managementActor("owner"); - const command = normalizeDeviceManagementCommand("model_profile.register", { - adapterVersionRef: `adapter-version:${adapterVersionId}`, - profileRef: "vendor.model.protocol.v1", - schemaVersion: "nodedc.device-model-profile.v1", - vendor: "Example Vendor", - model: "Model One", - deviceType: "tracker", - protocol: "GENERIC_TCP", - schemaArtifactRef: "artifact:model-profiles/vendor-model-v1", - profileDigest: `sha256:${"e".repeat(64)}`, - capabilities: ["telemetry.observe"], - lifecycleState: "active", - }); - const client = scriptedClient([ - step("begin"), - step("insert into device_management_command_receipts", { - rows: [{ id: "receipt-profile-activation" }], - }), - step("from device_adapter_versions av", { - rows: [{ - id: adapterVersionId, - adapter_package_id: adapterPackageId, - version: "1.0.0", - runtime_package_ref: "artifact:device-adapters/vendor-model-1.0.0", - content_digest: `sha256:${"f".repeat(64)}`, - contract_version: "nodedc.device-adapter.v1", - capabilities: ["telemetry.observe"], - lifecycle_state: "active", - package_lifecycle_state: "active", - created_at: now, - updated_at: now, - }], - }), - step("from device_model_profiles", { - rows: [{ - profile_ref: command.profileRef, - adapter_version_id: adapterVersionId, - schema_artifact_ref: command.schemaArtifactRef, - profile_digest: command.profileDigest, - capabilities: command.capabilities, - lifecycle_state: "draft", - }], - }), - step("insert into device_model_profiles", { - rows: [{ - profile_ref: command.profileRef, - schema_version: command.schemaVersion, - vendor: command.vendor, - model: command.model, - device_type: command.deviceType, - protocol: command.protocol, - adapter_version_id: adapterVersionId, - schema_artifact_ref: command.schemaArtifactRef, - profile_digest: command.profileDigest, - capabilities: command.capabilities, - lifecycle_state: "active", - created_at: now, - updated_at: now, - created: false, - }], - }), - step("insert into device_audit_events"), - step("update device_management_command_receipts"), - step("commit"), - ]); - const repository = repositoryWithClient(client); - - const result = await repository.executeManagementCommand(commandInput({ - actor, - commandKind: "model_profile.register", - command, - digestCharacter: "d", - })); - - assert.equal(result.result.created, false); - assert.equal(result.result.modelProfile.lifecycleState, "active"); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("denies project route mutation without an explicit project grant", async () => { - const actor = managementActor("owner"); - const command = normalizeDeviceManagementCommand("route.ensure", { - projectRef: `project:${projectId}`, - routeKey: "generic-ingress", - displayName: "Generic ingress", - edgeRef: `edge:${edgeId}`, - modelProfileRef: "vendor.model.protocol.v1", - listenerRef: "listener:generic-tcp-primary", - protocol: "GENERIC_TCP", - }); - const client = scriptedClient([ - step("begin"), - step("insert into device_management_command_receipts", { - rows: [{ id: "receipt-route" }], - }), - step("from device_projects p", { - rows: [{ - id: projectId, - lifecycle_state: "active", - owner_lifecycle_state: "active", - }], - }), - step("from device_project_grants", { rows: [] }), - step("rollback"), - ]); - const repository = repositoryWithClient(client); - - await assert.rejects( - repository.executeManagementCommand(commandInput({ - actor, - commandKind: "route.ensure", - command, - digestCharacter: "b", - })), - /device_project_capability_denied/, - ); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("stores enrollment digest but returns and audits only its masked projection", async () => { - const actor = managementActor("member"); - const command = normalizeDeviceManagementCommand( - "enrollment_intent.ensure", - { - projectRef: `project:${projectId}`, - enrollmentKey: "pilot-device", - routeRef: `route:${routeId}`, - modelProfileRef: "vendor.model.protocol.v1", - displayName: "Pilot device", - identifierKind: "serial", - identifierDigest: `hmac-sha256:${"c".repeat(64)}`, - identifierMasked: "********0001", - expiresAt: "2026-09-01T00:00:00.000Z", - }, - ); - const client = scriptedClient([ - step("begin"), - step("insert into device_management_command_receipts", { - rows: [{ id: "receipt-enrollment" }], - }), - step("from device_projects p", { - rows: [{ - id: projectId, - lifecycle_state: "active", - owner_lifecycle_state: "active", - }], - }), - step("from device_project_grants", { - rows: [{ - id: "44444444-4444-4444-8444-444444444444", - principal_kind: "user", - principal_ref: actor.userRef, - project_role: "engineer", - capability_allow: [], - capability_deny: [], - lifecycle_state: "active", - }], - }), - step("from device_routes", { - rows: [{ - id: routeId, - project_id: projectId, - route_key: "generic-ingress", - display_name: "Generic ingress", - edge_id: edgeId, - model_profile_ref: command.modelProfileRef, - listener_ref: "listener:generic-tcp-primary", - protocol: "GENERIC_TCP", - direction: "telemetry", - lifecycle_state: "active", - created_at: now, - updated_at: now, - }], - }), - step("insert into device_enrollment_intents", { - rows: [{ - id: "55555555-5555-4555-8555-555555555555", - project_id: projectId, - enrollment_key: command.enrollmentKey, - route_id: routeId, - model_profile_ref: command.modelProfileRef, - display_name: command.displayName, - expected_identifier_kind: command.identifierKind, - expected_identifier_masked: command.identifierMasked, - lifecycle_state: "pending", - expires_at: new Date(command.expiresAt), - claimed_device_id: null, - created_at: now, - updated_at: now, - created: true, - }], - }), - step("insert into device_audit_events"), - step("update device_management_command_receipts"), - step("commit"), - ]); - const repository = repositoryWithClient(client); - - const result = await repository.executeManagementCommand(commandInput({ - actor, - commandKind: "enrollment_intent.ensure", - command, - digestCharacter: "d", - })); - - assertSafeProjection(result.result.enrollmentIntent); - assert.equal( - result.result.enrollmentIntent.identifier.masked, - command.identifierMasked, - ); - assert.equal(JSON.stringify(result.result).includes(command.identifierDigest), false); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("lists only bounded active Edge channel registrations without secrets", async () => { - const client = scriptedClient([ - step("begin transaction read only"), - step("from device_edges", { - rows: [{ - id: edgeId, - channel_endpoint: "https://155.212.211.15/", - channel_servername: "155.212.211.15", - channel_generation_ref: "channel-generation:1", - channel_trust_bundle_ref: "edge-trust:moscow-edge", - channel_certificate_identities: [{ - generationRef: "edge-identity:1", - fingerprint: "AA:".repeat(31) + "AA", - status: "active", - }], - channel_lifecycle_state: "active", - }], - }), - step("commit"), - ]); - const repository = repositoryWithClient(client); - - const registrations = await repository.listActiveEdgeChannelRegistrations(8); - - assert.deepEqual(registrations[0], { - edgeRegistrationId: `edge:${edgeId}`, - endpoint: "https://155.212.211.15/", - servername: "155.212.211.15", - channelGeneration: "channel-generation:1", - trustBundleRef: "edge-trust:moscow-edge", - certificateIdentities: [{ - generationRef: "edge-identity:1", - fingerprint: "AA:".repeat(31) + "AA", - status: "active", - }], - lifecycleState: "active", - }); - assert.equal(JSON.stringify(registrations).includes("private"), false); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -function managementActor(hubRole) { - return normalizeManagementActor({ - userRef: "user:platform-owner", - hubRole, - groupRefs: [], - ownerScopes: [], - }); -} - -function commandInput({ actor, commandKind, command, digestCharacter }) { - return { - idempotencyKey: `phase23-${commandKind.replaceAll(".", "-")}-0001`, - commandKind, - requestDigest: `sha256:${digestCharacter.repeat(64)}`, - actor, - command, - }; -} - -function repositoryWithClient(client) { - return new PostgresDeviceRepository({ - pool: { - query: async () => ({ rows: [] }), - connect: async () => client, - end: async () => undefined, - }, - }); -} - -function step(includes, result = { rows: [] }) { - return { includes, result }; -} - -function scriptedClient(steps) { - const queue = [...steps]; - return { - released: false, - async query(sql) { - const next = queue.shift(); - assert.ok(next, `Unexpected query: ${sql}`); - assert.match(String(sql), new RegExp(escapeRegExp(next.includes), "i")); - return next.result; - }, - release() { - this.released = true; - }, - remaining() { - return queue.length; - }, - }; -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/device-plane/services/device-control-core/test/lifecycle-management.test.mjs b/device-plane/services/device-control-core/test/lifecycle-management.test.mjs deleted file mode 100644 index 22db9c8..0000000 --- a/device-plane/services/device-control-core/test/lifecycle-management.test.mjs +++ /dev/null @@ -1,111 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - DEVICE_LIFECYCLE_COMMAND_KINDS, - normalizeLifecycleManagementCommand, -} from "../src/lifecycle-management.mjs"; -import { - ALL_DEVICE_MANAGEMENT_COMMAND_KINDS, - normalizeDeviceManagementCommand, -} from "../src/management-command.mjs"; - -const projectRef = "project:11111111-1111-4111-8111-111111111111"; -const targetProjectRef = "project:22222222-2222-4222-8222-222222222222"; -const discoveryRef = "discovery:33333333-3333-4333-8333-333333333333"; -const enrollmentIntentRef = - "enrollment-intent:44444444-4444-4444-8444-444444444444"; -const deviceRef = "device:55555555-5555-4555-8555-555555555555"; - -test("lifecycle commands join the same strict management command surface", () => { - for (const kind of DEVICE_LIFECYCLE_COMMAND_KINDS) { - assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes(kind), true); - } - assert.equal( - normalizeDeviceManagementCommand("device.claim", claimInput()).projectId, - projectRef.slice("project:".length), - ); -}); - -test("claim accepts only opaque evidence references and presentation fields", () => { - const command = normalizeLifecycleManagementCommand( - "device.claim", - claimInput(), - ); - - assert.equal( - command.enrollmentIntentId, - enrollmentIntentRef.slice("enrollment-intent:".length), - ); - assert.equal(command.discoveryId, discoveryRef.slice("discovery:".length)); - assert.equal(command.deviceKey, "pilot-device"); - assert.equal("identifier" in command, false); - assert.equal("credentialRef" in command, false); -}); - -test("claim rejects raw identity and credential-shaped input", () => { - assert.throws( - () => normalizeLifecycleManagementCommand("device.claim", { - ...claimInput(), - identifier: "000000000000001", - }), - /device_management_command_field_unexpected:identifier/, - ); - assert.throws( - () => normalizeLifecycleManagementCommand("device.claim", { - ...claimInput(), - credentialRef: "secret:test", - }), - /device_management_command_field_unexpected:credentialRef/, - ); -}); - -test("transfer binds both project boundaries and rejects a no-op", () => { - const command = normalizeLifecycleManagementCommand("device.transfer", { - deviceRef, - sourceProjectRef: projectRef, - targetProjectRef, - targetDeviceKey: "transferred-device", - }); - - assert.equal(command.deviceId, deviceRef.slice("device:".length)); - assert.notEqual(command.sourceProjectId, command.targetProjectId); - assert.throws( - () => normalizeLifecycleManagementCommand("device.transfer", { - deviceRef, - sourceProjectRef: projectRef, - targetProjectRef: projectRef, - targetDeviceKey: "same-project", - }), - /device_transfer_target_same_as_source/, - ); -}); - -test("reject and expire require bounded machine-readable resolution codes", () => { - for (const kind of ["discovery.reject", "discovery.expire"]) { - const command = normalizeLifecycleManagementCommand(kind, { - projectRef, - discoveryRef, - resolutionCode: "operator.identity_mismatch", - }); - assert.equal(command.resolutionCode, "operator.identity_mismatch"); - } - assert.throws( - () => normalizeLifecycleManagementCommand("discovery.reject", { - projectRef, - discoveryRef, - resolutionCode: "free form reason is forbidden", - }), - /device_discovery_resolution_code_invalid/, - ); -}); - -function claimInput() { - return { - projectRef, - enrollmentIntentRef, - discoveryRef, - deviceKey: "pilot-device", - displayName: "Pilot device", - }; -} diff --git a/device-plane/services/device-control-core/test/lifecycle-repository.test.mjs b/device-plane/services/device-control-core/test/lifecycle-repository.test.mjs deleted file mode 100644 index 2e584d1..0000000 --- a/device-plane/services/device-control-core/test/lifecycle-repository.test.mjs +++ /dev/null @@ -1,461 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { assertSafeProjection } from "../../../packages/device-protocol-contract/src/index.mjs"; -import { normalizeDeviceManagementCommand } from "../src/management-command.mjs"; -import { PostgresDeviceRepository } from "../src/postgres-repository.mjs"; -import { normalizeManagementActor } from "../src/project-management.mjs"; - -const now = new Date("2026-08-10T00:00:00.000Z"); -const sourceProjectId = "11111111-1111-4111-8111-111111111111"; -const targetProjectId = "22222222-2222-4222-8222-222222222222"; -const sourceOwnerId = "33333333-3333-4333-8333-333333333333"; -const targetOwnerId = "44444444-4444-4444-8444-444444444444"; -const enrollmentId = "55555555-5555-4555-8555-555555555555"; -const discoveryId = "66666666-6666-4666-8666-666666666666"; -const deviceId = "77777777-7777-4777-8777-777777777777"; -const routeId = "88888888-8888-4888-8888-888888888888"; -const identifierDigest = `hmac-sha256:${"a".repeat(64)}`; - -test("claims only matching observed enrollment evidence into direct ownership", async () => { - const actor = managementActor("member"); - const command = normalizeDeviceManagementCommand("device.claim", { - projectRef: `project:${sourceProjectId}`, - enrollmentIntentRef: `enrollment-intent:${enrollmentId}`, - discoveryRef: `discovery:${discoveryId}`, - deviceKey: "pilot-device", - displayName: "Pilot device", - }); - const client = scriptedClient([ - step("begin"), - receiptStep("receipt-claim"), - projectStep(sourceProjectId, sourceOwnerId), - grantsStep(actor, "engineer"), - step("from device_enrollment_intents", { - rows: [enrollmentRow()], - }), - step("from device_discoveries", { - rows: [discoveryRow()], - }), - step("insert into device_instances", { - rows: [deviceRow({ - owner_scope_id: sourceOwnerId, - project_id: sourceProjectId, - device_key: command.deviceKey, - display_name: command.displayName, - })], - }), - step("insert into device_restricted_identifiers"), - step("update device_discoveries", { rows: [{ id: discoveryId }] }), - step("update device_enrollment_intents", { rows: [{ id: enrollmentId }] }), - step("insert into device_ownership_transitions"), - step("insert into device_audit_events"), - step("update device_management_command_receipts"), - step("commit"), - ]); - const repository = repositoryWithClient(client); - - const result = await repository.executeManagementCommand(commandInput({ - actor, - commandKind: "device.claim", - command, - digestCharacter: "b", - })); - - assert.equal(result.result.device.projectRef, `project:${sourceProjectId}`); - assert.equal(result.result.device.identifier.masked, "********0001"); - assert.equal(JSON.stringify(result.result).includes(identifierDigest), false); - assertSafeProjection(result.result); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("transfer requires explicit authority in the target project", async () => { - const actor = managementActor("owner"); - const command = transferCommand(); - const client = scriptedClient([ - step("begin"), - receiptStep("receipt-transfer-denied"), - step("from device_instances", { rows: [deviceRow()] }), - projectStep(sourceProjectId, sourceOwnerId), - grantsStep(actor, "owner"), - projectStep(targetProjectId, targetOwnerId), - step("from device_project_grants", { rows: [] }), - step("rollback"), - ]); - const repository = repositoryWithClient(client); - - await assert.rejects( - repository.executeManagementCommand(commandInput({ - actor, - commandKind: "device.transfer", - command, - digestCharacter: "c", - })), - /device_project_capability_denied/, - ); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("authorized transfer preserves history and detaches source collections", async () => { - const actor = managementActor("owner"); - const command = transferCommand(); - const client = scriptedClient([ - step("begin"), - receiptStep("receipt-transfer"), - step("from device_instances", { rows: [deviceRow()] }), - projectStep(sourceProjectId, sourceOwnerId), - grantsStep(actor, "owner"), - projectStep(targetProjectId, targetOwnerId), - grantsStep(actor, "owner"), - step("from device_sessions", { rows: [{ active: false }] }), - step("from device_credential_bindings", { rows: [{ active: false }] }), - step("from device_resource_bindings", { rows: [{ active: false }] }), - step("from device_configuration_state", { rows: [] }), - step("from device_commands", { rows: [{ active: false }] }), - step("delete from device_configuration_state", { rows: [], rowCount: 0 }), - step("delete from device_collection_members", { rows: [], rowCount: 2 }), - step("update device_instances", { - rows: [deviceRow({ - owner_scope_id: targetOwnerId, - project_id: targetProjectId, - device_key: command.targetDeviceKey, - })], - }), - step("update device_restricted_identifiers", { rows: [], rowCount: 1 }), - step("insert into device_ownership_transitions"), - step("insert into device_audit_events"), - step("insert into device_audit_events"), - step("update device_management_command_receipts"), - step("commit"), - ]); - const repository = repositoryWithClient(client); - - const result = await repository.executeManagementCommand(commandInput({ - actor, - commandKind: "device.transfer", - command, - digestCharacter: "d", - })); - - assert.equal(result.result.transferred, true); - assert.equal(result.result.device.projectRef, `project:${targetProjectId}`); - assert.equal(result.result.detachedCollectionCount, 2); - assert.equal(result.result.transferredIdentifierCount, 1); - assert.equal(result.result.clearedDesiredConfiguration, false); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("transfer fails closed while a credential binding is active", async () => { - const actor = managementActor("owner"); - const command = transferCommand(); - const client = scriptedClient([ - step("begin"), - receiptStep("receipt-transfer-credential-bound"), - step("from device_instances", { rows: [deviceRow()] }), - projectStep(sourceProjectId, sourceOwnerId), - grantsStep(actor, "owner"), - projectStep(targetProjectId, targetOwnerId), - grantsStep(actor, "owner"), - step("from device_sessions", { rows: [{ active: false }] }), - step("from device_credential_bindings", { rows: [{ active: true }] }), - step("rollback"), - ]); - const repository = repositoryWithClient(client); - - await assert.rejects( - repository.executeManagementCommand(commandInput({ - actor, - commandKind: "device.transfer", - command, - digestCharacter: "f", - })), - /device_transfer_active_credential_binding/, - ); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("transfer fails closed while a resource binding is pending approval", async () => { - const actor = managementActor("owner"); - const command = transferCommand(); - const client = scriptedClient([ - step("begin"), - receiptStep("receipt-transfer-resource-bound"), - step("from device_instances", { rows: [deviceRow()] }), - projectStep(sourceProjectId, sourceOwnerId), - grantsStep(actor, "owner"), - projectStep(targetProjectId, targetOwnerId), - grantsStep(actor, "owner"), - step("from device_sessions", { rows: [{ active: false }] }), - step("from device_credential_bindings", { rows: [{ active: false }] }), - step("from device_resource_bindings", { rows: [{ active: true }] }), - step("rollback"), - ]); - const repository = repositoryWithClient(client); - - await assert.rejects( - repository.executeManagementCommand(commandInput({ - actor, - commandKind: "device.transfer", - command, - digestCharacter: "1", - })), - /device_transfer_active_resource_binding/, - ); - assert.equal(client.remaining(), 0); -}); - -test("transfer fails closed with applied configuration", async () => { - const actor = managementActor("owner"); - const command = transferCommand(); - const client = scriptedClient([ - step("begin"), - receiptStep("receipt-transfer-applied-config"), - step("from device_instances", { rows: [deviceRow()] }), - projectStep(sourceProjectId, sourceOwnerId), - grantsStep(actor, "owner"), - projectStep(targetProjectId, targetOwnerId), - grantsStep(actor, "owner"), - step("from device_sessions", { rows: [{ active: false }] }), - step("from device_credential_bindings", { rows: [{ active: false }] }), - step("from device_resource_bindings", { rows: [{ active: false }] }), - step("from device_configuration_state", { - rows: [{ - desired_revision_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - applied_revision_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - }], - }), - step("rollback"), - ]); - const repository = repositoryWithClient(client); - - await assert.rejects( - repository.executeManagementCommand(commandInput({ - actor, - commandKind: "device.transfer", - command, - digestCharacter: "2", - })), - /device_transfer_applied_configuration/, - ); - assert.equal(client.remaining(), 0); -}); - -test("transfer fails closed with a nonterminal command", async () => { - const actor = managementActor("owner"); - const command = transferCommand(); - const client = scriptedClient([ - step("begin"), - receiptStep("receipt-transfer-command-active"), - step("from device_instances", { rows: [deviceRow()] }), - projectStep(sourceProjectId, sourceOwnerId), - grantsStep(actor, "owner"), - projectStep(targetProjectId, targetOwnerId), - grantsStep(actor, "owner"), - step("from device_sessions", { rows: [{ active: false }] }), - step("from device_credential_bindings", { rows: [{ active: false }] }), - step("from device_resource_bindings", { rows: [{ active: false }] }), - step("from device_configuration_state", { rows: [] }), - step("from device_commands", { rows: [{ active: true }] }), - step("rollback"), - ]); - const repository = repositoryWithClient(client); - - await assert.rejects( - repository.executeManagementCommand(commandInput({ - actor, - commandKind: "device.transfer", - command, - digestCharacter: "3", - })), - /device_transfer_nonterminal_command/, - ); - assert.equal(client.remaining(), 0); -}); - -test("reject resolves both quarantine and enrollment without exposing a digest", async () => { - const actor = managementActor("member"); - const command = normalizeDeviceManagementCommand("discovery.reject", { - projectRef: `project:${sourceProjectId}`, - discoveryRef: `discovery:${discoveryId}`, - resolutionCode: "operator.identity_mismatch", - }); - const client = scriptedClient([ - step("begin"), - receiptStep("receipt-reject"), - projectStep(sourceProjectId, sourceOwnerId), - grantsStep(actor, "engineer"), - step("from device_discoveries", { rows: [discoveryRow()] }), - step("from device_enrollment_intents", { rows: [enrollmentRow()] }), - step("update device_discoveries"), - step("update device_enrollment_intents"), - step("insert into device_audit_events"), - step("update device_management_command_receipts"), - step("commit"), - ]); - const repository = repositoryWithClient(client); - - const result = await repository.executeManagementCommand(commandInput({ - actor, - commandKind: "discovery.reject", - command, - digestCharacter: "e", - })); - - assert.equal(result.result.discovery.lifecycleState, "rejected"); - assert.equal(JSON.stringify(result.result).includes(identifierDigest), false); - assertSafeProjection(result.result); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -function transferCommand() { - return normalizeDeviceManagementCommand("device.transfer", { - deviceRef: `device:${deviceId}`, - sourceProjectRef: `project:${sourceProjectId}`, - targetProjectRef: `project:${targetProjectId}`, - targetDeviceKey: "transferred-device", - }); -} - -function enrollmentRow() { - return { - id: enrollmentId, - project_id: sourceProjectId, - route_id: routeId, - model_profile_ref: "vendor.model.protocol.v1", - expected_identifier_kind: "serial", - expected_identifier_digest: identifierDigest, - expected_identifier_masked: "********0001", - lifecycle_state: "observed", - observed_discovery_id: discoveryId, - claimed_device_id: null, - }; -} - -function discoveryRow() { - return { - id: discoveryId, - project_id: sourceProjectId, - route_id: routeId, - enrollment_intent_id: enrollmentId, - model_profile_ref: "vendor.model.protocol.v1", - protocol: "GENERIC_TCP", - identifier_kind: "serial", - identifier_digest: identifierDigest, - identifier_masked: "********0001", - lifecycle_state: "quarantine", - claimed_device_id: null, - }; -} - -function deviceRow(overrides = {}) { - return { - id: deviceId, - contour_id: null, - owner_scope_id: sourceOwnerId, - project_id: sourceProjectId, - device_key: "pilot-device", - model_profile_ref: "vendor.model.protocol.v1", - display_name: "Pilot device", - identifier_kind: "serial", - identifier_masked: "********0001", - lifecycle_state: "claimed", - created_at: now, - updated_at: now, - ...overrides, - }; -} - -function managementActor(hubRole) { - return normalizeManagementActor({ - userRef: "user:lifecycle-operator", - hubRole, - groupRefs: [], - ownerScopes: [], - }); -} - -function projectStep(projectId, ownerScopeId) { - return step("from device_projects p", { - rows: [{ - id: projectId, - owner_scope_id: ownerScopeId, - lifecycle_state: "active", - scope_kind: "company", - owner_ref: `client:${ownerScopeId}`, - owner_display_name: "Example Company", - owner_lifecycle_state: "active", - }], - }); -} - -function grantsStep(actor, projectRole) { - return step("from device_project_grants", { - rows: [{ - id: "99999999-9999-4999-8999-999999999999", - principal_kind: "user", - principal_ref: actor.userRef, - project_role: projectRole, - capability_allow: [], - capability_deny: [], - lifecycle_state: "active", - }], - }); -} - -function receiptStep(id) { - return step("insert into device_management_command_receipts", { - rows: [{ id }], - }); -} - -function commandInput({ actor, commandKind, command, digestCharacter }) { - return { - idempotencyKey: `phase24-${commandKind.replaceAll(".", "-")}-0001`, - commandKind, - requestDigest: `sha256:${digestCharacter.repeat(64)}`, - actor, - command, - }; -} - -function repositoryWithClient(client) { - return new PostgresDeviceRepository({ - pool: { - query: async () => ({ rows: [] }), - connect: async () => client, - end: async () => undefined, - }, - }); -} - -function step(includes, result = { rows: [] }) { - return { includes, result }; -} - -function scriptedClient(steps) { - const queue = [...steps]; - return { - released: false, - async query(sql) { - const next = queue.shift(); - assert.ok(next, `Unexpected query: ${sql}`); - assert.match(String(sql), new RegExp(escapeRegExp(next.includes), "i")); - return next.result; - }, - release() { - this.released = true; - }, - remaining() { - return queue.length; - }, - }; -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/device-plane/services/device-control-core/test/management-command-migration.test.mjs b/device-plane/services/device-control-core/test/management-command-migration.test.mjs deleted file mode 100644 index d868a32..0000000 --- a/device-plane/services/device-control-core/test/management-command-migration.test.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const migrationUrl = new URL( - "../migrations/003_device_management_commands.sql", - import.meta.url, -); -const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); - -test("management migration pins idempotency, audit and owner invariants", async () => { - const sql = await readFile(migrationUrl, "utf8"); - - assert.match(sql, /device_project_grants_owner_user_only/); - assert.match(sql, /project_role <> 'owner' or principal_kind = 'user'/); - assert.match(sql, /add column if not exists project_id uuid references device_projects\(id\)/); - assert.match(sql, /create table if not exists device_management_command_receipts/); - assert.match(sql, /unique \(actor_ref, command_kind, idempotency_key\)/); - assert.match(sql, /request_digest ~ '\^sha256:\[a-f0-9\]\{64\}\$'/); - assert.match(sql, /lifecycle_state in \('pending', 'completed'\)/); -}); - -test("management migration stores no tenant, device or credential seed", async () => { - const sql = await readFile(migrationUrl, "utf8"); - - assert.doesNotMatch(sql, /insert\s+into/i); - assert.doesNotMatch(sql, /dcctouch|arusnavi|b2|imei/i); - assert.doesNotMatch(sql, /password|secret|credential_ref/i); -}); - -test("repository applies management migration after project access", async () => { - const source = await readFile(repositoryUrl, "utf8"); - const projectAccessIndex = source.indexOf("002_device_project_access.sql"); - const managementIndex = source.indexOf("003_device_management_commands.sql"); - - assert.notEqual(projectAccessIndex, -1); - assert.notEqual(managementIndex, -1); - assert.ok(projectAccessIndex < managementIndex); -}); diff --git a/device-plane/services/device-control-core/test/management-repository.test.mjs b/device-plane/services/device-control-core/test/management-repository.test.mjs deleted file mode 100644 index 31e280f..0000000 --- a/device-plane/services/device-control-core/test/management-repository.test.mjs +++ /dev/null @@ -1,281 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { PostgresDeviceRepository } from "../src/postgres-repository.mjs"; -import { - normalizeManagementActor, - normalizeManagementCommand, -} from "../src/project-management.mjs"; - -const actor = normalizeManagementActor({ - userRef: "user:engineer", - hubRole: "admin", - groupRefs: [], - ownerScopes: [{ scopeKind: "company", ownerRef: "client:example" }], -}); -const command = normalizeManagementCommand("owner_scope.ensure", { - scopeKind: "company", - ownerRef: "client:example", - displayName: "Example Company", -}); -const baseInput = { - idempotencyKey: "phase2-repository-0001", - commandKind: "owner_scope.ensure", - requestDigest: `sha256:${"a".repeat(64)}`, - actor, - command, -}; - -test("replays a completed command without executing the domain mutation", async () => { - const client = scriptedClient([ - step("begin"), - step("insert into device_management_command_receipts", { rows: [] }), - step("from device_management_command_receipts", { - rows: [{ - id: "11111111-1111-4111-8111-111111111111", - request_digest: baseInput.requestDigest, - lifecycle_state: "completed", - response_body: { created: true, ownerScope: { ownerRef: "client:example" } }, - }], - }), - step("from device_owner_scopes", { - rows: [{ - id: "11111111-1111-4111-8111-111111111111", - scope_kind: "company", - owner_ref: "client:example", - display_name: "Example Company", - lifecycle_state: "active", - created_at: new Date("2026-08-10T00:00:00.000Z"), - updated_at: new Date("2026-08-10T00:00:00.000Z"), - }], - }), - step("commit"), - ]); - const repository = repositoryWithClient(client); - - const result = await repository.executeManagementCommand(baseInput); - - assert.equal(result.replayed, true); - assert.equal(result.result.ownerScope.ownerRef, "client:example"); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("rejects idempotency-key reuse with a different normalized request", async () => { - const client = scriptedClient([ - step("begin"), - step("insert into device_management_command_receipts", { rows: [] }), - step("from device_management_command_receipts", { - rows: [{ - id: "11111111-1111-4111-8111-111111111111", - request_digest: `sha256:${"b".repeat(64)}`, - lifecycle_state: "completed", - response_body: { created: true }, - }], - }), - step("rollback"), - ]); - const repository = repositoryWithClient(client); - - await assert.rejects( - repository.executeManagementCommand(baseInput), - /device_idempotency_key_conflict/, - ); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("does not replay a completed project command after project access is revoked", async () => { - const collectionCommand = normalizeManagementCommand("collection.ensure", { - projectRef: "project:11111111-1111-4111-8111-111111111111", - collectionKey: "field-devices", - name: "Field Devices", - }); - const input = { - idempotencyKey: "phase2-repository-collection-0001", - commandKind: "collection.ensure", - requestDigest: `sha256:${"c".repeat(64)}`, - actor, - command: collectionCommand, - }; - const now = new Date("2026-08-10T00:00:00.000Z"); - const client = scriptedClient([ - step("begin"), - step("insert into device_management_command_receipts", { rows: [] }), - step("from device_management_command_receipts", { - rows: [{ - id: "22222222-2222-4222-8222-222222222222", - request_digest: input.requestDigest, - lifecycle_state: "completed", - response_body: { created: true }, - }], - }), - step("from device_projects p", { - rows: [{ - id: "11111111-1111-4111-8111-111111111111", - owner_scope_id: "33333333-3333-4333-8333-333333333333", - project_key: "field-devices", - name: "Field Devices", - description: null, - lifecycle_state: "active", - created_at: now, - updated_at: now, - scope_kind: "company", - owner_ref: "client:example", - owner_display_name: "Example Company", - owner_lifecycle_state: "active", - owner_created_at: now, - owner_updated_at: now, - }], - }), - step("from device_project_grants", { rows: [] }), - step("rollback"), - ]); - const repository = repositoryWithClient(client); - - await assert.rejects( - repository.executeManagementCommand(input), - /device_project_capability_denied/, - ); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("refuses to revoke the last active project owner", async () => { - const ownerActor = normalizeManagementActor({ - ...actor, - hubRole: "owner", - }); - const revokeOwner = normalizeManagementCommand("project_grant.upsert", { - projectRef: "project:11111111-1111-4111-8111-111111111111", - principalKind: "user", - principalRef: ownerActor.userRef, - projectRole: "owner", - lifecycleState: "revoked", - }); - const input = { - idempotencyKey: "phase2-repository-owner-0001", - commandKind: "project_grant.upsert", - requestDigest: `sha256:${"d".repeat(64)}`, - actor: ownerActor, - command: revokeOwner, - }; - const now = new Date("2026-08-10T00:00:00.000Z"); - const client = scriptedClient([ - step("begin"), - step("insert into device_management_command_receipts", { - rows: [{ id: "receipt-created" }], - }), - step("from device_projects p", { - rows: [{ - id: "11111111-1111-4111-8111-111111111111", - owner_scope_id: "33333333-3333-4333-8333-333333333333", - project_key: "field-devices", - name: "Field Devices", - description: null, - lifecycle_state: "active", - created_at: now, - updated_at: now, - scope_kind: "company", - owner_ref: "client:example", - owner_display_name: "Example Company", - owner_lifecycle_state: "active", - owner_created_at: now, - owner_updated_at: now, - }], - }), - step("from device_project_grants", { - rows: [{ - id: "44444444-4444-4444-8444-444444444444", - principal_kind: "user", - principal_ref: ownerActor.userRef, - project_role: "owner", - capability_allow: [], - capability_deny: [], - lifecycle_state: "active", - created_at: now, - updated_at: now, - }], - }), - step("rollback"), - ]); - const repository = repositoryWithClient(client); - - await assert.rejects( - repository.executeManagementCommand(input), - /device_project_last_owner_required/, - ); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("commits an authorized generic owner-scope command and durable receipt", async () => { - const now = new Date("2026-08-10T00:00:00.000Z"); - const client = scriptedClient([ - step("begin"), - step("insert into device_management_command_receipts", { - rows: [{ id: "receipt-created" }], - }), - step("insert into device_owner_scopes", { - rows: [{ - id: "11111111-1111-4111-8111-111111111111", - scope_kind: "company", - owner_ref: "client:example", - display_name: "Example Company", - lifecycle_state: "active", - created_at: now, - updated_at: now, - created: true, - }], - }), - step("insert into device_audit_events"), - step("update device_management_command_receipts"), - step("commit"), - ]); - const repository = repositoryWithClient(client); - - const result = await repository.executeManagementCommand(baseInput); - - assert.equal(result.replayed, false); - assert.equal(result.result.created, true); - assert.equal(result.result.ownerScope.ownerRef, "client:example"); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -function repositoryWithClient(client) { - return new PostgresDeviceRepository({ - pool: { - query: async () => ({ rows: [] }), - connect: async () => client, - end: async () => undefined, - }, - }); -} - -function step(includes, result = { rows: [] }) { - return { includes, result }; -} - -function scriptedClient(steps) { - const queue = [...steps]; - return { - released: false, - async query(sql) { - const next = queue.shift(); - assert.ok(next, `Unexpected query: ${sql}`); - assert.match(String(sql), new RegExp(escapeRegExp(next.includes), "i")); - return next.result; - }, - release() { - this.released = true; - }, - remaining() { - return queue.length; - }, - }; -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/device-plane/services/device-control-core/test/management-runtime-contract.test.mjs b/device-plane/services/device-control-core/test/management-runtime-contract.test.mjs deleted file mode 100644 index 29e8b67..0000000 --- a/device-plane/services/device-control-core/test/management-runtime-contract.test.mjs +++ /dev/null @@ -1,55 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const appUrl = new URL("../src/app.mjs", import.meta.url); -const serverUrl = new URL("../src/server.mjs", import.meta.url); -const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); -const managerComposeUrl = new URL("../../../docker-compose.device-manager.yml", import.meta.url); - -test("management surface is internal, POST-only and disabled by default", async () => { - const source = await readFile(appUrl, "utf8"); - - assert.match(source, /managementApiEnabled = false/); - assert.match(source, /\/internal\/v1\/management\/owner-scopes:ensure/); - assert.match(source, /\/internal\/v1\/management\/projects:ensure/); - assert.match(source, /\/internal\/v1\/management\/collections:ensure/); - assert.match(source, /\/internal\/v1\/management\/project-grants:upsert/); - assert.match(source, /\/internal\/v1\/management\/device-bindings:ensure/); - assert.match(source, /\/internal\/v1\/management\/device-configuration-revisions:create/); - assert.match(source, /request\.method === "POST" && managementCommandKind/); - assert.doesNotMatch(source, /\/api\/public\/.*management/); - assert.doesNotMatch(source, /device-commands:(?:plan|confirm|dispatch)/); -}); - -test("management API is enabled only through a runner-owned file token", async () => { - const server = await readFile(serverUrl, "utf8"); - const compose = await readFile(managerComposeUrl, "utf8"); - - assert.match(server, /DEVICE_MANAGEMENT_API_ENABLED/); - assert.match(server, /DEVICE_MANAGEMENT_CORE_TOKEN_FILE/); - assert.match(compose, /DEVICE_MANAGEMENT_API_ENABLED: "true"/); - assert.match( - compose, - /DEVICE_MANAGEMENT_CORE_TOKEN_FILE: \/run\/nodedc-secrets\/management-core-token/, - ); - assert.match( - compose, - /source: \/volume1\/docker\/nodedc-device-plane\/secrets\/management-core-token/, - ); - assert.doesNotMatch(compose, /DEVICE_MANAGEMENT_CORE_TOKEN:\s/); -}); - -test("repository pins idempotency, audit and last-owner checks inside one transaction", async () => { - const source = await readFile(repositoryUrl, "utf8"); - - assert.match(source, /await client\.query\("begin"\)/); - assert.match(source, /await client\.query\("commit"\)/); - assert.match(source, /await client\.query\("rollback"\)/); - assert.match(source, /device_idempotency_key_conflict/); - assert.match(source, /device_project_last_owner_required/); - assert.match(source, /for update of p/); - assert.match(source, /authorizeManagementReplay/); - assert.match(source, /insert into device_audit_events/); - assert.match(source, /update device_management_command_receipts/); -}); diff --git a/device-plane/services/device-control-core/test/migration.test.mjs b/device-plane/services/device-control-core/test/migration.test.mjs deleted file mode 100644 index 391be27..0000000 --- a/device-plane/services/device-control-core/test/migration.test.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const migrationUrl = new URL( - "../migrations/001_device_plane_foundation.sql", - import.meta.url, -); - -test("foundation migration keeps restricted identifiers hashed and DB private", async () => { - const sql = await readFile(migrationUrl, "utf8"); - assert.match(sql, /identifier_digest text not null/); - assert.match(sql, /identifier_masked text not null/); - assert.doesNotMatch(sql, /imei\s+text/i); - assert.doesNotMatch(sql, /password\s+text/i); - assert.doesNotMatch(sql, /raw_packet/i); -}); - -test("foundation migration has quarantine, contour, binding and audit tables", async () => { - const sql = await readFile(migrationUrl, "utf8"); - for (const table of [ - "device_model_profiles", - "device_contours", - "device_discoveries", - "device_instances", - "device_bindings", - "device_audit_events", - ]) { - assert.match(sql, new RegExp(`create table if not exists ${table}`)); - } - assert.match(sql, /default 'quarantine'/); -}); diff --git a/device-plane/services/device-control-core/test/project-access-migration.test.mjs b/device-plane/services/device-control-core/test/project-access-migration.test.mjs deleted file mode 100644 index 180020c..0000000 --- a/device-plane/services/device-control-core/test/project-access-migration.test.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const migrationUrl = new URL( - "../migrations/002_device_project_access.sql", - import.meta.url, -); -const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); - -test("project access migration defines owner, project, collection and grant boundaries", async () => { - const sql = await readFile(migrationUrl, "utf8"); - - for (const table of [ - "device_owner_scopes", - "device_projects", - "device_collections", - "device_collection_members", - "device_project_grants", - ]) { - assert.match(sql, new RegExp(`create table if not exists ${table}`)); - } - - assert.match(sql, /scope_kind in \('company', 'personal'\)/); - assert.match(sql, /principal_kind in \('user', 'group'\)/); - assert.match( - sql, - /project_role in \('viewer', 'operator', 'engineer', 'admin', 'owner'\)/, - ); - assert.match(sql, /unique \(scope_kind, owner_ref\)/); - assert.match(sql, /unique \(owner_scope_id, project_key\)/); - assert.match(sql, /unique \(project_id, principal_kind, principal_ref\)/); - assert.match(sql, /not \(capability_allow && capability_deny\)/); - assert.match( - sql, - /foreign key \(collection_id, project_id\)\s+references device_collections\(id, project_id\)/, - ); - assert.match( - sql, - /foreign key \(device_id, project_id\)\s+references device_instances\(id, project_id\)/, - ); -}); - -test("project access migration contains no tenant, device or credential seed", async () => { - const sql = await readFile(migrationUrl, "utf8"); - - assert.doesNotMatch(sql, /insert\s+into/i); - assert.doesNotMatch(sql, /dcctouch|arusnavi|b2|imei/i); - assert.doesNotMatch(sql, /password|secret|token|credential_ref/i); -}); - -test("repository applies project access migration after the foundation", async () => { - const source = await readFile(repositoryUrl, "utf8"); - const foundationIndex = source.indexOf("001_device_plane_foundation.sql"); - const projectAccessIndex = source.indexOf("002_device_project_access.sql"); - - assert.notEqual(foundationIndex, -1); - assert.notEqual(projectAccessIndex, -1); - assert.ok(foundationIndex < projectAccessIndex); -}); diff --git a/device-plane/services/device-control-core/test/project-management.test.mjs b/device-plane/services/device-control-core/test/project-management.test.mjs deleted file mode 100644 index 37c0602..0000000 --- a/device-plane/services/device-control-core/test/project-management.test.mjs +++ /dev/null @@ -1,293 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - assertActorCanManageOwnerScope, - assertGrantMutationAllowed, - assertProjectCapability, - normalizeManagementActor, - normalizeManagementCommand, - resolveProjectAccess, -} from "../src/project-management.mjs"; - -const projectRef = "project:11111111-1111-4111-8111-111111111111"; - -test("normalizes strict generic management commands without seeded entities", () => { - const project = normalizeManagementCommand("project.ensure", { - scopeKind: "company", - ownerRef: "client:example", - projectKey: "field-devices", - name: "Field Devices", - description: "Generic project", - }); - assert.deepEqual(project, { - scopeKind: "company", - ownerRef: "client:example", - projectKey: "field-devices", - name: "Field Devices", - description: "Generic project", - }); - - assert.throws( - () => normalizeManagementCommand("project.ensure", { - scopeKind: "company", - ownerRef: "client:example", - projectKey: "field-devices", - name: "Field Devices", - rawPayload: "forbidden", - }), - /device_management_command_field_unexpected:rawPayload/, - ); -}); - -test("company scope requires an asserted scope and Hub admin ceiling", () => { - const scope = { scopeKind: "company", ownerRef: "client:example" }; - assert.doesNotThrow(() => assertActorCanManageOwnerScope(actor({ - hubRole: "admin", - ownerScopes: [scope], - }), scope)); - assert.throws( - () => assertActorCanManageOwnerScope(actor({ - hubRole: "viewer", - ownerScopes: [scope], - }), scope), - /device_owner_scope_access_denied/, - ); - assert.throws( - () => assertActorCanManageOwnerScope(actor({ hubRole: "owner" }), scope), - /device_owner_scope_access_denied/, - ); -}); - -test("personal scope is isolated to the matching Hub user", () => { - const scope = { scopeKind: "personal", ownerRef: "user:engineer" }; - assert.doesNotThrow(() => assertActorCanManageOwnerScope( - actor({ hubRole: "owner" }), - scope, - )); - assert.throws( - () => assertActorCanManageOwnerScope( - actor({ hubRole: "member" }), - scope, - ), - /device_owner_scope_access_denied/, - ); - assert.throws( - () => assertActorCanManageOwnerScope( - actor({ userRef: "user:other", hubRole: "owner" }), - scope, - ), - /device_owner_scope_access_denied/, - ); -}); - -test("Hub owner has no project access without an explicit project grant", () => { - const access = resolveProjectAccess({ - actor: actor({ hubRole: "owner" }), - grants: [], - }); - assert.equal(access.allowed, false); - assert.deepEqual(access.capabilities, []); -}); - -test("a direct user grant overrides broader group grants", () => { - const access = resolveProjectAccess({ - actor: actor({ hubRole: "owner", groupRefs: ["group:admins"] }), - grants: [ - grant({ - grantRef: "grant:group-admin", - principalKind: "group", - principalRef: "group:admins", - projectRole: "admin", - }), - grant({ - grantRef: "grant:direct-viewer", - principalKind: "user", - principalRef: "user:engineer", - projectRole: "viewer", - }), - ], - }); - assert.equal(access.projectRole, "viewer"); - assert.equal(access.capabilities.includes("access.manage"), false); -}); - -test("matching group grants combine bounded operator and engineer capabilities", () => { - const access = resolveProjectAccess({ - actor: actor({ - hubRole: "member", - groupRefs: ["group:operators", "group:engineers"], - }), - grants: [ - grant({ - grantRef: "grant:operator", - principalKind: "group", - principalRef: "group:operators", - projectRole: "operator", - }), - grant({ - grantRef: "grant:engineer", - principalKind: "group", - principalRef: "group:engineers", - projectRole: "engineer", - }), - ], - }); - assert.equal(access.capabilities.includes("device.enroll"), true); - assert.equal(access.capabilities.includes("command.dispatch"), true); - assert.equal(access.capabilities.includes("access.manage"), false); -}); - -test("configuration mutation belongs to engineer and admin, not operator", () => { - const engineer = resolveProjectAccess({ - actor: actor({ groupRefs: ["group:engineers"] }), - grants: [grant({ - principalKind: "group", - principalRef: "group:engineers", - projectRole: "engineer", - })], - }); - const operator = resolveProjectAccess({ - actor: actor({ groupRefs: ["group:operators"] }), - grants: [grant({ - principalKind: "group", - principalRef: "group:operators", - projectRole: "operator", - })], - }); - - assert.equal(engineer.capabilities.includes("configuration.manage"), true); - assert.equal(operator.capabilities.includes("configuration.manage"), false); -}); - -test("Hub ceiling and explicit deny prevent privilege escalation", () => { - const ownerGrant = grant({ - grantRef: "grant:owner", - principalKind: "user", - principalRef: "user:engineer", - projectRole: "owner", - capabilityDeny: ["credential.manage"], - }); - const hubAdmin = resolveProjectAccess({ - actor: actor({ hubRole: "admin" }), - grants: [ownerGrant], - }); - const hubOwner = resolveProjectAccess({ - actor: actor({ hubRole: "owner" }), - grants: [ownerGrant], - }); - - assert.equal(hubAdmin.capabilities.includes("device.transfer"), false); - assert.equal(hubOwner.capabilities.includes("device.transfer"), true); - assert.equal(hubOwner.capabilities.includes("credential.manage"), false); -}); - -test("owner grant mutations require both Hub and project ownership authority", () => { - const grants = [grant({ - grantRef: "grant:owner", - principalKind: "user", - principalRef: "user:engineer", - projectRole: "owner", - })]; - const ownerCommand = normalizeManagementCommand("project_grant.upsert", { - projectRef, - principalKind: "user", - principalRef: "user:second-owner", - projectRole: "owner", - }); - - assert.throws( - () => assertGrantMutationAllowed( - actor({ hubRole: "admin" }), - grants, - ownerCommand, - ), - /device_project_owner_transfer_denied/, - ); - assert.doesNotThrow(() => assertGrantMutationAllowed( - actor({ hubRole: "owner" }), - grants, - ownerCommand, - )); -}); - -test("grant normalization rejects group owners and capability overlap", () => { - assert.throws( - () => normalizeManagementCommand("project_grant.upsert", { - projectRef, - principalKind: "group", - principalRef: "group:owners", - projectRole: "owner", - }), - /device_project_owner_must_be_user/, - ); - assert.throws( - () => normalizeManagementCommand("project_grant.upsert", { - projectRef, - principalKind: "user", - principalRef: "user:operator", - projectRole: "operator", - capabilityAllow: ["command.dispatch"], - capabilityDeny: ["command.dispatch"], - }), - /device_project_capability_overlap/, - ); -}); - -test("capability checks fail closed for inactive or unrelated grants", () => { - assert.throws( - () => assertProjectCapability( - actor({ hubRole: "owner" }), - [grant({ lifecycleState: "revoked" })], - "project.read", - ), - /device_project_capability_denied/, - ); -}); - -test("denying project.read collapses every derived capability", () => { - const access = resolveProjectAccess({ - actor: actor({ hubRole: "owner" }), - grants: [grant({ - projectRole: "owner", - capabilityDeny: ["project.read"], - })], - }); - - assert.equal(access.allowed, false); - assert.deepEqual(access.capabilities, []); - assert.throws( - () => assertProjectCapability( - actor({ hubRole: "owner" }), - [grant({ - projectRole: "owner", - capabilityDeny: ["project.read"], - })], - "access.manage", - ), - /device_project_capability_denied/, - ); -}); - -function actor(overrides = {}) { - return normalizeManagementActor({ - userRef: "user:engineer", - hubRole: "member", - groupRefs: [], - ownerScopes: [], - ...overrides, - }); -} - -function grant(overrides = {}) { - return { - grantRef: "grant:default", - principalKind: "user", - principalRef: "user:engineer", - projectRole: "viewer", - capabilityAllow: [], - capabilityDeny: [], - lifecycleState: "active", - ...overrides, - }; -} diff --git a/device-plane/services/device-control-core/test/project-query-repository.test.mjs b/device-plane/services/device-control-core/test/project-query-repository.test.mjs deleted file mode 100644 index 7458eda..0000000 --- a/device-plane/services/device-control-core/test/project-query-repository.test.mjs +++ /dev/null @@ -1,391 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -import { - getDeviceProjectWorkspace, - listAccessibleDeviceProjects, -} from "../src/project-query-repository.mjs"; -import { normalizeManagementActor } from "../src/project-management.mjs"; - -const projectId = "11111111-1111-4111-8111-111111111111"; -const actor = normalizeManagementActor({ - userRef: "user:device-admin", - hubRole: "admin", - groupRefs: ["group:engineers"], - ownerScopes: [], -}); -const timestamp = "2026-08-10T00:00:00.000Z"; - -test("project list applies direct-grant precedence and returns bounded summaries", async () => { - const client = { - async query(sql) { - assert.match(sql, /from device_projects p/); - return { - rows: [ - projectGrantRow({ - grant_id: "22222222-2222-4222-8222-222222222222", - principal_kind: "group", - principal_ref: "group:engineers", - project_role: "engineer", - }), - projectGrantRow({ - grant_id: "33333333-3333-4333-8333-333333333333", - principal_kind: "user", - principal_ref: "user:device-admin", - project_role: "viewer", - }), - ], - }; - }, - }; - - const projects = await listAccessibleDeviceProjects(client, actor); - assert.equal(projects.length, 1); - assert.equal(projects[0].access.projectRole, "viewer"); - assert.equal(projects[0].access.capabilities.includes("collection.manage"), false); - assert.deepEqual(projects[0].counts, { - devices: 3, - collections: 2, - discoveries: 1, - }); -}); - -test("project workspace returns only masked identity projections", async () => { - const client = workspaceClient(); - const workspace = await getDeviceProjectWorkspace(client, actor, projectId); - - assert.equal(workspace.project.projectRef, `project:${projectId}`); - assert.equal(workspace.devices[0].identifier.masked, "***********0001"); - assert.equal(workspace.discoveries[0].identifier.masked, "***********0001"); - assert.equal(workspace.enrollments[0].expectedIdentifier.masked, "***********0001"); - assert.equal( - workspace.enrollments[0].enrollmentIntentRef, - "enrollment-intent:77777777-7777-4777-8777-777777777777", - ); - assert.equal(workspace.adapterPackages[0].packageKey, "generic-tracker"); - assert.equal(workspace.modelProfiles[0].modelProfileRef, "vendor.model.v1"); - assert.equal(workspace.routes[0].activeSessionCount, 1); - assert.equal(workspace.sessions[0].frameCount, 12); - assert.equal(workspace.bindings[0].lifecycleState, "pending_external_approval"); - assert.equal(workspace.configurationRevisions[0].revisionNumber, 1); - assert.equal(workspace.commands[0].lifecycleState, "acknowledged"); - assert.equal(workspace.auditEvents[0].eventType, "device.observed"); - assert.equal(workspace.grants[0].principalRef, "user:device-admin"); - assert.equal(workspace.policies.commandTransport, "disabled"); - const serialized = JSON.stringify(workspace); - assert.equal(serialized.includes("hmac-sha256"), false); - assert.equal(serialized.includes("ndc-credref"), false); - assert.equal(serialized.includes("transport-message-secret"), false); - assert.equal(serialized.includes("external-approval-proof"), false); - assert.equal(serialized.includes("raw-audit-payload"), false); -}); - -test("project workspace authorization remains compatible with read-only transactions", async () => { - const queries = []; - const client = workspaceClient({ queries }); - - await getDeviceProjectWorkspace(client, actor, projectId); - - assert.ok(queries.length > 0); - assert.equal( - queries.some((sql) => /\bfor\s+(?:no\s+key\s+)?(?:update|share)\b/i.test(sql)), - false, - ); -}); - -test("project read source never selects identifier digests or credential refs", async () => { - const source = await readFile( - new URL("../src/project-query-repository.mjs", import.meta.url), - "utf8", - ); - assert.doesNotMatch( - source, - /\b(?:identifier_digest|expected_identifier_digest|credential_ref|parameters_digest|parameters_projection|transport_message_ref|external_approval_ref|external_approval_digest)\b/, - ); - assert.doesNotMatch(source, /\b(?:dae\.payload|dcr\.configuration)\b/); -}); - -function workspaceClient({ queries = [] } = {}) { - let grantReads = 0; - return { - async query(sql) { - queries.push(sql); - if (/from device_projects p/.test(sql)) { - return { rows: [projectGrantRow()] }; - } - if (/from device_project_grants/.test(sql)) { - grantReads += 1; - return { rows: [storedGrantRow()] }; - } - if (/from device_adapter_packages ap/.test(sql)) { - return { rows: [{ - id: "99999999-9999-4999-8999-999999999999", - package_key: "generic-tracker", - display_name: "Generic tracker", - publisher_ref: "publisher:nodedc", - lifecycle_state: "active", - created_at: timestamp, - updated_at: timestamp, - }] }; - } - if (/from device_adapter_versions av/.test(sql)) { - return { rows: [{ - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - adapter_package_id: "99999999-9999-4999-8999-999999999999", - version: "1.0.0", - runtime_package_ref: "artifact:generic-tracker:1.0.0", - content_digest: `sha256:${"a".repeat(64)}`, - contract_version: "device-adapter.v1", - capabilities: ["telemetry"], - lifecycle_state: "active", - created_at: timestamp, - updated_at: timestamp, - }] }; - } - if (/from device_model_profiles dmp/.test(sql)) { - return { rows: [{ - profile_ref: "vendor.model.v1", - adapter_version_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - schema_version: "1.0.0", - vendor: "Vendor", - model: "Model", - device_type: "tracker", - protocol: "INTERNAL", - schema_artifact_ref: "schema:vendor.model.v1", - profile_digest: `sha256:${"b".repeat(64)}`, - capabilities: ["telemetry"], - lifecycle_state: "active", - created_at: timestamp, - updated_at: timestamp, - }] }; - } - if (/from device_edges de/.test(sql)) { - return { rows: [{ - id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - edge_key: "edge-one", - display_name: "Edge one", - deployment_ref: "deployment:edge-one", - lifecycle_state: "active", - created_at: timestamp, - updated_at: timestamp, - }] }; - } - if (/from device_routes dr/.test(sql)) { - return { rows: [{ - id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", - route_key: "route-one", - display_name: "Route one", - edge_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - edge_name: "Edge one", - model_profile_ref: "vendor.model.v1", - profile_vendor: "Vendor", - profile_model: "Model", - listener_ref: "listener:generic", - protocol: "INTERNAL", - direction: "bidirectional", - lifecycle_state: "active", - session_count: "1", - active_session_count: "1", - created_at: timestamp, - updated_at: timestamp, - }] }; - } - if (/select ds\.id, ds\.route_id, dr\.display_name/.test(sql)) { - return { rows: [{ - id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", - route_id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", - route_name: "Route one", - device_id: "44444444-4444-4444-8444-444444444444", - device_name: "Pilot device", - protocol: "INTERNAL", - lifecycle_state: "online", - connected_at: timestamp, - last_seen_at: timestamp, - disconnected_at: null, - close_reason_code: null, - frame_count: "12", - byte_count: "1024", - }] }; - } - if (/from device_resource_bindings drb/.test(sql)) { - return { rows: [{ - id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", - binding_key: "foundry-map", - display_name: "Foundry map", - source_kind: "collection", - device_id: null, - collection_id: "55555555-5555-4555-8555-555555555555", - source_name: "Pilot fleet", - target_kind: "foundry.application", - target_ref: "application:pilot-map", - capabilities: ["observe"], - lifecycle_state: "pending_external_approval", - source_approved_at: timestamp, - external_approval_ref: "external-approval-proof", - created_at: timestamp, - updated_at: timestamp, - }] }; - } - if (/from device_configuration_revisions dcr/.test(sql)) { - return { rows: [{ - id: "ffffffff-ffff-4fff-8fff-ffffffffffff", - device_id: "44444444-4444-4444-8444-444444444444", - device_name: "Pilot device", - revision_number: "1", - model_profile_ref: "vendor.model.v1", - schema_artifact_ref: "schema:vendor.model.v1", - configuration_digest: `sha256:${"c".repeat(64)}`, - configuration: { raw: "must-not-leak" }, - change_summary: "Pilot configuration", - created_at: timestamp, - }] }; - } - if (/from device_configuration_state dcs/.test(sql)) { - return { rows: [{ - device_id: "44444444-4444-4444-8444-444444444444", - device_name: "Pilot device", - desired_revision_id: "ffffffff-ffff-4fff-8fff-ffffffffffff", - applied_revision_id: null, - applied_at: null, - updated_at: timestamp, - }] }; - } - if (/from device_commands dc/.test(sql)) { - return { rows: [{ - id: "12121212-1212-4121-8121-121212121212", - device_id: "44444444-4444-4444-8444-444444444444", - device_name: "Pilot device", - command_key: "safe-ping", - command_catalog_ref: "catalog:safe-ping:v1", - command_type: "device.ping", - risk_class: "low", - lifecycle_state: "acknowledged", - planned_at: timestamp, - expires_at: "2026-08-11T00:00:00.000Z", - confirmed_at: timestamp, - dispatched_at: timestamp, - acknowledged_at: timestamp, - terminal_at: null, - terminal_reason_code: null, - transport_message_ref: "transport-message-secret", - parameters_projection: { raw: "must-not-leak" }, - created_at: timestamp, - updated_at: timestamp, - }] }; - } - if (/from device_audit_events dae/.test(sql)) { - return { rows: [{ - id: "13131313-1313-4131-8131-131313131313", - event_type: "device.observed", - actor_ref: "user:device-admin", - device_id: "44444444-4444-4444-8444-444444444444", - discovery_id: "66666666-6666-4666-8666-666666666666", - occurred_at: timestamp, - payload: { raw: "raw-audit-payload" }, - }] }; - } - if (/from device_instances di\n left join lateral/.test(sql)) { - return { rows: [{ - id: "44444444-4444-4444-8444-444444444444", - device_key: "pilot-device", - display_name: "Pilot device", - model_profile_ref: "vendor.model.v1", - lifecycle_state: "online", - identifier_kind: "imei", - identifier_masked: "***********0001", - session_state: "online", - last_seen_at: timestamp, - created_at: timestamp, - updated_at: timestamp, - }] }; - } - if (/from device_collections dc/.test(sql)) { - return { rows: [{ - id: "55555555-5555-4555-8555-555555555555", - collection_key: "pilot-fleet", - name: "Pilot fleet", - description: null, - lifecycle_state: "active", - member_count: "1", - created_at: timestamp, - updated_at: timestamp, - }] }; - } - if (/from device_discoveries dd/.test(sql)) { - return { rows: [{ - id: "66666666-6666-4666-8666-666666666666", - identifier_kind: "imei", - identifier_masked: "***********0001", - model_profile_ref: "vendor.model.v1", - protocol: "INTERNAL", - lifecycle_state: "quarantine", - first_observed_at: timestamp, - last_observed_at: timestamp, - enrollment_intent_id: "77777777-7777-4777-8777-777777777777", - claimed_device_id: null, - }] }; - } - if (/from device_enrollment_intents dei/.test(sql)) { - return { rows: [{ - id: "77777777-7777-4777-8777-777777777777", - enrollment_key: "pilot-enrollment", - display_name: "Pilot device", - model_profile_ref: "vendor.model.v1", - expected_identifier_kind: "imei", - expected_identifier_masked: "***********0001", - lifecycle_state: "observed", - observed_discovery_id: "66666666-6666-4666-8666-666666666666", - claimed_device_id: null, - expires_at: null, - created_at: timestamp, - updated_at: timestamp, - }] }; - } - throw new Error(`unexpected_query:${sql}`); - }, - get grantReads() { - return grantReads; - }, - }; -} - -function projectGrantRow(overrides = {}) { - return { - id: projectId, - project_key: "pilot-project", - name: "Pilot project", - description: null, - lifecycle_state: "active", - owner_scope_id: "88888888-8888-4888-8888-888888888888", - scope_kind: "company", - owner_ref: "client:dctouch", - owner_display_name: "DCTOUCH", - owner_lifecycle_state: "active", - grant_id: "33333333-3333-4333-8333-333333333333", - principal_kind: "user", - principal_ref: "user:device-admin", - project_role: "viewer", - capability_allow: [], - capability_deny: [], - grant_lifecycle_state: "active", - device_count: "3", - collection_count: "2", - discovery_count: "1", - created_at: timestamp, - updated_at: timestamp, - ...overrides, - }; -} - -function storedGrantRow() { - return { - id: "33333333-3333-4333-8333-333333333333", - principal_kind: "user", - principal_ref: "user:device-admin", - project_role: "admin", - capability_allow: [], - capability_deny: [], - lifecycle_state: "active", - }; -} diff --git a/device-plane/services/device-control-core/test/sensitive-reference-management.test.mjs b/device-plane/services/device-control-core/test/sensitive-reference-management.test.mjs deleted file mode 100644 index 7c44748..0000000 --- a/device-plane/services/device-control-core/test/sensitive-reference-management.test.mjs +++ /dev/null @@ -1,157 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS, - normalizeSensitiveReferenceManagementCommand, -} from "../src/sensitive-reference-management.mjs"; -import { - ALL_DEVICE_MANAGEMENT_COMMAND_KINDS, - normalizeDeviceManagementCommand, -} from "../src/management-command.mjs"; -import { - normalizeNdcCredentialReference as normalizeRuntimeCredentialReference, -} from "../src/credential-reference.mjs"; -import { - normalizeNdcCredentialReference as normalizePlatformCredentialReference, -} from "../../../../packages/external-provider-contract/src/credential-reference.mjs"; - -const projectRef = "project:11111111-1111-4111-8111-111111111111"; -const deviceRef = "device:22222222-2222-4222-8222-222222222222"; - -test("credential binding commands share the strict management surface", () => { - for (const kind of DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS) { - assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes(kind), true); - } - assert.equal( - normalizeDeviceManagementCommand( - "device_credential_binding.upsert", - upsertInput(), - ).projectId, - projectRef.slice("project:".length), - ); -}); - -test("credential binding accepts only the platform canonical opaque ref", () => { - const command = normalizeSensitiveReferenceManagementCommand( - "device_credential_binding.upsert", - upsertInput(), - ); - - assert.deepEqual(command.credentialRef, { - owner: "ndc_l2_credentials", - reference: "ndc-credref:pilot-command-0001", - }); - assert.equal(Object.isFrozen(command.credentialRef), true); - assert.throws( - () => normalizeSensitiveReferenceManagementCommand( - "device_credential_binding.upsert", - { - ...upsertInput(), - credentialRef: { - owner: "device_core", - reference: "ndc-credref:pilot-command-0001", - }, - }, - ), - /ndc_credential_reference_owner_invalid/, - ); - assert.throws( - () => normalizeSensitiveReferenceManagementCommand( - "device_credential_binding.upsert", - { - ...upsertInput(), - credentialRef: { - owner: "ndc_l2_credentials", - reference: "Bearer plaintext-is-forbidden", - }, - }, - ), - /ndc_credential_reference_value_invalid/, - ); -}); - -test("runtime credential reference adapter matches the platform contract", () => { - const accepted = [ - { - owner: "ndc_l2_credentials", - reference: "ndc-credref:pilot-command-0001", - }, - { - owner: "ndc_l2_credentials", - reference: "ndc-credref:A1234567", - }, - ]; - for (const input of accepted) { - assert.deepEqual( - normalizeRuntimeCredentialReference(input), - normalizePlatformCredentialReference(input), - ); - } - - const rejected = [ - null, - [], - { owner: "device_core", reference: "ndc-credref:pilot-command-0001" }, - { owner: "ndc_l2_credentials", reference: "secret:test" }, - { - owner: "ndc_l2_credentials", - reference: "ndc-credref:pilot-command-0001", - token: "forbidden", - }, - ]; - for (const input of rejected) { - let runtimeError; - let platformError; - try { - normalizeRuntimeCredentialReference(input); - } catch (error) { - runtimeError = error; - } - try { - normalizePlatformCredentialReference(input); - } catch (error) { - platformError = error; - } - assert.equal(runtimeError?.message, platformError?.message); - } -}); - -test("credential binding rejects raw secret-shaped fields", () => { - for (const field of ["password", "token", "secretValue", "endpoint"]) { - assert.throws( - () => normalizeSensitiveReferenceManagementCommand( - "device_credential_binding.upsert", - { ...upsertInput(), [field]: "forbidden" }, - ), - new RegExp(`device_management_command_field_unexpected:${field}`), - ); - } -}); - -test("credential revoke has no credential reference input", () => { - const command = normalizeSensitiveReferenceManagementCommand( - "device_credential_binding.revoke", - { - projectRef, - deviceRef, - purpose: "tracker.command", - resolutionCode: "operator.rotation", - }, - ); - - assert.equal(command.resolutionCode, "operator.rotation"); - assert.equal("credentialRef" in command, false); -}); - -function upsertInput() { - return { - projectRef, - deviceRef, - purpose: "tracker.command", - credentialRef: { - owner: "ndc_l2_credentials", - reference: "ndc-credref:pilot-command-0001", - }, - }; -} diff --git a/device-plane/services/device-control-core/test/sensitive-reference-migration.test.mjs b/device-plane/services/device-control-core/test/sensitive-reference-migration.test.mjs deleted file mode 100644 index 261131e..0000000 --- a/device-plane/services/device-control-core/test/sensitive-reference-migration.test.mjs +++ /dev/null @@ -1,49 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const schemaUrl = new URL( - "../migrations/008_device_sensitive_references.sql", - import.meta.url, -); -const commandsUrl = new URL( - "../migrations/009_device_sensitive_reference_commands.sql", - import.meta.url, -); -const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); - -test("sensitive reference schema stores only digest, mask and canonical refs", async () => { - const sql = await readFile(schemaUrl, "utf8"); - - assert.match(sql, /create table if not exists device_restricted_identifiers/); - assert.match(sql, /identifier_digest text not null/); - assert.match(sql, /identifier_masked text not null/); - assert.match(sql, /device_restricted_identifiers_active_identity_idx/); - assert.match(sql, /device_restricted_identifiers_primary_idx/); - assert.match(sql, /device_identifier_ownership_mismatch/); - assert.match(sql, /device_active_identifier_ownership_mismatch/); - assert.match(sql, /deferrable initially deferred/); - assert.match(sql, /create table if not exists device_credential_bindings/); - assert.match(sql, /credential_owner = 'ndc_l2_credentials'/); - assert.match(sql, /\^ndc-credref:/); - assert.match(sql, /device_credential_binding_ownership_mismatch/); - assert.match(sql, /device_transfer_active_credential_binding/); - assert.match(sql, /owner_scope_id is null or credential_ref is null/); - assert.doesNotMatch(sql, /imei\s+text|serial\s+text|password\s+text|token\s+text/i); - assert.doesNotMatch(sql, /insert\s+into/i); -}); - -test("credential commands extend durable receipts after their schema", async () => { - const commands = await readFile(commandsUrl, "utf8"); - const repository = await readFile(repositoryUrl, "utf8"); - - assert.match(commands, /'device_credential_binding\.upsert'/); - assert.match(commands, /'device_credential_binding\.revoke'/); - const schemaIndex = repository.indexOf("008_device_sensitive_references.sql"); - const commandsIndex = repository.indexOf( - "009_device_sensitive_reference_commands.sql", - ); - assert.notEqual(schemaIndex, -1); - assert.notEqual(commandsIndex, -1); - assert.ok(schemaIndex < commandsIndex); -}); diff --git a/device-plane/services/device-control-core/test/sensitive-reference-repository.test.mjs b/device-plane/services/device-control-core/test/sensitive-reference-repository.test.mjs deleted file mode 100644 index 386fee6..0000000 --- a/device-plane/services/device-control-core/test/sensitive-reference-repository.test.mjs +++ /dev/null @@ -1,271 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { normalizeDeviceManagementCommand } from "../src/management-command.mjs"; -import { PostgresDeviceRepository } from "../src/postgres-repository.mjs"; -import { normalizeManagementActor } from "../src/project-management.mjs"; - -const now = new Date("2026-08-10T00:00:00.000Z"); -const projectId = "11111111-1111-4111-8111-111111111111"; -const ownerId = "22222222-2222-4222-8222-222222222222"; -const deviceId = "33333333-3333-4333-8333-333333333333"; -const bindingId = "44444444-4444-4444-8444-444444444444"; -const canonicalRef = "ndc-credref:pilot-command-0001"; - -test("creates a canonical binding without returning or auditing its reference", async () => { - const actor = managementActor(); - const command = upsertCommand(); - const client = scriptedClient([ - step("begin"), - receiptStep("receipt-credential-upsert"), - projectStep(), - grantsStep(actor), - step("from device_instances", { rows: [deviceRow()] }), - step("from device_credential_bindings", { rows: [] }), - step("insert into device_credential_bindings", { - rows: [bindingRow()], - }), - step("insert into device_audit_events"), - step("update device_management_command_receipts"), - step("commit"), - ]); - const repository = repositoryWithClient(client); - - const result = await repository.executeManagementCommand(commandInput({ - actor, - commandKind: "device_credential_binding.upsert", - command, - digestCharacter: "a", - })); - - assert.equal(result.result.created, true); - assert.equal(result.result.rotated, false); - assert.equal( - result.result.credentialBinding.credentialBindingRef, - `credential-binding:${bindingId}`, - ); - assert.equal(JSON.stringify(result.result).includes(canonicalRef), false); - const auditCall = client.calls.find((call) => - String(call.sql).includes("insert into device_audit_events") - ); - assert.ok(auditCall); - assert.equal(JSON.stringify(auditCall.params).includes(canonicalRef), false); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("revokes by device and purpose without accepting a credential ref", async () => { - const actor = managementActor(); - const command = normalizeDeviceManagementCommand( - "device_credential_binding.revoke", - { - projectRef: `project:${projectId}`, - deviceRef: `device:${deviceId}`, - purpose: "tracker.command", - resolutionCode: "operator.rotation", - }, - ); - const client = scriptedClient([ - step("begin"), - receiptStep("receipt-credential-revoke"), - projectStep(), - grantsStep(actor), - step("from device_instances", { rows: [deviceRow()] }), - step("update device_credential_bindings", { - rows: [bindingRow({ lifecycle_state: "revoked" })], - }), - step("insert into device_audit_events"), - step("update device_management_command_receipts"), - step("commit"), - ]); - const repository = repositoryWithClient(client); - - const result = await repository.executeManagementCommand(commandInput({ - actor, - commandKind: "device_credential_binding.revoke", - command, - digestCharacter: "b", - })); - - assert.equal(result.result.revoked, true); - assert.equal(result.result.credentialBinding.lifecycleState, "revoked"); - assert.equal("credentialRef" in command, false); - assert.equal(JSON.stringify(result.result).includes(canonicalRef), false); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -test("rotates an active binding atomically and keeps both refs out of audit", async () => { - const actor = managementActor(); - const command = upsertCommand(); - const oldRef = "ndc-credref:pilot-command-old-0001"; - const client = scriptedClient([ - step("begin"), - receiptStep("receipt-credential-rotate"), - projectStep(), - grantsStep(actor), - step("from device_instances", { rows: [deviceRow()] }), - step("from device_credential_bindings", { - rows: [bindingRow({ credential_ref: oldRef })], - }), - step("update device_credential_bindings"), - step("insert into device_credential_bindings", { - rows: [bindingRow({ - id: "66666666-6666-4666-8666-666666666666", - })], - }), - step("insert into device_audit_events"), - step("update device_management_command_receipts"), - step("commit"), - ]); - const repository = repositoryWithClient(client); - - const result = await repository.executeManagementCommand(commandInput({ - actor, - commandKind: "device_credential_binding.upsert", - command, - digestCharacter: "c", - })); - - assert.equal(result.result.created, true); - assert.equal(result.result.rotated, true); - const auditCall = client.calls.find((call) => - String(call.sql).includes("insert into device_audit_events") - ); - assert.ok(auditCall); - assert.equal(JSON.stringify(auditCall.params).includes(oldRef), false); - assert.equal(JSON.stringify(auditCall.params).includes(canonicalRef), false); - assert.equal(client.remaining(), 0); - assert.equal(client.released, true); -}); - -function upsertCommand() { - return normalizeDeviceManagementCommand( - "device_credential_binding.upsert", - { - projectRef: `project:${projectId}`, - deviceRef: `device:${deviceId}`, - purpose: "tracker.command", - credentialRef: { - owner: "ndc_l2_credentials", - reference: canonicalRef, - }, - }, - ); -} - -function managementActor() { - return normalizeManagementActor({ - userRef: "user:credential-operator", - hubRole: "admin", - groupRefs: [], - ownerScopes: [], - }); -} - -function projectStep() { - return step("from device_projects p", { - rows: [{ - id: projectId, - owner_scope_id: ownerId, - lifecycle_state: "active", - scope_kind: "company", - owner_ref: "client:example-company", - owner_display_name: "Example Company", - owner_lifecycle_state: "active", - }], - }); -} - -function grantsStep(actor) { - return step("from device_project_grants", { - rows: [{ - id: "55555555-5555-4555-8555-555555555555", - principal_kind: "user", - principal_ref: actor.userRef, - project_role: "admin", - capability_allow: [], - capability_deny: [], - lifecycle_state: "active", - }], - }); -} - -function deviceRow() { - return { - id: deviceId, - owner_scope_id: ownerId, - project_id: projectId, - lifecycle_state: "claimed", - }; -} - -function bindingRow(overrides = {}) { - return { - id: bindingId, - device_id: deviceId, - owner_scope_id: ownerId, - project_id: projectId, - purpose: "tracker.command", - credential_owner: "ndc_l2_credentials", - lifecycle_state: "active", - created_at: now, - updated_at: now, - ...overrides, - }; -} - -function receiptStep(id) { - return step("insert into device_management_command_receipts", { - rows: [{ id }], - }); -} - -function commandInput({ actor, commandKind, command, digestCharacter }) { - return { - idempotencyKey: `phase24-${commandKind.replaceAll(".", "-")}-0001`, - commandKind, - requestDigest: `sha256:${digestCharacter.repeat(64)}`, - actor, - command, - }; -} - -function repositoryWithClient(client) { - return new PostgresDeviceRepository({ - pool: { - query: async () => ({ rows: [] }), - connect: async () => client, - end: async () => undefined, - }, - }); -} - -function step(includes, result = { rows: [] }) { - return { includes, result }; -} - -function scriptedClient(steps) { - const queue = [...steps]; - return { - calls: [], - released: false, - async query(sql, params = []) { - this.calls.push({ sql, params }); - const next = queue.shift(); - assert.ok(next, `Unexpected query: ${sql}`); - assert.match(String(sql), new RegExp(escapeRegExp(next.includes), "i")); - return next.result; - }, - release() { - this.released = true; - }, - remaining() { - return queue.length; - }, - }; -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/device-plane/services/device-control-core/test/typed-command-runtime.test.mjs b/device-plane/services/device-control-core/test/typed-command-runtime.test.mjs deleted file mode 100644 index 6bbdb3c..0000000 --- a/device-plane/services/device-control-core/test/typed-command-runtime.test.mjs +++ /dev/null @@ -1,106 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { createTypedCommandRuntime } from "../src/typed-command-runtime.mjs"; - -const projectRef = "project:11111111-1111-4111-8111-111111111111"; -const deviceRef = "device:22222222-2222-4222-8222-222222222222"; - -test("keeps the B2 access code transient and emits only a typed offer", async () => { - const planned = []; - const dispatched = []; - const runtime = createTypedCommandRuntime({ - now: () => new Date("2026-08-12T18:00:00.000Z"), - repository: { - async planTypedServicePing(value) { - planned.push(value); - return { - replayed: false, - commandId: "33333333-3333-4333-8333-333333333333", - command: { - lifecycleState: "queued", - expiresAt: "2026-08-12T18:05:00.000Z", - }, - }; - }, - async dispatchTypedCommand(value) { - dispatched.push(value); - return { lifecycleState: "dispatched" }; - }, - async recordTypedCommandStatus() {}, - }, - }); - await runtime.planServicePing({ - idempotencyKey: "idem-00000001", - actor: { userRef: "user:test" }, - input: { projectRef, deviceRef, accessCode: "123456", expiresInSeconds: 300 }, - }); - assert.equal(JSON.stringify(planned).includes("123456"), false); - const offer = await runtime.offerForDevice(deviceRef); - assert.equal(offer.commandType, "service.ping"); - assert.equal(offer.accessCode, "123456"); - assert.match(offer.transportMessageRef, /^edge-command:/); - assert.equal(dispatched.length, 1); -}); - -test("expires a transient authorization through the durable ledger", async () => { - let current = new Date("2026-08-12T18:00:00.000Z"); - const dispatches = []; - const runtime = createTypedCommandRuntime({ - now: () => current, - repository: { - async planTypedServicePing() { - return { - replayed: false, - commandId: "33333333-3333-4333-8333-333333333333", - command: { - lifecycleState: "queued", - expiresAt: "2026-08-12T18:00:30.000Z", - }, - }; - }, - async dispatchTypedCommand(value) { - dispatches.push(value); - return null; - }, - async recordTypedCommandStatus() {}, - }, - }); - await runtime.planServicePing({ - idempotencyKey: "idem-00000002", - actor: { userRef: "user:test" }, - input: { projectRef, deviceRef, accessCode: "123456", expiresInSeconds: 30 }, - }); - current = new Date("2026-08-12T18:00:31.000Z"); - assert.equal(await runtime.offerForDevice(deviceRef), null); - assert.equal(dispatches.length, 1); - assert.equal(runtime.status().transientAuthorizations, 0); -}); - -test("does not recreate a transient authorization on an idempotent replay", async () => { - const runtime = createTypedCommandRuntime({ - repository: { - async planTypedServicePing() { - return { - replayed: true, - commandId: "33333333-3333-4333-8333-333333333333", - command: { - lifecycleState: "queued", - expiresAt: "2026-08-12T18:05:00.000Z", - }, - }; - }, - async dispatchTypedCommand() { - throw new Error("must_not_dispatch_replayed_secret"); - }, - async recordTypedCommandStatus() {}, - }, - }); - await runtime.planServicePing({ - idempotencyKey: "idem-00000003", - actor: { userRef: "user:test" }, - input: { projectRef, deviceRef, accessCode: "654321", expiresInSeconds: 300 }, - }); - assert.equal(runtime.status().transientAuthorizations, 0); - assert.equal(await runtime.offerForDevice(deviceRef), null); -}); diff --git a/device-plane/services/device-edge-backhaul/Dockerfile b/device-plane/services/device-edge-backhaul/Dockerfile deleted file mode 100644 index 6873a46..0000000 --- a/device-plane/services/device-edge-backhaul/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM alpine:3.22 - -RUN apk add --no-cache openssh-client-default netcat-openbsd \ - && addgroup -g 1000 edge-backhaul \ - && adduser -D -H -u 1000 -G edge-backhaul -s /sbin/nologin edge-backhaul - -USER 1000:1000 - -ENTRYPOINT ["/usr/bin/ssh"] diff --git a/device-plane/services/device-edge-backhaul/test/source.test.mjs b/device-plane/services/device-edge-backhaul/test/source.test.mjs deleted file mode 100644 index e4e2583..0000000 --- a/device-plane/services/device-edge-backhaul/test/source.test.mjs +++ /dev/null @@ -1,34 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; - -const here = dirname(fileURLToPath(import.meta.url)); -const devicePlane = resolve(here, "../../.."); - -test("edge backhaul is key-only, pinned and never host-published", async () => { - const compose = await readFile( - resolve(devicePlane, "docker-compose.device-edge.backhaul.yml"), - "utf8", - ); - const descriptor = JSON.parse(await readFile( - resolve(devicePlane, "deployment/device-edge-backhaul-v1.json"), - "utf8", - )); - - assert.match(compose, /StrictHostKeyChecking=yes/); - assert.match(compose, /UserKnownHostsFile=\/run\/trust\/known_hosts/); - assert.match(compose, /PasswordAuthentication=no/); - assert.match(compose, /KbdInteractiveAuthentication=no/); - assert.match(compose, /ExitOnForwardFailure=yes/); - assert.match(compose, /ProxyCommand=nc -X 5 -x nodedc-device-edge-tailnet-1:1055/); - assert.match(compose, /\.\.\/secrets\/backhaul\/id_ed25519/); - assert.doesNotMatch(compose, /\.\.\/keys\/edge-to-synology/); - assert.match(compose, /0\.0\.0\.0:19921:127\.0\.0\.1:9921/); - assert.doesNotMatch(compose, /^\s+ports:/m); - assert.doesNotMatch(compose, /0\.0\.0\.0:9921/); - assert.equal(descriptor.hostPortPublication, "disabled"); - assert.equal(descriptor.deviceIngress, "disabled"); - assert.equal(descriptor.commandTransport, "disabled"); -}); diff --git a/device-plane/services/device-edge-channel/package.json b/device-plane/services/device-edge-channel/package.json deleted file mode 100644 index a12e550..0000000 --- a/device-plane/services/device-edge-channel/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@nodedc/device-edge-channel", - "version": "0.1.0", - "private": true, - "type": "module", - "exports": { - ".": "./src/runtime.mjs" - }, - "engines": { - "node": ">=20" - } -} diff --git a/device-plane/services/device-edge-channel/src/runtime.mjs b/device-plane/services/device-edge-channel/src/runtime.mjs deleted file mode 100644 index e5cd861..0000000 --- a/device-plane/services/device-edge-channel/src/runtime.mjs +++ /dev/null @@ -1,616 +0,0 @@ -import { randomUUID, X509Certificate } from "node:crypto"; -import { createSecureServer } from "node:http2"; - -import { - DEVICE_EDGE_CHANNEL_LIMITS, - DEVICE_EDGE_CHANNEL_PATH, - createChannelEnvelope, - createChannelEnvelopeDecoder, - encodeChannelEnvelope, - normalizeCertificateFingerprint, -} from "../../../packages/device-edge-channel-contract/src/index.mjs"; -import { - normalizeAdapterAcceptance, - normalizeAdapterMessage, - normalizeDiscoverySignal, -} from "../../../packages/device-protocol-contract/src/index.mjs"; - -const CHANNEL_TRACKER_SESSION_ID = "channel:control"; -const CHANNEL_PROFILE_REF = "channel.control.v1"; - -export function createDeviceEdgeChannelServer(options = {}) { - const config = normalizeConfig(options); - let trust = config.trust; - const pending = new Map(); - let active = null; - let started = false; - let heartbeatTimer = null; - let totalChannelsAccepted = 0; - let totalChannelsRejected = 0; - let totalEventsSubmitted = 0; - let totalEventsAccepted = 0; - let totalEventsRejected = 0; - let totalProtocolFailures = 0; - - const server = createSecureServer({ - key: trust.key, - cert: trust.cert, - ca: trust.ca, - minVersion: "TLSv1.3", - maxVersion: "TLSv1.3", - allowHTTP1: false, - requestCert: true, - rejectUnauthorized: true, - settings: { - enablePush: false, - maxConcurrentStreams: 1, - initialWindowSize: 1024 * 1024, - }, - }); - - server.on("stream", (stream, headers) => { - if (headers[":method"] !== "POST" || headers[":path"] !== DEVICE_EDGE_CHANNEL_PATH) { - stream.respond({ ":status": 404, "cache-control": "no-store" }); - stream.end(); - return; - } - if (!isApprovedCorePeer(stream)) { - totalChannelsRejected += 1; - stream.respond({ ":status": 403, "cache-control": "no-store" }); - stream.end(); - return; - } - if (active) { - totalChannelsRejected += 1; - stream.respond({ ":status": 409, "cache-control": "no-store" }); - stream.end(); - return; - } - - const state = { - stream, - decoder: createChannelEnvelopeDecoder({ - direction: "core-to-edge", - maxEnvelopeBytes: config.maxEnvelopeBytes, - }), - accepted: false, - edgeSequence: 0, - coreSequence: 0, - lastCoreActivityAt: config.clock(), - closed: false, - }; - active = state; - stream.respond({ - ":status": 200, - "content-type": "application/x-ndjson", - "cache-control": "no-store", - "x-content-type-options": "nosniff", - }); - send(state, "channel.hello", { - status: "ready", - transport: "http2-mtls", - trustGeneration: trust.generationRef, - commandTransport: config.commandTransport, - }, { - trackerSessionId: CHANNEL_TRACKER_SESSION_ID, - adapterProfileRef: CHANNEL_PROFILE_REF, - correlationId: `correlation:${randomUUID()}`, - }); - - let processing = Promise.resolve(); - stream.on("data", (chunk) => { - stream.pause(); - processing = processing - .then(async () => { - const envelopes = state.decoder.push(chunk); - for (const envelope of envelopes) { - await handleCoreEnvelope(state, envelope); - } - }) - .catch(() => protocolFailure(state)) - .finally(() => { - if (!state.closed) stream.resume(); - }); - }); - stream.on("aborted", () => closeActive(state)); - stream.on("close", () => closeActive(state)); - stream.on("error", () => closeActive(state)); - }); - server.on("tlsClientError", () => { - totalChannelsRejected += 1; - }); - server.on("sessionError", () => { - totalProtocolFailures += 1; - }); - - return Object.freeze({ - async start() { - if (started) return server.address(); - await listen(server, config.port, config.host); - started = true; - heartbeatTimer = setInterval(checkChannelHealth, config.keepaliveMs); - heartbeatTimer.unref?.(); - return server.address(); - }, - async stop() { - clearInterval(heartbeatTimer); - heartbeatTimer = null; - const current = active; - if (current) { - current.stream.close(); - closeActive(current); - } - rejectAllPending("device_edge_channel_stopped"); - if (started) await closeServer(server); - started = false; - }, - async submitDiscovery(signal) { - const normalized = normalizeDiscoverySignal(signal); - const result = await submitEvent("discovery.observed", { - signal: normalized, - }, { - trackerSessionId: normalized.sessionRef, - adapterProfileRef: normalized.modelProfileRef, - eventAt: normalized.observedAt, - }); - if (!result?.discovery) { - throw new Error("device_edge_channel_discovery_acceptance_invalid"); - } - return Object.freeze({ - ...result.discovery, - ...(result.commandOffer ? { commandOffer: result.commandOffer } : {}), - }); - }, - async submitAdapterMessage(message) { - const normalized = normalizeAdapterMessage(message, { - maxBytes: config.maxEnvelopeBytes, - }); - const result = await submitEvent("adapter.message", { - message: normalized, - }, { - trackerSessionId: normalized.sessionRef, - adapterProfileRef: normalized.protocolProfileRef, - eventAt: normalized.observedAt, - }); - const acceptance = normalizeAdapterAcceptance(result?.acceptance); - if (acceptance.idempotencyKey !== normalized.idempotencyKey) { - throw new Error("device_edge_channel_acceptance_mismatch"); - } - return Object.freeze({ - ...acceptance, - ...(result.commandOffer ? { commandOffer: result.commandOffer } : {}), - }); - }, - async submitCommandStatus(status) { - const normalized = normalizeCommandStatus(status); - const result = await submitEvent("command.status", { - status: normalized, - }, { - trackerSessionId: normalized.sessionRef, - adapterProfileRef: normalized.adapterProfileRef, - eventAt: normalized.observedAt, - }); - if (result?.status !== "recorded") { - throw new Error("device_edge_channel_command_status_invalid"); - } - return Object.freeze({ status: "recorded" }); - }, - status() { - return Object.freeze({ - listening: started, - channel: active?.accepted ? "accepted" : active ? "negotiating" : "absent", - edgeRegistrationId: config.edgeRegistrationId, - channelGeneration: config.channelGeneration, - trustGeneration: trust.generationRef, - edgeCertificateFingerprint: trust.certificateFingerprint, - pendingAcceptances: pending.size, - channelsAccepted: totalChannelsAccepted, - channelsRejected: totalChannelsRejected, - eventsSubmitted: totalEventsSubmitted, - eventsAccepted: totalEventsAccepted, - eventsRejected: totalEventsRejected, - protocolFailures: totalProtocolFailures, - trackerIngress: "disabled", - commandTransport: config.commandTransport, - }); - }, - rotateTrust(next) { - const nextTrust = normalizeTrust(next); - if (nextTrust.generationRef === trust.generationRef) { - throw new TypeError("device_edge_channel_trust_generation_unchanged"); - } - server.setSecureContext({ - key: nextTrust.key, - cert: nextTrust.cert, - ca: nextTrust.ca, - minVersion: "TLSv1.3", - maxVersion: "TLSv1.3", - }); - trust = nextTrust; - const current = active; - if (current) { - current.stream.close(); - closeActive(current); - } - return Object.freeze({ - trustGeneration: trust.generationRef, - edgeCertificateFingerprint: trust.certificateFingerprint, - channel: "reconnect-required", - }); - }, - disconnectActiveChannel() { - active?.stream.close(); - }, - }); - - async function submitEvent(messageKind, payload, metadata) { - const state = active; - if (!state?.accepted || state.closed) { - throw new Error("device_edge_channel_unavailable"); - } - if (pending.size >= config.maxPendingAcceptances) { - throw new Error("device_edge_channel_acceptance_window_full"); - } - const correlationId = `correlation:${randomUUID()}`; - totalEventsSubmitted += 1; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pending.delete(correlationId); - totalEventsRejected += 1; - reject(new Error("device_edge_channel_acceptance_timeout")); - }, config.acceptanceTimeoutMs); - timer.unref?.(); - pending.set(correlationId, { resolve, reject, timer }); - try { - send(state, messageKind, payload, { - ...metadata, - correlationId, - }); - } catch (error) { - clearTimeout(timer); - pending.delete(correlationId); - totalEventsRejected += 1; - reject(error); - } - }); - } - - async function handleCoreEnvelope(state, envelope) { - assertActiveState(state); - if ( - envelope.edgeRegistrationId !== config.edgeRegistrationId - || envelope.channelGeneration !== config.channelGeneration - || envelope.sequence !== state.coreSequence + 1 - ) { - throw new Error("device_edge_channel_core_envelope_mismatch"); - } - state.coreSequence = envelope.sequence; - state.lastCoreActivityAt = config.clock(); - - if (!state.accepted) { - if (envelope.messageKind !== "channel.accepted") { - throw new Error("device_edge_channel_acceptance_required"); - } - if ( - envelope.payload?.status !== "accepted" - || envelope.payload?.commandTransport !== config.commandTransport - ) { - throw new Error("device_edge_channel_acceptance_invalid"); - } - state.accepted = true; - totalChannelsAccepted += 1; - return; - } - if (envelope.messageKind === "channel.heartbeat") return; - if (!["event.accepted", "event.rejected"].includes(envelope.messageKind)) { - throw new Error("device_edge_channel_core_message_unhandled"); - } - const receipt = pending.get(envelope.correlationId); - if (!receipt) { - throw new Error("device_edge_channel_correlation_unknown"); - } - clearTimeout(receipt.timer); - pending.delete(envelope.correlationId); - if (envelope.messageKind === "event.accepted") { - totalEventsAccepted += 1; - receipt.resolve(envelope.payload?.result); - return; - } - totalEventsRejected += 1; - receipt.reject(new Error(normalizeRejectionCode(envelope.payload?.errorCode))); - } - - function send(state, messageKind, payload, metadata) { - assertActiveState(state); - state.edgeSequence += 1; - const now = config.now(); - const envelope = createChannelEnvelope({ - edgeRegistrationId: config.edgeRegistrationId, - channelGeneration: config.channelGeneration, - trackerSessionId: metadata.trackerSessionId, - adapterProfileRef: metadata.adapterProfileRef, - sequence: state.edgeSequence, - eventAt: metadata.eventAt ?? now, - receivedAt: now, - messageKind, - correlationId: metadata.correlationId, - payload, - }, { - direction: "edge-to-core", - maxEnvelopeBytes: config.maxEnvelopeBytes, - }); - const encoded = encodeChannelEnvelope(envelope, { - direction: "edge-to-core", - maxEnvelopeBytes: config.maxEnvelopeBytes, - }); - if (!state.stream.write(encoded)) { - state.stream.once("drain", () => {}); - } - } - - function checkChannelHealth() { - const state = active; - if (!state || state.closed) return; - if (config.clock() - state.lastCoreActivityAt >= config.deadPeerMs) { - protocolFailure(state); - return; - } - if (state.accepted) { - try { - send(state, "channel.heartbeat", { status: "alive" }, { - trackerSessionId: CHANNEL_TRACKER_SESSION_ID, - adapterProfileRef: CHANNEL_PROFILE_REF, - correlationId: `correlation:${randomUUID()}`, - }); - } catch { - protocolFailure(state); - } - } - } - - function isApprovedCorePeer(stream) { - const socket = stream.session?.socket; - if (!socket?.authorized) return false; - let fingerprint; - try { - fingerprint = normalizeCertificateFingerprint( - socket.getPeerCertificate()?.fingerprint256, - ); - } catch { - return false; - } - return trust.allowedCoreFingerprints.has(fingerprint); - } - - function protocolFailure(state) { - totalProtocolFailures += 1; - state.stream.close(); - closeActive(state); - } - - function closeActive(state) { - if (state.closed) return; - state.closed = true; - if (active === state) active = null; - rejectAllPending("device_edge_channel_disconnected"); - } - - function rejectAllPending(code) { - for (const receipt of pending.values()) { - clearTimeout(receipt.timer); - receipt.reject(new Error(code)); - } - pending.clear(); - } - - function assertActiveState(state) { - if (!state || state.closed || active !== state) { - throw new Error("device_edge_channel_unavailable"); - } - } -} - -function normalizeConfig(options) { - const edgeRegistrationId = normalizeRef( - options.edgeRegistrationId, - "edge_registration_id", - ); - const channelGeneration = normalizeRef( - options.channelGeneration, - "channel_generation", - ); - const trust = normalizeTrust({ - ...options.tls, - generationRef: options.trustGeneration, - }); - const keepaliveMs = normalizeDuration(options.keepaliveMs, 10, - DEVICE_EDGE_CHANNEL_LIMITS.keepaliveMs, "keepalive"); - const deadPeerMs = normalizeDuration(options.deadPeerMs, keepaliveMs * 2, - DEVICE_EDGE_CHANNEL_LIMITS.deadPeerMs, "dead_peer"); - if (deadPeerMs < keepaliveMs * 2) { - throw new TypeError("device_edge_channel_dead_peer_invalid"); - } - return Object.freeze({ - edgeRegistrationId, - channelGeneration, - commandTransport: normalizeCommandTransport(options.commandTransport), - trust, - host: normalizeHost(options.host ?? "127.0.0.1"), - port: normalizePort(options.port ?? 443), - keepaliveMs, - deadPeerMs, - acceptanceTimeoutMs: normalizeDuration( - options.acceptanceTimeoutMs, - 10, - 5_000, - "acceptance_timeout", - ), - maxEnvelopeBytes: normalizeInteger( - options.maxEnvelopeBytes, - 256, - DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes, - DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes, - "max_envelope_bytes", - ), - maxPendingAcceptances: normalizeInteger( - options.maxPendingAcceptances, - 1, - DEVICE_EDGE_CHANNEL_LIMITS.maxPendingAcceptances, - DEVICE_EDGE_CHANNEL_LIMITS.maxPendingAcceptances, - "max_pending_acceptances", - ), - clock: typeof options.clock === "function" ? options.clock : Date.now, - now: typeof options.now === "function" - ? () => new Date(options.now()).toISOString() - : () => new Date().toISOString(), - }); -} - -function normalizeCommandTransport(value) { - const normalized = value ?? "disabled"; - if (!["disabled", "typed-service-ping-v1"].includes(normalized)) { - throw new TypeError("device_edge_channel_command_transport_invalid"); - } - return normalized; -} - -function normalizeCommandStatus(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new TypeError("device_edge_channel_command_status_invalid"); - } - const commandRef = normalizeRef(value.commandRef, "command_ref"); - const transportMessageRef = normalizeRef( - value.transportMessageRef, - "transport_message_ref", - ); - const sessionRef = normalizeRef(value.sessionRef, "tracker_session_ref"); - const adapterProfileRef = normalizeRef( - value.adapterProfileRef, - "adapter_profile_ref", - ); - if (!["acknowledged", "unknown"].includes(value.lifecycleState)) { - throw new TypeError("device_edge_channel_command_lifecycle_invalid"); - } - const resultCode = String(value.resultCode || ""); - if (!/^[a-z][a-z0-9._-]{1,63}$/.test(resultCode)) { - throw new TypeError("device_edge_channel_command_result_invalid"); - } - const observedAt = new Date(value.observedAt); - if (Number.isNaN(observedAt.getTime())) { - throw new TypeError("device_edge_channel_command_observed_at_invalid"); - } - return Object.freeze({ - commandRef, - transportMessageRef, - sessionRef, - adapterProfileRef, - lifecycleState: value.lifecycleState, - resultCode, - observedAt: observedAt.toISOString(), - }); -} - -function normalizeTrust(value) { - if (!value || typeof value !== "object") { - throw new TypeError("device_edge_channel_tls_invalid"); - } - for (const key of ["key", "cert", "ca"]) { - if (!(typeof value[key] === "string" || Buffer.isBuffer(value[key]))) { - throw new TypeError(`device_edge_channel_tls_${key}_invalid`); - } - } - if ( - !Array.isArray(value.allowedCoreFingerprints) - || value.allowedCoreFingerprints.length < 1 - || value.allowedCoreFingerprints.length > 2 - ) { - throw new TypeError("device_edge_channel_core_identity_allowlist_invalid"); - } - const generationRef = normalizeRef( - value.generationRef, - "trust_generation", - ); - let certificateFingerprint; - try { - certificateFingerprint = normalizeCertificateFingerprint( - new X509Certificate(value.cert).fingerprint256, - ); - } catch { - throw new TypeError("device_edge_channel_tls_cert_invalid"); - } - const allowedCoreFingerprints = new Set( - value.allowedCoreFingerprints.map(normalizeCertificateFingerprint), - ); - if (allowedCoreFingerprints.size !== value.allowedCoreFingerprints.length) { - throw new TypeError("device_edge_channel_core_identity_allowlist_duplicate"); - } - return Object.freeze({ - generationRef, - key: value.key, - cert: value.cert, - ca: value.ca, - certificateFingerprint, - allowedCoreFingerprints, - }); -} - -function normalizeRejectionCode(value) { - if (typeof value !== "string" || !/^[a-z][a-z0-9._:-]{2,127}$/.test(value)) { - return "device_edge_channel_event_rejected"; - } - return value; -} - -function normalizeRef(value, field) { - if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) { - throw new TypeError(`device_edge_channel_${field}_invalid`); - } - return value; -} - -function normalizeHost(value) { - if (typeof value !== "string" || value.length < 1 || value.length > 253) { - throw new TypeError("device_edge_channel_host_invalid"); - } - return value; -} - -function normalizePort(value) { - const number = Number(value); - if (!Number.isSafeInteger(number) || number < 0 || number > 65_535) { - throw new TypeError("device_edge_channel_port_invalid"); - } - return number; -} - -function normalizeDuration(value, minimum, fallback, field) { - return normalizeInteger(value, minimum, 120_000, fallback, field); -} - -function normalizeInteger(value, minimum, maximum, fallback, field) { - const number = value == null ? fallback : Number(value); - if (!Number.isSafeInteger(number) || number < minimum || number > maximum) { - throw new TypeError(`device_edge_channel_${field}_invalid`); - } - return number; -} - -function listen(server, port, host) { - return new Promise((resolve, reject) => { - const onError = (error) => { - server.off("listening", onListening); - reject(error); - }; - const onListening = () => { - server.off("error", onError); - resolve(); - }; - server.once("error", onError); - server.once("listening", onListening); - server.listen(port, host); - }); -} - -function closeServer(server) { - return new Promise((resolve) => server.close(() => resolve())); -} diff --git a/device-plane/services/device-edge-channel/src/server.mjs b/device-plane/services/device-edge-channel/src/server.mjs deleted file mode 100644 index c02882e..0000000 --- a/device-plane/services/device-edge-channel/src/server.mjs +++ /dev/null @@ -1,203 +0,0 @@ -import { lstat, readFile } from "node:fs/promises"; -import { createServer } from "node:http"; -import { pathToFileURL } from "node:url"; - -import { createDeviceEdgeChannelServer } from "./runtime.mjs"; - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - await main(); -} - -async function main() { - const runtime = await readRuntimeConfiguration(process.env); - const channel = createDeviceEdgeChannelServer(runtime.channel); - const health = createHealthServer(channel, runtime.health); - let stopping = false; - - await channel.start(); - await listen(health, runtime.health.port, runtime.health.host); - - console.log(JSON.stringify({ - event: "device_edge_channel_started", - host: runtime.channel.host, - port: runtime.channel.port, - healthHost: runtime.health.host, - healthPort: runtime.health.port, - edgeRegistrationId: runtime.channel.edgeRegistrationId, - channelGeneration: runtime.channel.channelGeneration, - trustGeneration: runtime.channel.trustGeneration, - trackerIngress: "disabled", - commandTransport: "disabled", - })); - - process.on("SIGTERM", shutdown); - process.on("SIGINT", shutdown); - - async function shutdown() { - if (stopping) return; - stopping = true; - await Promise.allSettled([ - channel.stop(), - closeServer(health), - ]); - process.exit(0); - } -} - -export async function readRuntimeConfiguration(environment = {}) { - const configPath = requiredPath( - environment.DEVICE_EDGE_CHANNEL_CONFIG_FILE, - "device_edge_channel_config_file_required", - ); - const keyPath = requiredPath( - environment.DEVICE_EDGE_CHANNEL_KEY_FILE, - "device_edge_channel_key_file_required", - ); - const certificatePath = requiredPath( - environment.DEVICE_EDGE_CHANNEL_CERTIFICATE_FILE, - "device_edge_channel_certificate_file_required", - ); - const coreTrustPath = requiredPath( - environment.DEVICE_EDGE_CHANNEL_CORE_TRUST_FILE, - "device_edge_channel_core_trust_file_required", - ); - const config = normalizeRuntimeDocument(JSON.parse( - await readBoundedRegularFile(configPath, 32 * 1024, "utf8"), - )); - const [key, cert, ca] = await Promise.all([ - readBoundedRegularFile(keyPath, 32 * 1024), - readBoundedRegularFile(certificatePath, 32 * 1024), - readBoundedRegularFile(coreTrustPath, 64 * 1024), - ]); - return Object.freeze({ - channel: Object.freeze({ - edgeRegistrationId: config.edgeRegistrationId, - channelGeneration: config.channelGeneration, - trustGeneration: config.trustGeneration, - host: normalizeHost(environment.DEVICE_EDGE_CHANNEL_HOST ?? "0.0.0.0"), - port: normalizePort(environment.DEVICE_EDGE_CHANNEL_PORT, 443), - tls: Object.freeze({ - key, - cert, - ca, - allowedCoreFingerprints: config.allowedCoreFingerprints, - }), - }), - health: Object.freeze({ - host: normalizeHost( - environment.DEVICE_EDGE_CHANNEL_HEALTH_HOST ?? "127.0.0.1", - ), - port: normalizePort(environment.DEVICE_EDGE_CHANNEL_HEALTH_PORT, 18222), - }), - }); -} - -export function normalizeRuntimeDocument(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new TypeError("device_edge_channel_runtime_config_invalid"); - } - const allowedKeys = new Set([ - "schemaVersion", - "edgeRegistrationId", - "channelGeneration", - "trustGeneration", - "allowedCoreFingerprints", - ]); - if (Object.keys(value).some((key) => !allowedKeys.has(key))) { - throw new TypeError("device_edge_channel_runtime_config_key_invalid"); - } - if (value.schemaVersion !== "nodedc.device-edge.channel-runtime.v1") { - throw new TypeError("device_edge_channel_runtime_schema_invalid"); - } - const fingerprints = value.allowedCoreFingerprints; - if (!Array.isArray(fingerprints) || fingerprints.length < 1 || fingerprints.length > 2) { - throw new TypeError("device_edge_channel_runtime_core_identity_invalid"); - } - if (new Set(fingerprints).size !== fingerprints.length) { - throw new TypeError("device_edge_channel_runtime_core_identity_invalid"); - } - return Object.freeze({ - schemaVersion: value.schemaVersion, - edgeRegistrationId: normalizeRef(value.edgeRegistrationId, "edge_registration"), - channelGeneration: normalizeRef(value.channelGeneration, "channel_generation"), - trustGeneration: normalizeRef(value.trustGeneration, "trust_generation"), - allowedCoreFingerprints: Object.freeze(fingerprints.map((fingerprint) => { - if (typeof fingerprint !== "string" || !/^([A-F0-9]{2}:){31}[A-F0-9]{2}$/.test(fingerprint)) { - throw new TypeError("device_edge_channel_runtime_core_identity_invalid"); - } - return fingerprint; - })), - }); -} - -function createHealthServer(channel, healthConfig) { - return createServer((request, response) => { - response.setHeader("Content-Type", "application/json; charset=utf-8"); - response.setHeader("Cache-Control", "no-store"); - response.setHeader("X-Content-Type-Options", "nosniff"); - if (request.method !== "GET" || request.url !== "/healthz") { - response.statusCode = 404; - response.end(JSON.stringify({ ok: false, error: "not_found" })); - return; - } - const status = channel.status(); - response.statusCode = 200; - response.end(JSON.stringify({ - ok: true, - service: "nodedc-device-edge-channel", - health: `${healthConfig.host}:${healthConfig.port}`, - ...status, - })); - }); -} - -async function readBoundedRegularFile(path, maximumBytes, encoding = null) { - const state = await lstat(path); - if (!state.isFile() || state.isSymbolicLink() || state.size < 1 || state.size > maximumBytes) { - throw new Error("device_edge_channel_runtime_file_invalid"); - } - return readFile(path, encoding ?? undefined); -} - -function requiredPath(value, code) { - if (typeof value !== "string" || value.trim() === "" || !value.startsWith("/")) { - throw new Error(code); - } - return value; -} - -function normalizeRef(value, field) { - if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) { - throw new TypeError(`device_edge_channel_runtime_${field}_invalid`); - } - return value; -} - -function normalizeHost(value) { - if (typeof value !== "string" || value.length < 1 || value.length > 253) { - throw new TypeError("device_edge_channel_runtime_host_invalid"); - } - return value; -} - -function normalizePort(value, fallback) { - const number = Number(value ?? fallback); - if (!Number.isSafeInteger(number) || number < 1 || number > 65_535) { - throw new TypeError("device_edge_channel_runtime_port_invalid"); - } - return number; -} - -function listen(server, port, host) { - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(port, host, () => { - server.off("error", reject); - resolve(); - }); - }); -} - -function closeServer(server) { - return new Promise((resolve) => server.close(() => resolve())); -} diff --git a/device-plane/services/device-edge-channel/test/channel-integration.test.mjs b/device-plane/services/device-edge-channel/test/channel-integration.test.mjs deleted file mode 100644 index c15b55e..0000000 --- a/device-plane/services/device-edge-channel/test/channel-integration.test.mjs +++ /dev/null @@ -1,765 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { randomUUID, X509Certificate } from "node:crypto"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { connect as connectHttp2 } from "node:http2"; -import { createServer as createTcpServer } from "node:net"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { after, before, test } from "node:test"; - -import { - DEVICE_ADAPTER_ACCEPTANCE_SCHEMA, - DEVICE_ADAPTER_MESSAGE_SCHEMA, - DEVICE_DISCOVERY_SIGNAL_SCHEMA, -} from "../../../packages/device-protocol-contract/src/index.mjs"; -import { createDeviceGatewayCoreChannelClient } from - "../../device-gateway-core/src/runtime.mjs"; -import { createDeviceGatewayIngest } from - "../../device-control-core/src/gateway-ingest.mjs"; -import { createDeviceEdgeChannelServer } from "../src/runtime.mjs"; - -let fixtureDirectory; -let certificates; - -before(async () => { - fixtureDirectory = await mkdtemp(join(tmpdir(), "nodedc-edge-channel-")); - certificates = await generateCertificateFixture(fixtureDirectory); -}); - -after(async () => { - await rm(fixtureDirectory, { recursive: true, force: true }); -}); - -test("accepts synthetic discovery and durable message results over Core-initiated mTLS", async () => { - const acceptedMessages = new Map(); - let messageCalls = 0; - const pair = await startPair({ - observeDiscovery: productionDiscoveryObserver(), - acceptMessage: async (message) => { - messageCalls += 1; - const previous = acceptedMessages.get(message.idempotencyKey); - if (previous) return { ...previous, replayed: true }; - const acceptance = acceptanceFor(message, false); - acceptedMessages.set(message.idempotencyKey, acceptance); - return acceptance; - }, - }); - - try { - const discovery = await pair.edge.submitDiscovery(discoverySignal()); - assert.equal(discovery.lifecycleState, "quarantine"); - assert.equal(discovery.identifier.masked, "***********0001"); - assert.equal("created" in discovery, false); - assert.equal("value" in discovery, false); - - const message = adapterMessage(); - const first = await pair.edge.submitAdapterMessage(message); - const replay = await pair.edge.submitAdapterMessage(message); - assert.equal(first.replayed, false); - assert.equal(replay.replayed, true); - assert.equal(messageCalls, 2); - assert.equal(pair.edge.status().eventsAccepted, 3); - assert.equal(pair.core.status().eventsAccepted, 3); - assert.equal(pair.edge.status().trackerIngress, "disabled"); - assert.equal(pair.core.status().commandTransport, "disabled"); - } finally { - await stopPair(pair); - } -}); - -test("recovers the claimed device from telemetry after a Core restart and completes a typed command", async () => { - const commandRef = "command:11111111-1111-4111-8111-111111111111"; - const deviceRef = "device:22222222-2222-4222-8222-222222222222"; - const transportMessageRef = "edge-command:33333333-3333-4333-8333-333333333333"; - const recorded = []; - let offers = 0; - const pair = await startPair({ - commandTransport: "typed-service-ping-v1", - acceptMessage: async (message) => ({ - value: acceptanceFor(message, false), - claimedDeviceRef: deviceRef, - }), - offerCommand: async (offeredDeviceRef) => { - assert.equal(offeredDeviceRef, deviceRef); - offers += 1; - return { - commandRef, - commandType: "service.ping", - accessCode: "123456", - transportMessageRef, - }; - }, - recordCommandStatus: async (status) => recorded.push(status), - }); - - try { - const receipt = await pair.edge.submitAdapterMessage(adapterMessage()); - assert.equal(receipt.status, "accepted"); - assert.deepEqual(receipt.commandOffer, { - commandRef, - commandType: "service.ping", - accessCode: "123456", - transportMessageRef, - }); - assert.equal(offers, 1); - - await pair.edge.submitCommandStatus({ - commandRef, - transportMessageRef, - lifecycleState: "acknowledged", - resultCode: "serv_ok", - observedAt: "2026-08-12T17:30:00.000Z", - sessionRef: "session:pilot-1", - adapterProfileRef: "arusnavi.internal.b2.v1", - }); - assert.equal(recorded.length, 1); - assert.equal(recorded[0].resultCode, "serv_ok"); - assert.equal(pair.core.status().negotiatedCommandTransport, "typed-service-ping-v1"); - } finally { - await stopPair(pair); - } -}); - -test("keeps the channel alive and reconnects without losing idempotency", async () => { - const acceptedMessages = new Map(); - const pair = await startPair({ - keepaliveMs: 20, - deadPeerMs: 60, - reconnectMinimumMs: 20, - reconnectMaximumMs: 40, - acceptMessage: async (message) => { - const previous = acceptedMessages.get(message.idempotencyKey); - if (previous) return { ...previous, replayed: true }; - const acceptance = acceptanceFor(message, false); - acceptedMessages.set(message.idempotencyKey, acceptance); - return acceptance; - }, - }); - - try { - await delay(120); - assert.equal(pair.edge.status().channel, "accepted"); - const message = adapterMessage(); - assert.equal((await pair.edge.submitAdapterMessage(message)).replayed, false); - - pair.edge.disconnectActiveChannel(); - await waitFor(() => pair.core.status().connectionAttempts >= 2 - && pair.edge.status().channel === "accepted", 2_000); - assert.equal((await pair.edge.submitAdapterMessage(message)).replayed, true); - assert.ok(pair.core.status().reconnects >= 1); - } finally { - await stopPair(pair); - } -}); - -test("bounds a stalled TCP/TLS handshake and reconnects", async () => { - const sockets = new Set(); - const server = createTcpServer((socket) => { - sockets.add(socket); - socket.once("close", () => sockets.delete(socket)); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - const core = createCoreClient({ - address: server.address(), - connectTimeoutMs: 30, - reconnectMinimumMs: 20, - reconnectMaximumMs: 20, - }); - try { - await core.start(); - await waitFor(() => core.status().connectionAttempts >= 2, 1_000); - assert.match(core.status().lastErrorCode, /connect_timeout/); - assert.ok(core.status().reconnects >= 1); - } finally { - await core.stop(); - for (const socket of sockets) socket.destroy(); - await new Promise((resolve) => server.close(resolve)); - } -}); - -test("rotates the Edge certificate through staged overlap and rejects retired identity", async () => { - const edge = createEdgeServer(); - const address = await edge.start(); - let registration = edgeRegistration(address, [ - { - generationRef: "trust-generation:1", - fingerprint: certificates.edge.fingerprint, - status: "active", - }, - { - generationRef: "trust-generation:2", - fingerprint: certificates.edgeNext.fingerprint, - status: "staged", - }, - ]); - const core = createCoreClient({ - address, - registrationProvider: async () => registration, - }); - try { - await core.start(); - await core.waitForReady(2_000); - await waitFor(() => edge.status().channel === "accepted", 2_000); - assert.equal(core.status().edgeTrustGeneration, "trust-generation:1"); - - edge.rotateTrust({ - generationRef: "trust-generation:2", - key: certificates.edgeNext.key, - cert: certificates.edgeNext.cert, - ca: certificates.ca, - allowedCoreFingerprints: [certificates.core.fingerprint], - }); - await waitFor(() => core.status().connectionAttempts >= 2 - && core.status().channel === "accepted" - && core.status().edgeTrustGeneration === "trust-generation:2" - && edge.status().channel === "accepted", 2_000); - - registration = edgeRegistration(address, [{ - generationRef: "trust-generation:2", - fingerprint: certificates.edgeNext.fingerprint, - status: "active", - }]); - assert.equal((await edge.submitAdapterMessage(adapterMessage())).status, "accepted"); - - const failuresBeforeRollback = core.status().protocolFailures; - edge.rotateTrust({ - generationRef: "trust-generation:3", - key: certificates.edge.key, - cert: certificates.edge.cert, - ca: certificates.ca, - allowedCoreFingerprints: [certificates.core.fingerprint], - }); - await waitFor(() => core.status().protocolFailures > failuresBeforeRollback, 2_000); - assert.notEqual(core.status().channel, "accepted"); - } finally { - await core.stop(); - await edge.stop(); - } -}); - -test("rejects a revoked Edge registration before opening a channel", async () => { - const edge = createEdgeServer(); - const address = await edge.start(); - const core = createCoreClient({ - address, - lifecycleState: "revoked", - }); - try { - await core.start(); - await waitFor(() => core.status().connectionAttempts >= 1, 500); - assert.equal(core.status().channel, "absent"); - assert.equal(edge.status().channelsAccepted, 0); - assert.match(core.status().lastErrorCode, /registration_inactive/); - } finally { - await core.stop(); - await edge.stop(); - } -}); - -test("rejects an Edge hello from an unexpected channel generation", async () => { - const edge = createEdgeServer({ - channelGeneration: "generation:edge-unexpected", - }); - const address = await edge.start(); - const core = createCoreClient({ address }); - try { - await core.start(); - await waitFor(() => core.status().protocolFailures >= 1, 2_000); - assert.notEqual(core.status().channel, "accepted"); - assert.match(core.status().lastErrorCode, /channel_generation_mismatch/); - assert.equal(edge.status().channelsAccepted, 0); - } finally { - await core.stop(); - await edge.stop(); - } -}); - -test("rejects an authenticated but non-allowlisted Core certificate", async () => { - const edge = createEdgeServer(); - const address = await edge.start(); - const core = createCoreClient({ - address, - clientCertificate: certificates.intruder, - }); - try { - await core.start(); - await waitFor(() => edge.status().channelsRejected >= 1, 2_000); - assert.equal(edge.status().channelsAccepted, 0); - assert.equal(edge.status().channel, "absent"); - } finally { - await core.stop(); - await edge.stop(); - } -}); - -test("rejects an unknown Edge certificate fingerprint", async () => { - const edge = createEdgeServer(); - const address = await edge.start(); - const core = createCoreClient({ - address, - expectedEdgeFingerprint: certificates.intruder.fingerprint, - }); - try { - await core.start(); - await waitFor(() => core.status().protocolFailures >= 1, 2_000); - assert.equal(core.status().channelsAccepted, 0); - assert.match(core.status().lastErrorCode, /edge_identity_mismatch/); - } finally { - await core.stop(); - await edge.stop(); - } -}); - -test("returns a conclusive rejection when Core cannot accept a package", async () => { - const pair = await startPair({ - acceptMessage: async () => { - throw new Error("device_control_core_unavailable"); - }, - }); - try { - await assert.rejects( - pair.edge.submitAdapterMessage(adapterMessage()), - /device_control_core_unavailable/, - ); - assert.equal(pair.edge.status().eventsRejected, 1); - assert.equal(pair.core.status().eventsRejected, 1); - } finally { - await stopPair(pair); - } -}); - -test("rejects a discovery when Core does not return its durable receipt", async () => { - const pair = await startPair({ - observeDiscovery: async () => ({ - schemaVersion: "nodedc.device.discovery-view.v1", - lifecycleState: "quarantine", - commandTransport: "disabled", - identifier: { kind: "imei", masked: "***********1088" }, - }), - }); - try { - await assert.rejects( - pair.edge.submitDiscovery(discoverySignal()), - /device_gateway_core_discovery_receipt_invalid/, - ); - assert.equal(pair.edge.status().eventsRejected, 1); - assert.equal(pair.core.status().eventsRejected, 1); - } finally { - await stopPair(pair); - } -}); - -test("isolates tracker session ordering while allowing cross-session progress", async () => { - let releaseSlow; - const slowGate = new Promise((resolve) => { - releaseSlow = resolve; - }); - const calls = []; - const pair = await startPair({ - acceptMessage: async (message) => { - calls.push(message.sessionRef); - if (message.sessionRef === "session:slow") await slowGate; - return acceptanceFor(message, false); - }, - }); - try { - let slowResolved = false; - const slow = pair.edge.submitAdapterMessage(adapterMessage({ - sessionRef: "session:slow", - messageRef: "message:slow-1", - idempotencyKey: `sha256:${"c".repeat(64)}`, - })).then((value) => { - slowResolved = true; - return value; - }); - await waitFor(() => calls.includes("session:slow"), 500); - const fast = await pair.edge.submitAdapterMessage(adapterMessage({ - sessionRef: "session:fast", - messageRef: "message:fast-1", - idempotencyKey: `sha256:${"d".repeat(64)}`, - })); - assert.equal(fast.status, "accepted"); - assert.equal(slowResolved, false); - releaseSlow(); - assert.equal((await slow).status, "accepted"); - } finally { - releaseSlow?.(); - await stopPair(pair); - } -}); - -test("preserves per-session order and applies a bounded acceptance window", async () => { - let releaseFirst; - const firstGate = new Promise((resolve) => { - releaseFirst = resolve; - }); - const calls = []; - const pair = await startPair({ - maxPendingAcceptances: 2, - acceptMessage: async (message) => { - calls.push(message.messageRef); - if (message.messageRef === "message:ordered-1") await firstGate; - return acceptanceFor(message, false); - }, - }); - try { - const first = pair.edge.submitAdapterMessage(adapterMessage({ - sessionRef: "session:ordered", - messageRef: "message:ordered-1", - idempotencyKey: `sha256:${"e".repeat(64)}`, - sequence: 1, - })); - await waitFor(() => calls.length === 1, 500); - const second = pair.edge.submitAdapterMessage(adapterMessage({ - sessionRef: "session:ordered", - messageRef: "message:ordered-2", - idempotencyKey: `sha256:${"f".repeat(64)}`, - sequence: 2, - })); - await delay(30); - assert.deepEqual(calls, ["message:ordered-1"]); - await assert.rejects( - pair.edge.submitAdapterMessage(adapterMessage({ - sessionRef: "session:overflow", - messageRef: "message:overflow-1", - idempotencyKey: `sha256:${"1".repeat(64)}`, - })), - /acceptance_window_full/, - ); - releaseFirst(); - await Promise.all([first, second]); - assert.deepEqual(calls, ["message:ordered-1", "message:ordered-2"]); - } finally { - releaseFirst?.(); - await stopPair(pair); - } -}); - -test("closes the logical session on an unknown message kind", async () => { - const edge = createEdgeServer(); - const address = await edge.start(); - const session = connectHttp2(`https://127.0.0.1:${address.port}`, { - key: certificates.core.key, - cert: certificates.core.cert, - ca: certificates.ca, - servername: "localhost", - minVersion: "TLSv1.3", - maxVersion: "TLSv1.3", - rejectUnauthorized: true, - }); - try { - await onceEvent(session, "connect"); - const request = session.request({ - ":method": "POST", - ":path": "/internal/v1/device-edge/channel", - }, { endStream: false }); - const hello = await readFirstEnvelope(request); - const invalid = { - schemaVersion: hello.schemaVersion, - edgeRegistrationId: hello.edgeRegistrationId, - channelGeneration: hello.channelGeneration, - trackerSessionId: "channel:control", - adapterProfileRef: "channel.control.v1", - sequence: 1, - eventAt: new Date().toISOString(), - receivedAt: new Date().toISOString(), - payloadBytes: 2, - messageKind: "tcp.forward", - correlationId: `correlation:${randomUUID()}`, - payload: {}, - }; - request.write(`${JSON.stringify(invalid)}\n`); - await waitFor(() => edge.status().protocolFailures >= 1, 1_000); - assert.equal(edge.status().channel, "absent"); - request.close(); - } finally { - session.close(); - await edge.stop(); - } -}); - -async function startPair(options = {}) { - const edge = createEdgeServer(options); - const address = await edge.start(); - const core = createCoreClient({ address, ...options }); - await core.start(); - await core.waitForReady(2_000); - await waitFor(() => edge.status().channel === "accepted", 2_000); - return { edge, core }; -} - -function createEdgeServer(options = {}) { - const edgeCertificate = options.edgeCertificate ?? certificates.edge; - return createDeviceEdgeChannelServer({ - edgeRegistrationId: "edge:pilot-1", - channelGeneration: options.channelGeneration ?? "generation:pilot-1", - trustGeneration: options.edgeTrustGeneration ?? "trust-generation:1", - host: "127.0.0.1", - port: 0, - tls: { - key: edgeCertificate.key, - cert: edgeCertificate.cert, - ca: certificates.ca, - allowedCoreFingerprints: options.allowedCoreFingerprints - ?? [certificates.core.fingerprint], - }, - keepaliveMs: options.keepaliveMs ?? 50, - deadPeerMs: options.deadPeerMs ?? 150, - acceptanceTimeoutMs: 500, - maxPendingAcceptances: options.maxPendingAcceptances, - commandTransport: options.commandTransport, - }); -} - -function createCoreClient(options) { - const clientCertificate = options.clientCertificate ?? certificates.core; - const registration = edgeRegistration(options.address, - options.edgeCertificateIdentities ?? [{ - generationRef: "trust-generation:1", - fingerprint: options.expectedEdgeFingerprint - ?? certificates.edge.fingerprint, - status: "active", - }], - options.lifecycleState ?? "active"); - return createDeviceGatewayCoreChannelClient({ - ...(options.registrationProvider - ? { registrationProvider: options.registrationProvider } - : { registration }), - tls: { - key: clientCertificate.key, - cert: clientCertificate.cert, - ca: certificates.ca, - }, - coreIdentity: "workload:device-gateway-core", - keepaliveMs: options.keepaliveMs ?? 50, - deadPeerMs: options.deadPeerMs ?? 150, - reconnectMinimumMs: options.reconnectMinimumMs ?? 20, - reconnectMaximumMs: options.reconnectMaximumMs ?? 80, - connectTimeoutMs: options.connectTimeoutMs, - random: () => 0, - observeDiscovery: options.observeDiscovery ?? productionDiscoveryObserver(), - acceptMessage: options.acceptMessage ?? (async (message) => - acceptanceFor(message, false)), - commandTransport: options.commandTransport, - offerCommand: options.offerCommand, - recordCommandStatus: options.recordCommandStatus, - }); -} - -function productionDiscoveryObserver() { - return createDeviceGatewayIngest({ - identifierPepper: "test-only-device-edge-channel-identifier-pepper", - repository: { - async upsertQuarantineDiscovery(value) { - return { - created: true, - value: { - ...value.safeView, - discoveryRef: "discovery:pilot-1", - }, - }; - }, - async acceptAdapterMessage() { - throw new Error("device_edge_channel_test_unexpected_ingest_message"); - }, - }, - }).observeDiscovery; -} - -function edgeRegistration(address, certificateIdentities, lifecycleState = "active") { - return { - edgeRegistrationId: "edge:pilot-1", - channelGeneration: "generation:pilot-1", - endpoint: `https://127.0.0.1:${address.port}/`, - servername: "localhost", - certificateIdentities, - lifecycleState, - }; -} - -async function stopPair(pair) { - await pair.core.stop(); - await pair.edge.stop(); -} - -function discoverySignal() { - return { - schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA, - sessionRef: "session:pilot-1", - modelProfileRef: "arusnavi.internal.b2.v1", - protocol: "INTERNAL", - observedAt: new Date().toISOString(), - identifier: { kind: "imei", value: "860000000000001" }, - evidence: { - transport: "tcp", - bytesObserved: 10, - framingStatus: "verified", - specificationRef: "arusnavi.internal.protocol.v1", - }, - }; -} - -function adapterMessage(overrides = {}) { - return { - schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA, - edgeRef: "edge:pilot-1", - adapterRef: "arusnavi-b2", - protocolProfileRef: "arusnavi.internal.b2.v1", - protocol: "INTERNAL", - sessionRef: "session:pilot-1", - messageRef: "message:pilot-1", - messageType: "telemetry.package", - sequence: 1, - observedAt: new Date().toISOString(), - idempotencyKey: `sha256:${"a".repeat(64)}`, - identifier: { kind: "imei", value: "860000000000001" }, - payloadSchemaRef: "arusnavi.internal.package-metadata.v1", - payload: { - packageNumber: 1, - packetCount: 1, - byteLength: 11, - packageDigest: `sha256:${"b".repeat(64)}`, - }, - ...overrides, - }; -} - -function acceptanceFor(message, replayed) { - return { - schemaVersion: DEVICE_ADAPTER_ACCEPTANCE_SCHEMA, - acceptanceRef: "acceptance:pilot-1", - idempotencyKey: message.idempotencyKey, - status: "accepted", - replayed, - acceptedAt: "2026-08-11T12:00:00.000Z", - }; -} - -async function generateCertificateFixture(directory) { - runOpenSsl(directory, [ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-sha256", - "-days", "1", "-subj", "/CN=NODEDC Test Device Edge CA", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-keyout", "ca.key", "-out", "ca.crt", - ]); - await issueCertificate(directory, "edge", "localhost", [ - "subjectAltName=DNS:localhost,IP:127.0.0.1", - "extendedKeyUsage=serverAuth", - ]); - await issueCertificate(directory, "edge-next", "localhost", [ - "subjectAltName=DNS:localhost,IP:127.0.0.1", - "extendedKeyUsage=serverAuth", - ]); - await issueCertificate(directory, "core", "nodedc-device-gateway-core", [ - "extendedKeyUsage=clientAuth", - ]); - await issueCertificate(directory, "intruder", "unapproved-core", [ - "extendedKeyUsage=clientAuth", - ]); - const ca = await readFile(join(directory, "ca.crt")); - return { - ca, - edge: await readCertificate(directory, "edge"), - edgeNext: await readCertificate(directory, "edge-next"), - core: await readCertificate(directory, "core"), - intruder: await readCertificate(directory, "intruder"), - }; -} - -async function issueCertificate(directory, name, commonName, extensions) { - runOpenSsl(directory, [ - "req", "-newkey", "rsa:2048", "-nodes", "-sha256", - "-subj", `/CN=${commonName}`, - "-keyout", `${name}.key`, "-out", `${name}.csr`, - ]); - const extensionFile = `${name}.ext`; - await writeFile(join(directory, extensionFile), [ - "basicConstraints=critical,CA:FALSE", - "keyUsage=critical,digitalSignature,keyEncipherment", - ...extensions, - ].join("\n")); - runOpenSsl(directory, [ - "x509", "-req", "-sha256", "-days", "1", - "-in", `${name}.csr`, "-CA", "ca.crt", "-CAkey", "ca.key", - "-CAcreateserial", "-extfile", extensionFile, "-out", `${name}.crt`, - ]); -} - -async function readCertificate(directory, name) { - const cert = await readFile(join(directory, `${name}.crt`)); - return { - key: await readFile(join(directory, `${name}.key`)), - cert, - fingerprint: new X509Certificate(cert).fingerprint256, - }; -} - -function runOpenSsl(directory, arguments_) { - const result = spawnSync("openssl", arguments_, { - cwd: directory, - encoding: "utf8", - }); - if (result.status !== 0) { - throw new Error(`openssl_failed:${result.stderr}`); - } -} - -function readFirstEnvelope(stream) { - return new Promise((resolve, reject) => { - let buffered = ""; - const onData = (chunk) => { - buffered += chunk.toString("utf8"); - const newline = buffered.indexOf("\n"); - if (newline < 0) return; - cleanup(); - resolve(JSON.parse(buffered.slice(0, newline))); - }; - const onError = (error) => { - cleanup(); - reject(error); - }; - const cleanup = () => { - stream.off("data", onData); - stream.off("error", onError); - }; - stream.on("data", onData); - stream.once("error", onError); - }); -} - -function onceEvent(emitter, event) { - return new Promise((resolve, reject) => { - const onEvent = (...args) => { - cleanup(); - resolve(args); - }; - const onError = (error) => { - cleanup(); - reject(error); - }; - const cleanup = () => { - emitter.off(event, onEvent); - emitter.off("error", onError); - }; - emitter.once(event, onEvent); - emitter.once("error", onError); - }); -} - -async function waitFor(predicate, timeoutMs) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return; - await delay(10); - } - assert.fail("condition_timeout"); -} - -function delay(milliseconds) { - return new Promise((resolve) => setTimeout(resolve, milliseconds)); -} diff --git a/device-plane/services/device-edge-channel/test/server-config.test.mjs b/device-plane/services/device-edge-channel/test/server-config.test.mjs deleted file mode 100644 index 80cfd72..0000000 --- a/device-plane/services/device-edge-channel/test/server-config.test.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { normalizeRuntimeDocument } from "../src/server.mjs"; - -const fingerprint = Array.from({ length: 32 }, () => "AB").join(":"); - -test("runtime document accepts one generation-bound Core identity", () => { - const result = normalizeRuntimeDocument({ - schemaVersion: "nodedc.device-edge.channel-runtime.v1", - edgeRegistrationId: "edge:moscow-vps-1", - channelGeneration: "channel:1", - trustGeneration: "trust:1", - allowedCoreFingerprints: [fingerprint], - }); - - assert.equal(result.edgeRegistrationId, "edge:moscow-vps-1"); - assert.deepEqual(result.allowedCoreFingerprints, [fingerprint]); -}); - -test("runtime document rejects hidden authority and missing identity", () => { - assert.throws(() => normalizeRuntimeDocument({ - schemaVersion: "nodedc.device-edge.channel-runtime.v1", - edgeRegistrationId: "edge:moscow-vps-1", - channelGeneration: "channel:1", - trustGeneration: "trust:1", - allowedCoreFingerprints: [fingerprint], - endpoint: "https://attacker.invalid", - }), /runtime_config_key_invalid/); - - assert.throws(() => normalizeRuntimeDocument({ - schemaVersion: "nodedc.device-edge.channel-runtime.v1", - edgeRegistrationId: "edge:moscow-vps-1", - channelGeneration: "channel:1", - trustGeneration: "trust:1", - allowedCoreFingerprints: [], - }), /runtime_core_identity_invalid/); - - assert.throws(() => normalizeRuntimeDocument({ - schemaVersion: "nodedc.device-edge.channel-runtime.v1", - edgeRegistrationId: "edge:moscow-vps-1", - channelGeneration: "channel:1", - trustGeneration: "trust:1", - allowedCoreFingerprints: [fingerprint, fingerprint], - }), /runtime_core_identity_invalid/); -}); diff --git a/device-plane/services/device-edge-channel/test/vps-runtime-config.test.mjs b/device-plane/services/device-edge-channel/test/vps-runtime-config.test.mjs deleted file mode 100644 index f055de0..0000000 --- a/device-plane/services/device-edge-channel/test/vps-runtime-config.test.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { - normalizeTrackerIngressConfiguration, -} from "../../../vps/edge-process/device-edge-runtime.mjs"; - -test("VPS tracker ingress resolves one allowlisted profile within bounded limits", () => { - const config = normalizeTrackerIngressConfiguration({}, "edge:moscow-vps-1"); - - assert.equal(config.protocolProfileRef, "arusnavi.b2.internal.v1"); - assert.equal(config.tcpHost, "0.0.0.0"); - assert.equal(config.tcpPort, 9921); - assert.equal(config.maxConcurrentSessions, 128); - assert.equal(config.maxSessionsPerAddress, 16); - assert.equal(config.maxConnectionsPerMinutePerAddress, 60); - assert.equal(config.maxAggregateBufferedBytes, 32 * 1024 * 1024); -}); - -test("VPS tracker ingress rejects hidden bind and non-allowlisted adapters", () => { - assert.throws(() => normalizeTrackerIngressConfiguration({ - DEVICE_GATEWAY_TCP_HOST: "127.0.0.1", - }, "edge:moscow-vps-1"), /public_host_invalid/); - - assert.throws(() => normalizeTrackerIngressConfiguration({ - DEVICE_GATEWAY_PROTOCOL_PROFILE_REF: "vendor.unknown.v1", - }, "edge:moscow-vps-1"), /profile_not_allowlisted/); - - assert.throws(() => normalizeTrackerIngressConfiguration({ - DEVICE_GATEWAY_MAX_SESSIONS: "129", - }, "edge:moscow-vps-1"), /session_limit_invalid/); -}); diff --git a/device-plane/services/device-edge-relay/Dockerfile b/device-plane/services/device-edge-relay/Dockerfile deleted file mode 100644 index 64ad838..0000000 --- a/device-plane/services/device-edge-relay/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM node:22-alpine - -WORKDIR /app - -COPY services/device-edge-relay/src ./src - -USER node - -CMD ["node", "src/server.mjs"] diff --git a/device-plane/services/device-edge-relay/package.json b/device-plane/services/device-edge-relay/package.json deleted file mode 100644 index 1afe259..0000000 --- a/device-plane/services/device-edge-relay/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "@nodedc/device-edge-relay", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "start": "node src/server.mjs", - "test": "node --test test/*.test.mjs" - }, - "engines": { - "node": ">=20" - } -} diff --git a/device-plane/services/device-edge-relay/src/runtime.mjs b/device-plane/services/device-edge-relay/src/runtime.mjs deleted file mode 100644 index 20ca8da..0000000 --- a/device-plane/services/device-edge-relay/src/runtime.mjs +++ /dev/null @@ -1,384 +0,0 @@ -import { createServer as createHttpServer } from "node:http"; -import { connect, createServer as createTcpServer } from "node:net"; - -export function createDeviceEdgeRelayRuntime(options = {}) { - const config = normalizeConfig(options); - const sessions = new Map(); - const sessionsByAddress = new Map(); - const connectionWindows = new Map(); - let totalAccepted = 0; - let totalRejected = 0; - let totalForwarded = 0; - - const tcpServer = createTcpServer({ allowHalfOpen: true }, (socket) => { - const remoteAddress = config.resolveRemoteAddress(socket.remoteAddress); - if ( - !allowsSource(remoteAddress) - || sessions.size >= config.maxConcurrentSessions - || currentAddressSessions(remoteAddress) >= config.maxSessionsPerAddress - || !consumeConnectionPermit(remoteAddress) - ) { - totalRejected += 1; - socket.destroy(); - return; - } - - const session = { - remoteAddress, - socket, - upstream: null, - closed: false, - forwarded: false, - inboundBytes: 0, - outboundBytes: 0, - }; - sessions.set(socket, session); - incrementAddressSessions(remoteAddress); - totalAccepted += 1; - - socket.setNoDelay(true); - socket.setTimeout(config.sessionTimeoutMs); - socket.pause(); - socket.on("timeout", () => rejectSession(session)); - socket.on("close", () => closeSession(session)); - socket.on("error", () => rejectSession(session)); - socket.on("data", (chunk) => { - session.inboundBytes += chunk.length; - if (session.inboundBytes > config.maxBytesPerDirection) { - rejectSession(session); - } - }); - - const upstream = connect({ - host: config.upstreamHost, - port: config.upstreamPort, - }); - session.upstream = upstream; - upstream.setNoDelay(true); - upstream.setTimeout(config.sessionTimeoutMs); - upstream.on("connect", () => { - if (session.closed) { - upstream.destroy(); - return; - } - session.forwarded = true; - totalForwarded += 1; - socket.pipe(upstream); - upstream.pipe(socket); - socket.resume(); - }); - upstream.on("timeout", () => rejectSession(session)); - upstream.on("error", () => rejectSession(session)); - upstream.on("close", () => closeSession(session)); - upstream.on("data", (chunk) => { - session.outboundBytes += chunk.length; - if (session.outboundBytes > config.maxBytesPerDirection) { - rejectSession(session); - } - }); - }); - - const healthServer = createHttpServer((request, response) => { - response.setHeader("Content-Type", "application/json; charset=utf-8"); - response.setHeader("Cache-Control", "no-store"); - response.setHeader("X-Content-Type-Options", "nosniff"); - if (request.method !== "GET" || request.url !== "/healthz") { - response.statusCode = 404; - response.end('{"ok":false,"error":"device_edge_relay_route_not_found"}\n'); - return; - } - response.statusCode = 200; - response.end(`${JSON.stringify({ - ok: true, - service: "nodedc-device-edge-relay", - ingress: config.ingressEnabled ? "relay-only" : "disabled", - protocolInspection: "disabled", - commandTransport: "disabled", - sourceAdmission: config.sourcePolicy, - sessions: { - active: sessions.size, - accepted: totalAccepted, - rejected: totalRejected, - forwarded: totalForwarded, - }, - })}\n`); - }); - - return { - async start() { - await listen(healthServer, config.healthPort, config.healthHost); - if (config.ingressEnabled) { - await listen(tcpServer, config.tcpPort, config.tcpHost); - } - return { - healthAddress: healthServer.address(), - tcpAddress: config.ingressEnabled ? tcpServer.address() : null, - }; - }, - async stop() { - for (const session of sessions.values()) rejectSession(session); - await Promise.all([ - closeServer(healthServer), - config.ingressEnabled ? closeServer(tcpServer) : Promise.resolve(), - ]); - }, - status() { - return { - activeSessions: sessions.size, - totalAccepted, - totalRejected, - totalForwarded, - ingress: config.ingressEnabled ? "relay-only" : "disabled", - protocolInspection: "disabled", - commandTransport: "disabled", - sourceAdmission: config.sourcePolicy, - }; - }, - }; - - function currentAddressSessions(remoteAddress) { - return sessionsByAddress.get(remoteAddress) || 0; - } - - function incrementAddressSessions(remoteAddress) { - sessionsByAddress.set( - remoteAddress, - currentAddressSessions(remoteAddress) + 1, - ); - } - - function decrementAddressSessions(remoteAddress) { - const current = currentAddressSessions(remoteAddress); - if (current <= 1) { - sessionsByAddress.delete(remoteAddress); - } else { - sessionsByAddress.set(remoteAddress, current - 1); - } - } - - function consumeConnectionPermit(remoteAddress) { - const nowMs = config.now().getTime(); - for (const [address, window] of connectionWindows) { - if (nowMs - window.startedAt >= 60_000) { - connectionWindows.delete(address); - } - } - const current = connectionWindows.get(remoteAddress); - if (!current || nowMs - current.startedAt >= 60_000) { - if (connectionWindows.size >= config.maxTrackedSourceAddresses) { - return false; - } - connectionWindows.set(remoteAddress, { startedAt: nowMs, count: 1 }); - return true; - } - if (current.count >= config.maxConnectionsPerMinutePerAddress) return false; - current.count += 1; - return true; - } - - function allowsSource(remoteAddress) { - if (config.sourcePolicy === "any") return true; - return isPublicIpv4Address(remoteAddress); - } - - function rejectSession(session) { - if (!session.closed) totalRejected += 1; - session.socket.destroy(); - session.upstream?.destroy(); - closeSession(session); - } - - function closeSession(session) { - if (session.closed) return; - session.closed = true; - sessions.delete(session.socket); - decrementAddressSessions(session.remoteAddress); - } -} - -function normalizeConfig(input) { - const ingressEnabled = input.ingressEnabled === true; - return { - ingressEnabled, - healthHost: normalizeHost(input.healthHost, "127.0.0.1"), - healthPort: parseInteger( - input.healthPort, - 18221, - 0, - 65535, - "device_edge_relay_health_port_invalid", - ), - tcpHost: normalizeTcpHost(input.tcpHost, ingressEnabled), - tcpPort: parseInteger( - input.tcpPort, - 9921, - 0, - 65535, - "device_edge_relay_tcp_port_invalid", - ), - upstreamHost: ingressEnabled - ? normalizeUpstreamHost(input.upstreamHost) - : "disabled", - upstreamPort: ingressEnabled - ? parseInteger( - input.upstreamPort, - undefined, - 1, - 65535, - "device_edge_relay_upstream_port_invalid", - ) - : 0, - maxConcurrentSessions: parseInteger( - input.maxConcurrentSessions, - 100, - 1, - 10000, - "device_edge_relay_session_limit_invalid", - ), - maxSessionsPerAddress: parseInteger( - input.maxSessionsPerAddress, - 10, - 1, - 1000, - "device_edge_relay_address_session_limit_invalid", - ), - maxConnectionsPerMinutePerAddress: parseInteger( - input.maxConnectionsPerMinutePerAddress, - 30, - 1, - 10000, - "device_edge_relay_connection_rate_invalid", - ), - maxTrackedSourceAddresses: parseInteger( - input.maxTrackedSourceAddresses, - 2048, - 1, - 65_536, - "device_edge_relay_source_table_limit_invalid", - ), - maxBytesPerDirection: parseInteger( - input.maxBytesPerDirection, - 262_144, - 1_024, - 16 * 1024 * 1024, - "device_edge_relay_byte_limit_invalid", - ), - sourcePolicy: normalizeSourcePolicy(input.sourcePolicy, ingressEnabled), - resolveRemoteAddress: typeof input.resolveRemoteAddress === "function" - ? input.resolveRemoteAddress - : normalizeRemoteAddress, - sessionTimeoutMs: parseInteger( - input.sessionTimeoutMs, - 10000, - 100, - 60000, - "device_edge_relay_session_timeout_invalid", - ), - now: typeof input.now === "function" ? input.now : () => new Date(), - }; -} - -function normalizeHost(value, fallback) { - const normalized = String(value || fallback).trim(); - if (!["127.0.0.1", "::1", "0.0.0.0", "::"].includes(normalized)) { - throw new TypeError("device_edge_relay_health_host_invalid"); - } - return normalized; -} - -function normalizeTcpHost(value, ingressEnabled) { - const fallback = ingressEnabled ? "0.0.0.0" : "127.0.0.1"; - const normalized = String(value || fallback).trim(); - const allowed = ingressEnabled ? ["0.0.0.0", "::"] : ["127.0.0.1", "::1"]; - if (!allowed.includes(normalized)) { - throw new TypeError( - ingressEnabled - ? "device_edge_relay_public_ingress_host_invalid" - : "device_edge_relay_baseline_loopback_only", - ); - } - return normalized; -} - -function normalizeUpstreamHost(value) { - const normalized = String(value || "").trim(); - if ( - normalized.length === 0 - || normalized.length > 253 - || /[/:\\s]/.test(normalized) - ) { - throw new TypeError("device_edge_relay_upstream_host_invalid"); - } - return normalized; -} - -function normalizeRemoteAddress(value) { - const normalized = String(value || "unknown").trim(); - return normalized.slice(0, 64) || "unknown"; -} - -function normalizeSourcePolicy(value, ingressEnabled) { - const fallback = ingressEnabled ? "public-ipv4-only" : "any"; - const normalized = String(value || fallback).trim().toLowerCase(); - if (!["any", "public-ipv4-only"].includes(normalized)) { - throw new TypeError("device_edge_relay_source_policy_invalid"); - } - if (ingressEnabled && normalized !== "public-ipv4-only") { - throw new TypeError("device_edge_relay_ingress_source_policy_invalid"); - } - return normalized; -} - -function isPublicIpv4Address(value) { - const normalized = String(value || "").trim().replace(/^::ffff:/i, ""); - const parts = normalized.split("."); - if (parts.length !== 4) return false; - const octets = parts.map((part) => Number(part)); - if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { - return false; - } - const [first, second, third] = octets; - if ( - first === 0 - || first === 10 - || first === 127 - || first >= 224 - || (first === 100 && second >= 64 && second <= 127) - || (first === 169 && second === 254) - || (first === 172 && second >= 16 && second <= 31) - || (first === 192 && second === 0 && third === 0) - || (first === 192 && second === 0 && third === 2) - || (first === 192 && second === 88 && third === 99) - || (first === 192 && second === 168) - || (first === 198 && (second === 18 || second === 19)) - || (first === 198 && second === 51 && third === 100) - || (first === 203 && second === 0 && third === 113) - ) { - return false; - } - return true; -} - -function parseInteger(value, fallback, minimum, maximum, errorCode) { - const parsed = Number(value ?? fallback); - if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { - throw new TypeError(errorCode); - } - return parsed; -} - -function listen(server, port, host) { - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(port, host, () => { - server.off("error", reject); - resolve(); - }); - }); -} - -function closeServer(server) { - return new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); -} diff --git a/device-plane/services/device-edge-relay/src/server.mjs b/device-plane/services/device-edge-relay/src/server.mjs deleted file mode 100644 index 8ca8429..0000000 --- a/device-plane/services/device-edge-relay/src/server.mjs +++ /dev/null @@ -1,85 +0,0 @@ -import { createDeviceEdgeRelayRuntime } from "./runtime.mjs"; - -const runtime = createDeviceEdgeRelayRuntime({ - ingressEnabled: parseBoolean( - process.env.DEVICE_EDGE_RELAY_INGRESS_ENABLED, - false, - ), - healthHost: process.env.DEVICE_EDGE_RELAY_HEALTH_HOST || "127.0.0.1", - healthPort: parsePort(process.env.DEVICE_EDGE_RELAY_HEALTH_PORT, 18221), - tcpHost: process.env.DEVICE_EDGE_RELAY_TCP_HOST, - tcpPort: parsePort(process.env.DEVICE_EDGE_RELAY_TCP_PORT, 9921), - upstreamHost: process.env.DEVICE_EDGE_RELAY_UPSTREAM_HOST, - upstreamPort: parsePort( - process.env.DEVICE_EDGE_RELAY_UPSTREAM_PORT, - undefined, - ), - maxConcurrentSessions: parsePositiveInt( - process.env.DEVICE_EDGE_RELAY_MAX_SESSIONS, - 100, - ), - maxSessionsPerAddress: parsePositiveInt( - process.env.DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS, - 10, - ), - maxConnectionsPerMinutePerAddress: parsePositiveInt( - process.env.DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS, - 30, - ), - maxTrackedSourceAddresses: parsePositiveInt( - process.env.DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES, - 2048, - ), - maxBytesPerDirection: parsePositiveInt( - process.env.DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION, - 262_144, - ), - sourcePolicy: process.env.DEVICE_EDGE_RELAY_SOURCE_POLICY, - sessionTimeoutMs: parsePositiveInt( - process.env.DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS, - 10000, - ), -}); - -const addresses = await runtime.start(); -console.log(JSON.stringify({ - event: "device_edge_relay_started", - health: addresses.healthAddress, - tcp: addresses.tcpAddress, - ingress: runtime.status().ingress, - protocolInspection: "disabled", - commandTransport: "disabled", -})); - -process.on("SIGTERM", shutdown); -process.on("SIGINT", shutdown); - -async function shutdown() { - await runtime.stop(); - process.exit(0); -} - -function parsePort(value, fallback) { - if (value === undefined && fallback === undefined) return undefined; - const parsed = Number(value ?? fallback); - if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) { - throw new Error("device_edge_relay_port_invalid"); - } - return parsed; -} - -function parsePositiveInt(value, fallback) { - const parsed = Number(value || fallback); - if (!Number.isSafeInteger(parsed) || parsed < 1) { - throw new Error("device_edge_relay_positive_integer_invalid"); - } - return parsed; -} - -function parseBoolean(value, fallback) { - if (value === undefined || value === null || value === "") return fallback; - const normalized = String(value).trim().toLowerCase(); - if (["1", "true", "yes", "on"].includes(normalized)) return true; - if (["0", "false", "no", "off"].includes(normalized)) return false; - throw new Error("device_edge_relay_boolean_invalid"); -} diff --git a/device-plane/services/device-edge-relay/test/ingress-source.test.mjs b/device-plane/services/device-edge-relay/test/ingress-source.test.mjs deleted file mode 100644 index 128cda5..0000000 --- a/device-plane/services/device-edge-relay/test/ingress-source.test.mjs +++ /dev/null @@ -1,110 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; - -const devicePlaneRoot = resolve( - dirname(fileURLToPath(import.meta.url)), - "../../..", -); - -test("single-NIC ingress source has no host publication and a fixed ipvlan", async () => { - const baseline = await readFile( - resolve(devicePlaneRoot, "docker-compose.device-edge.yml"), - "utf8", - ); - const ingress = await readFile( - resolve(devicePlaneRoot, "docker-compose.device-edge.ingress.yml"), - "utf8", - ); - - assert.match( - baseline, - /DEVICE_EDGE_RELAY_HEALTH_HOST: 127\.0\.0\.1/, - ); - assert.doesNotMatch(baseline, /^\s+ports:/m); - assert.doesNotMatch(baseline, /device-edge-control/); - - for (const required of [ - 'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"', - "DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul", - 'DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"', - "name: nodedc-device-edge-ingress", - "driver: ipvlan", - "parent: enp1s0f0", - "ipvlan_mode: l2", - "ipv4_address: 192.168.71.253", - "subnet: 192.168.68.0/22", - "gateway: 192.168.68.1", - "gw_priority: 100", - "DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only", - 'DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"', - 'DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"', - ]) { - assert.ok(ingress.includes(required), `missing ingress boundary: ${required}`); - } - - for (const forbidden of [ - "ports:", - "network_mode: host", - "privileged: true", - "DEVICE_EDGE_RELAY_COMMAND", - "0.0.0.0:9921:9921", - ]) { - assert.ok( - !ingress.includes(forbidden), - `forbidden ingress boundary: ${forbidden}`, - ); - } -}); - -test("ingress descriptor keeps address approval and router exposure gated", async () => { - const descriptor = JSON.parse(await readFile( - resolve( - devicePlaneRoot, - "deployment/device-edge-ingress-ipvlan-v1.json", - ), - "utf8", - )); - - assert.equal(descriptor.component, "device-edge"); - assert.deepEqual(descriptor.selectedServices, ["device-edge-relay"]); - assert.deepEqual( - descriptor.preservedServices, - ["device-edge-backhaul", "tailnet"], - ); - assert.equal(descriptor.ingressIpv4Approval, "approved-outside-dhcp-pool"); - assert.equal(descriptor.hostPortPublication, "disabled"); - assert.equal(descriptor.healthPublication, "disabled"); - assert.equal(descriptor.privateUpstream, "device-edge-backhaul:19921"); - assert.equal(descriptor.protocolInspection, "gateway-owned"); - assert.equal(descriptor.discoveryLifecycle, "quarantine"); - assert.equal(descriptor.commandTransport, "disabled"); - assert.equal(descriptor.gelios, "untouched"); - assert.equal(descriptor.amneziaHostFullTunnel, "preserved"); - assert.equal(descriptor.routerNatFirewall, "separate-manual-gate"); -}); - -test("admission-gate descriptor pins the fail-closed relay boundary", async () => { - const descriptor = JSON.parse(await readFile( - resolve( - devicePlaneRoot, - "deployment/device-edge-admission-gate-v1.json", - ), - "utf8", - )); - - assert.equal( - descriptor.schemaVersion, - "nodedc.device-edge.admission-gate.v1", - ); - assert.equal(descriptor.sourceAdmission, "public-ipv4-only"); - assert.equal(descriptor.maxTrackedSourceAddresses, 2048); - assert.equal(descriptor.maxBytesPerDirection, 262144); - assert.equal(descriptor.hostPortPublication, "disabled"); - assert.equal(descriptor.healthPublication, "disabled"); - assert.equal(descriptor.commandTransport, "disabled"); - assert.equal(descriptor.gelios, "untouched"); - assert.equal(descriptor.routerNatFirewall, "separate-manual-gate"); -}); diff --git a/device-plane/services/device-edge-relay/test/runtime.test.mjs b/device-plane/services/device-edge-relay/test/runtime.test.mjs deleted file mode 100644 index 28ffd40..0000000 --- a/device-plane/services/device-edge-relay/test/runtime.test.mjs +++ /dev/null @@ -1,163 +0,0 @@ -import assert from "node:assert/strict"; -import { createServer, connect } from "node:net"; -import test from "node:test"; - -import { createDeviceEdgeRelayRuntime } from "../src/runtime.mjs"; - -test("baseline starts only loopback health and no device TCP listener", async () => { - const runtime = createDeviceEdgeRelayRuntime({ healthPort: 0 }); - const addresses = await runtime.start(); - try { - assert.equal(addresses.tcpAddress, null); - const response = await fetch( - `http://127.0.0.1:${addresses.healthAddress.port}/healthz`, - ); - assert.equal(response.status, 200); - const body = await response.json(); - assert.equal(body.ingress, "disabled"); - assert.equal(body.protocolInspection, "disabled"); - assert.equal(body.commandTransport, "disabled"); - } finally { - await runtime.stop(); - } -}); - -test("relay is transparent and never emits its own protocol bytes", async () => { - const upstream = await startEchoServer(); - const runtime = createDeviceEdgeRelayRuntime({ - healthPort: 0, - ingressEnabled: true, - tcpHost: "0.0.0.0", - tcpPort: 0, - upstreamHost: "127.0.0.1", - upstreamPort: upstream.port, - resolveRemoteAddress: () => "8.8.8.8", - }); - const addresses = await runtime.start(); - try { - const response = await sendAndCollect( - addresses.tcpAddress.port, - Buffer.from("ff230102030405060708", "hex"), - ); - assert.equal(response.toString("hex"), "ff230102030405060708"); - assert.equal(runtime.status().totalForwarded, 1); - assert.equal(runtime.status().commandTransport, "disabled"); - } finally { - await runtime.stop(); - await closeServer(upstream.server); - } -}); - -test("enabled ingress rejects a non-public source before opening upstream", async () => { - const upstream = await startEchoServer(); - const runtime = createDeviceEdgeRelayRuntime({ - healthPort: 0, - ingressEnabled: true, - tcpHost: "0.0.0.0", - tcpPort: 0, - upstreamHost: "127.0.0.1", - upstreamPort: upstream.port, - resolveRemoteAddress: () => "127.0.0.1", - }); - const addresses = await runtime.start(); - try { - const response = await sendAndCollect( - addresses.tcpAddress.port, - Buffer.from("denied"), - ); - assert.equal(response.length, 0); - assert.equal(runtime.status().totalAccepted, 0); - assert.equal(runtime.status().totalForwarded, 0); - assert.equal(runtime.status().sourceAdmission, "public-ipv4-only"); - } finally { - await runtime.stop(); - await closeServer(upstream.server); - } -}); - -test("relay terminates a byte stream that exceeds its per-direction budget", async () => { - const upstream = await startEchoServer(); - const runtime = createDeviceEdgeRelayRuntime({ - healthPort: 0, - ingressEnabled: true, - tcpHost: "0.0.0.0", - tcpPort: 0, - upstreamHost: "127.0.0.1", - upstreamPort: upstream.port, - resolveRemoteAddress: () => "8.8.8.8", - maxBytesPerDirection: 1024, - }); - const addresses = await runtime.start(); - try { - const response = await sendAndCollect( - addresses.tcpAddress.port, - Buffer.alloc(1025, 0x5d), - ); - assert.ok(response.length <= 1024); - assert.equal(runtime.status().totalForwarded, 1); - assert.ok(runtime.status().totalRejected >= 1); - } finally { - await runtime.stop(); - await closeServer(upstream.server); - } -}); - -test("production ingress cannot opt out of public IPv4 admission", () => { - assert.throws( - () => createDeviceEdgeRelayRuntime({ - ingressEnabled: true, - tcpHost: "0.0.0.0", - upstreamHost: "device-edge-backhaul", - upstreamPort: 19921, - sourcePolicy: "any", - }), - /device_edge_relay_ingress_source_policy_invalid/, - ); -}); - -test("enabled relay requires a concrete private upstream", () => { - assert.throws( - () => createDeviceEdgeRelayRuntime({ - ingressEnabled: true, - tcpHost: "0.0.0.0", - upstreamPort: 19921, - }), - /device_edge_relay_upstream_host_invalid/, - ); -}); - -test("baseline rejects a non-loopback device binding", () => { - assert.throws( - () => createDeviceEdgeRelayRuntime({ tcpHost: "0.0.0.0" }), - /device_edge_relay_baseline_loopback_only/, - ); -}); - -function startEchoServer() { - const server = createServer((socket) => socket.pipe(socket)); - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - server.off("error", reject); - resolve({ server, port: server.address().port }); - }); - }); -} - -function sendAndCollect(port, payload) { - return new Promise((resolve) => { - const chunks = []; - const socket = connect({ host: "127.0.0.1", port }, () => { - socket.end(payload); - }); - socket.on("data", (chunk) => chunks.push(chunk)); - socket.on("close", () => resolve(Buffer.concat(chunks))); - socket.on("error", () => {}); - }); -} - -function closeServer(server) { - return new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); -} diff --git a/device-plane/services/device-gateway-core/package.json b/device-plane/services/device-gateway-core/package.json deleted file mode 100644 index 6109d2e..0000000 --- a/device-plane/services/device-gateway-core/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@nodedc/device-gateway-core", - "version": "0.1.0", - "private": true, - "type": "module", - "exports": { - ".": "./src/runtime.mjs" - }, - "engines": { - "node": ">=20" - } -} diff --git a/device-plane/services/device-gateway-core/src/runtime.mjs b/device-plane/services/device-gateway-core/src/runtime.mjs deleted file mode 100644 index 4fa1255..0000000 --- a/device-plane/services/device-gateway-core/src/runtime.mjs +++ /dev/null @@ -1,3 +0,0 @@ -export { - createDeviceGatewayCoreChannelClient, -} from "../../device-control-core/src/device-gateway-core-runtime.mjs"; diff --git a/device-plane/services/device-gateway/Dockerfile b/device-plane/services/device-gateway/Dockerfile deleted file mode 100644 index 539e468..0000000 --- a/device-plane/services/device-gateway/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM node:22-alpine - -WORKDIR /app - -COPY package.json package-lock.json ./ -COPY packages/device-protocol-contract ./packages/device-protocol-contract -COPY packages/device-adapter-runtime ./packages/device-adapter-runtime -COPY packages/device-adapter-catalog ./packages/device-adapter-catalog -COPY packages/arusnavi-b2-adapter ./packages/arusnavi-b2-adapter -COPY services/device-gateway ./services/device-gateway -COPY services/device-control-core/package.json ./services/device-control-core/package.json -COPY services/device-edge-relay/package.json ./services/device-edge-relay/package.json - -RUN npm ci --omit=dev --ignore-scripts - -USER node - -CMD ["node", "services/device-gateway/src/server.mjs"] diff --git a/device-plane/services/device-gateway/package.json b/device-plane/services/device-gateway/package.json deleted file mode 100644 index c4cdeb3..0000000 --- a/device-plane/services/device-gateway/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "@nodedc/device-gateway", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "start": "node src/server.mjs", - "test": "node --test test/*.test.mjs" - }, - "engines": { - "node": ">=20" - } -} diff --git a/device-plane/services/device-gateway/src/core-client.mjs b/device-plane/services/device-gateway/src/core-client.mjs deleted file mode 100644 index 6d75ebb..0000000 --- a/device-plane/services/device-gateway/src/core-client.mjs +++ /dev/null @@ -1,147 +0,0 @@ -import { - normalizeAdapterAcceptance, -} from "../../../packages/device-protocol-contract/src/index.mjs"; - -export function createCoreGatewayClient(options = {}) { - const { - coreUrl, - gatewayToken, - timeoutMs = 5000, - fetchImpl = fetch, - } = options; - const baseUrl = normalizeCoreBaseUrl(coreUrl); - validateClientOptions({ gatewayToken, timeoutMs, fetchImpl }); - const discoveryEndpoint = endpointUrl( - baseUrl, - "/internal/v1/device-discoveries:observe", - ); - const messageEndpoint = endpointUrl( - baseUrl, - "/internal/v1/gateway/messages:accept", - ); - - return Object.freeze({ - async observeDiscovery(signal) { - const body = await postJson({ - endpoint: discoveryEndpoint, - gatewayToken, - timeoutMs, - fetchImpl, - value: signal, - maxResponseBytes: 32 * 1024, - }); - if ( - !body.discovery - || !["quarantine", "claimed"].includes(body.discovery.lifecycleState) - || body.discovery.commandTransport !== "disabled" - ) { - throw new Error("device_gateway_core_ingest_contract_invalid"); - } - return body.discovery; - }, - async acceptMessage(message) { - const body = await postJson({ - endpoint: messageEndpoint, - gatewayToken, - timeoutMs, - fetchImpl, - value: message, - maxResponseBytes: 16 * 1024, - }); - const acceptance = normalizeAdapterAcceptance(body.acceptance); - if (acceptance.idempotencyKey !== message?.idempotencyKey) { - throw new Error("device_gateway_core_acceptance_mismatch"); - } - return acceptance; - }, - }); -} - -export function createCoreDiscoveryClient({ - coreUrl, - gatewayToken, - timeoutMs = 5000, - fetchImpl = fetch, -} = {}) { - return createCoreGatewayClient({ - coreUrl, - gatewayToken, - timeoutMs, - fetchImpl, - }).observeDiscovery; -} - -function validateClientOptions({ gatewayToken, timeoutMs, fetchImpl }) { - if (typeof gatewayToken !== "string" || gatewayToken.length < 32) { - throw new TypeError("device_gateway_core_token_invalid"); - } - const normalizedTimeout = Number(timeoutMs); - if ( - !Number.isSafeInteger(normalizedTimeout) - || normalizedTimeout < 100 - || normalizedTimeout > 30_000 - ) { - throw new TypeError("device_gateway_core_timeout_invalid"); - } - if (typeof fetchImpl !== "function") { - throw new TypeError("device_gateway_core_fetch_invalid"); - } -} - -async function postJson({ - endpoint, - gatewayToken, - timeoutMs, - fetchImpl, - value, - maxResponseBytes, -}) { - const response = await fetchImpl(endpoint, { - method: "POST", - headers: { - Authorization: `Bearer ${gatewayToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(value), - signal: AbortSignal.timeout(Number(timeoutMs)), - }); - const body = await readBoundedJson(response, maxResponseBytes); - if (!response.ok || body?.ok !== true) { - throw new Error("device_gateway_core_ingest_failed"); - } - return body; -} - -function normalizeCoreBaseUrl(value) { - let url; - try { - url = new URL(String(value || "")); - } catch { - throw new TypeError("device_gateway_core_url_invalid"); - } - if (url.protocol !== "http:" || url.username || url.password) { - throw new TypeError("device_gateway_core_url_invalid"); - } - if (url.pathname !== "/" || url.search || url.hash) { - throw new TypeError("device_gateway_core_url_invalid"); - } - return url; -} - -function endpointUrl(baseUrl, pathname) { - const url = new URL(baseUrl); - url.pathname = pathname; - return url.toString(); -} - -async function readBoundedJson(response, maxBytes) { - const text = await response.text(); - if (Buffer.byteLength(text, "utf8") > maxBytes) { - throw new Error("device_gateway_core_response_too_large"); - } - try { - return JSON.parse(text); - } catch { - throw new Error("device_gateway_core_response_invalid"); - } -} diff --git a/device-plane/services/device-gateway/src/runtime.mjs b/device-plane/services/device-gateway/src/runtime.mjs deleted file mode 100644 index 6900ede..0000000 --- a/device-plane/services/device-gateway/src/runtime.mjs +++ /dev/null @@ -1,646 +0,0 @@ -import { createHash, randomUUID } from "node:crypto"; -import { createServer as createHttpServer } from "node:http"; -import { createServer as createTcpServer } from "node:net"; - -import { - assertDeviceAdapterSession, -} from "../../../packages/device-adapter-runtime/src/index.mjs"; -import { - DEVICE_ADAPTER_MESSAGE_SCHEMA, - DEVICE_DISCOVERY_SIGNAL_SCHEMA, - normalizeAdapterAcceptance, - normalizeAdapterMessage, -} from "../../../packages/device-protocol-contract/src/index.mjs"; - -export function createDeviceGatewayRuntime(options = {}) { - const config = normalizeConfig(options); - const sessions = new Map(); - const sessionsByAddress = new Map(); - const connectionWindows = new Map(); - let totalAccepted = 0; - let totalRejected = 0; - let totalDiscoveries = 0; - let totalMessagesAccepted = 0; - let totalPackagesAcknowledged = 0; - let totalBufferedBytes = 0; - - const tcpServer = createTcpServer((socket) => { - const remoteAddress = normalizeRemoteAddress(socket.remoteAddress); - if ( - sessions.size >= config.maxConcurrentSessions - || currentAddressSessions(remoteAddress) >= config.maxSessionsPerAddress - || !consumeConnectionPermit(remoteAddress) - ) { - totalRejected += 1; - socket.destroy(); - return; - } - - const sessionRef = `session:${randomUUID()}`; - const session = { - sessionRef, - remoteAddress, - buffer: Buffer.alloc(0), - adapterSession: assertDeviceAdapterSession( - config.adapter.createSession({ - profileRef: config.profile.profileRef, - }), - ), - identifier: null, - sequence: 0, - state: "awaiting-header", - processing: false, - rejected: false, - closed: false, - pendingCommand: null, - }; - sessions.set(socket, session); - incrementAddressSessions(remoteAddress); - totalAccepted += 1; - socket.setNoDelay(true); - socket.setTimeout(config.sessionTimeoutMs); - - socket.on("data", (chunk) => { - if (session.closed || session.rejected) return; - socket.pause(); - appendBuffer(session, chunk); - if ( - session.buffer.length > config.maxBufferedBytes - || totalBufferedBytes > config.maxAggregateBufferedBytes - ) { - rejectSession(socket, session); - return; - } - if (session.processing) return; - session.processing = true; - void processSession(socket, session) - .catch(() => rejectSession(socket, session)) - .finally(() => { - session.processing = false; - if (!session.closed && !session.rejected) socket.resume(); - }); - }); - socket.on("timeout", () => rejectSession(socket, session)); - socket.on("close", () => closeSession(socket, session)); - socket.on("error", () => closeSession(socket, session)); - }); - - const healthServer = createHttpServer((request, response) => { - response.setHeader("Content-Type", "application/json; charset=utf-8"); - response.setHeader("Cache-Control", "no-store"); - response.setHeader("X-Content-Type-Options", "nosniff"); - if (request.method !== "GET" || request.url !== "/healthz") { - response.statusCode = 404; - return response.end('{"ok":false,"error":"device_gateway_route_not_found"}\n'); - } - response.statusCode = 200; - return response.end(`${JSON.stringify({ - ok: true, - service: "nodedc-device-gateway", - adapter: config.adapter?.adapterRef ?? "disabled", - protocolProfile: config.profile?.profileRef ?? "disabled", - framing: config.profile?.framing?.status ?? "disabled", - tcpListener: config.listenEnabled ? "telemetry-ingest" : "disabled", - publicIngress: config.publicIngressEnabled - ? "telemetry-ingest" - : "disabled", - commandTransport: config.commandTransport, - sessions: { - active: sessions.size, - accepted: totalAccepted, - rejected: totalRejected, - discoveries: totalDiscoveries, - messagesAccepted: totalMessagesAccepted, - packagesAcknowledged: totalPackagesAcknowledged, - bufferedBytes: totalBufferedBytes, - }, - })}\n`); - }); - - return { - async start() { - await listen(healthServer, config.healthPort, config.healthHost); - if (config.listenEnabled) { - await listen(tcpServer, config.tcpPort, config.tcpHost); - } - return { - healthAddress: healthServer.address(), - tcpAddress: config.listenEnabled ? tcpServer.address() : null, - }; - }, - async stop() { - for (const [socket, session] of sessions) { - closeSession(socket, session); - socket.destroy(); - } - await Promise.all([ - closeServer(healthServer), - config.listenEnabled ? closeServer(tcpServer) : Promise.resolve(), - ]); - }, - status() { - return { - adapter: config.adapter?.adapterRef ?? "disabled", - protocolProfile: config.profile?.profileRef ?? "disabled", - framing: config.profile?.framing?.status ?? "disabled", - activeSessions: sessions.size, - totalAccepted, - totalRejected, - totalDiscoveries, - totalMessagesAccepted, - totalPackagesAcknowledged, - totalBufferedBytes, - commandTransport: config.commandTransport, - publicIngress: config.publicIngressEnabled - ? "telemetry-ingest" - : "disabled", - }; - }, - }; - - async function processSession(socket, session) { - while (!session.closed && !session.rejected) { - if (session.state === "awaiting-header") { - const parsed = session.adapterSession.parseHeader(session.buffer); - if (parsed.status === "incomplete") return; - - const observedAt = config.now().toISOString(); - const discovery = { - schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA, - sessionRef: session.sessionRef, - ...(config.routeRef ? { routeRef: config.routeRef } : {}), - modelProfileRef: config.profile.profileRef, - protocol: config.profile.protocol, - observedAt, - identifier: { - kind: parsed.identifier.kind, - value: parsed.identifier.value, - }, - evidence: parsed.evidence, - }; - const acceptedDiscovery = await config.onDiscovery(discovery); - assertDiscoveryAccepted(acceptedDiscovery); - session.identifier = discovery.identifier; - consumeBuffer(session, parsed.bytesConsumed); - session.state = "packages"; - totalDiscoveries += 1; - await writeWithBackpressure(socket, session.adapterSession.buildHeaderAcknowledgement( - Math.floor(new Date(observedAt).getTime() / 1000), - )); - if (acceptedDiscovery.commandOffer) { - await dispatchCommandOffer( - socket, - session, - acceptedDiscovery.commandOffer, - ); - } - continue; - } - - if (session.pendingCommand) { - const result = session.adapterSession.parseTypedCommandResponse( - session.buffer, - session.pendingCommand, - ); - if (result.status === "incomplete") return; - if (result.status === "acknowledged") { - const command = session.pendingCommand; - consumeBuffer(session, result.bytesConsumed); - session.pendingCommand = null; - await config.onCommandStatus({ - commandRef: command.commandRef, - transportMessageRef: command.transportMessageRef, - lifecycleState: "acknowledged", - resultCode: result.resultCode, - observedAt: config.now().toISOString(), - sessionRef: session.sessionRef, - adapterProfileRef: config.profile.profileRef, - }); - continue; - } - } - - const parsed = session.adapterSession.parseMessage(session.buffer); - if (parsed.status === "incomplete") return; - const observedAt = config.now().toISOString(); - session.sequence += 1; - const message = normalizeAdapterMessage({ - schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA, - edgeRef: config.edgeRef, - adapterRef: config.adapter.adapterRef, - protocolProfileRef: config.profile.profileRef, - protocol: config.profile.protocol, - sessionRef: session.sessionRef, - ...(config.routeRef ? { routeRef: config.routeRef } : {}), - messageRef: messageRef(parsed), - messageType: parsed.messageType, - sequence: session.sequence, - observedAt, - idempotencyKey: messageIdempotencyKey({ - adapterRef: config.adapter.adapterRef, - profileRef: config.profile.profileRef, - identifier: session.identifier, - payload: parsed.payload, - }), - identifier: session.identifier, - payloadSchemaRef: parsed.payloadSchemaRef, - payload: parsed.payload, - }); - const acceptedMessage = await config.onMessage(message); - const { - commandOffer, - ...acceptanceValue - } = acceptedMessage; - const acceptance = normalizeAdapterAcceptance(acceptanceValue); - if (acceptance.idempotencyKey !== message.idempotencyKey) { - throw new TypeError("device_gateway_core_acceptance_mismatch"); - } - consumeBuffer(session, parsed.bytesConsumed); - totalMessagesAccepted += 1; - totalPackagesAcknowledged += 1; - await writeWithBackpressure( - socket, - session.adapterSession.buildMessageAcknowledgement(parsed), - ); - if (commandOffer) { - await dispatchCommandOffer( - socket, - session, - commandOffer, - ); - } - } - } - - async function dispatchCommandOffer(socket, session, value) { - if (session.pendingCommand) { - throw new Error("device_gateway_command_already_pending"); - } - const command = normalizeCommandOffer(value); - const bytes = session.adapterSession.buildTypedCommand(command); - session.pendingCommand = command; - await writeWithBackpressure(socket, bytes); - } - - function currentAddressSessions(remoteAddress) { - return sessionsByAddress.get(remoteAddress) || 0; - } - - function incrementAddressSessions(remoteAddress) { - sessionsByAddress.set( - remoteAddress, - currentAddressSessions(remoteAddress) + 1, - ); - } - - function decrementAddressSessions(remoteAddress) { - const current = currentAddressSessions(remoteAddress); - if (current <= 1) { - sessionsByAddress.delete(remoteAddress); - } else { - sessionsByAddress.set(remoteAddress, current - 1); - } - } - - function consumeConnectionPermit(remoteAddress) { - const nowMs = config.now().getTime(); - for (const [address, window] of connectionWindows) { - if (nowMs - window.startedAt >= 60_000) { - connectionWindows.delete(address); - } - } - const current = connectionWindows.get(remoteAddress); - if (!current || nowMs - current.startedAt >= 60_000) { - if (connectionWindows.size >= config.maxTrackedSourceAddresses) { - return false; - } - connectionWindows.set(remoteAddress, { - startedAt: nowMs, - count: 1, - }); - return true; - } - if (current.count >= config.maxConnectionsPerMinutePerAddress) { - return false; - } - current.count += 1; - return true; - } - - function rejectSession(socket, session) { - if (!session.rejected) { - session.rejected = true; - totalRejected += 1; - } - socket.destroy(); - } - - function closeSession(socket, session) { - if (session.closed) return; - session.closed = true; - totalBufferedBytes = Math.max(0, totalBufferedBytes - session.buffer.length); - session.buffer = Buffer.alloc(0); - if (session.pendingCommand && config.onCommandStatus) { - const command = session.pendingCommand; - session.pendingCommand = null; - void config.onCommandStatus({ - commandRef: command.commandRef, - transportMessageRef: command.transportMessageRef, - lifecycleState: "unknown", - resultCode: "tracker_session_closed", - observedAt: config.now().toISOString(), - sessionRef: session.sessionRef, - adapterProfileRef: config.profile.profileRef, - }).catch(() => undefined); - } - sessions.delete(socket); - decrementAddressSessions(session.remoteAddress); - } - - function appendBuffer(session, chunk) { - session.buffer = Buffer.concat( - [session.buffer, chunk], - session.buffer.length + chunk.length, - ); - totalBufferedBytes += chunk.length; - } - - function consumeBuffer(session, bytesConsumed) { - const consumed = Math.min(bytesConsumed, session.buffer.length); - session.buffer = session.buffer.subarray(consumed); - totalBufferedBytes = Math.max(0, totalBufferedBytes - consumed); - } -} - -function normalizeConfig(input) { - const listenEnabled = input.listenEnabled === true; - const publicIngressEnabled = input.publicIngressEnabled === true; - if (publicIngressEnabled && !listenEnabled) { - throw new TypeError("device_gateway_public_ingress_listener_required"); - } - if (publicIngressEnabled && input.coreChannelAuthenticated !== true) { - throw new TypeError("device_gateway_authenticated_core_channel_required"); - } - if ( - listenEnabled - && (typeof input.onDiscovery !== "function" - || typeof input.onMessage !== "function") - ) { - throw new TypeError("device_gateway_core_acceptance_sink_required"); - } - const registration = listenEnabled - ? resolveAdapterRegistration(input.adapterRegistry, input.protocolProfileRef) - : null; - if (registration?.profile?.commandTransport?.status === "typed-service-ping-v1") { - const probe = assertDeviceAdapterSession(registration.adapter.createSession({ - profileRef: registration.profile.profileRef, - })); - if ( - typeof probe.buildTypedCommand !== "function" - || typeof probe.parseTypedCommandResponse !== "function" - || typeof input.onCommandStatus !== "function" - ) { - throw new TypeError("device_gateway_typed_command_runtime_required"); - } - } - return { - listenEnabled, - publicIngressEnabled, - adapter: registration?.adapter ?? null, - profile: registration?.profile ?? null, - commandTransport: - registration?.profile?.commandTransport?.status ?? "disabled", - edgeRef: listenEnabled - ? normalizeOpaqueRef(input.edgeRef, "device_gateway_edge_ref_invalid") - : "edge:disabled", - routeRef: input.routeRef == null || input.routeRef === "" - ? undefined - : normalizeRouteRef(input.routeRef), - healthHost: normalizeHealthHost(input.healthHost, "127.0.0.1"), - healthPort: parseInteger( - input.healthPort, - 18121, - 0, - 65535, - "device_gateway_health_port_invalid", - ), - tcpHost: normalizeTcpHost( - input.tcpHost, - publicIngressEnabled ? "0.0.0.0" : "127.0.0.1", - publicIngressEnabled, - ), - tcpPort: parseInteger( - input.tcpPort, - 9921, - 0, - 65535, - "device_gateway_tcp_port_invalid", - ), - maxBufferedBytes: parseInteger( - input.maxBufferedBytes, - registration?.profile?.framing?.maxBufferedBytes ?? 256 * 1024, - 1024, - registration?.profile?.framing?.maxBufferedBytes ?? 256 * 1024, - "device_gateway_buffer_limit_invalid", - ), - maxAggregateBufferedBytes: parseInteger( - input.maxAggregateBufferedBytes, - 32 * 1024 * 1024, - 1024, - 32 * 1024 * 1024, - "device_gateway_aggregate_buffer_limit_invalid", - ), - maxConcurrentSessions: parseInteger( - input.maxConcurrentSessions, - 128, - 1, - 10000, - "device_gateway_session_limit_invalid", - ), - maxSessionsPerAddress: parseInteger( - input.maxSessionsPerAddress, - 16, - 1, - 1000, - "device_gateway_address_session_limit_invalid", - ), - maxConnectionsPerMinutePerAddress: parseInteger( - input.maxConnectionsPerMinutePerAddress, - 60, - 1, - 10000, - "device_gateway_address_rate_limit_invalid", - ), - maxTrackedSourceAddresses: parseInteger( - input.maxTrackedSourceAddresses, - 2048, - 1, - 65536, - "device_gateway_source_tracking_limit_invalid", - ), - sessionTimeoutMs: parseInteger( - input.sessionTimeoutMs, - 10000, - 100, - 60000, - "device_gateway_session_timeout_invalid", - ), - onDiscovery: typeof input.onDiscovery === "function" - ? input.onDiscovery - : undefined, - onMessage: typeof input.onMessage === "function" - ? input.onMessage - : undefined, - onCommandStatus: typeof input.onCommandStatus === "function" - ? input.onCommandStatus - : undefined, - now: typeof input.now === "function" ? input.now : () => new Date(), - }; -} - -function normalizeCommandOffer(value) { - if ( - !value - || typeof value !== "object" - || Array.isArray(value) - || typeof value.commandRef !== "string" - || !/^command:[0-9a-f-]{36}$/i.test(value.commandRef) - || value.commandType !== "service.ping" - || typeof value.accessCode !== "string" - || !/^\d{6}$/.test(value.accessCode) - || typeof value.transportMessageRef !== "string" - || !/^edge-command:[0-9a-f-]{36}$/i.test(value.transportMessageRef) - ) { - throw new TypeError("device_gateway_command_offer_invalid"); - } - return Object.freeze({ - commandRef: value.commandRef.toLowerCase(), - commandType: value.commandType, - accessCode: value.accessCode, - transportMessageRef: value.transportMessageRef.toLowerCase(), - }); -} - -function resolveAdapterRegistration(registry, profileRef) { - if (!registry || typeof registry.resolveProfile !== "function") { - throw new TypeError("device_gateway_adapter_registry_required"); - } - return registry.resolveProfile(profileRef); -} - -function normalizeOpaqueRef(value, errorCode) { - if ( - typeof value !== "string" - || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value) - ) { - throw new TypeError(errorCode); - } - return value; -} - -function normalizeRouteRef(value) { - if ( - typeof value !== "string" - || !/^route:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) - ) { - throw new TypeError("device_gateway_route_ref_invalid"); - } - return value.toLowerCase(); -} - -function assertDiscoveryAccepted(value) { - if ( - !value - || typeof value !== "object" - || !["quarantine", "claimed"].includes(value.lifecycleState) - ) { - throw new TypeError("device_gateway_core_discovery_not_accepted"); - } -} - -function messageRef(parsed) { - const digest = String(parsed?.payload?.packageDigest || ""); - if (!/^sha256:[a-f0-9]{64}$/.test(digest)) { - throw new TypeError("device_gateway_adapter_message_digest_invalid"); - } - return `package:${parsed.packageNumber}:${digest.slice("sha256:".length)}`; -} - -function messageIdempotencyKey({ adapterRef, profileRef, identifier, payload }) { - return `sha256:${createHash("sha256") - .update(JSON.stringify({ adapterRef, profileRef, identifier, payload }), "utf8") - .digest("hex")}`; -} - -function writeWithBackpressure(socket, bytes) { - if (!Buffer.isBuffer(bytes) || bytes.length === 0 || bytes.length > 4096) { - throw new TypeError("device_gateway_adapter_ack_invalid"); - } - if (socket.write(bytes)) return Promise.resolve(); - return new Promise((resolve, reject) => { - const onDrain = () => { - socket.off("error", onError); - resolve(); - }; - const onError = (error) => { - socket.off("drain", onDrain); - reject(error); - }; - socket.once("drain", onDrain); - socket.once("error", onError); - }); -} - -function normalizeHealthHost(value, fallback) { - const normalized = String(value || fallback).trim(); - if (!["127.0.0.1", "::1", "0.0.0.0", "::"].includes(normalized)) { - throw new TypeError("device_gateway_health_host_invalid"); - } - return normalized; -} - -function normalizeTcpHost(value, fallback, publicIngressEnabled) { - const normalized = String(value || fallback).trim(); - const allowed = publicIngressEnabled - ? ["0.0.0.0", "::"] - : ["127.0.0.1", "::1"]; - if (!allowed.includes(normalized)) { - throw new TypeError( - publicIngressEnabled - ? "device_gateway_public_ingress_host_invalid" - : "device_gateway_baseline_loopback_only", - ); - } - return normalized; -} - -function normalizeRemoteAddress(value) { - const normalized = String(value || "unknown").trim(); - return normalized.slice(0, 64) || "unknown"; -} - -function parseInteger(value, fallback, minimum, maximum, errorCode) { - const parsed = Number(value ?? fallback); - if ( - !Number.isSafeInteger(parsed) - || parsed < minimum - || parsed > maximum - ) { - throw new TypeError(errorCode); - } - return parsed; -} - -function listen(server, port, host) { - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(port, host, resolve); - }); -} - -function closeServer(server) { - if (!server.listening) return Promise.resolve(); - return new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); -} diff --git a/device-plane/services/device-gateway/src/server.mjs b/device-plane/services/device-gateway/src/server.mjs deleted file mode 100644 index dd0d65a..0000000 --- a/device-plane/services/device-gateway/src/server.mjs +++ /dev/null @@ -1,175 +0,0 @@ -import { readFile } from "node:fs/promises"; - -import { - DEVICE_ADAPTER_CATALOG, -} from "../../../packages/device-adapter-catalog/src/index.mjs"; -import { createCoreGatewayClient } from "./core-client.mjs"; -import { createDeviceGatewayRuntime } from "./runtime.mjs"; - -const config = await readConfig(); -const coreClient = config.listenEnabled - ? createCoreGatewayClient({ - coreUrl: config.coreUrl, - gatewayToken: config.gatewayToken, - timeoutMs: config.coreTimeoutMs, - }) - : undefined; -const runtime = createDeviceGatewayRuntime({ - listenEnabled: config.listenEnabled, - publicIngressEnabled: config.publicIngressEnabled, - coreChannelAuthenticated: false, - adapterRegistry: DEVICE_ADAPTER_CATALOG.registry, - protocolProfileRef: config.protocolProfileRef, - edgeRef: config.edgeRef, - routeRef: config.routeRef, - healthHost: config.healthHost, - healthPort: config.healthPort, - tcpHost: config.tcpHost, - tcpPort: config.tcpPort, - maxBufferedBytes: config.maxBufferedBytes, - maxAggregateBufferedBytes: config.maxAggregateBufferedBytes, - maxConcurrentSessions: config.maxConcurrentSessions, - maxSessionsPerAddress: config.maxSessionsPerAddress, - maxConnectionsPerMinutePerAddress: - config.maxConnectionsPerMinutePerAddress, - maxTrackedSourceAddresses: config.maxTrackedSourceAddresses, - sessionTimeoutMs: config.sessionTimeoutMs, - onDiscovery: coreClient?.observeDiscovery, - onMessage: coreClient?.acceptMessage, -}); - -const addresses = await runtime.start(); -console.log(JSON.stringify({ - event: "device_gateway_started", - health: addresses.healthAddress, - tcp: addresses.tcpAddress, - publicIngress: config.publicIngressEnabled - ? "telemetry-ingest" - : "disabled", - commandTransport: "disabled", -})); - -process.on("SIGTERM", shutdown); -process.on("SIGINT", shutdown); - -async function shutdown() { - await runtime.stop(); - process.exit(0); -} - -async function readConfig() { - const listenEnabled = parseBoolean( - process.env.DEVICE_GATEWAY_LISTEN_ENABLED, - false, - ); - const publicIngressEnabled = parseBoolean( - process.env.DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED, - false, - ); - return { - listenEnabled, - publicIngressEnabled, - protocolProfileRef: String( - process.env.DEVICE_GATEWAY_PROTOCOL_PROFILE_REF - || DEVICE_ADAPTER_CATALOG.defaultProfileRef, - ), - edgeRef: listenEnabled - ? requiredValue( - process.env.DEVICE_GATEWAY_EDGE_REF, - "device_gateway_edge_ref_required", - ) - : "edge:disabled", - routeRef: String(process.env.DEVICE_GATEWAY_ROUTE_REF || ""), - healthHost: String( - process.env.DEVICE_GATEWAY_HEALTH_HOST || "127.0.0.1", - ), - healthPort: parsePort(process.env.DEVICE_GATEWAY_HEALTH_PORT, 18121), - tcpHost: String( - process.env.DEVICE_GATEWAY_TCP_HOST - || (publicIngressEnabled ? "0.0.0.0" : "127.0.0.1"), - ), - tcpPort: parsePort(process.env.DEVICE_GATEWAY_TCP_PORT, 9921), - maxBufferedBytes: parsePositiveInt( - process.env.DEVICE_GATEWAY_MAX_BUFFERED_BYTES, - 65536, - ), - maxAggregateBufferedBytes: parsePositiveInt( - process.env.DEVICE_GATEWAY_MAX_AGGREGATE_BUFFERED_BYTES, - 32 * 1024 * 1024, - ), - maxConcurrentSessions: parsePositiveInt( - process.env.DEVICE_GATEWAY_MAX_SESSIONS, - 128, - ), - maxSessionsPerAddress: parsePositiveInt( - process.env.DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS, - 16, - ), - maxConnectionsPerMinutePerAddress: parsePositiveInt( - process.env.DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS, - 60, - ), - maxTrackedSourceAddresses: parsePositiveInt( - process.env.DEVICE_GATEWAY_MAX_TRACKED_SOURCE_ADDRESSES, - 2048, - ), - sessionTimeoutMs: parsePositiveInt( - process.env.DEVICE_GATEWAY_SESSION_TIMEOUT_MS, - 10000, - ), - coreUrl: listenEnabled - ? requiredValue( - process.env.DEVICE_GATEWAY_CORE_URL, - "device_gateway_core_url_required", - ) - : "", - gatewayToken: listenEnabled - ? await readRequiredSecretFile( - process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE, - "device_gateway_core_token_file_required", - ) - : "", - coreTimeoutMs: parsePositiveInt( - process.env.DEVICE_GATEWAY_CORE_TIMEOUT_MS, - 5000, - ), - }; -} - -async function readRequiredSecretFile(path, errorCode) { - const normalized = requiredValue(path, errorCode); - const value = (await readFile(normalized, "utf8")).trim(); - if (value.length < 32) throw new Error(errorCode); - return value; -} - -function requiredValue(value, errorCode) { - if (typeof value !== "string" || value.trim() === "") { - throw new Error(errorCode); - } - return value.trim(); -} - -function parsePort(value, fallback) { - const parsed = Number(value || fallback); - if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) { - throw new Error("device_gateway_port_invalid"); - } - return parsed; -} - -function parsePositiveInt(value, fallback) { - const parsed = Number(value || fallback); - if (!Number.isSafeInteger(parsed) || parsed < 1) { - throw new Error("device_gateway_positive_integer_invalid"); - } - return parsed; -} - -function parseBoolean(value, fallback) { - if (value === undefined || value === null || value === "") return fallback; - const normalized = String(value).trim().toLowerCase(); - if (["1", "true", "yes", "on"].includes(normalized)) return true; - if (["0", "false", "no", "off"].includes(normalized)) return false; - throw new Error("device_gateway_boolean_invalid"); -} diff --git a/device-plane/services/device-gateway/test/core-client.test.mjs b/device-plane/services/device-gateway/test/core-client.test.mjs deleted file mode 100644 index cba6b1c..0000000 --- a/device-plane/services/device-gateway/test/core-client.test.mjs +++ /dev/null @@ -1,118 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - createCoreDiscoveryClient, - createCoreGatewayClient, -} from "../src/core-client.mjs"; - -const gatewayToken = "test-only-gateway-token-with-32-bytes"; - -test("posts a discovery through the authenticated internal Core boundary", async () => { - let captured; - const observe = createCoreDiscoveryClient({ - coreUrl: "http://device-control-core:18120", - gatewayToken, - fetchImpl: async (url, options) => { - captured = { url, options }; - return new Response(JSON.stringify({ - ok: true, - discovery: { - lifecycleState: "quarantine", - commandTransport: "disabled", - }, - }), { - status: 201, - headers: { "Content-Type": "application/json" }, - }); - }, - }); - const signal = { - schemaVersion: "nodedc.device.discovery-signal.v1", - sessionRef: "session:test", - }; - const discovery = await observe(signal); - assert.equal( - captured.url, - "http://device-control-core:18120/internal/v1/device-discoveries:observe", - ); - assert.equal( - captured.options.headers.Authorization, - `Bearer ${gatewayToken}`, - ); - assert.deepEqual(JSON.parse(captured.options.body), signal); - assert.equal(discovery.lifecycleState, "quarantine"); -}); - -test("fails closed when Core does not return an accepted discovery view", async () => { - const observe = createCoreDiscoveryClient({ - coreUrl: "http://device-control-core:18120", - gatewayToken, - fetchImpl: async () => new Response(JSON.stringify({ - ok: true, - discovery: { - lifecycleState: "observed", - commandTransport: "disabled", - }, - }), { status: 200 }), - }); - await assert.rejects( - () => observe({ schemaVersion: "test" }), - /device_gateway_core_ingest_contract_invalid/, - ); -}); - -test("accepts a package only through the explicit Core acceptance contract", async () => { - let captured; - const client = createCoreGatewayClient({ - coreUrl: "http://device-control-core:18120", - gatewayToken, - fetchImpl: async (url, options) => { - captured = { url, options }; - const message = JSON.parse(options.body); - return new Response(JSON.stringify({ - ok: true, - acceptance: { - schemaVersion: "nodedc.device-adapter-acceptance.v1", - acceptanceRef: "acceptance:test-001", - idempotencyKey: message.idempotencyKey, - status: "accepted", - replayed: false, - acceptedAt: "2026-08-11T12:00:00.000Z", - }, - }), { status: 201 }); - }, - }); - const message = { idempotencyKey: `sha256:${"a".repeat(64)}` }; - const acceptance = await client.acceptMessage(message); - assert.equal( - captured.url, - "http://device-control-core:18120/internal/v1/gateway/messages:accept", - ); - assert.equal(acceptance.status, "accepted"); - assert.equal(acceptance.idempotencyKey, message.idempotencyKey); -}); - -test("rejects a mismatched or non-durable Core package response", async () => { - const client = createCoreGatewayClient({ - coreUrl: "http://device-control-core:18120", - gatewayToken, - fetchImpl: async () => new Response(JSON.stringify({ - ok: true, - acceptance: { - schemaVersion: "nodedc.device-adapter-acceptance.v1", - acceptanceRef: "acceptance:test-001", - idempotencyKey: `sha256:${"b".repeat(64)}`, - status: "accepted", - replayed: false, - acceptedAt: "2026-08-11T12:00:00.000Z", - }, - }), { status: 201 }), - }); - await assert.rejects( - () => client.acceptMessage({ - idempotencyKey: `sha256:${"a".repeat(64)}`, - }), - /device_gateway_core_acceptance_mismatch/, - ); -}); diff --git a/device-plane/services/device-gateway/test/discovery-integration.test.mjs b/device-plane/services/device-gateway/test/discovery-integration.test.mjs deleted file mode 100644 index 6404ed2..0000000 --- a/device-plane/services/device-gateway/test/discovery-integration.test.mjs +++ /dev/null @@ -1,149 +0,0 @@ -import assert from "node:assert/strict"; -import { connect } from "node:net"; -import test from "node:test"; - -import { - DEVICE_ADAPTER_CATALOG, -} from "../../../packages/device-adapter-catalog/src/index.mjs"; -import { - createControlCoreApp, -} from "../../device-control-core/src/app.mjs"; -import { createCoreGatewayClient } from "../src/core-client.mjs"; -import { createDeviceGatewayRuntime } from "../src/runtime.mjs"; - -const gatewayToken = "test-only-gateway-token-with-32-bytes"; -const identifierPepper = "test-only-identifier-pepper-with-32-bytes"; -const specificationHeader = Buffer.from( - "FF23E9EF782DE7120300", - "hex", -); -const specificationPackage = Buffer.from( - "5B01010000FBDEC251EC5D", - "hex", -); - -test("B2 HEADER2 becomes a persisted masked quarantine discovery before ACK", async () => { - let stored; - let storedMessage; - const core = createControlCoreApp({ - discoveryIngestEnabled: true, - gatewayToken, - identifierPepper, - repository: { - health: async () => "ready", - upsertQuarantineDiscovery: async (value) => { - stored = value; - return { - created: true, - value: { - ...value.safeView, - discoveryRef: "discovery:integration-001", - }, - }; - }, - acceptAdapterMessage: async (value) => { - storedMessage = value; - return { - acceptance: { - schemaVersion: "nodedc.device-adapter-acceptance.v1", - acceptanceRef: "acceptance:integration-001", - idempotencyKey: value.safeView.idempotencyKey, - status: "accepted", - replayed: false, - acceptedAt: "2026-08-11T12:00:00.000Z", - }, - }; - }, - }, - }); - await listen(core); - const coreAddress = core.address(); - const observe = createCoreGatewayClient({ - coreUrl: `http://127.0.0.1:${coreAddress.port}`, - gatewayToken, - }); - const gateway = createDeviceGatewayRuntime({ - healthPort: 0, - tcpHost: "0.0.0.0", - tcpPort: 0, - listenEnabled: true, - publicIngressEnabled: true, - coreChannelAuthenticated: true, - adapterRegistry: DEVICE_ADAPTER_CATALOG.registry, - protocolProfileRef: DEVICE_ADAPTER_CATALOG.defaultProfileRef, - edgeRef: "edge:integration-001", - now: () => new Date(0x52db95de * 1000), - onDiscovery: observe.observeDiscovery, - onMessage: observe.acceptMessage, - onCommandStatus: async () => undefined, - }); - const addresses = await gateway.start(); - try { - const response = await exchange( - addresses.tcpAddress.port, - Buffer.concat([specificationHeader, specificationPackage]), - 13, - ); - assert.equal( - response.toString("hex").toUpperCase(), - "7B0400A0DE95DB527D7B00017D", - ); - assert.match(stored.identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/); - assert.equal(stored.safeView.lifecycleState, "quarantine"); - assert.equal(stored.safeView.identifier.masked, "***********7769"); - assert.equal(stored.safeView.commandTransport, "disabled"); - assert.equal( - JSON.stringify(stored).includes("865209039777769"), - false, - ); - assert.equal(storedMessage.safeView.messageType, "telemetry.package"); - assert.equal(storedMessage.safeView.identifier.masked, "***********7769"); - assert.equal( - JSON.stringify(storedMessage).includes("865209039777769"), - false, - ); - } finally { - await gateway.stop(); - await close(core); - } -}); - -function listen(server) { - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); -} - -function close(server) { - return new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - server.closeAllConnections?.(); - }); -} - -function exchange(port, payload, expectedBytes) { - return new Promise((resolve, reject) => { - const chunks = []; - let byteLength = 0; - const socket = connect({ host: "127.0.0.1", port }, () => { - socket.write(payload); - }); - socket.on("data", (chunk) => { - chunks.push(chunk); - byteLength += chunk.length; - if (byteLength >= expectedBytes) { - socket.destroy(); - resolve(Buffer.concat(chunks, byteLength)); - } - }); - socket.on("error", reject); - socket.on("close", () => { - if (byteLength < expectedBytes) { - reject(new Error( - `device_gateway_test_socket_closed_early:${byteLength}/${expectedBytes}`, - )); - } - }); - }); -} diff --git a/device-plane/services/device-gateway/test/runtime.test.mjs b/device-plane/services/device-gateway/test/runtime.test.mjs deleted file mode 100644 index 7ef2b2e..0000000 --- a/device-plane/services/device-gateway/test/runtime.test.mjs +++ /dev/null @@ -1,413 +0,0 @@ -import assert from "node:assert/strict"; -import { connect } from "node:net"; -import test from "node:test"; - -import { - DEVICE_ADAPTER_CATALOG, -} from "../../../packages/device-adapter-catalog/src/index.mjs"; -import { createDeviceGatewayRuntime } from "../src/runtime.mjs"; - -const specificationHeader = Buffer.from( - "FF23E9EF782DE7120300", - "hex", -); -const specificationPackage = Buffer.from( - "5B01010000FBDEC251EC5D", - "hex", -); - -test("baseline health exposes no public ingress and no command transport", async () => { - const runtime = createDeviceGatewayRuntime({ - healthPort: 0, - listenEnabled: false, - }); - const addresses = await runtime.start(); - try { - const response = await fetch( - `http://127.0.0.1:${addresses.healthAddress.port}/healthz`, - ); - assert.equal(response.status, 200); - const body = await response.json(); - assert.equal(body.publicIngress, "disabled"); - assert.equal(body.commandTransport, "disabled"); - assert.equal(body.tcpListener, "disabled"); - assert.equal(addresses.tcpAddress, null); - } finally { - await runtime.stop(); - } -}); - -test("telemetry ingress persists HEADER2 before acknowledging packages", async () => { - const captured = []; - const runtime = createDeviceGatewayRuntime({ - healthPort: 0, - tcpHost: "0.0.0.0", - tcpPort: 0, - listenEnabled: true, - publicIngressEnabled: true, - coreChannelAuthenticated: true, - now: () => new Date(0x52db95de * 1000), - ...gatewayAdapterOptions(), - onDiscovery: async (value) => { - captured.push(value); - return { lifecycleState: "quarantine" }; - }, - onMessage: async (message) => acceptanceFor(message), - }); - const addresses = await runtime.start(); - const client = await connectAndCollect(addresses.tcpAddress.port); - try { - client.socket.write(specificationHeader.subarray(0, 4)); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(client.bytes().length, 0); - - client.socket.write(specificationHeader.subarray(4)); - await client.waitForBytes(9); - assert.equal( - client.bytes().subarray(0, 9).toString("hex").toUpperCase(), - "7B0400A0DE95DB527D", - ); - assert.equal(captured.length, 1); - assert.equal(captured[0].identifier.value, "865209039777769"); - assert.equal(captured[0].evidence.framingStatus, "verified"); - assert.equal(captured[0].commandTransport, undefined); - - client.socket.write(specificationPackage); - await client.waitForBytes(13); - assert.equal( - client.bytes().subarray(9).toString("hex").toUpperCase(), - "7B00017D", - ); - assert.equal(runtime.status().totalDiscoveries, 1); - assert.equal(runtime.status().totalMessagesAccepted, 1); - assert.equal(runtime.status().totalPackagesAcknowledged, 1); - assert.equal(runtime.status().commandTransport, "typed-service-ping-v1"); - assert.equal(runtime.status().publicIngress, "telemetry-ingest"); - - const response = await fetch( - `http://127.0.0.1:${addresses.healthAddress.port}/healthz`, - ); - const body = await response.json(); - assert.equal(body.framing, "verified-read-only"); - assert.equal(body.tcpListener, "telemetry-ingest"); - assert.equal(body.publicIngress, "telemetry-ingest"); - assert.equal(body.commandTransport, "typed-service-ping-v1"); - } finally { - client.socket.destroy(); - await runtime.stop(); - } -}); - -test("dispatches a typed service ping on the next telemetry package and records SERV OK", async () => { - const statuses = []; - const runtime = createDeviceGatewayRuntime({ - healthPort: 0, - tcpPort: 0, - listenEnabled: true, - ...gatewayAdapterOptions(), - onDiscovery: async () => ({ lifecycleState: "claimed" }), - onMessage: async (message) => ({ - ...acceptanceFor(message), - commandOffer: { - commandRef: "command:11111111-1111-4111-8111-111111111111", - commandType: "service.ping", - accessCode: "123456", - transportMessageRef: "edge-command:22222222-2222-4222-8222-222222222222", - }, - }), - onCommandStatus: async (status) => statuses.push(status), - }); - const addresses = await runtime.start(); - const client = await connectAndCollect(addresses.tcpAddress.port); - try { - client.socket.write(Buffer.concat([specificationHeader, specificationPackage])); - await client.waitForBytes(28); - assert.equal( - client.bytes().subarray(13).toString("ascii"), - "123456*SERV*1.1", - ); - client.socket.write(Buffer.from("SERV OK", "ascii")); - await waitFor(() => statuses.length === 1); - assert.deepEqual(statuses[0], { - commandRef: "command:11111111-1111-4111-8111-111111111111", - transportMessageRef: "edge-command:22222222-2222-4222-8222-222222222222", - lifecycleState: "acknowledged", - resultCode: "serv_ok", - observedAt: statuses[0].observedAt, - sessionRef: statuses[0].sessionRef, - adapterProfileRef: "arusnavi.b2.internal.v1", - }); - } finally { - client.socket.destroy(); - await runtime.stop(); - } -}); - -test("does not acknowledge malformed or unverified initial bytes", async () => { - const captured = []; - const runtime = createDeviceGatewayRuntime({ - healthPort: 0, - tcpPort: 0, - listenEnabled: true, - ...gatewayAdapterOptions(), - onDiscovery: async (value) => { - captured.push(value); - return { lifecycleState: "quarantine" }; - }, - onMessage: async (message) => acceptanceFor(message), - }); - const addresses = await runtime.start(); - try { - const received = await sendAndCollect( - addresses.tcpAddress.port, - Buffer.from("not-a-b2-header", "utf8"), - ); - assert.equal(received.length, 0); - assert.equal(captured.length, 0); - assert.equal(runtime.status().totalRejected, 1); - } finally { - await runtime.stop(); - } -}); - -test("does not acknowledge a header when Core rejects discovery", async () => { - const runtime = createDeviceGatewayRuntime({ - healthPort: 0, - tcpPort: 0, - listenEnabled: true, - ...gatewayAdapterOptions(), - onDiscovery: async () => { - throw new Error("core_unavailable"); - }, - onMessage: async (message) => acceptanceFor(message), - }); - const addresses = await runtime.start(); - try { - const received = await sendAndCollect( - addresses.tcpAddress.port, - specificationHeader, - ); - assert.equal(received.length, 0); - assert.equal(runtime.status().totalDiscoveries, 0); - assert.equal(runtime.status().totalRejected, 1); - } finally { - await runtime.stop(); - } -}); - -test("does not acknowledge a package when durable Core acceptance fails", async () => { - const runtime = createDeviceGatewayRuntime({ - healthPort: 0, - tcpPort: 0, - listenEnabled: true, - now: () => new Date(0x52db95de * 1000), - ...gatewayAdapterOptions(), - onDiscovery: async () => ({ lifecycleState: "quarantine" }), - onMessage: async () => { - throw new Error("core_commit_failed"); - }, - }); - const addresses = await runtime.start(); - try { - const received = await sendAndCollect( - addresses.tcpAddress.port, - Buffer.concat([specificationHeader, specificationPackage]), - ); - assert.equal( - received.toString("hex").toUpperCase(), - "7B0400A0DE95DB527D", - ); - assert.equal(runtime.status().totalMessagesAccepted, 0); - assert.equal(runtime.status().totalPackagesAcknowledged, 0); - assert.equal(runtime.status().totalRejected, 1); - } finally { - await runtime.stop(); - } -}); - -test("acknowledges an idempotent Core replay as accepted delivery", async () => { - const runtime = createDeviceGatewayRuntime({ - healthPort: 0, - tcpPort: 0, - listenEnabled: true, - now: () => new Date(0x52db95de * 1000), - ...gatewayAdapterOptions(), - onDiscovery: async () => ({ lifecycleState: "quarantine" }), - onMessage: async (message) => acceptanceFor(message, true), - }); - const addresses = await runtime.start(); - try { - const received = await sendAndCollect( - addresses.tcpAddress.port, - Buffer.concat([specificationHeader, specificationPackage]), - ); - assert.equal( - received.toString("hex").toUpperCase(), - "7B0400A0DE95DB527D7B00017D", - ); - assert.equal(runtime.status().totalMessagesAccepted, 1); - assert.equal(runtime.status().totalPackagesAcknowledged, 1); - } finally { - await runtime.stop(); - } -}); - -test("closes an oversized tracker buffer and releases its aggregate budget", async () => { - const runtime = createDeviceGatewayRuntime({ - healthPort: 0, - tcpPort: 0, - listenEnabled: true, - maxBufferedBytes: 1024, - maxAggregateBufferedBytes: 1024, - ...gatewayAdapterOptions(), - onDiscovery: async () => ({ lifecycleState: "quarantine" }), - onMessage: async (message) => acceptanceFor(message), - }); - const addresses = await runtime.start(); - try { - const received = await sendAndCollect( - addresses.tcpAddress.port, - Buffer.alloc(1025, 0x01), - ); - assert.equal(received.length, 0); - assert.equal(runtime.status().totalRejected, 1); - assert.equal(runtime.status().totalBufferedBytes, 0); - assert.equal(runtime.status().activeSessions, 0); - } finally { - await runtime.stop(); - } -}); - -test("public ingress requires both discovery and durable message acceptance sinks", () => { - assert.throws( - () => createDeviceGatewayRuntime({ - listenEnabled: true, - publicIngressEnabled: true, - coreChannelAuthenticated: true, - tcpHost: "0.0.0.0", - }), - /device_gateway_core_acceptance_sink_required/, - ); -}); - -test("public ingress cannot start on the legacy bearer HTTP Core client", () => { - assert.throws( - () => createDeviceGatewayRuntime({ - listenEnabled: true, - publicIngressEnabled: true, - tcpHost: "0.0.0.0", - ...gatewayAdapterOptions(), - onDiscovery: async () => ({ lifecycleState: "quarantine" }), - onMessage: async (message) => acceptanceFor(message), - }), - /device_gateway_authenticated_core_channel_required/, - ); -}); - -test("baseline rejects non-loopback binding", () => { - assert.throws( - () => createDeviceGatewayRuntime({ - listenEnabled: true, - tcpHost: "0.0.0.0", - ...gatewayAdapterOptions(), - onDiscovery: async () => ({ lifecycleState: "quarantine" }), - onMessage: async (message) => acceptanceFor(message), - }), - /device_gateway_baseline_loopback_only/, - ); -}); - -test("container health may bind all interfaces while TCP stays disabled", async () => { - const runtime = createDeviceGatewayRuntime({ - healthHost: "0.0.0.0", - healthPort: 0, - listenEnabled: false, - }); - const addresses = await runtime.start(); - try { - assert.equal(addresses.healthAddress.address, "0.0.0.0"); - assert.equal(addresses.tcpAddress, null); - assert.equal(runtime.status().publicIngress, "disabled"); - } finally { - await runtime.stop(); - } -}); - -function connectAndCollect(port) { - return new Promise((resolve, reject) => { - const chunks = []; - let byteLength = 0; - const waiters = []; - const socket = connect({ host: "127.0.0.1", port }, () => { - resolve({ - socket, - bytes: () => Buffer.concat(chunks, byteLength), - waitForBytes: (minimum) => { - if (byteLength >= minimum) return Promise.resolve(); - return new Promise((waitResolve, waitReject) => { - waiters.push({ minimum, waitResolve, waitReject }); - }); - }, - }); - }); - socket.on("data", (chunk) => { - chunks.push(chunk); - byteLength += chunk.length; - for (let index = waiters.length - 1; index >= 0; index -= 1) { - if (byteLength >= waiters[index].minimum) { - waiters[index].waitResolve(); - waiters.splice(index, 1); - } - } - }); - socket.on("error", (error) => { - for (const waiter of waiters.splice(0)) waiter.waitReject(error); - reject(error); - }); - socket.on("close", () => { - for (const waiter of waiters.splice(0)) { - waiter.waitReject(new Error("device_gateway_test_socket_closed")); - } - }); - }); -} - -function sendAndCollect(port, payload) { - return new Promise((resolve, reject) => { - const chunks = []; - const socket = connect({ host: "127.0.0.1", port }, () => { - socket.end(payload); - }); - socket.on("data", (chunk) => chunks.push(chunk)); - socket.on("close", () => resolve(Buffer.concat(chunks))); - socket.on("error", reject); - }); -} - -function gatewayAdapterOptions() { - return { - adapterRegistry: DEVICE_ADAPTER_CATALOG.registry, - protocolProfileRef: DEVICE_ADAPTER_CATALOG.defaultProfileRef, - edgeRef: "edge:test-001", - onCommandStatus: async () => undefined, - }; -} - -function acceptanceFor(message, replayed = false) { - return { - schemaVersion: "nodedc.device-adapter-acceptance.v1", - acceptanceRef: "acceptance:test-001", - idempotencyKey: message.idempotencyKey, - status: "accepted", - replayed, - acceptedAt: "2026-08-11T12:00:00.000Z", - }; -} - -async function waitFor(predicate, timeoutMs = 1_000) { - const deadline = Date.now() + timeoutMs; - while (!predicate()) { - if (Date.now() >= deadline) throw new Error("test_wait_timeout"); - await new Promise((resolve) => setTimeout(resolve, 5)); - } -} diff --git a/device-plane/services/device-manager/Dockerfile b/device-plane/services/device-manager/Dockerfile deleted file mode 100644 index 01e20c2..0000000 --- a/device-plane/services/device-manager/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -FROM node:22-alpine - -ENV NODE_ENV=production -ENV HOST=0.0.0.0 -ENV PORT=18122 - -WORKDIR /app - -COPY server ./server -COPY dist ./dist - -USER node - -EXPOSE 18122 - -CMD ["node", "server/device-manager-server.mjs"] diff --git a/device-plane/vps/config/00-nodedc-b2-vps.conf b/device-plane/vps/config/00-nodedc-b2-vps.conf deleted file mode 100644 index 3b92552..0000000 --- a/device-plane/vps/config/00-nodedc-b2-vps.conf +++ /dev/null @@ -1,16 +0,0 @@ -PasswordAuthentication no -KbdInteractiveAuthentication no -PermitRootLogin prohibit-password -PubkeyAuthentication yes -AuthenticationMethods publickey -X11Forwarding no -AllowAgentForwarding no -AllowTcpForwarding no -GatewayPorts no -PermitTunnel no -PermitUserEnvironment no -MaxAuthTries 3 -LoginGraceTime 20 -ClientAliveInterval 60 -ClientAliveCountMax 3 -UseDNS no diff --git a/device-plane/vps/config/backhaul_ssh_config b/device-plane/vps/config/backhaul_ssh_config deleted file mode 100644 index 5d0b799..0000000 --- a/device-plane/vps/config/backhaul_ssh_config +++ /dev/null @@ -1,23 +0,0 @@ -Host device-backhaul-target - HostName 100.109.216.21 - Port 2222 - User device-backhaul - AddressFamily inet - IdentityFile /var/lib/nodedc-b2-vps/trust/backhaul_ed25519 - IdentitiesOnly yes - PreferredAuthentications publickey - PasswordAuthentication no - KbdInteractiveAuthentication no - StrictHostKeyChecking yes - UserKnownHostsFile /var/lib/nodedc-b2-vps/trust/backhaul_known_hosts - GlobalKnownHostsFile /dev/null - ProxyCommand /usr/bin/nc -X 5 -x 127.0.0.1:1055 %h %p - LocalForward 127.0.0.1:19921 127.0.0.1:9921 - ExitOnForwardFailure yes - ServerAliveInterval 30 - ServerAliveCountMax 3 - TCPKeepAlive yes - ClearAllForwardings no - RequestTTY no - SessionType none - LogLevel VERBOSE diff --git a/device-plane/vps/config/nftables-core-channel.conf b/device-plane/vps/config/nftables-core-channel.conf deleted file mode 100644 index 070ae9c..0000000 --- a/device-plane/vps/config/nftables-core-channel.conf +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/sbin/nft -f - -flush ruleset - -table inet nodedc_b2_vps { - chain input { - type filter hook input priority -10; policy drop; - - iifname "lo" accept - ct state invalid drop - ct state established,related accept - ip protocol icmp accept - ip6 nexthdr ipv6-icmp accept - - tcp dport 22 ct state new limit rate 30/minute burst 60 packets accept - tcp dport 443 ct state new limit rate 120/minute burst 120 packets accept - } - - chain forward { - type filter hook forward priority -10; policy drop; - } - - chain output { - type filter hook output priority -10; policy accept; - } -} diff --git a/device-plane/vps/config/nftables-foundation.conf b/device-plane/vps/config/nftables-foundation.conf deleted file mode 100644 index be08dc0..0000000 --- a/device-plane/vps/config/nftables-foundation.conf +++ /dev/null @@ -1,22 +0,0 @@ -flush ruleset - -table inet nodedc_b2_vps { - chain input { - type filter hook input priority -10; policy drop; - - iifname "lo" accept - ct state invalid drop - ct state established,related accept - ip protocol icmp accept - ip6 nexthdr ipv6-icmp accept - tcp dport 22 ct state new limit rate 30/minute burst 60 packets accept - } - - chain forward { - type filter hook forward priority -10; policy drop; - } - - chain output { - type filter hook output priority -10; policy accept; - } -} diff --git a/device-plane/vps/config/nftables-relay.conf b/device-plane/vps/config/nftables-relay.conf deleted file mode 100644 index dfdd114..0000000 --- a/device-plane/vps/config/nftables-relay.conf +++ /dev/null @@ -1,24 +0,0 @@ -flush ruleset - -table inet nodedc_b2_vps { - chain input { - type filter hook input priority -10; policy drop; - - iifname "lo" accept - ct state invalid drop - ct state established,related accept - ip protocol icmp accept - ip6 nexthdr ipv6-icmp accept - tcp dport 22 ct state new limit rate 30/minute burst 60 packets accept - tcp dport 9921 ct state new limit rate over 300/second drop - tcp dport 9921 accept - } - - chain forward { - type filter hook forward priority -10; policy drop; - } - - chain output { - type filter hook output priority -10; policy accept; - } -} diff --git a/device-plane/vps/config/nftables-tracker-ingress.conf b/device-plane/vps/config/nftables-tracker-ingress.conf deleted file mode 100644 index f62de05..0000000 --- a/device-plane/vps/config/nftables-tracker-ingress.conf +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/sbin/nft -f - -flush ruleset - -table inet nodedc_b2_vps { - chain input { - type filter hook input priority -10; policy drop; - - iifname "lo" accept - ct state invalid drop - ct state established,related accept - ip protocol icmp accept - ip6 nexthdr ipv6-icmp accept - - tcp dport 22 ct state new limit rate 30/minute burst 60 packets accept - tcp dport 443 ct state new limit rate 120/minute burst 120 packets accept - tcp dport 9921 ct state new limit rate 600/minute burst 128 packets accept - } - - chain forward { - type filter hook forward priority -10; policy drop; - } - - chain output { - type filter hook output priority -10; policy accept; - } -} diff --git a/device-plane/vps/edge-process/device-edge-runtime.mjs b/device-plane/vps/edge-process/device-edge-runtime.mjs deleted file mode 100644 index 4376e3b..0000000 --- a/device-plane/vps/edge-process/device-edge-runtime.mjs +++ /dev/null @@ -1,247 +0,0 @@ -import { createServer } from "node:http"; -import { pathToFileURL } from "node:url"; - -import { - DEVICE_ADAPTER_CATALOG, -} from "../../packages/device-adapter-catalog/src/index.mjs"; -import { - createDeviceEdgeChannelServer, -} from "../../services/device-edge-channel/src/runtime.mjs"; -import { - readRuntimeConfiguration, -} from "../../services/device-edge-channel/src/server.mjs"; -import { - createDeviceGatewayRuntime, -} from "../../services/device-gateway/src/runtime.mjs"; - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - await main(); -} - -export async function main(environment = process.env) { - const base = await readRuntimeConfiguration(environment); - const tracker = normalizeTrackerIngressConfiguration( - environment, - base.channel.edgeRegistrationId, - ); - const channel = createDeviceEdgeChannelServer({ - ...base.channel, - commandTransport: "typed-service-ping-v1", - }); - const gateway = createDeviceGatewayRuntime({ - listenEnabled: true, - publicIngressEnabled: true, - coreChannelAuthenticated: true, - adapterRegistry: DEVICE_ADAPTER_CATALOG.registry, - protocolProfileRef: tracker.protocolProfileRef, - edgeRef: tracker.edgeRef, - healthHost: tracker.healthHost, - healthPort: tracker.healthPort, - tcpHost: tracker.tcpHost, - tcpPort: tracker.tcpPort, - maxBufferedBytes: tracker.maxBufferedBytes, - maxAggregateBufferedBytes: tracker.maxAggregateBufferedBytes, - maxConcurrentSessions: tracker.maxConcurrentSessions, - maxSessionsPerAddress: tracker.maxSessionsPerAddress, - maxConnectionsPerMinutePerAddress: - tracker.maxConnectionsPerMinutePerAddress, - maxTrackedSourceAddresses: tracker.maxTrackedSourceAddresses, - sessionTimeoutMs: tracker.sessionTimeoutMs, - onDiscovery: (signal) => channel.submitDiscovery(signal), - onMessage: (message) => channel.submitAdapterMessage(message), - onCommandStatus: (status) => channel.submitCommandStatus(status), - }); - const health = createCombinedHealthServer(channel, gateway, base.health); - let stopping = false; - - try { - await channel.start(); - await gateway.start(); - await listen(health, base.health.port, base.health.host); - } catch (error) { - await Promise.allSettled([ - gateway.stop(), - channel.stop(), - closeServer(health), - ]); - throw error; - } - - console.log(JSON.stringify({ - event: "device_edge_runtime_started", - channel: `${base.channel.host}:${base.channel.port}`, - health: `${base.health.host}:${base.health.port}`, - trackerIngress: `${tracker.tcpHost}:${tracker.tcpPort}`, - adapterProfile: tracker.protocolProfileRef, - edgeRegistrationId: base.channel.edgeRegistrationId, - channelGeneration: base.channel.channelGeneration, - trustGeneration: base.channel.trustGeneration, - commandTransport: "typed-service-ping-v1", - })); - - process.on("SIGTERM", shutdown); - process.on("SIGINT", shutdown); - - async function shutdown() { - if (stopping) return; - stopping = true; - await Promise.allSettled([ - gateway.stop(), - channel.stop(), - closeServer(health), - ]); - process.exit(0); - } -} - -export function normalizeTrackerIngressConfiguration(environment = {}, edgeRef) { - return Object.freeze({ - edgeRef: normalizeRef(edgeRef, "device_edge_runtime_edge_ref_invalid"), - protocolProfileRef: normalizeProfileRef( - environment.DEVICE_GATEWAY_PROTOCOL_PROFILE_REF - ?? DEVICE_ADAPTER_CATALOG.defaultProfileRef, - ), - healthHost: normalizeLoopbackHost( - environment.DEVICE_GATEWAY_HEALTH_HOST ?? "127.0.0.1", - ), - healthPort: normalizePort(environment.DEVICE_GATEWAY_HEALTH_PORT, 18221), - tcpHost: normalizePublicHost( - environment.DEVICE_GATEWAY_TCP_HOST ?? "0.0.0.0", - ), - tcpPort: normalizePort(environment.DEVICE_GATEWAY_TCP_PORT, 9921), - maxBufferedBytes: normalizeInteger( - environment.DEVICE_GATEWAY_MAX_BUFFERED_BYTES, - 64 * 1024, - 1024, - 256 * 1024, - "device_edge_runtime_session_buffer_invalid", - ), - maxAggregateBufferedBytes: normalizeInteger( - environment.DEVICE_GATEWAY_MAX_AGGREGATE_BUFFERED_BYTES, - 32 * 1024 * 1024, - 1024, - 32 * 1024 * 1024, - "device_edge_runtime_aggregate_buffer_invalid", - ), - maxConcurrentSessions: normalizeInteger( - environment.DEVICE_GATEWAY_MAX_SESSIONS, - 128, - 1, - 128, - "device_edge_runtime_session_limit_invalid", - ), - maxSessionsPerAddress: normalizeInteger( - environment.DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS, - 16, - 1, - 16, - "device_edge_runtime_address_session_limit_invalid", - ), - maxConnectionsPerMinutePerAddress: normalizeInteger( - environment.DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS, - 60, - 1, - 60, - "device_edge_runtime_address_rate_limit_invalid", - ), - maxTrackedSourceAddresses: normalizeInteger( - environment.DEVICE_GATEWAY_MAX_TRACKED_SOURCE_ADDRESSES, - 2048, - 1, - 2048, - "device_edge_runtime_source_tracking_limit_invalid", - ), - sessionTimeoutMs: normalizeInteger( - environment.DEVICE_GATEWAY_SESSION_TIMEOUT_MS, - 10_000, - 100, - 60_000, - "device_edge_runtime_session_timeout_invalid", - ), - }); -} - -function createCombinedHealthServer(channel, gateway, healthConfig) { - return createServer((request, response) => { - response.setHeader("Content-Type", "application/json; charset=utf-8"); - response.setHeader("Cache-Control", "no-store"); - response.setHeader("X-Content-Type-Options", "nosniff"); - if (request.method !== "GET" || request.url !== "/healthz") { - response.statusCode = 404; - response.end(JSON.stringify({ ok: false, error: "not_found" })); - return; - } - response.statusCode = 200; - response.end(JSON.stringify({ - ok: true, - service: "nodedc-device-edge-runtime", - health: `${healthConfig.host}:${healthConfig.port}`, - ...channel.status(), - trackerIngress: "telemetry-ingest", - tracker: gateway.status(), - commandTransport: "typed-service-ping-v1", - })); - }); -} - -function normalizeRef(value, errorCode) { - if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) { - throw new TypeError(errorCode); - } - return value; -} - -function normalizeProfileRef(value) { - if (typeof value !== "string" || !/^[a-z][a-z0-9._-]{2,127}$/.test(value)) { - throw new TypeError("device_edge_runtime_profile_ref_invalid"); - } - DEVICE_ADAPTER_CATALOG.registry.resolveProfile(value); - return value; -} - -function normalizeLoopbackHost(value) { - if (!["127.0.0.1", "::1"].includes(value)) { - throw new TypeError("device_edge_runtime_health_host_invalid"); - } - return value; -} - -function normalizePublicHost(value) { - if (!["0.0.0.0", "::"].includes(value)) { - throw new TypeError("device_edge_runtime_public_host_invalid"); - } - return value; -} - -function normalizePort(value, fallback) { - return normalizeInteger( - value, - fallback, - 1, - 65_535, - "device_edge_runtime_port_invalid", - ); -} - -function normalizeInteger(value, fallback, minimum, maximum, errorCode) { - const normalized = Number(value ?? fallback); - if (!Number.isSafeInteger(normalized) || normalized < minimum || normalized > maximum) { - throw new TypeError(errorCode); - } - return normalized; -} - -function listen(server, port, host) { - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(port, host, () => { - server.off("error", reject); - resolve(); - }); - }); -} - -function closeServer(server) { - if (!server.listening) return Promise.resolve(); - return new Promise((resolve) => server.close(() => resolve())); -} diff --git a/device-plane/vps/systemd/nodedc-b2-backhaul.service b/device-plane/vps/systemd/nodedc-b2-backhaul.service deleted file mode 100644 index d429896..0000000 --- a/device-plane/vps/systemd/nodedc-b2-backhaul.service +++ /dev/null @@ -1,33 +0,0 @@ -[Unit] -Description=NODE.DC B2 VPS encrypted private backhaul -After=network-online.target nodedc-b2-tailscaled.service -Wants=network-online.target -Requires=nodedc-b2-tailscaled.service - -[Service] -Type=simple -User=nodedc-backhaul -Group=nodedc-backhaul -ExecStart=/usr/bin/ssh -N -F /opt/nodedc-b2-vps/config/backhaul_ssh_config device-backhaul-target -Restart=always -RestartSec=3s -NoNewPrivileges=yes -PrivateTmp=yes -PrivateDevices=yes -ProtectSystem=strict -ProtectHome=yes -ProtectKernelTunables=yes -ProtectKernelModules=yes -ProtectControlGroups=yes -RestrictSUIDSGID=yes -LockPersonality=yes -MemoryDenyWriteExecute=yes -RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 -SystemCallArchitectures=native -MemoryMax=64M -TasksMax=32 -LimitNOFILE=1024 -UMask=0077 - -[Install] -WantedBy=multi-user.target diff --git a/device-plane/vps/systemd/nodedc-b2-relay.service b/device-plane/vps/systemd/nodedc-b2-relay.service deleted file mode 100644 index 2f579e5..0000000 --- a/device-plane/vps/systemd/nodedc-b2-relay.service +++ /dev/null @@ -1,48 +0,0 @@ -[Unit] -Description=NODE.DC B2 VPS bounded raw TCP relay -After=network-online.target nodedc-b2-backhaul.service -Wants=network-online.target -Requires=nodedc-b2-backhaul.service - -[Service] -Type=simple -User=nodedc-relay -Group=nodedc-relay -WorkingDirectory=/opt/nodedc-b2-vps -Environment=DEVICE_EDGE_RELAY_HEALTH_HOST=127.0.0.1 -Environment=DEVICE_EDGE_RELAY_HEALTH_PORT=18221 -Environment=DEVICE_EDGE_RELAY_INGRESS_ENABLED=true -Environment=DEVICE_EDGE_RELAY_TCP_HOST=0.0.0.0 -Environment=DEVICE_EDGE_RELAY_TCP_PORT=9921 -Environment=DEVICE_EDGE_RELAY_UPSTREAM_HOST=127.0.0.1 -Environment=DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921 -Environment=DEVICE_EDGE_RELAY_MAX_SESSIONS=128 -Environment=DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS=16 -Environment=DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS=60 -Environment=DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES=4096 -Environment=DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION=67108864 -Environment=DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS=300000 -Environment=DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only -ExecStart=/opt/nodedc-b2-vps/runtime/node/bin/node /opt/nodedc-b2-vps/services/device-edge-relay/src/server.mjs -Restart=always -RestartSec=3s -NoNewPrivileges=yes -PrivateTmp=yes -PrivateDevices=yes -ProtectSystem=strict -ProtectHome=yes -ProtectKernelTunables=yes -ProtectKernelModules=yes -ProtectControlGroups=yes -RestrictSUIDSGID=yes -LockPersonality=yes -MemoryDenyWriteExecute=no -RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 -SystemCallArchitectures=native -MemoryMax=192M -TasksMax=64 -LimitNOFILE=4096 -UMask=0077 - -[Install] -WantedBy=multi-user.target diff --git a/device-plane/vps/systemd/nodedc-b2-tailscaled.service b/device-plane/vps/systemd/nodedc-b2-tailscaled.service deleted file mode 100644 index 5e6fc42..0000000 --- a/device-plane/vps/systemd/nodedc-b2-tailscaled.service +++ /dev/null @@ -1,36 +0,0 @@ -[Unit] -Description=NODE.DC B2 VPS private Tailscale transport -After=network-online.target -Wants=network-online.target - -[Service] -Type=notify -User=nodedc-edge -Group=nodedc-edge -RuntimeDirectory=nodedc-b2-vps -RuntimeDirectoryMode=0750 -StateDirectory=nodedc-b2-vps/tailscale -StateDirectoryMode=0700 -ExecStart=/opt/nodedc-b2-vps/runtime/tailscale/tailscaled --state=/var/lib/nodedc-b2-vps/tailscale/tailscaled.state --socket=/run/nodedc-b2-vps/tailscaled.sock --tun=userspace-networking --socks5-server=127.0.0.1:1055 -Restart=always -RestartSec=3s -NoNewPrivileges=yes -PrivateTmp=yes -PrivateDevices=yes -ProtectSystem=strict -ProtectHome=yes -ProtectKernelTunables=yes -ProtectKernelModules=yes -ProtectControlGroups=yes -RestrictSUIDSGID=yes -LockPersonality=yes -MemoryDenyWriteExecute=no -RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK -SystemCallArchitectures=native -MemoryMax=160M -TasksMax=96 -LimitNOFILE=8192 -UMask=0077 - -[Install] -WantedBy=multi-user.target diff --git a/device-plane/vps/systemd/nodedc-device-edge-channel.service b/device-plane/vps/systemd/nodedc-device-edge-channel.service deleted file mode 100644 index 8f73a48..0000000 --- a/device-plane/vps/systemd/nodedc-device-edge-channel.service +++ /dev/null @@ -1,50 +0,0 @@ -[Unit] -Description=NODE.DC provider-neutral Device Edge Core channel -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=nodedc-channel -Group=nodedc-channel -ExecStart=/opt/nodedc-b2-vps/runtime/node/bin/node /opt/nodedc-b2-vps/services/device-edge-channel/src/server.mjs -Environment=DEVICE_EDGE_CHANNEL_HOST=0.0.0.0 -Environment=DEVICE_EDGE_CHANNEL_PORT=443 -Environment=DEVICE_EDGE_CHANNEL_HEALTH_HOST=127.0.0.1 -Environment=DEVICE_EDGE_CHANNEL_HEALTH_PORT=18222 -Environment=DEVICE_EDGE_CHANNEL_CONFIG_FILE=/var/lib/nodedc-b2-vps/channel-trust/runtime.json -Environment=DEVICE_EDGE_CHANNEL_KEY_FILE=/var/lib/nodedc-b2-vps/channel-trust/edge-private-key.pem -Environment=DEVICE_EDGE_CHANNEL_CERTIFICATE_FILE=/var/lib/nodedc-b2-vps/channel-trust/edge-certificate.pem -Environment=DEVICE_EDGE_CHANNEL_CORE_TRUST_FILE=/var/lib/nodedc-b2-vps/channel-trust/core-certificate.pem -Restart=always -RestartSec=2 -TimeoutStartSec=20 -TimeoutStopSec=15 -NoNewPrivileges=yes -PrivateTmp=yes -PrivateDevices=yes -ProtectSystem=strict -ProtectHome=yes -ProtectKernelTunables=yes -ProtectKernelModules=yes -ProtectKernelLogs=yes -ProtectControlGroups=yes -ProtectClock=yes -ProtectHostname=yes -RestrictSUIDSGID=yes -RestrictRealtime=yes -LockPersonality=yes -MemoryDenyWriteExecute=no -SystemCallArchitectures=native -RestrictAddressFamilies=AF_INET AF_INET6 -CapabilityBoundingSet=CAP_NET_BIND_SERVICE -AmbientCapabilities=CAP_NET_BIND_SERVICE -UMask=0077 -MemoryMax=128M -MemorySwapMax=0 -CPUQuota=50% -TasksMax=64 -LimitNOFILE=1024 - -[Install] -WantedBy=multi-user.target diff --git a/device-plane/vps/systemd/nodedc-device-edge-runtime.service b/device-plane/vps/systemd/nodedc-device-edge-runtime.service deleted file mode 100644 index 27d284c..0000000 --- a/device-plane/vps/systemd/nodedc-device-edge-runtime.service +++ /dev/null @@ -1,62 +0,0 @@ -[Unit] -Description=NODE.DC provider-neutral Device Edge runtime -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=nodedc-channel -Group=nodedc-channel -ExecStart=/opt/nodedc-b2-vps/runtime/node/bin/node /opt/nodedc-b2-vps/vps/edge-process/device-edge-runtime.mjs -Environment=DEVICE_EDGE_CHANNEL_HOST=0.0.0.0 -Environment=DEVICE_EDGE_CHANNEL_PORT=443 -Environment=DEVICE_EDGE_CHANNEL_HEALTH_HOST=127.0.0.1 -Environment=DEVICE_EDGE_CHANNEL_HEALTH_PORT=18222 -Environment=DEVICE_EDGE_CHANNEL_CONFIG_FILE=/var/lib/nodedc-b2-vps/channel-trust/runtime.json -Environment=DEVICE_EDGE_CHANNEL_KEY_FILE=/var/lib/nodedc-b2-vps/channel-trust/edge-private-key.pem -Environment=DEVICE_EDGE_CHANNEL_CERTIFICATE_FILE=/var/lib/nodedc-b2-vps/channel-trust/edge-certificate.pem -Environment=DEVICE_EDGE_CHANNEL_CORE_TRUST_FILE=/var/lib/nodedc-b2-vps/channel-trust/core-certificate.pem -Environment=DEVICE_GATEWAY_PROTOCOL_PROFILE_REF=arusnavi.b2.internal.v1 -Environment=DEVICE_GATEWAY_HEALTH_HOST=127.0.0.1 -Environment=DEVICE_GATEWAY_HEALTH_PORT=18221 -Environment=DEVICE_GATEWAY_TCP_HOST=0.0.0.0 -Environment=DEVICE_GATEWAY_TCP_PORT=9921 -Environment=DEVICE_GATEWAY_MAX_BUFFERED_BYTES=65536 -Environment=DEVICE_GATEWAY_MAX_AGGREGATE_BUFFERED_BYTES=33554432 -Environment=DEVICE_GATEWAY_MAX_SESSIONS=128 -Environment=DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS=16 -Environment=DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS=60 -Environment=DEVICE_GATEWAY_MAX_TRACKED_SOURCE_ADDRESSES=2048 -Environment=DEVICE_GATEWAY_SESSION_TIMEOUT_MS=10000 -Restart=always -RestartSec=2 -TimeoutStartSec=20 -TimeoutStopSec=15 -NoNewPrivileges=yes -PrivateTmp=yes -PrivateDevices=yes -ProtectSystem=strict -ProtectHome=yes -ProtectKernelTunables=yes -ProtectKernelModules=yes -ProtectKernelLogs=yes -ProtectControlGroups=yes -ProtectClock=yes -ProtectHostname=yes -RestrictSUIDSGID=yes -RestrictRealtime=yes -LockPersonality=yes -MemoryDenyWriteExecute=no -SystemCallArchitectures=native -RestrictAddressFamilies=AF_INET AF_INET6 -CapabilityBoundingSet=CAP_NET_BIND_SERVICE -AmbientCapabilities=CAP_NET_BIND_SERVICE -UMask=0077 -MemoryMax=192M -MemorySwapMax=0 -CPUQuota=75% -TasksMax=128 -LimitNOFILE=1024 - -[Install] -WantedBy=multi-user.target diff --git a/infra/deploy-runner/build-device-control-core-release-artifact.mjs b/infra/deploy-runner/build-device-control-core-release-artifact.mjs deleted file mode 100644 index 6124149..0000000 --- a/infra/deploy-runner/build-device-control-core-release-artifact.mjs +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const platformRoot = resolve(scriptDir, "../.."); -const devicePlaneRoot = resolve(platformRoot, "device-plane"); -const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); -const [ - patchId = "device-control-core-release-v2-20260812-025", - predecessorPatchId, - predecessorSha256, - ...extra -] = process.argv.slice(2); -if ( - extra.length - || !/^device-control-core-release-[A-Za-z0-9._-]{1,67}$/.test(patchId) - || ((predecessorPatchId === undefined) !== (predecessorSha256 === undefined)) - || ( - predecessorPatchId !== undefined - && ( - !/^device-control-core-release-[A-Za-z0-9._-]{1,67}$/.test(predecessorPatchId) - || predecessorPatchId === patchId - || !/^[0-9a-f]{64}$/.test(predecessorSha256) - ) - ) -) { - throw new Error( - "usage: build-device-control-core-release-artifact.mjs " - + "[device-control-core-release-id] [predecessor-release-id predecessor-sha256]", - ); -} - -const isV2 = patchId.startsWith("device-control-core-release-v2-"); -const expectedV2Predecessor = Object.freeze({ - patchId: predecessorPatchId ?? "device-control-core-release-20260812-024", - artifactSha256: predecessorSha256 ?? "a289e909283109642e6bba3d9822a31f63423cfe0bbcd52705979681bd2bc793", -}); -const descriptorPath = isV2 - ? "deployment/device-control-core-release-v2.json" - : "deployment/device-control-core-release-v1.json"; -const entries = [ - ".dockerignore", - "package.json", - "package-lock.json", - "packages/device-protocol-contract", - "packages/device-edge-channel-contract", - "services/device-control-core", - descriptorPath, -]; -const stage = await mkdtemp(join(tmpdir(), "nodedc-device-control-core-release-")); -const payload = join(stage, "payload"); -const target = join(artifactDir, `nodedc-device-plane-${patchId}.tgz`); - -try { - await mkdir(payload, { recursive: true }); - for (const entry of entries) { - if (entry === descriptorPath) { - const descriptor = JSON.parse(await readFile(resolve(devicePlaneRoot, entry), "utf8")); - if (descriptor.releaseId !== "__PATCH_ID__") { - throw new Error("device_control_core_release_template_id_mismatch"); - } - descriptor.releaseId = patchId; - if (predecessorPatchId !== undefined) { - descriptor.predecessor = { - kind: "release", - patchId: predecessorPatchId, - artifactSha256: predecessorSha256, - }; - } - const destination = join(payload, entry); - await mkdir(dirname(destination), { recursive: true }); - await writeFile(destination, `${JSON.stringify(descriptor, null, 2)}\n`, "utf8"); - continue; - } - await copySafe(resolve(devicePlaneRoot, entry), join(payload, entry), devicePlaneRoot); - } - - await validateDockerCopySources( - join(payload, "services/device-control-core/Dockerfile"), - payload, - ); - for (const modulePath of [ - "services/device-control-core/src/device-gateway-core-runtime.mjs", - "packages/device-protocol-contract/src/index.mjs", - "packages/device-edge-channel-contract/src/index.mjs", - ]) { - const imported = spawnSync( - process.execPath, - ["--input-type=module", "--eval", `import(${JSON.stringify(pathToFileURL(join(payload, modulePath)).href)})`], - { cwd: payload, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }, - ); - if (imported.status !== 0) { - throw new Error(`device_control_core_release_staged_module_import_failed:${modulePath}:${imported.stderr || imported.stdout}`); - } - } - - const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8")); - if ( - descriptor.schemaVersion !== `nodedc.device-plane.device-control-core-release.${isV2 ? "v2" : "v1"}` - || descriptor.releaseId !== patchId - || descriptor.action !== "upgrade" - || descriptor.service !== "device-control-core" - || descriptor.composeActivation !== "preserve-active-v4-topology" - || descriptor.identityRecovery !== "forbidden-valid-existing-identity-required" - || descriptor.tlsPurpose !== "clientAuth" - || descriptor.direction !== "core-initiated" - || descriptor.endpointPolicy !== "public-ipv4-standard-https-tcp-443-only" - || JSON.stringify(descriptor.coreNetworks) !== JSON.stringify(["device-plane-private", "device-plane-egress"]) - || descriptor.publicIngress !== "none-on-synology" - || descriptor.edgeRegistrations !== "preserved" - || descriptor.commandTransport !== (isV2 ? "typed-service-ping-v1" : "disabled") - || descriptor.gelios !== (isV2 ? "untouched-legacy-only" : "untouched") - || descriptor.rollback !== "restore-preapply-source-and-core-runtime" - || ( - isV2 - && ( - descriptor.commandCatalog !== "allowlisted-adapter-typed-commands-only" - || descriptor.credentialBoundary !== "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned" - || descriptor.predecessor?.patchId !== expectedV2Predecessor.patchId - || descriptor.predecessor?.artifactSha256 !== expectedV2Predecessor.artifactSha256 - ) - ) - ) { - throw new Error("device_control_core_release_contract_mismatch"); - } - - await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, "utf8"); - await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8"); - await mkdir(artifactDir, { recursive: true }); - const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], { - encoding: "utf8", - maxBuffer: 128 * 1024 * 1024, - }); - if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); - const sha256 = createHash("sha256").update(await readFile(target)).digest("hex"); - console.log(JSON.stringify({ - ok: true, - patchId, - component: "device-plane", - artifact: target, - sha256, - entries, - services: ["device-control-core"], - preserved: ["device-manager", "device-gateway", "device-postgres", "device-backhaul-target", "edge registrations", "mTLS identity", "Gelios"], - }, null, 2)); -} finally { - await rm(stage, { recursive: true, force: true }); -} - -async function copySafe(source, destination, sourceBoundary) { - const sourceStat = await lstat(source); - if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(sourceBoundary, source)}`); - if (sourceStat.isFile()) { - if (source.endsWith(".test.mjs") || source.endsWith(".map")) return; - await mkdir(dirname(destination), { recursive: true }); - await cp(source, destination, { force: true, verbatimSymlinks: true }); - return; - } - if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${source}`); - await mkdir(destination, { recursive: true }); - for (const entry of await readdir(source, { withFileTypes: true })) { - if ([".DS_Store", ".git", "node_modules", "test"].includes(entry.name) || entry.name.startsWith(".env")) continue; - await copySafe(join(source, entry.name), join(destination, entry.name), sourceBoundary); - } -} - -async function validateDockerCopySources(dockerfilePath, buildContext) { - const dockerfile = await readFile(dockerfilePath, "utf8"); - for (const [index, rawLine] of dockerfile.split("\n").entries()) { - const line = rawLine.trim(); - if (!/^COPY\s+/i.test(line)) continue; - if (line.endsWith("\\") || /^COPY\s+\[/i.test(line)) { - throw new Error(`unsupported_docker_copy_syntax:${dockerfilePath}:${index + 1}`); - } - const tokens = line.split(/\s+/).slice(1); - while (tokens[0]?.startsWith("--")) tokens.shift(); - if (tokens.length < 2) throw new Error(`invalid_docker_copy:${dockerfilePath}:${index + 1}`); - for (const source of tokens.slice(0, -1)) { - if (/[*?[\]{}]/.test(source)) throw new Error(`docker_copy_glob_rejected:${dockerfilePath}:${index + 1}:${source}`); - const resolvedSource = resolve(buildContext, source); - const relativeSource = relative(buildContext, resolvedSource); - if (!relativeSource || relativeSource.startsWith("..") || resolve(buildContext, relativeSource) !== resolvedSource) { - throw new Error(`docker_copy_source_outside_context:${dockerfilePath}:${index + 1}:${source}`); - } - try { - await lstat(resolvedSource); - } catch (error) { - if (error?.code === "ENOENT") throw new Error(`docker_copy_source_missing:${dockerfilePath}:${index + 1}:${source}`); - throw error; - } - } - } -} - -function canonicalTarScript() { - return [ - "import gzip,io,pathlib,sys,tarfile", - "root=pathlib.Path(sys.argv[2])", - "with open(sys.argv[1],'wb') as out:", - " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", - " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", - " for top in ('manifest.env','files.txt','payload'):", - " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", - " for x in paths:", - " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", - " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", - ].join("\n"); -} diff --git a/infra/deploy-runner/build-device-edge-core-channel-bootstrap-artifact.mjs b/infra/deploy-runner/build-device-edge-core-channel-bootstrap-artifact.mjs deleted file mode 100644 index 815ea5b..0000000 --- a/infra/deploy-runner/build-device-edge-core-channel-bootstrap-artifact.mjs +++ /dev/null @@ -1,278 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const platformRoot = resolve(scriptDir, "../.."); -const devicePlaneRoot = resolve(platformRoot, "device-plane"); -const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); -const [patchId = "device-edge-core-channel-bootstrap-20260811-017", ...extra] = process.argv.slice(2); -if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { - throw new Error("usage: build-device-edge-core-channel-bootstrap-artifact.mjs [patch-id]"); -} - -const upgradeV4 = patchId.startsWith("device-edge-core-channel-upgrade-v4-"); -const upgradeV2 = !upgradeV4 && patchId.startsWith("device-edge-core-channel-upgrade-v2-"); -const upgradeV1 = !upgradeV4 && !upgradeV2 && patchId.startsWith("device-edge-core-channel-upgrade-"); -const upgrade = upgradeV1 || upgradeV2 || upgradeV4; -const descriptorPath = upgradeV4 - ? "deployment/device-edge-core-channel-upgrade-v4.json" - : upgradeV2 - ? "deployment/device-edge-core-channel-upgrade-v2.json" - : upgradeV1 - ? "deployment/device-edge-core-channel-upgrade-v1.json" - : "deployment/device-edge-core-channel-bootstrap-v1.json"; -const composePath = upgradeV4 - ? "docker-compose.device-plane.yml" - : "docker-compose.device-edge-core-channel.yml"; -const entries = [ - ".dockerignore", - "package.json", - "package-lock.json", - composePath, - "packages/device-protocol-contract", - "packages/device-edge-channel-contract", - "services/device-control-core", - descriptorPath, -]; -const stage = await mkdtemp(join(tmpdir(), "nodedc-device-edge-core-channel-")); -const payload = join(stage, "payload"); -const target = join(artifactDir, `nodedc-device-plane-${patchId}.tgz`); - -try { - await mkdir(payload, { recursive: true }); - for (const entry of entries) { - if (entry === descriptorPath) { - const descriptor = JSON.parse(await readFile(resolve(devicePlaneRoot, entry), "utf8")); - if (descriptor.transitionId !== "__PATCH_ID__") { - throw new Error("device_edge_core_channel_template_id_mismatch"); - } - descriptor.transitionId = patchId; - const destination = join(payload, entry); - await mkdir(dirname(destination), { recursive: true }); - await writeFile(destination, `${JSON.stringify(descriptor, null, 2)}\n`, "utf8"); - continue; - } - await copySafe(resolve(devicePlaneRoot, entry), join(payload, entry), devicePlaneRoot); - } - - await validateDockerCopySources( - join(payload, "services/device-control-core/Dockerfile"), - payload, - ); - for (const modulePath of [ - "services/device-control-core/src/sensitive-reference-management.mjs", - "services/device-control-core/src/device-gateway-core-runtime.mjs", - "packages/device-edge-channel-contract/src/index.mjs", - ]) { - const imported = spawnSync( - process.execPath, - ["--input-type=module", "--eval", `import(${JSON.stringify(pathToFileURL(join(payload, modulePath)).href)})`], - { cwd: payload, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }, - ); - if (imported.status !== 0) { - throw new Error(`device_edge_core_channel_staged_module_import_failed:${modulePath}:${imported.stderr || imported.stdout}`); - } - } - - const compose = await readFile(join(payload, composePath), "utf8"); - const commonComposeRequired = [ - "device-control-core:", - ]; - const composeRequired = upgradeV4 - ? [ - ...commonComposeRequired, - " - device-plane-private", - " - device-plane-control", - ] - : [ - ...commonComposeRequired, - "DEVICE_EDGE_CHANNEL_ENABLED: \"true\"", - "DEVICE_EDGE_CHANNEL_CORE_KEY_FILE: /run/nodedc-secrets/device-edge-channel/core-private-key.pem", - "DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE: /run/nodedc-secrets/device-edge-channel/core-certificate.pem", - "DEVICE_EDGE_CHANNEL_TRUST_ROOT: /run/nodedc-secrets/device-edge-channel/peers", - "source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-private-key.pem", - "source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/core-certificate.pem", - "source: /volume1/docker/nodedc-device-plane/secrets/device-edge-channel/peers", - "name: nodedc-device-plane-egress", - ]; - for (const required of composeRequired) { - if (!compose.includes(required)) { - throw new Error(`device_edge_core_channel_compose_contract_missing:${required}`); - } - } - if (upgradeV4) { - const coreBlock = compose.split("\n device-control-core:", 2)[1]?.split("\n device-gateway:", 1)[0] || ""; - const gatewayBlock = compose.split("\n device-gateway:", 2)[1]?.split("\nnetworks:", 1)[0] || ""; - if ( - !coreBlock.includes(" - device-plane-private") - || coreBlock.includes(" - device-plane-control") - || !gatewayBlock.includes(" - device-plane-private") - || !gatewayBlock.includes(" - device-plane-control") - ) { - throw new Error("device_edge_core_channel_v4_network_boundary_mismatch"); - } - } - const composeForbidden = upgradeV4 - ? ["gw_priority:", "network_mode:", "privileged:"] - : [ - "device-manager:", - "device-gateway:", - "device-postgres:", - "PRIVATE KEY", - "ports:", - "network_mode:", - "privileged:", - ]; - for (const forbidden of composeForbidden) { - if (compose.includes(forbidden)) { - throw new Error(`device_edge_core_channel_compose_boundary_violation:${forbidden}`); - } - } - - const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8")); - const descriptorContractMatches = upgradeV4 - ? ( - descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-upgrade.v4" - && descriptor.action === "upgrade" - && descriptor.composeActivation === "replace-core-network-membership-with-private-plus-egress" - && descriptor.identityRecovery === "forbidden-valid-existing-identity-required" - && descriptor.endpointPolicy === "public-ipv4-standard-https-tcp-443-only" - && descriptor.upgradePredecessor?.patchId === "device-edge-core-channel-upgrade-v2-20260812-021" - && descriptor.upgradePredecessor?.artifactSha256 === "e40a6fd24edfecac09e42cd82635a77850541bcf047788db3e9c55d2b9e58867" - && descriptor.failedAttempt?.patchId === "device-edge-core-channel-upgrade-v3-20260812-022" - && descriptor.failedAttempt?.artifactSha256 === "9e2b409a4b2d19711db434e90d03ac8e3db77bd74949f83cace7949f33caf613" - && descriptor.failedAttempt?.backupId === "device-plane-device-edge-core-channel-upgrade-v3-20260812-022-20260812-123620" - && JSON.stringify(descriptor.coreNetworks) === JSON.stringify(["device-plane-private", "device-plane-egress"]) - && descriptor.removedCoreNetwork === "device-plane-control" - && descriptor.composeCompatibility === "synology-compose-v2.20-no-gw-priority" - && descriptor.edgeRegistrations === "preserved" - && descriptor.rollback === "restore-upgrade-v2-021-source-and-preapply-core-runtime" - ) - : upgradeV2 - ? ( - descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-upgrade.v2" - && descriptor.action === "upgrade" - && descriptor.composeActivation === "preserve-dedicated-additive-override" - && descriptor.identityRecovery === "forbidden-valid-existing-identity-required" - && descriptor.endpointPolicy === "public-ipv4-standard-https-tcp-443-only" - && descriptor.upgradePredecessor?.patchId === "device-edge-core-channel-upgrade-20260812-019" - && descriptor.upgradePredecessor?.artifactSha256 === "8e9a220275959f378c1c4b00be5c7192e79afe2134eaab808a64e515870a8438" - && descriptor.edgeRegistrations === "preserved" - && descriptor.rollback === "restore-upgrade-019-source-and-preapply-core-runtime" - ) - : upgradeV1 - ? ( - descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-upgrade.v1" - && descriptor.action === "upgrade" - && descriptor.composeActivation === "preserve-dedicated-additive-override" - && descriptor.identityRecovery === "forbidden-valid-existing-identity-required" - && descriptor.endpointPolicy === "public-ipv4-standard-https-tcp-443-only" - && descriptor.bootstrapPredecessor?.patchId === "device-edge-core-channel-bootstrap-20260812-018" - && descriptor.bootstrapPredecessor?.artifactSha256 === "5598b7388b491fe524ab46038ce476482a93a6cf07d8ca5e00206c69ded02931" - ) - : ( - descriptor.schemaVersion === "nodedc.device-plane.device-edge-core-channel-bootstrap.v1" - && descriptor.action === "activate" - && descriptor.composeActivation === "dedicated-additive-override" - && descriptor.identityRecovery === "exact-invalid-unexported-failed-predecessor-only" - ); - if ( - !descriptorContractMatches - || descriptor.transitionId !== patchId - || descriptor.service !== "device-control-core" - || descriptor.tlsPurpose !== "clientAuth" - || descriptor.direction !== "core-initiated" - || descriptor.publicIngress !== "none-on-synology" - || descriptor.commandTransport !== "disabled" - || descriptor.gelios !== "untouched" - ) { - throw new Error("device_edge_core_channel_bootstrap_contract_mismatch"); - } - - await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, "utf8"); - await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8"); - await mkdir(artifactDir, { recursive: true }); - const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], { - encoding: "utf8", - maxBuffer: 128 * 1024 * 1024, - }); - if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); - const sha256 = createHash("sha256").update(await readFile(target)).digest("hex"); - console.log(JSON.stringify({ - ok: true, - patchId, - component: "device-plane", - artifact: target, - sha256, - entries, - services: ["device-control-core"], - preserved: ["device-manager", "device-gateway", "device-postgres", "device-backhaul-target", "Gelios"], - }, null, 2)); -} finally { - await rm(stage, { recursive: true, force: true }); -} - -async function copySafe(source, destination, sourceBoundary) { - const sourceStat = await lstat(source); - if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(sourceBoundary, source)}`); - if (sourceStat.isFile()) { - if (source.endsWith(".test.mjs") || source.endsWith(".map")) return; - await mkdir(dirname(destination), { recursive: true }); - await cp(source, destination, { force: true, verbatimSymlinks: true }); - return; - } - if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${source}`); - await mkdir(destination, { recursive: true }); - for (const entry of await readdir(source, { withFileTypes: true })) { - if ([".DS_Store", ".git", "node_modules", "test"].includes(entry.name) || entry.name.startsWith(".env")) continue; - await copySafe(join(source, entry.name), join(destination, entry.name), sourceBoundary); - } -} - -async function validateDockerCopySources(dockerfilePath, buildContext) { - const dockerfile = await readFile(dockerfilePath, "utf8"); - for (const [index, rawLine] of dockerfile.split("\n").entries()) { - const line = rawLine.trim(); - if (!/^COPY\s+/i.test(line)) continue; - if (line.endsWith("\\") || /^COPY\s+\[/i.test(line)) { - throw new Error(`unsupported_docker_copy_syntax:${dockerfilePath}:${index + 1}`); - } - const tokens = line.split(/\s+/).slice(1); - while (tokens[0]?.startsWith("--")) tokens.shift(); - if (tokens.length < 2) throw new Error(`invalid_docker_copy:${dockerfilePath}:${index + 1}`); - for (const source of tokens.slice(0, -1)) { - if (/[*?[\]{}]/.test(source)) throw new Error(`docker_copy_glob_rejected:${dockerfilePath}:${index + 1}:${source}`); - const resolvedSource = resolve(buildContext, source); - const relativeSource = relative(buildContext, resolvedSource); - if (!relativeSource || relativeSource.startsWith("..") || resolve(buildContext, relativeSource) !== resolvedSource) { - throw new Error(`docker_copy_source_outside_context:${dockerfilePath}:${index + 1}:${source}`); - } - try { - await lstat(resolvedSource); - } catch (error) { - if (error?.code === "ENOENT") throw new Error(`docker_copy_source_missing:${dockerfilePath}:${index + 1}:${source}`); - throw error; - } - } - } -} - -function canonicalTarScript() { - return [ - "import gzip,io,pathlib,sys,tarfile", - "root=pathlib.Path(sys.argv[2])", - "with open(sys.argv[1],'wb') as out:", - " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", - " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", - " for top in ('manifest.env','files.txt','payload'):", - " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", - " for x in paths:", - " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", - " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", - ].join("\n"); -} diff --git a/infra/deploy-runner/build-device-edge-ingress-artifact.mjs b/infra/deploy-runner/build-device-edge-ingress-artifact.mjs deleted file mode 100644 index 68827b3..0000000 --- a/infra/deploy-runner/build-device-edge-ingress-artifact.mjs +++ /dev/null @@ -1,265 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { - cp, - lstat, - mkdir, - mkdtemp, - readFile, - readdir, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const platformRoot = resolve(scriptDir, "../.."); -const sourceRoot = resolve(platformRoot, "device-plane"); -const artifactDir = resolve( - process.env.NODEDC_DEPLOY_ARTIFACT_DIR - || resolve(scriptDir, "../deploy-artifacts"), -); -const [ - patchId = "device-edge-admission-gate-20260804-002", - ...extra -] = process.argv.slice(2); - -if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { - throw new Error( - "usage: build-device-edge-ingress-artifact.mjs [patch-id]", - ); -} - -const files = [ - "docker-compose.device-edge.yml", - "docker-compose.device-edge.ingress.yml", - "services/device-edge-relay/Dockerfile", - "services/device-edge-relay/src", - "deployment/device-edge-admission-gate-v1.json", -]; -const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]); -const descriptor = await assertBoundary(); -if (descriptor.ingressIpv4Approval !== "approved-outside-dhcp-pool") { - throw new Error("device_edge_ingress_ipv4_approval_pending"); -} - -const stage = await mkdtemp(join(tmpdir(), "nodedc-device-edge-ingress-")); -const payload = join(stage, "payload"); -const target = join(artifactDir, `nodedc-device-edge-${patchId}.tgz`); - -try { - await mkdir(payload, { recursive: true }); - for (const sourceRelative of files) { - await copySafe( - resolve(sourceRoot, sourceRelative), - join(payload, sourceRelative), - ); - } - await writeFile( - join(stage, "manifest.env"), - `id=${patchId}\ncomponent=device-edge\ntype=app-overlay\n`, - "utf8", - ); - await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8"); - await mkdir(artifactDir, { recursive: true }); - - const tar = spawnSync( - "python3", - ["-c", canonicalTarScript(), target, stage], - { encoding: "utf8", maxBuffer: 128 * 1024 * 1024 }, - ); - if (tar.status !== 0) { - throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); - } - - const digest = createHash("sha256") - .update(await readFile(target)) - .digest("hex"); - console.log(JSON.stringify({ - ok: true, - patchId, - artifact: target, - sha256: digest, - component: "device-edge", - transition: "reviewed-ipvlan-b2-relay-admission-gate", - entries: files, - services: ["device-edge-relay"], - preservedRuntime: ["device-edge-backhaul", "tailnet", "Gelios"], - ingress: { - parent: descriptor.parentInterface, - subnet: descriptor.lanSubnet, - gateway: descriptor.lanGateway, - ipv4: descriptor.ingressIpv4, - ipv4Approval: descriptor.ingressIpv4Approval, - tcp: 9921, - hostPortPublication: "disabled", - sourceAdmission: descriptor.sourceAdmission, - maxTrackedSourceAddresses: descriptor.maxTrackedSourceAddresses, - maxBytesPerDirection: descriptor.maxBytesPerDirection, - lifecycle: "quarantine", - commandTransport: "disabled", - }, - rollback: descriptor.rollback, - }, null, 2)); -} finally { - await rm(stage, { recursive: true, force: true }); -} - -async function assertBoundary() { - const baseline = await readFile( - resolve(sourceRoot, "docker-compose.device-edge.yml"), - "utf8", - ); - const ingress = await readFile( - resolve(sourceRoot, "docker-compose.device-edge.ingress.yml"), - "utf8", - ); - const descriptor = JSON.parse(await readFile( - resolve( - sourceRoot, - "deployment/device-edge-admission-gate-v1.json", - ), - "utf8", - )); - - for (const fragment of [ - "DEVICE_EDGE_RELAY_HEALTH_HOST: 127.0.0.1", - 'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "false"', - "read_only: true", - 'user: "1000:1000"', - "no-new-privileges:true", - "cap_drop:", - "- ALL", - ]) { - if (!baseline.includes(fragment)) { - throw new Error(`device_edge_baseline_boundary_missing:${fragment}`); - } - } - for (const forbidden of ["ports:", "device-edge-control"]){ - if (baseline.includes(forbidden)) { - throw new Error(`device_edge_baseline_boundary_violation:${forbidden}`); - } - } - - for (const fragment of [ - 'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"', - "DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul", - 'DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"', - "DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only", - 'DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"', - 'DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"', - "name: nodedc-device-edge-ingress", - "driver: ipvlan", - "parent: enp1s0f0", - "ipvlan_mode: l2", - "ipv4_address: 192.168.71.253", - "gw_priority: 100", - "subnet: 192.168.68.0/22", - "gateway: 192.168.68.1", - ]) { - if (!ingress.includes(fragment)) { - throw new Error(`device_edge_ingress_boundary_missing:${fragment}`); - } - } - for (const forbidden of [ - "ports:", - "network_mode: host", - "privileged: true", - "DEVICE_EDGE_RELAY_COMMAND", - "0.0.0.0:9921:9921", - ]) { - if (ingress.includes(forbidden)) { - throw new Error(`device_edge_ingress_boundary_violation:${forbidden}`); - } - } - - const expected = { - schemaVersion: "nodedc.device-edge.admission-gate.v1", - mode: "single-nic-ipvlan-b2-relay-only", - runtimeHost: "ndcmini12", - component: "device-edge", - selectedServices: ["device-edge-relay"], - preservedServices: ["device-edge-backhaul", "tailnet"], - composeProject: "nodedc-device-edge", - composeFiles: [ - "docker-compose.device-edge.yml", - "docker-compose.device-edge.ingress.yml", - ], - parentInterface: "enp1s0f0", - lanSubnet: "192.168.68.0/22", - lanGateway: "192.168.68.1", - ingressIpv4: "192.168.71.253", - ingressIpv4Approval: "approved-outside-dhcp-pool", - ingressNetwork: "nodedc-device-edge-ingress", - deviceTcpListen: "192.168.71.253:9921", - hostPortPublication: "disabled", - healthPublication: "disabled", - privateUpstream: "device-edge-backhaul:19921", - sourceAdmission: "public-ipv4-only", - maxTrackedSourceAddresses: 2048, - maxBytesPerDirection: 262144, - protocolInspection: "gateway-owned", - identityTrust: "claimed-not-ownership-proof", - discoveryLifecycle: "quarantine", - commandTransport: "disabled", - gelios: "untouched", - amneziaHostFullTunnel: "preserved", - routerNatFirewall: "separate-manual-gate", - rollback: "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation", - }; - if (JSON.stringify(descriptor) !== JSON.stringify(expected)) { - throw new Error("device_edge_ingress_descriptor_mismatch"); - } - return descriptor; -} - -function canonicalTarScript() { - return [ - "import gzip,io,pathlib,sys,tarfile", - "root=pathlib.Path(sys.argv[2])", - "with open(sys.argv[1],'wb') as out:", - " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", - " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", - " for top in ('manifest.env','files.txt','payload'):", - " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", - " for x in paths:", - " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", - " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", - " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", - ].join("\n"); -} - -async function copySafe(source, destination) { - const sourceStat = await lstat(source); - if (sourceStat.isSymbolicLink()) { - throw new Error(`source_symlink_rejected:${relative(sourceRoot, source)}`); - } - if (sourceStat.isFile()) { - await mkdir(dirname(destination), { recursive: true }); - await cp(source, destination, { force: true, verbatimSymlinks: true }); - return; - } - if (!sourceStat.isDirectory()) { - throw new Error(`source_type_rejected:${source}`); - } - await mkdir(destination, { recursive: true }); - for (const entry of await readdir(source, { withFileTypes: true })) { - if ( - ignoredBasenames.has(entry.name) - || entry.name.startsWith(".env") - ) { - continue; - } - const childSource = join(source, entry.name); - const childDestination = join(destination, entry.name); - if (entry.isSymbolicLink()) { - throw new Error( - `source_symlink_rejected:${relative(sourceRoot, childSource)}`, - ); - } - await copySafe(childSource, childDestination); - } -} diff --git a/infra/deploy-runner/build-device-edge-vps-artifact.mjs b/infra/deploy-runner/build-device-edge-vps-artifact.mjs deleted file mode 100644 index f06a93b..0000000 --- a/infra/deploy-runner/build-device-edge-vps-artifact.mjs +++ /dev/null @@ -1,465 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { - cp, - lstat, - mkdir, - mkdtemp, - readFile, - readdir, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { basename, dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const platformRoot = resolve(scriptDir, "../.."); -const sourceRoot = resolve(platformRoot, "device-plane"); -const artifactDir = resolve( - process.env.NODEDC_DEPLOY_ARTIFACT_DIR - || resolve(scriptDir, "../deploy-artifacts"), -); -const runtimeCache = resolve( - process.env.NODEDC_DEVICE_EDGE_VPS_RUNTIME_DIR || "/tmp", -); - -const [phase, patchId, ...extra] = process.argv.slice(2); -if ( - extra.length - || ![ - "foundation", - "runtime-reconciliation", - "backhaul", - "relay", - "core-channel", - "tailscale-retirement", - "tracker-ingress", - "command-transport", - ].includes(phase) - || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId || "") -) { - throw new Error( - "usage: build-device-edge-vps-artifact.mjs ", - ); -} - -const supersededTransportPhases = new Set(["backhaul", "relay"]); -if ( - supersededTransportPhases.has(phase) - && process.env.NODEDC_ALLOW_SUPERSEDED_TRANSPORT !== "test-only" -) { - throw new Error("vps_initiated_transport_frozen:ADR-0001"); -} -const acceptedSharedSourcePhases = new Set(["core-channel", "tracker-ingress"]); -if (acceptedSharedSourcePhases.has(phase)) { - throw new Error(`accepted_vps_phase_rebuild_frozen:${phase}:ADR-0001`); -} - -const nodeArchive = "node-v22.23.2-linux-x64.tar.xz"; -const tailscaleArchive = "tailscale_1.102.2_amd64.tgz"; -const runtimeDigests = new Map([ - [nodeArchive, "d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307"], - [tailscaleArchive, "ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8"], -]); - -const entriesByPhase = { - foundation: [ - "vps/config/00-nodedc-b2-vps.conf", - "vps/config/nftables-foundation.conf", - "vps/systemd/nodedc-b2-tailscaled.service", - "deployment/device-edge-vps-foundation-v1.json", - `vendor/${nodeArchive}`, - `vendor/${tailscaleArchive}`, - ], - "runtime-reconciliation": [ - "deployment/device-edge-vps-runtime-reconciliation-v1.json", - ], - backhaul: [ - "vps/config/backhaul_ssh_config", - "vps/systemd/nodedc-b2-backhaul.service", - "deployment/device-edge-vps-backhaul-v1.json", - ], - relay: [ - "vps/config/nftables-relay.conf", - "vps/systemd/nodedc-b2-relay.service", - "services/device-edge-relay/src", - "deployment/device-edge-vps-relay-v1.json", - ], - "core-channel": [ - "packages/device-protocol-contract/package.json", - "packages/device-protocol-contract/src", - "packages/device-edge-channel-contract/package.json", - "packages/device-edge-channel-contract/src", - "services/device-edge-channel/package.json", - "services/device-edge-channel/src", - "vps/config/nftables-core-channel.conf", - "vps/systemd/nodedc-device-edge-channel.service", - "deployment/device-edge-vps-core-channel-v1.json", - ], - "tailscale-retirement": [ - "deployment/device-edge-vps-tailscale-retirement-v1.json", - ], - "tracker-ingress": [ - "packages/device-adapter-runtime/package.json", - "packages/device-adapter-runtime/src", - "packages/device-adapter-catalog/package.json", - "packages/device-adapter-catalog/src", - "packages/arusnavi-b2-adapter/package.json", - "packages/arusnavi-b2-adapter/src", - "services/device-gateway/src/runtime.mjs", - "vps/edge-process/device-edge-runtime.mjs", - "vps/config/nftables-tracker-ingress.conf", - "vps/systemd/nodedc-device-edge-runtime.service", - "deployment/device-edge-vps-tracker-ingress-v1.json", - ], - "command-transport": [ - "packages/device-edge-channel-contract/package.json", - "packages/device-edge-channel-contract/src", - "services/device-edge-channel/package.json", - "services/device-edge-channel/src", - "packages/device-adapter-runtime/package.json", - "packages/device-adapter-runtime/src", - "packages/device-adapter-catalog/package.json", - "packages/device-adapter-catalog/src", - "packages/arusnavi-b2-adapter/package.json", - "packages/arusnavi-b2-adapter/src", - "services/device-gateway/src/runtime.mjs", - "vps/edge-process/device-edge-runtime.mjs", - "deployment/device-edge-vps-command-transport-v1.json", - ], -}; -const entries = entriesByPhase[phase]; -const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]); -const stage = await mkdtemp(join(tmpdir(), `nodedc-device-edge-vps-${phase}-`)); -const payload = join(stage, "payload"); -const target = join( - artifactDir, - `nodedc-device-edge-vps-${patchId}.tgz`, -); - -await assertBoundary(); - -try { - await mkdir(payload, { recursive: true }); - for (const entry of entries) { - if (entry.startsWith("vendor/")) { - const name = basename(entry); - const source = resolve(runtimeCache, name); - const actual = createHash("sha256").update(await readFile(source)).digest("hex"); - if (actual !== runtimeDigests.get(name)) { - throw new Error(`runtime_digest_mismatch:${name}:${actual}`); - } - await mkdir(dirname(join(payload, entry)), { recursive: true }); - await cp(source, join(payload, entry), { force: true }); - continue; - } - await copySafe(resolve(sourceRoot, entry), join(payload, entry)); - } - - await writeFile( - join(stage, "manifest.env"), - `id=${patchId}\ncomponent=device-edge-vps\ntype=app-overlay\n`, - "utf8", - ); - await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8"); - await mkdir(artifactDir, { recursive: true }); - - const tar = spawnSync( - "python3", - ["-c", canonicalTarScript(), target, stage], - { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }, - ); - if (tar.status !== 0) { - throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); - } - - const bytes = await readFile(target); - const digest = createHash("sha256").update(bytes).digest("hex"); - console.log(JSON.stringify({ - ok: true, - patchId, - phase, - artifact: target, - sha256: digest, - size: bytes.length, - component: "device-edge-vps", - entries, - publicIngress: phase === "relay" - ? "tcp/9921" - : ["core-channel", "tailscale-retirement"].includes(phase) - ? "tcp/443-mtls-only" - : ["tracker-ingress", "command-transport"].includes(phase) - ? "tcp/443-mtls+tcp/9921-telemetry" - : "disabled", - commandTransport: phase === "command-transport" - ? "typed-service-ping-v1" - : "disabled", - gelios: "untouched", - }, null, 2)); -} finally { - await rm(stage, { recursive: true, force: true }); -} - -async function assertBoundary() { - const descriptorPath = resolve( - sourceRoot, - `deployment/device-edge-vps-${phase}-v1.json`, - ); - const descriptor = JSON.parse(await readFile(descriptorPath, "utf8")); - if ( - descriptor.component !== "device-edge-vps" - || descriptor.runtimeHost !== "koffyvngij" - || descriptor.commandTransport !== (phase === "command-transport" - ? "typed-service-ping-v1" - : "disabled") - || !String(descriptor.gelios || "").startsWith("untouched") - || !String(descriptor.rollback || "").length - ) { - throw new Error(`descriptor_boundary_mismatch:${phase}`); - } - - const selectedText = await Promise.all( - entries - .filter((entry) => !entry.startsWith("vendor/") && !entry.endsWith("/src")) - .map((entry) => readFile(resolve(sourceRoot, entry), "utf8")), - ); - const combined = selectedText.join("\n"); - for (const forbidden of [ - "PRIVATE KEY", - "AuthKey", - "TS_AUTHKEY", - "PasswordAuthentication yes", - "commandTransport\": \"enabled", - "device.dc.ru", - ]) { - if (combined.includes(forbidden)) { - throw new Error(`vps_boundary_violation:${forbidden}`); - } - } - - if (phase === "foundation") { - for (const required of [ - "PermitRootLogin prohibit-password", - "PasswordAuthentication no", - "AllowTcpForwarding no", - "policy drop", - "tcp dport 22", - "--tun=userspace-networking", - "--socks5-server=127.0.0.1:1055", - ]) { - if (!combined.includes(required)) { - throw new Error(`foundation_boundary_missing:${required}`); - } - } - if (combined.includes("tcp dport 9921")) { - throw new Error("foundation_must_not_open_9921"); - } - } - if (phase === "runtime-reconciliation") { - for (const required of [ - "recover-exact-runtime-executable-modes-after-failed-core-channel-publish", - "restore-root-owned-executable-mode-0755-for-exact-known-binaries", - '"publicCoreChannel": "disabled"', - '"trackerIngress": "disabled"', - ]) { - if (!combined.includes(required)) { - throw new Error(`runtime_reconciliation_boundary_missing:${required}`); - } - } - } - if (phase === "backhaul") { - for (const required of [ - "\"runtimeUser\": \"nodedc-backhaul\"", - "User=nodedc-backhaul", - "HostName 100.109.216.21", - "Port 2222", - "StrictHostKeyChecking yes", - "LocalForward 127.0.0.1:19921 127.0.0.1:9921", - "ProxyCommand /usr/bin/nc -X 5 -x 127.0.0.1:1055", - "MemoryMax=64M", - ]) { - if (!combined.includes(required)) { - throw new Error(`backhaul_boundary_missing:${required}`); - } - } - } - if (phase === "relay") { - for (const required of [ - "\"runtimeUser\": \"nodedc-relay\"", - "User=nodedc-relay", - "tcp dport 9921", - "DEVICE_EDGE_RELAY_UPSTREAM_HOST=127.0.0.1", - "DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921", - "DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only", - "MemoryMax=192M", - ]) { - if (!combined.includes(required)) { - throw new Error(`relay_boundary_missing:${required}`); - } - } - } - if (phase === "core-channel") { - for (const required of [ - "\"runtimeUser\": \"nodedc-channel\"", - "\"trackerIngress\": \"disabled\"", - "User=nodedc-channel", - "ExecStart=/opt/nodedc-b2-vps/runtime/node/bin/node /opt/nodedc-b2-vps/services/device-edge-channel/src/server.mjs", - "MemoryDenyWriteExecute=no", - "CapabilityBoundingSet=CAP_NET_BIND_SERVICE", - "AmbientCapabilities=CAP_NET_BIND_SERVICE", - "tcp dport 443", - "MemoryMax=128M", - "MemorySwapMax=0", - "CPUQuota=50%", - "TasksMax=64", - "LimitNOFILE=1024", - ]) { - if (!combined.includes(required)) { - throw new Error(`core_channel_boundary_missing:${required}`); - } - } - for (const forbidden of [ - "tcp dport 9921", - "LocalForward", - "tailscale-userspace", - "DEVICE_EDGE_RELAY_UPSTREAM", - "--jitless", - ]) { - if (combined.includes(forbidden)) { - throw new Error(`core_channel_boundary_violation:${forbidden}`); - } - } - } - if (phase === "tailscale-retirement") { - for (const required of [ - '"predecessorPatch": "device-edge-vps-core-channel-20260812-010"', - '"runtimeAction": "stop-disable-remove-userspace-tailscale-runtime-state-and-superseded-trust"', - '"trackerIngress": "disabled"', - '"externalRevocation": "delete-exact-nodedc-b2-vps-machine-in-tailnet-after-deploy-ok"', - ]) { - if (!combined.includes(required)) { - throw new Error(`tailscale_retirement_boundary_missing:${required}`); - } - } - for (const forbidden of [ - "tcp dport 9921", - "LocalForward", - "commandTransport\": \"enabled", - ]) { - if (combined.includes(forbidden)) { - throw new Error(`tailscale_retirement_boundary_violation:${forbidden}`); - } - } - } - if (phase === "tracker-ingress") { - for (const required of [ - '"predecessorPatch": "device-edge-vps-tailscale-retirement-20260812-011"', - '"runtimeComposition": "single-process-core-channel-plus-universal-device-gateway"', - '"trackerIngress": "enabled:allowlisted-adapters-only"', - '"acknowledgementBoundary": "tracker-ack-only-after-core-durable-acceptance"', - '"initialAdapterProfile": "arusnavi.b2.internal.v1"', - "createDeviceGatewayRuntime", - "DEVICE_ADAPTER_CATALOG.registry", - "onDiscovery: (signal) => channel.submitDiscovery(signal)", - "onMessage: (message) => channel.submitAdapterMessage(message)", - "tcp dport 9921", - "User=nodedc-channel", - "MemoryMax=192M", - "MemorySwapMax=0", - "CPUQuota=75%", - "TasksMax=128", - "LimitNOFILE=1024", - ]) { - if (!combined.includes(required)) { - throw new Error(`tracker_ingress_boundary_missing:${required}`); - } - } - for (const forbidden of [ - "LocalForward", - "tailscale-userspace", - "DEVICE_EDGE_RELAY_UPSTREAM", - 'commandTransport": "enabled', - "device.dc.ru", - ]) { - if (combined.includes(forbidden)) { - throw new Error(`tracker_ingress_boundary_violation:${forbidden}`); - } - } - } - if (phase === "command-transport") { - for (const required of [ - '"predecessorPatch": "device-edge-vps-tracker-ingress-20260812-012"', - '"runtimeService": "nodedc-device-edge-channel.service"', - '"runtimeComposition": "single-process-core-channel-plus-universal-device-gateway"', - '"commandTransport": "typed-service-ping-v1"', - '"commandCatalog": "allowlisted-adapter-typed-commands-only"', - '"responseBoundary": "exact-adapter-parser-serv-ok-only"', - "buildTypedCommand", - "parseTypedCommandResponse", - "submitCommandStatus", - '"service.ping"', - ]) { - if (!combined.includes(required)) { - throw new Error(`command_transport_boundary_missing:${required}`); - } - } - for (const forbidden of [ - "LocalForward", - "tailscale-userspace", - "DEVICE_EDGE_RELAY_UPSTREAM", - "device.dc.ru", - "PRIVATE KEY", - "TS_AUTHKEY", - ]) { - if (combined.includes(forbidden)) { - throw new Error(`command_transport_boundary_violation:${forbidden}`); - } - } - } -} - -function canonicalTarScript() { - return [ - "import gzip,io,pathlib,sys,tarfile", - "root=pathlib.Path(sys.argv[2])", - "with open(sys.argv[1],'wb') as out:", - " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", - " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", - " for top in ('manifest.env','files.txt','payload'):", - " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", - " for x in paths:", - " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", - " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", - " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", - ].join("\n"); -} - -async function copySafe(source, destination) { - const sourceStat = await lstat(source); - if (sourceStat.isSymbolicLink()) { - throw new Error(`source_symlink_rejected:${relative(sourceRoot, source)}`); - } - if (sourceStat.isFile()) { - await mkdir(dirname(destination), { recursive: true }); - await cp(source, destination, { force: true, verbatimSymlinks: true }); - return; - } - if (!sourceStat.isDirectory()) { - throw new Error(`source_type_rejected:${source}`); - } - await mkdir(destination, { recursive: true }); - for (const entry of await readdir(source, { withFileTypes: true })) { - if (ignoredBasenames.has(entry.name) || entry.name.startsWith(".env")) { - continue; - } - const childSource = join(source, entry.name); - const childDestination = join(destination, entry.name); - if (entry.isSymbolicLink()) { - throw new Error(`source_symlink_rejected:${relative(sourceRoot, childSource)}`); - } - await copySafe(childSource, childDestination); - } -} diff --git a/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs b/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs deleted file mode 100644 index 383186c..0000000 --- a/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs +++ /dev/null @@ -1,248 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const platformRoot = resolve(scriptDir, "../.."); -const devicePlaneRoot = resolve(platformRoot, "device-plane"); -const designRoot = resolve(process.env.NODEDC_DEVICE_MANAGER_SOURCE_ROOT || resolve(platformRoot, "../NODEDC_DESIGN_GUIDELINE")); -const managerRoot = resolve(designRoot, "apps/device-manager"); -const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); -const [patchId = "device-manager-release-v3-20260812-026", ...extra] = process.argv.slice(2); -if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-device-manager-control-plane-artifact.mjs [patch-id]"); - -const descriptorPath = patchId.startsWith("device-manager-release-v3-") - ? "deployment/device-manager-release-v3.json" - : "deployment/device-manager-release-v1.json"; - -const isV3 = descriptorPath.endsWith("release-v3.json"); -const entries = isV3 ? [ - "docker-compose.device-manager.yml", - "services/device-manager", - descriptorPath, -] : [ - ".dockerignore", - "package.json", - "package-lock.json", - "docker-compose.device-manager.yml", - "packages/device-protocol-contract", - "packages/device-edge-channel-contract", - "packages/arusnavi-b2-adapter", - "services/device-control-core", - "services/device-gateway/package.json", - "services/device-edge-relay/package.json", - "services/device-manager", - descriptorPath, -]; -const stage = await mkdtemp(join(tmpdir(), "nodedc-device-manager-control-plane-")); -const payload = join(stage, "payload"); -const target = join(artifactDir, `nodedc-device-plane-${patchId}.tgz`); -try { - const build = spawnSync("npm", ["run", "build", "--workspace", "@nodedc/device-manager"], { - cwd: designRoot, - encoding: "utf8", - maxBuffer: 64 * 1024 * 1024, - }); - if (build.status !== 0) throw new Error(`device_manager_build_failed:${build.stderr || build.stdout}`); - await mkdir(payload, { recursive: true }); - for (const entry of entries) { - if (entry === descriptorPath) { - const descriptor = JSON.parse(await readFile(resolve(devicePlaneRoot, entry), "utf8")); - if (descriptor.releaseId !== "__PATCH_ID__") { - throw new Error("device_manager_release_template_id_mismatch"); - } - descriptor.releaseId = patchId; - const destination = join(payload, entry); - await mkdir(dirname(destination), { recursive: true }); - await writeFile(destination, `${JSON.stringify(descriptor, null, 2)}\n`, "utf8"); - continue; - } - if (entry === "services/device-manager") { - const destination = join(payload, entry); - await mkdir(destination, { recursive: true }); - await copySafe(resolve(devicePlaneRoot, "services/device-manager/Dockerfile"), join(destination, "Dockerfile"), devicePlaneRoot); - await copySafe(resolve(managerRoot, "server"), join(destination, "server"), managerRoot); - await copySafe(resolve(managerRoot, "dist"), join(destination, "dist"), managerRoot); - continue; - } - await copySafe(resolve(devicePlaneRoot, entry), join(payload, entry), devicePlaneRoot); - } - if (!isV3) { - await validateDockerCopySources( - join(payload, "services/device-control-core/Dockerfile"), - payload, - ); - } - await validateDockerCopySources( - join(payload, "services/device-manager/Dockerfile"), - join(payload, "services/device-manager"), - ); - for (const modulePath of isV3 ? [] : [ - "services/device-control-core/src/sensitive-reference-management.mjs", - "services/device-control-core/src/device-gateway-core-runtime.mjs", - "packages/device-edge-channel-contract/src/index.mjs", - ]) { - const coreImport = spawnSync( - process.execPath, - [ - "--input-type=module", - "--eval", - `import(${JSON.stringify(pathToFileURL(join(payload, modulePath)).href)})`, - ], - { - cwd: payload, - encoding: "utf8", - maxBuffer: 16 * 1024 * 1024, - }, - ); - if (coreImport.status !== 0) { - throw new Error( - `device_control_core_staged_module_import_failed:${modulePath}:${coreImport.stderr || coreImport.stdout}`, - ); - } - } - const compose = await readFile(join(payload, "docker-compose.device-manager.yml"), "utf8"); - for (const required of [ - "device-manager:", - "DEVICE_MANAGEMENT_API_ENABLED: \"true\"", - "NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token", - "NODEDC_DEVICE_CORE_TOKEN_FILE: /run/nodedc-secrets/management-core-token", - "name: nodedc-platform_edge", - ]) if (!compose.includes(required)) throw new Error(`device_manager_compose_contract_missing:${required}`); - for (const forbidden of [ - "NODEDC_INTERNAL_ACCESS_TOKEN:", - "NODEDC_PLATFORM_SERVICE_TOKEN:", - "PRIVATE KEY", - "DEVICE_EDGE_CHANNEL_", - "device-edge-channel/", - "nodedc-device-plane-egress", - "0.0.0.0:18122", - "0.0.0.0:9921:9921", - "- \"9921:9921\"", - ]) { - if (compose.includes(forbidden)) throw new Error(`device_manager_compose_boundary_violation:${forbidden}`); - } - const descriptor = JSON.parse(await readFile(join(payload, descriptorPath), "utf8")); - const predecessor = descriptor.predecessor; - const commonContractInvalid = ( - descriptor.releaseId !== patchId - || !["activate", "upgrade"].includes(descriptor.action) - || !predecessor - || !["reconciliation", "release"].includes(predecessor.kind) - || !/^[A-Za-z0-9._-]{1,96}$/.test(predecessor.patchId || "") - || !/^[a-f0-9]{64}$/.test(predecessor.artifactSha256 || "") - || (descriptor.action === "activate") !== (predecessor.kind === "reconciliation") - || descriptor.healthGate !== "bounded-container-grace+core-contract" - || descriptor.rollback !== "restore-preapply-snapshot" - ); - if (commonContractInvalid) throw new Error("device_manager_activation_successor_contract_mismatch"); - if (descriptorPath.endsWith("release-v3.json")) { - if ( - descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v3" - || descriptor.commandTransport !== "typed-service-ping-v1" - || descriptor.commandCatalog !== "allowlisted-adapter-typed-commands-only" - || descriptor.credentialBoundary !== "transient-core-memory-then-single-pinned-mtls-command-envelope-to-edge-never-persisted-never-logged-never-returned" - || descriptor.controlCorePredecessor?.patchId !== "device-control-core-release-v2-20260812-025" - || descriptor.controlCorePredecessor?.artifactSha256 !== "c61b1f0de1bae23de0caa7289036865ea419ff5705611416f736ca929d1592db" - || descriptor.edgeChannelPredecessor?.patchId !== "device-edge-core-channel-upgrade-v4-20260812-023" - || descriptor.edgeChannelPredecessor?.artifactSha256 !== "c10d5b6b7d55ab239f85b6c8130e34ce9f84985e3b46e6e5534733156c7982fc" - || descriptor.edgeChannel !== "preserve-active-v4-core-initiated-pinned-mtls" - || descriptor.edgeChannelEgress !== "preserve-dedicated-core-only-bridge-no-host-ingress-public-ipv4-tcp-443-only" - || descriptor.gelios !== "untouched-legacy-only" - ) throw new Error("device_manager_v3_typed_command_contract_mismatch"); - } else if ( - descriptor.schemaVersion !== "nodedc.device-plane.device-manager-release.v1" - || descriptor.commandTransport !== "disabled" - || descriptor.gelios !== "untouched" - ) { - throw new Error("device_manager_v1_contract_mismatch"); - } - await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, "utf8"); - await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8"); - await mkdir(artifactDir, { recursive: true }); - const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], { encoding: "utf8", maxBuffer: 128 * 1024 * 1024 }); - if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); - const sha256 = createHash("sha256").update(await readFile(target)).digest("hex"); - console.log(JSON.stringify({ - ok: true, - patchId, - component: "device-plane", - artifact: target, - sha256, - entries, - services: isV3 ? ["device-manager"] : ["device-control-core", "device-manager"], - preserved: ["device-postgres", "device-gateway", "device-backhaul-target", "Gelios"], - }, null, 2)); -} finally { - await rm(stage, { recursive: true, force: true }); -} - -async function copySafe(source, destination, sourceBoundary) { - const sourceStat = await lstat(source); - if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(sourceBoundary, source)}`); - if (sourceStat.isFile()) { - if (source.endsWith(".test.mjs") || source.endsWith(".map")) return; - await mkdir(dirname(destination), { recursive: true }); - await cp(source, destination, { force: true, verbatimSymlinks: true }); - return; - } - if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${source}`); - await mkdir(destination, { recursive: true }); - for (const entry of await readdir(source, { withFileTypes: true })) { - if ([".DS_Store", ".git", "node_modules", "test"].includes(entry.name) || entry.name.startsWith(".env")) continue; - await copySafe(join(source, entry.name), join(destination, entry.name), sourceBoundary); - } -} - -async function validateDockerCopySources(dockerfilePath, buildContext) { - const dockerfile = await readFile(dockerfilePath, "utf8"); - for (const [index, rawLine] of dockerfile.split("\n").entries()) { - const line = rawLine.trim(); - if (!/^COPY\s+/i.test(line)) continue; - if (line.endsWith("\\") || /^COPY\s+\[/i.test(line)) { - throw new Error(`unsupported_docker_copy_syntax:${dockerfilePath}:${index + 1}`); - } - const tokens = line.split(/\s+/).slice(1); - while (tokens[0]?.startsWith("--")) tokens.shift(); - if (tokens.length < 2) { - throw new Error(`invalid_docker_copy:${dockerfilePath}:${index + 1}`); - } - for (const source of tokens.slice(0, -1)) { - if (/[*?[\]{}]/.test(source)) { - throw new Error(`docker_copy_glob_rejected:${dockerfilePath}:${index + 1}:${source}`); - } - const resolvedSource = resolve(buildContext, source); - const relativeSource = relative(buildContext, resolvedSource); - if (!relativeSource || relativeSource.startsWith("..") || resolve(buildContext, relativeSource) !== resolvedSource) { - throw new Error(`docker_copy_source_outside_context:${dockerfilePath}:${index + 1}:${source}`); - } - try { - await lstat(resolvedSource); - } catch (error) { - if (error?.code === "ENOENT") { - throw new Error(`docker_copy_source_missing:${dockerfilePath}:${index + 1}:${source}`); - } - throw error; - } - } - } -} - -function canonicalTarScript() { - return [ - "import gzip,io,pathlib,sys,tarfile", - "root=pathlib.Path(sys.argv[2])", - "with open(sys.argv[1],'wb') as out:", - " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", - " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", - " for top in ('manifest.env','files.txt','payload'):", - " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", - " for x in paths:", - " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", - " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", - ].join("\n"); -} diff --git a/infra/deploy-runner/build-device-manager-control-plane-reconciliation-artifact.mjs b/infra/deploy-runner/build-device-manager-control-plane-reconciliation-artifact.mjs deleted file mode 100644 index ab7edcb..0000000 --- a/infra/deploy-runner/build-device-manager-control-plane-reconciliation-artifact.mjs +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const platformRoot = resolve(scriptDir, "../.."); -const sourceRoot = resolve(platformRoot, "device-plane"); -const artifactDir = resolve( - process.env.NODEDC_DEPLOY_ARTIFACT_DIR - || resolve(scriptDir, "../deploy-artifacts"), -); -const [ - patchId = "device-manager-control-plane-reconciliation-20260811-002", - ...extra -] = process.argv.slice(2); -if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { - throw new Error( - "usage: build-device-manager-control-plane-reconciliation-artifact.mjs [patch-id]", - ); -} - -const entry = "deployment/device-manager-control-plane-reconciliation-v1.json"; -const stage = await mkdtemp(join(tmpdir(), "nodedc-device-manager-reconciliation-")); -const payload = join(stage, "payload"); -const target = resolve( - artifactDir, - `nodedc-device-plane-${patchId}.tgz`, -); - -try { - const descriptor = JSON.parse(await readFile(resolve(sourceRoot, entry), "utf8")); - const expected = { - schemaVersion: "nodedc.device-plane.device-manager-control-plane-reconciliation.v1", - mode: "failed-control-plane-baseline-adoption", - failedPatchId: "device-manager-control-plane-20260810-001", - failedArtifactSha256: - "50e275c1085286bcb3bb2b273aefc8bbba70f446ca2c7bd464dc745710a291a6", - backupId: - "device-plane-device-manager-control-plane-20260810-001-20260811-000321", - sourceAction: "publish-reconciliation-marker-only", - runtimeAction: "read-only-acceptance", - preservedServices: [ - "device-control-core", - "device-gateway", - "device-postgres", - "device-backhaul-target", - ], - absentService: "device-manager", - databaseVolume: "nodedc-device-plane-postgres-data", - publicIngress: "disabled", - commandTransport: "disabled", - gelios: "untouched", - rollback: "marker-only-runtime-unchanged", - }; - if (JSON.stringify(descriptor) !== JSON.stringify(expected)) { - throw new Error("device_manager_reconciliation_descriptor_mismatch"); - } - - await mkdir(dirname(join(payload, entry)), { recursive: true }); - await cp(resolve(sourceRoot, entry), join(payload, entry), { force: true }); - await writeFile( - join(stage, "manifest.env"), - `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, - "utf8", - ); - await writeFile(join(stage, "files.txt"), `${entry}\n`, "utf8"); - await mkdir(artifactDir, { recursive: true }); - const tar = spawnSync( - "python3", - ["-c", canonicalTarScript(), target, stage], - { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }, - ); - if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); - const sha256 = createHash("sha256") - .update(await readFile(target)) - .digest("hex"); - console.log(JSON.stringify({ - ok: true, - patchId, - component: "device-plane", - artifact: target, - sha256, - entries: [entry], - build: [], - services: [], - transition: "failed-control-plane-baseline-adoption", - runtimeAction: "read-only-acceptance", - sourceAction: "publish-reconciliation-marker-only", - }, null, 2)); -} finally { - await rm(stage, { recursive: true, force: true }); -} - -function canonicalTarScript() { - return [ - "import gzip,io,pathlib,sys,tarfile", - "root=pathlib.Path(sys.argv[2])", - "with open(sys.argv[1],'wb') as out:", - " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", - " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", - " for top in ('manifest.env','files.txt','payload'):", - " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", - " for x in paths:", - " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", - " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", - ].join("\n"); -} diff --git a/infra/deploy-runner/build-device-manager-control-plane-v2-reconciliation-artifact.mjs b/infra/deploy-runner/build-device-manager-control-plane-v2-reconciliation-artifact.mjs deleted file mode 100644 index 5de795b..0000000 --- a/infra/deploy-runner/build-device-manager-control-plane-v2-reconciliation-artifact.mjs +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const platformRoot = resolve(scriptDir, "../.."); -const sourceRoot = resolve(platformRoot, "device-plane"); -const artifactDir = resolve( - process.env.NODEDC_DEPLOY_ARTIFACT_DIR - || resolve(scriptDir, "../deploy-artifacts"), -); -const [ - patchId = "device-manager-control-plane-v2-reconciliation-20260811-004", - ...extra -] = process.argv.slice(2); -if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { - throw new Error( - "usage: build-device-manager-control-plane-v2-reconciliation-artifact.mjs [patch-id]", - ); -} - -const entry = "deployment/device-manager-control-plane-v2-reconciliation-v1.json"; -const stage = await mkdtemp(join(tmpdir(), "nodedc-device-manager-v2-reconciliation-")); -const payload = join(stage, "payload"); -const target = resolve(artifactDir, `nodedc-device-plane-${patchId}.tgz`); - -try { - const descriptor = JSON.parse(await readFile(resolve(sourceRoot, entry), "utf8")); - const expected = { - schemaVersion: "nodedc.device-plane.device-manager-control-plane-v2-reconciliation.v1", - mode: "failed-v2-control-plane-baseline-adoption", - failedPatchId: "device-manager-control-plane-20260811-003", - failedArtifactSha256: - "ba29618ffbfed55448768794f28b18dda439ddb39a1d2a4f1dece19de7f29990", - backupId: - "device-plane-device-manager-control-plane-20260811-003-20260811-012505", - failureClass: "deterministic-runtime-module-resolution", - missingModule: "/packages/external-provider-contract/src/credential-reference.mjs", - correctiveAction: "runtime-local-contract-adapter+staged-module-import-gate", - sourceAction: "publish-reconciliation-marker-only", - runtimeAction: "read-only-acceptance", - preservedServices: [ - "device-control-core", - "device-gateway", - "device-postgres", - "device-backhaul-target", - ], - absentService: "device-manager", - databaseVolume: "nodedc-device-plane-postgres-data", - publicIngress: "disabled", - commandTransport: "disabled", - gelios: "untouched", - rollback: "marker-only-runtime-unchanged", - }; - if (JSON.stringify(descriptor) !== JSON.stringify(expected)) { - throw new Error("device_manager_v2_reconciliation_descriptor_mismatch"); - } - - await mkdir(dirname(join(payload, entry)), { recursive: true }); - await cp(resolve(sourceRoot, entry), join(payload, entry), { force: true }); - await writeFile( - join(stage, "manifest.env"), - `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, - "utf8", - ); - await writeFile(join(stage, "files.txt"), `${entry}\n`, "utf8"); - await mkdir(artifactDir, { recursive: true }); - const tar = spawnSync( - "python3", - ["-c", canonicalTarScript(), target, stage], - { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }, - ); - if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); - const sha256 = createHash("sha256") - .update(await readFile(target)) - .digest("hex"); - console.log(JSON.stringify({ - ok: true, - patchId, - component: "device-plane", - artifact: target, - sha256, - entries: [entry], - build: [], - services: [], - transition: "failed-v2-control-plane-baseline-adoption", - runtimeAction: "read-only-acceptance", - sourceAction: "publish-reconciliation-marker-only", - }, null, 2)); -} finally { - await rm(stage, { recursive: true, force: true }); -} - -function canonicalTarScript() { - return [ - "import gzip,io,pathlib,sys,tarfile", - "root=pathlib.Path(sys.argv[2])", - "with open(sys.argv[1],'wb') as out:", - " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", - " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", - " for top in ('manifest.env','files.txt','payload'):", - " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", - " for x in paths:", - " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", - " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", - ].join("\n"); -} diff --git a/infra/deploy-runner/build-device-plane-artifact.mjs b/infra/deploy-runner/build-device-plane-artifact.mjs deleted file mode 100644 index b3b1cd7..0000000 --- a/infra/deploy-runner/build-device-plane-artifact.mjs +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { - cp, - lstat, - mkdir, - mkdtemp, - readFile, - readdir, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const platformRoot = resolve(scriptDir, "../.."); -const sourceRoot = resolve(platformRoot, "device-plane"); -const failedFoundationCompose = resolve( - scriptDir, - "fixtures/device-plane-foundation-internal-only-v1.yml", -); -const artifactDir = resolve( - process.env.NODEDC_DEPLOY_ARTIFACT_DIR - || resolve(scriptDir, "../deploy-artifacts"), -); -const [patchId = "device-plane-foundation-20260725-001", ...extra] = - process.argv.slice(2); - -if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { - throw new Error("usage: build-device-plane-artifact.mjs [patch-id]"); -} - -const files = [ - ".dockerignore", - "package.json", - "package-lock.json", - "docker-compose.device-plane.yml", - "packages/device-protocol-contract", - "packages/arusnavi-b2-adapter", - "services/device-control-core", - "services/device-gateway", -]; -const ignoredBasenames = new Set([ - ".DS_Store", - ".git", - "node_modules", -]); -const ignoredDirectoryNames = new Set(["test"]); -const stage = await mkdtemp(join(tmpdir(), "nodedc-device-plane-artifact-")); -const payload = join(stage, "payload"); -const target = join(artifactDir, `nodedc-device-plane-${patchId}.tgz`); - -await assertSourceBoundary(); - -try { - await mkdir(payload, { recursive: true }); - for (const sourceRelative of files) { - const source = sourceRelative === "docker-compose.device-plane.yml" - ? failedFoundationCompose - : resolve(sourceRoot, sourceRelative); - await copySafe( - source, - join(payload, sourceRelative), - ); - } - await writeFile( - join(stage, "manifest.env"), - `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, - "utf8", - ); - await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8"); - await mkdir(artifactDir, { recursive: true }); - - const tar = spawnSync( - "python3", - ["-c", canonicalTarScript(), target, stage], - { - encoding: "utf8", - maxBuffer: 128 * 1024 * 1024, - }, - ); - if (tar.status !== 0) { - throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); - } - - const digest = createHash("sha256") - .update(await readFile(target)) - .digest("hex"); - console.log(JSON.stringify({ - ok: true, - patchId, - artifact: target, - sha256: digest, - component: "device-plane", - entries: files, - services: ["device-control-core", "device-gateway"], - preserved: [ - "device-postgres", - "nodedc-device-plane-postgres-data", - "Gelios", - ], - excluded: [ - ".env*", - "node_modules", - "**/test", - "docs", - "runtime", - "secrets", - ], - }, null, 2)); -} finally { - await rm(stage, { recursive: true, force: true }); -} - -async function assertSourceBoundary() { - const composeSource = failedFoundationCompose; - const compose = await readFile( - composeSource, - "utf8", - ); - for (const fragment of [ - 'DEVICE_DISCOVERY_INGEST_ENABLED: "false"', - 'DEVICE_GATEWAY_LISTEN_ENABLED: "false"', - '"127.0.0.1:18120:18120"', - '"127.0.0.1:18121:18121"', - "source: /volume1/docker/nodedc-device-plane/secrets/postgres-password", - "create_host_path: false", - "name: nodedc-device-plane-postgres-data", - "pull_policy: never", - ]) { - if (!compose.includes(fragment)) { - throw new Error(`device_plane_compose_boundary_missing:${fragment}`); - } - } - for (const forbidden of [ - "9921:9921", - "0.0.0.0:9921", - "DEVICE_DISCOVERY_INGEST_ENABLED: \"true\"", - "DEVICE_GATEWAY_LISTEN_ENABLED: \"true\"", - "POSTGRES_PASSWORD:", - ]) { - if (compose.includes(forbidden)) { - throw new Error(`device_plane_compose_boundary_violation:${forbidden}`); - } - } -} - -function canonicalTarScript() { - return [ - "import gzip,io,pathlib,sys,tarfile", - "root=pathlib.Path(sys.argv[2])", - "with open(sys.argv[1],'wb') as out:", - " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", - " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", - " for top in ('manifest.env','files.txt','payload'):", - " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", - " for x in paths:", - " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", - " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", - " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", - ].join("\n"); -} - -async function copySafe(source, destination) { - const sourceStat = await lstat(source); - if (sourceStat.isSymbolicLink()) { - throw new Error( - `source_symlink_rejected:${relative(sourceRoot, source)}`, - ); - } - if (sourceStat.isFile()) { - await mkdir(dirname(destination), { recursive: true }); - await cp(source, destination, { force: true, verbatimSymlinks: true }); - return; - } - if (!sourceStat.isDirectory()) { - throw new Error(`source_type_rejected:${source}`); - } - - await mkdir(destination, { recursive: true }); - for (const entry of await readdir(source, { withFileTypes: true })) { - if ( - ignoredBasenames.has(entry.name) - || entry.name.startsWith(".env") - || (entry.isDirectory() && ignoredDirectoryNames.has(entry.name)) - ) { - continue; - } - const childSource = join(source, entry.name); - const childDestination = join(destination, entry.name); - if (entry.isSymbolicLink()) { - throw new Error( - `source_symlink_rejected:${relative(sourceRoot, childSource)}`, - ); - } - await copySafe(childSource, childDestination); - } -} diff --git a/infra/deploy-runner/build-device-plane-b2-discovery-ingress-artifact.mjs b/infra/deploy-runner/build-device-plane-b2-discovery-ingress-artifact.mjs deleted file mode 100644 index 129917d..0000000 --- a/infra/deploy-runner/build-device-plane-b2-discovery-ingress-artifact.mjs +++ /dev/null @@ -1,271 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { - cp, - lstat, - mkdir, - mkdtemp, - readFile, - readdir, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const platformRoot = resolve(scriptDir, "../.."); -const sourceRoot = resolve(platformRoot, "device-plane"); -const artifactDir = resolve( - process.env.NODEDC_DEPLOY_ARTIFACT_DIR - || resolve(scriptDir, "../deploy-artifacts"), -); -const [ - patchId = "device-plane-b2-discovery-loopback-20260726-002", - ...extra -] = process.argv.slice(2); - -if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { - throw new Error( - "usage: build-device-plane-b2-discovery-ingress-artifact.mjs " - + "[patch-id]", - ); -} - -const files = [ - ".dockerignore", - "package.json", - "package-lock.json", - "docker-compose.device-plane.yml", - "packages/device-protocol-contract", - "packages/arusnavi-b2-adapter", - "services/device-control-core", - "services/device-gateway", - "services/device-edge-relay/package.json", - "deployment/device-plane-b2-discovery-ingress-v1.json", -]; -const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]); -const ignoredDirectoryNames = new Set(["test"]); -const stage = await mkdtemp( - join(tmpdir(), "nodedc-device-plane-b2-discovery-ingress-"), -); -const payload = join(stage, "payload"); -const target = join( - artifactDir, - `nodedc-device-plane-${patchId}.tgz`, -); - -await assertBoundary(); - -try { - await mkdir(payload, { recursive: true }); - for (const sourceRelative of files) { - await copySafe( - resolve(sourceRoot, sourceRelative), - join(payload, sourceRelative), - ); - } - await writeFile( - join(stage, "manifest.env"), - `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, - "utf8", - ); - await writeFile( - join(stage, "files.txt"), - `${files.join("\n")}\n`, - "utf8", - ); - await mkdir(artifactDir, { recursive: true }); - - const tar = spawnSync( - "python3", - ["-c", canonicalTarScript(), target, stage], - { - encoding: "utf8", - maxBuffer: 128 * 1024 * 1024, - }, - ); - if (tar.status !== 0) { - throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); - } - - const digest = createHash("sha256") - .update(await readFile(target)) - .digest("hex"); - console.log(JSON.stringify({ - ok: true, - patchId, - artifact: target, - sha256: digest, - component: "device-plane", - transition: "verified-b2-loopback-discovery-only", - entries: files, - services: ["device-control-core", "device-gateway"], - preservedRuntime: [ - "device-postgres", - "nodedc-device-plane-postgres-data", - "Gelios", - ], - ingress: { - transport: "tcp", - published: "127.0.0.1:9921:9921", - mode: "loopback-discovery-only", - framing: "verified-read-only", - lifecycle: "quarantine", - commandTransport: "disabled", - }, - rollback: "restore-source-and-predecessor-stateless-runtime", - excluded: [ - ".env*", - "node_modules", - "**/test", - "docs", - "runtime", - "secrets", - ], - }, null, 2)); -} finally { - await rm(stage, { recursive: true, force: true }); -} - -async function assertBoundary() { - const compose = await readFile( - resolve(sourceRoot, "docker-compose.device-plane.yml"), - "utf8", - ); - for (const fragment of [ - 'DEVICE_DISCOVERY_INGEST_ENABLED: "true"', - 'DEVICE_GATEWAY_LISTEN_ENABLED: "true"', - 'DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false"', - "DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120", - "DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token", - '"127.0.0.1:18120:18120"', - '"127.0.0.1:18121:18121"', - '"127.0.0.1:9921:9921"', - "name: nodedc-device-plane-private", - "internal: true", - "name: nodedc-device-plane-control", - "internal: false", - 'com.docker.network.bridge.enable_ip_masquerade: "false"', - "name: nodedc-device-plane-postgres-data", - "pull_policy: never", - ]) { - if (!compose.includes(fragment)) { - throw new Error( - `device_plane_b2_ingress_boundary_missing:${fragment}`, - ); - } - } - for (const forbidden of [ - "POSTGRES_PASSWORD:", - "DEVICE_GATEWAY_CORE_TOKEN:", - "DEVICE_IDENTIFIER_PEPPER:", - "DEVICE_GATEWAY_COMMAND", - "9921:9921/udp", - ]) { - if (compose.includes(forbidden)) { - throw new Error( - `device_plane_b2_ingress_boundary_violation:${forbidden}`, - ); - } - } - - const descriptor = JSON.parse(await readFile( - resolve( - sourceRoot, - "deployment/device-plane-b2-discovery-ingress-v1.json", - ), - "utf8", - )); - const expected = { - schemaVersion: "nodedc.device-plane.b2-discovery-ingress.v1", - mode: "verified-b2-loopback-discovery-only", - predecessorPatchId: - "device-plane-foundation-network-publication-20260725-003", - predecessorArtifactSha256: - "6fdd5a12c310786db1753882fc1378184fe378d2cc533633a8c73c951521b7bf", - sourceAction: "publish-verified-b2-loopback-discovery-source", - runtimeAction: "build-and-recreate-stateless-services", - selectedServices: ["device-control-core", "device-gateway"], - preservedServices: ["device-postgres"], - privateNetwork: "nodedc-device-plane-private", - controlNetwork: "nodedc-device-plane-control", - publishedPorts: [ - "127.0.0.1:18120:18120", - "127.0.0.1:18121:18121", - "127.0.0.1:9921:9921/tcp", - ], - protocolProfile: "arusnavi.b2.internal.v1", - framingSpecification: - "arusnavi.internal.protocol-sheet.gid-12.v1", - identityTrust: "claimed-not-ownership-proof", - discoveryLifecycle: "quarantine", - commandTransport: "disabled", - gelios: "untouched", - databaseVolume: "nodedc-device-plane-postgres-data", - rollback: "restore-source-and-predecessor-stateless-runtime", - }; - if (JSON.stringify(descriptor) !== JSON.stringify(expected)) { - throw new Error("device_plane_b2_ingress_descriptor_mismatch"); - } -} - -function canonicalTarScript() { - return [ - "import gzip,io,pathlib,sys,tarfile", - "root=pathlib.Path(sys.argv[2])", - "with open(sys.argv[1],'wb') as out:", - " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", - " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", - " for top in ('manifest.env','files.txt','payload'):", - " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", - " for x in paths:", - " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", - " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", - " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", - ].join("\n"); -} - -async function copySafe(source, destination) { - const sourceStat = await lstat(source); - if (sourceStat.isSymbolicLink()) { - throw new Error( - `source_symlink_rejected:${relative(sourceRoot, source)}`, - ); - } - if (sourceStat.isFile()) { - await mkdir(dirname(destination), { recursive: true }); - await cp(source, destination, { - force: true, - verbatimSymlinks: true, - }); - return; - } - if (!sourceStat.isDirectory()) { - throw new Error(`source_type_rejected:${source}`); - } - - await mkdir(destination, { recursive: true }); - for (const entry of await readdir(source, { withFileTypes: true })) { - if ( - ignoredBasenames.has(entry.name) - || entry.name.startsWith(".env") - || ( - entry.isDirectory() - && ignoredDirectoryNames.has(entry.name) - ) - ) { - continue; - } - const childSource = join(source, entry.name); - const childDestination = join(destination, entry.name); - if (entry.isSymbolicLink()) { - throw new Error( - `source_symlink_rejected:${relative(sourceRoot, childSource)}`, - ); - } - await copySafe(childSource, childDestination); - } -} diff --git a/infra/deploy-runner/build-device-plane-b2-discovery-loopback-recovery-artifact.mjs b/infra/deploy-runner/build-device-plane-b2-discovery-loopback-recovery-artifact.mjs deleted file mode 100644 index dfe1102..0000000 --- a/infra/deploy-runner/build-device-plane-b2-discovery-loopback-recovery-artifact.mjs +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { - cp, - lstat, - mkdir, - mkdtemp, - readFile, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const platformRoot = resolve(scriptDir, "../.."); -const sourceRoot = resolve(platformRoot, "device-plane"); -const descriptorRelative = - "deployment/device-plane-b2-discovery-loopback-recovery-v1.json"; -const artifactDir = resolve( - process.env.NODEDC_DEPLOY_ARTIFACT_DIR - || resolve(scriptDir, "../deploy-artifacts"), -); -const [ - patchId = "device-plane-b2-discovery-loopback-recovery-20260802-004", - ...extra -] = process.argv.slice(2); - -if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { - throw new Error( - "usage: " - + "build-device-plane-b2-discovery-loopback-recovery-artifact.mjs " - + "[patch-id]", - ); -} - -const files = [descriptorRelative]; -const stage = await mkdtemp( - join(tmpdir(), "nodedc-device-plane-b2-loopback-recovery-"), -); -const payload = join(stage, "payload"); -const target = join( - artifactDir, - `nodedc-device-plane-${patchId}.tgz`, -); - -await assertRecoveryDescriptor(); - -try { - const source = resolve(sourceRoot, descriptorRelative); - const sourceStat = await lstat(source); - if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) { - throw new Error("device_plane_b2_recovery_descriptor_unsafe"); - } - await mkdir(dirname(join(payload, descriptorRelative)), { - recursive: true, - }); - await cp(source, join(payload, descriptorRelative), { - force: true, - verbatimSymlinks: true, - }); - await writeFile( - join(stage, "manifest.env"), - `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, - "utf8", - ); - await writeFile( - join(stage, "files.txt"), - `${files.join("\n")}\n`, - "utf8", - ); - await mkdir(artifactDir, { recursive: true }); - - const tar = spawnSync( - "python3", - ["-c", canonicalTarScript(), target, stage], - { - encoding: "utf8", - maxBuffer: 128 * 1024 * 1024, - }, - ); - if (tar.status !== 0) { - throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); - } - - const digest = createHash("sha256") - .update(await readFile(target)) - .digest("hex"); - console.log(JSON.stringify({ - ok: true, - patchId, - artifact: target, - sha256: digest, - component: "device-plane", - transition: "failed-b2-loopback-build-reconciliation", - entries: files, - build: [], - services: [], - preservedRuntime: [ - "device-control-core", - "device-gateway", - "device-postgres", - "nodedc-device-plane-postgres-data", - "Gelios", - ], - sourceAction: "publish-reconciliation-marker-only", - runtimeAction: "read-only-acceptance", - ingress: "disabled:127.0.0.1:9921/tcp:closed", - rollback: "marker-only-runtime-unchanged", - excluded: [ - "application-source", - "compose", - "Dockerfile", - "secrets", - "runtime", - "database", - "Gelios", - ], - }, null, 2)); -} finally { - await rm(stage, { recursive: true, force: true }); -} - -async function assertRecoveryDescriptor() { - const descriptor = JSON.parse(await readFile( - resolve(sourceRoot, descriptorRelative), - "utf8", - )); - const expected = { - schemaVersion: "nodedc.device-plane.b2-discovery-loopback-recovery.v1", - mode: "failed-b2-loopback-build-reconciliation", - failedPatchId: "device-plane-b2-discovery-loopback-20260801-003", - failedArtifactSha256: - "7273c5bf67fe6bc1f1da66ad726009240d39ee3aee58201b96c23d6f707a3d84", - failedBackupId: - "device-plane-device-plane-b2-discovery-loopback-20260801-003-20260802-154311", - sourceAction: "publish-reconciliation-marker-only", - runtimeAction: "read-only-acceptance", - preservedServices: [ - "device-control-core", - "device-gateway", - "device-postgres", - ], - expectedLoopbackPorts: [ - "127.0.0.1:18120:18120", - "127.0.0.1:18121:18121", - ], - closedPort: "127.0.0.1:9921/tcp", - databaseVolume: "nodedc-device-plane-postgres-data", - commandTransport: "disabled", - gelios: "untouched", - rollback: "marker-only-runtime-unchanged", - }; - if (JSON.stringify(descriptor) !== JSON.stringify(expected)) { - throw new Error("device_plane_b2_recovery_descriptor_mismatch"); - } -} - -function canonicalTarScript() { - return [ - "import gzip,io,pathlib,sys,tarfile", - "root=pathlib.Path(sys.argv[2])", - "with open(sys.argv[1],'wb') as out:", - " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", - " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", - " for top in ('manifest.env','files.txt','payload'):", - " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", - " for x in paths:", - " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", - " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", - " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", - ].join("\n"); -} diff --git a/infra/deploy-runner/build-device-plane-backhaul-target-artifact.mjs b/infra/deploy-runner/build-device-plane-backhaul-target-artifact.mjs deleted file mode 100644 index 1bf7cb2..0000000 --- a/infra/deploy-runner/build-device-plane-backhaul-target-artifact.mjs +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { - cp, - lstat, - mkdir, - mkdtemp, - readFile, - readdir, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const platformRoot = resolve(scriptDir, "../.."); -const sourceRoot = resolve(platformRoot, "device-plane"); -const artifactDir = resolve( - process.env.NODEDC_DEPLOY_ARTIFACT_DIR - || resolve(scriptDir, "../deploy-artifacts"), -); -const [ - patchId = "device-plane-backhaul-target-tailnet-serve-20260804-002", - ...extra -] = process.argv.slice(2); - -if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { - throw new Error( - "usage: build-device-plane-backhaul-target-artifact.mjs [patch-id]", - ); -} - -const files = [ - "docker-compose.device-plane.backhaul-target.yml", - "services/device-backhaul-target", - "deployment/device-plane-backhaul-target-tailnet-serve-v1.json", -]; -const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]); -const stage = await mkdtemp( - join(tmpdir(), "nodedc-device-plane-backhaul-target-"), -); -const payload = join(stage, "payload"); -const target = join( - artifactDir, - `nodedc-device-plane-${patchId}.tgz`, -); - -await assertBoundary(); - -try { - await mkdir(payload, { recursive: true }); - for (const sourceRelative of files) { - await copySafe( - resolve(sourceRoot, sourceRelative), - join(payload, sourceRelative), - ); - } - await writeFile( - join(stage, "manifest.env"), - `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, - "utf8", - ); - await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8"); - await mkdir(artifactDir, { recursive: true }); - - const tar = spawnSync( - "python3", - ["-c", canonicalTarScript(), target, stage], - { encoding: "utf8", maxBuffer: 128 * 1024 * 1024 }, - ); - if (tar.status !== 0) { - throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); - } - - const digest = createHash("sha256") - .update(await readFile(target)) - .digest("hex"); - console.log(JSON.stringify({ - ok: true, - patchId, - artifact: target, - sha256: digest, - component: "device-plane", - transition: "failed-backhaul-target-to-loopback-tailnet-serve", - entries: files, - services: ["device-backhaul-target"], - preservedRuntime: [ - "device-control-core", - "device-gateway", - "device-postgres", - "nodedc-device-plane-postgres-data", - "Gelios", - ], - ingress: { - loopbackListen: "127.0.0.1:2222/tcp", - tailnetListen: "100.109.216.21:2222/tcp", - transport: "tailscale-serve-private-ssh", - serveTarget: "tcp://127.0.0.1:2222", - permittedTarget: "127.0.0.1:9921", - dockerPortPublication: "disabled", - routerNatFirewall: "unchanged", - edgePublicIngress: "disabled", - funnel: "disabled", - commandTransport: "disabled", - }, - runtimeTrust: "runner-managed-not-in-artifact", - rollback: "remove-tailnet-serve-target-and-restore-source", - }, null, 2)); -} finally { - await rm(stage, { recursive: true, force: true }); -} - -async function assertBoundary() { - const compose = await readFile( - resolve(sourceRoot, "docker-compose.device-plane.backhaul-target.yml"), - "utf8", - ); - for (const fragment of [ - "device-backhaul-target:", - "image: nodedc/device-backhaul-target:local", - "network_mode: host", - '"127.0.0.1", "2222"', - "/secrets/backhaul-target/ssh_host_ed25519_key", - "/secrets/backhaul-target/authorized_keys", - "no-new-privileges:true", - ]) { - if (!compose.includes(fragment)) { - throw new Error(`device_plane_backhaul_boundary_missing:${fragment}`); - } - } - for (const forbidden of [ - "PasswordAuthentication yes", - "0.0.0.0:2222", - "9921:9921/udp", - "DEVICE_GATEWAY_COMMAND", - ]) { - if (compose.includes(forbidden)) { - throw new Error(`device_plane_backhaul_boundary_violation:${forbidden}`); - } - } - - const sshd = await readFile( - resolve(sourceRoot, "services/device-backhaul-target/sshd_config"), - "utf8", - ); - for (const fragment of [ - "ListenAddress 127.0.0.1", - "PasswordAuthentication no", - "KbdInteractiveAuthentication no", - "AllowTcpForwarding local", - "PermitOpen 127.0.0.1:9921", - "GatewayPorts no", - "PermitTunnel no", - "AllowAgentForwarding no", - "PermitTTY no", - "ForceCommand /bin/false", - ]) { - if (!sshd.includes(fragment)) { - throw new Error(`device_plane_backhaul_sshd_boundary_missing:${fragment}`); - } - } - - const descriptor = JSON.parse(await readFile( - resolve( - sourceRoot, - "deployment/device-plane-backhaul-target-tailnet-serve-v1.json", - ), - "utf8", - )); - const expected = { - schemaVersion: "nodedc.device-plane.backhaul-target-tailnet-serve.v1", - mode: "failed-backhaul-target-to-loopback-tailnet-serve", - failedPatchId: "device-plane-backhaul-target-20260803-001", - failedArtifactSha256: - "ed0bda4110a756c32be68990e2e0f647409d5a77eec7e26c18502bafbdc1bb76", - failedBackupId: - "device-plane-device-plane-backhaul-target-20260803-001-20260804-035519", - predecessorPatchId: - "device-plane-b2-discovery-loopback-20260803-006", - predecessorArtifactSha256: - "25f9e9e55e283e9b7bb5e128ff14a244f848b1c063acca9724a23206131c9adf", - sourceAction: "publish-loopback-backhaul-target-source", - runtimeAction: "build-create-target-and-register-private-tailnet-serve", - composeOverlay: "docker-compose.device-plane.backhaul-target.yml", - selectedServices: ["device-backhaul-target"], - preservedServices: [ - "device-control-core", - "device-gateway", - "device-postgres", - ], - loopbackListenAddress: "127.0.0.1", - listenPort: 2222, - tailnetAddress: "100.109.216.21", - tailnetExposure: "tailscale-serve-private", - tailscaleServeTarget: "tcp://127.0.0.1:2222", - permittedTarget: "127.0.0.1:9921", - networkMode: "host", - dockerPortPublication: "disabled", - routerNatFirewall: "unchanged", - edgePublicIngress: "disabled", - funnel: "disabled", - commandTransport: "disabled", - gelios: "untouched", - databaseVolume: "nodedc-device-plane-postgres-data", - runtimeTrust: "runner-managed", - rollback: "remove-tailnet-serve-target-and-restore-source", - }; - if (JSON.stringify(descriptor) !== JSON.stringify(expected)) { - throw new Error("device_plane_backhaul_descriptor_mismatch"); - } -} - -function canonicalTarScript() { - return [ - "import gzip,io,pathlib,sys,tarfile", - "root=pathlib.Path(sys.argv[2])", - "with open(sys.argv[1],'wb') as out:", - " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", - " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", - " for top in ('manifest.env','files.txt','payload'):", - " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", - " for x in paths:", - " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", - " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", - " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", - ].join("\n"); -} - -async function copySafe(source, destination) { - const sourceStat = await lstat(source); - if (sourceStat.isSymbolicLink()) { - throw new Error(`source_symlink_rejected:${relative(sourceRoot, source)}`); - } - if (sourceStat.isFile()) { - await mkdir(dirname(destination), { recursive: true }); - await cp(source, destination, { force: true, verbatimSymlinks: true }); - return; - } - if (!sourceStat.isDirectory()) { - throw new Error(`source_type_rejected:${source}`); - } - await mkdir(destination, { recursive: true }); - for (const entry of await readdir(source, { withFileTypes: true })) { - if ( - ignoredBasenames.has(entry.name) - || entry.name.startsWith(".env") - || entry.name.endsWith("~") - ) { - continue; - } - await copySafe(join(source, entry.name), join(destination, entry.name)); - } -} diff --git a/infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs b/infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs deleted file mode 100644 index 27dcc61..0000000 --- a/infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { - cp, - lstat, - mkdir, - mkdtemp, - readFile, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const platformRoot = resolve(scriptDir, "../.."); -const sourceRoot = resolve(platformRoot, "device-plane"); -const descriptorRelative = - "deployment/device-plane-backhaul-vps-enrollment-v1.json"; -const artifactDir = resolve( - process.env.NODEDC_DEPLOY_ARTIFACT_DIR - || resolve(scriptDir, "../deploy-artifacts"), -); -const [ - patchId = "device-plane-backhaul-vps-enrollment-20260806-001", - ...extra -] = process.argv.slice(2); - -if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { - throw new Error( - "usage: build-device-plane-backhaul-vps-enrollment-artifact.mjs [patch-id]", - ); -} - -if (process.env.NODEDC_ALLOW_SUPERSEDED_TRANSPORT !== "test-only") { - throw new Error("vps_initiated_transport_frozen:ADR-0001"); -} - -const files = [descriptorRelative]; -const stage = await mkdtemp( - join(tmpdir(), "nodedc-device-plane-vps-enrollment-"), -); -const payload = join(stage, "payload"); -const target = join( - artifactDir, - `nodedc-device-plane-${patchId}.tgz`, -); - -await assertDescriptor(); - -try { - const source = resolve(sourceRoot, descriptorRelative); - const sourceStat = await lstat(source); - if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) { - throw new Error("device_plane_vps_enrollment_descriptor_unsafe"); - } - await mkdir(dirname(join(payload, descriptorRelative)), { - recursive: true, - }); - await cp(source, join(payload, descriptorRelative), { - force: true, - verbatimSymlinks: true, - }); - await writeFile( - join(stage, "manifest.env"), - `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, - "utf8", - ); - await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8"); - await mkdir(artifactDir, { recursive: true }); - - const tar = spawnSync( - "python3", - ["-c", canonicalTarScript(), target, stage], - { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }, - ); - if (tar.status !== 0) { - throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); - } - - const bytes = await readFile(target); - const sha256 = createHash("sha256").update(bytes).digest("hex"); - console.log(JSON.stringify({ - ok: true, - patchId, - artifact: target, - sha256, - component: "device-plane", - transition: "rotate-backhaul-client-mini-to-vps", - entries: files, - build: [], - services: ["device-backhaul-target"], - preservedRuntime: [ - "device-control-core", - "device-gateway", - "device-postgres", - "nodedc-device-plane-postgres-data", - "Tailscale Serve", - "Gelios", - ], - publicIngress: "disabled", - commandTransport: "disabled", - runtimeKeyMaterial: "external-enrollment-only", - rollback: "restore-previous-authorized-key-and-recreate-target", - }, null, 2)); -} finally { - await rm(stage, { recursive: true, force: true }); -} - -async function assertDescriptor() { - const descriptor = JSON.parse(await readFile( - resolve(sourceRoot, descriptorRelative), - "utf8", - )); - if ( - descriptor.schemaVersion - !== "nodedc.device-plane.backhaul-vps-enrollment.v1" - || descriptor.mode !== "rotate-backhaul-client-mini-to-vps" - || descriptor.predecessorPatchId - !== "device-plane-backhaul-target-tailnet-serve-20260804-002" - || descriptor.predecessorArtifactSha256 - !== "219408705dd4d80a962ed00eeb53a69df0b9ab6458443734d5c9cd1d1f795eba" - || descriptor.nextKeyFingerprint - !== "SHA256:HHTiDYiCRxSiKjBLCip6JMSzGfLGrDz5g8SIkosJcVw" - || descriptor.commandTransport !== "disabled" - || descriptor.gelios !== "untouched" - || descriptor.edgePublicIngress !== "disabled" - ) { - throw new Error("device_plane_vps_enrollment_descriptor_mismatch"); - } - const text = JSON.stringify(descriptor); - for (const forbidden of [ - "PRIVATE KEY", - "authorized_keys", - "TS_AUTHKEY", - "password", - ]) { - if (text.includes(forbidden)) { - throw new Error(`device_plane_vps_enrollment_boundary:${forbidden}`); - } - } -} - -function canonicalTarScript() { - return [ - "import gzip,io,pathlib,sys,tarfile", - "root=pathlib.Path(sys.argv[2])", - "with open(sys.argv[1],'wb') as out:", - " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", - " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", - " for top in ('manifest.env','files.txt','payload'):", - " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", - " for x in paths:", - " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", - " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", - " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", - ].join("\n"); -} diff --git a/infra/deploy-runner/build-device-plane-foundation-network-publication-artifact.mjs b/infra/deploy-runner/build-device-plane-foundation-network-publication-artifact.mjs deleted file mode 100644 index 1498af3..0000000 --- a/infra/deploy-runner/build-device-plane-foundation-network-publication-artifact.mjs +++ /dev/null @@ -1,274 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { - cp, - lstat, - mkdir, - mkdtemp, - readFile, - readdir, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const platformRoot = resolve(scriptDir, "../.."); -const sourceRoot = resolve(platformRoot, "device-plane"); -const networkPublicationCompose = resolve( - scriptDir, - "fixtures/device-plane-foundation-network-publication-v1.yml", -); -const artifactDir = resolve( - process.env.NODEDC_DEPLOY_ARTIFACT_DIR - || resolve(scriptDir, "../deploy-artifacts"), -); -const [ - patchId = "device-plane-foundation-network-publication-20260725-003", - ...extra -] = process.argv.slice(2); - -if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { - throw new Error( - "usage: build-device-plane-foundation-network-publication-artifact.mjs " - + "[patch-id]", - ); -} - -const files = [ - ".dockerignore", - "package.json", - "package-lock.json", - "docker-compose.device-plane.yml", - "packages/device-protocol-contract", - "packages/arusnavi-b2-adapter", - "services/device-control-core", - "services/device-gateway", - "deployment/device-plane-foundation-network-publication-v1.json", -]; -const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]); -const ignoredDirectoryNames = new Set(["test"]); -const stage = await mkdtemp( - join(tmpdir(), "nodedc-device-plane-network-publication-"), -); -const payload = join(stage, "payload"); -const target = join( - artifactDir, - `nodedc-device-plane-${patchId}.tgz`, -); - -await assertBoundary(); - -try { - await mkdir(payload, { recursive: true }); - for (const sourceRelative of files) { - const source = sourceRelative === "docker-compose.device-plane.yml" - ? networkPublicationCompose - : resolve(sourceRoot, sourceRelative); - await copySafe( - source, - join(payload, sourceRelative), - ); - } - await writeFile( - join(stage, "manifest.env"), - `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, - "utf8", - ); - await writeFile( - join(stage, "files.txt"), - `${files.join("\n")}\n`, - "utf8", - ); - await mkdir(artifactDir, { recursive: true }); - - const tar = spawnSync( - "python3", - ["-c", canonicalTarScript(), target, stage], - { - encoding: "utf8", - maxBuffer: 128 * 1024 * 1024, - }, - ); - if (tar.status !== 0) { - throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); - } - - const digest = createHash("sha256") - .update(await readFile(target)) - .digest("hex"); - console.log(JSON.stringify({ - ok: true, - patchId, - artifact: target, - sha256: digest, - component: "device-plane", - transition: "failed-foundation-network-publication-correction", - entries: files, - build: [], - services: ["device-control-core", "device-gateway"], - preservedRuntime: [ - "device-postgres", - "nodedc-device-plane-postgres-data", - "Gelios", - ], - networkChange: { - private: "preserved:internal", - control: "create:non-internal:no-masquerade", - published: [ - "127.0.0.1:18120:18120", - "127.0.0.1:18121:18121", - ], - disabled: ["9921", "public-ingress", "command-transport"], - }, - rollback: - "restore-partial-source-and-internal-only-stateless-runtime", - excluded: [ - ".env*", - "node_modules", - "**/test", - "docs", - "runtime", - "secrets", - ], - }, null, 2)); -} finally { - await rm(stage, { recursive: true, force: true }); -} - -async function assertBoundary() { - const compose = await readFile( - networkPublicationCompose, - "utf8", - ); - for (const fragment of [ - 'DEVICE_DISCOVERY_INGEST_ENABLED: "false"', - 'DEVICE_GATEWAY_LISTEN_ENABLED: "false"', - '"127.0.0.1:18120:18120"', - '"127.0.0.1:18121:18121"', - "name: nodedc-device-plane-private", - "internal: true", - "name: nodedc-device-plane-control", - "internal: false", - 'com.docker.network.bridge.enable_ip_masquerade: "false"', - "name: nodedc-device-plane-postgres-data", - "pull_policy: never", - ]) { - if (!compose.includes(fragment)) { - throw new Error( - `device_plane_network_publication_boundary_missing:${fragment}`, - ); - } - } - for (const forbidden of [ - "9921:9921", - "0.0.0.0:9921", - 'DEVICE_DISCOVERY_INGEST_ENABLED: "true"', - 'DEVICE_GATEWAY_LISTEN_ENABLED: "true"', - "POSTGRES_PASSWORD:", - ]) { - if (compose.includes(forbidden)) { - throw new Error( - `device_plane_network_publication_boundary_violation:${forbidden}`, - ); - } - } - - const descriptor = JSON.parse(await readFile( - resolve( - sourceRoot, - "deployment/device-plane-foundation-network-publication-v1.json", - ), - "utf8", - )); - const expected = { - schemaVersion: - "nodedc.device-plane.foundation-network-publication.v1", - mode: "failed-foundation-network-publication-correction", - failedRecoveryPatchId: - "device-plane-foundation-recovery-20260725-002", - failedRecoveryArtifactSha256: - "9183cc385142584bfd12510bb0a3e6b833b2fd26607436f2486a564c628ea1bf", - failedRecoveryBackupId: - "device-plane-device-plane-foundation-recovery-20260725-002-20260725-232447", - sourceAction: "publish-network-corrected-foundation-source", - runtimeAction: "recreate-stateless-services-no-build", - selectedServices: ["device-control-core", "device-gateway"], - preservedServices: ["device-postgres"], - privateNetwork: "nodedc-device-plane-private", - controlNetwork: "nodedc-device-plane-control", - publishedLoopbackPorts: [ - "127.0.0.1:18120:18120", - "127.0.0.1:18121:18121", - ], - databaseVolume: "nodedc-device-plane-postgres-data", - rollback: - "restore-partial-source-and-internal-only-stateless-runtime", - }; - if (JSON.stringify(descriptor) !== JSON.stringify(expected)) { - throw new Error( - "device_plane_network_publication_descriptor_mismatch", - ); - } -} - -function canonicalTarScript() { - return [ - "import gzip,io,pathlib,sys,tarfile", - "root=pathlib.Path(sys.argv[2])", - "with open(sys.argv[1],'wb') as out:", - " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", - " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", - " for top in ('manifest.env','files.txt','payload'):", - " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", - " for x in paths:", - " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", - " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", - " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", - ].join("\n"); -} - -async function copySafe(source, destination) { - const sourceStat = await lstat(source); - if (sourceStat.isSymbolicLink()) { - throw new Error( - `source_symlink_rejected:${relative(sourceRoot, source)}`, - ); - } - if (sourceStat.isFile()) { - await mkdir(dirname(destination), { recursive: true }); - await cp(source, destination, { - force: true, - verbatimSymlinks: true, - }); - return; - } - if (!sourceStat.isDirectory()) { - throw new Error(`source_type_rejected:${source}`); - } - - await mkdir(destination, { recursive: true }); - for (const entry of await readdir(source, { withFileTypes: true })) { - if ( - ignoredBasenames.has(entry.name) - || entry.name.startsWith(".env") - || ( - entry.isDirectory() - && ignoredDirectoryNames.has(entry.name) - ) - ) { - continue; - } - const childSource = join(source, entry.name); - const childDestination = join(destination, entry.name); - if (entry.isSymbolicLink()) { - throw new Error( - `source_symlink_rejected:${relative(sourceRoot, childSource)}`, - ); - } - await copySafe(childSource, childDestination); - } -} diff --git a/infra/deploy-runner/build-device-plane-foundation-recovery-artifact.mjs b/infra/deploy-runner/build-device-plane-foundation-recovery-artifact.mjs deleted file mode 100755 index c82a43b..0000000 --- a/infra/deploy-runner/build-device-plane-foundation-recovery-artifact.mjs +++ /dev/null @@ -1,255 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { - cp, - lstat, - mkdir, - mkdtemp, - readFile, - readdir, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const platformRoot = resolve(scriptDir, "../.."); -const sourceRoot = resolve(platformRoot, "device-plane"); -const predecessorCompose = resolve( - scriptDir, - "fixtures/device-plane-foundation-internal-only-v1.yml", -); -const artifactDir = resolve( - process.env.NODEDC_DEPLOY_ARTIFACT_DIR - || resolve(scriptDir, "../deploy-artifacts"), -); -const [ - patchId = "device-plane-foundation-recovery-20260725-002", - ...extra -] = process.argv.slice(2); - -if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { - throw new Error( - "usage: build-device-plane-foundation-recovery-artifact.mjs [patch-id]", - ); -} - -const files = [ - ".dockerignore", - "package.json", - "package-lock.json", - "docker-compose.device-plane.yml", - "packages/device-protocol-contract", - "packages/arusnavi-b2-adapter", - "services/device-control-core", - "services/device-gateway", - "deployment/device-plane-foundation-recovery-v1.json", -]; -const ignoredBasenames = new Set([ - ".DS_Store", - ".git", - "node_modules", -]); -const ignoredDirectoryNames = new Set(["test"]); -const stage = await mkdtemp( - join(tmpdir(), "nodedc-device-plane-foundation-recovery-"), -); -const payload = join(stage, "payload"); -const target = join( - artifactDir, - `nodedc-device-plane-${patchId}.tgz`, -); - -await assertRecoveryBoundary(); - -try { - await mkdir(payload, { recursive: true }); - for (const sourceRelative of files) { - const source = sourceRelative === "docker-compose.device-plane.yml" - ? predecessorCompose - : resolve(sourceRoot, sourceRelative); - await copySafe( - source, - join(payload, sourceRelative), - ); - } - await writeFile( - join(stage, "manifest.env"), - `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, - "utf8", - ); - await writeFile( - join(stage, "files.txt"), - `${files.join("\n")}\n`, - "utf8", - ); - await mkdir(artifactDir, { recursive: true }); - - const tar = spawnSync( - "python3", - ["-c", canonicalTarScript(), target, stage], - { - encoding: "utf8", - maxBuffer: 128 * 1024 * 1024, - }, - ); - if (tar.status !== 0) { - throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); - } - - const digest = createHash("sha256") - .update(await readFile(target)) - .digest("hex"); - console.log(JSON.stringify({ - ok: true, - patchId, - artifact: target, - sha256: digest, - component: "device-plane", - transition: "failed-foundation-live-runtime-adoption", - entries: files, - build: [], - services: [], - preservedRuntime: [ - "device-control-core", - "device-gateway", - "device-postgres", - "nodedc-device-plane-postgres-data", - ], - sourceAction: "publish-exact-failed-artifact-source", - runtimeAction: "read-only-acceptance", - excluded: [ - ".env*", - "node_modules", - "**/test", - "docs", - "runtime", - "secrets", - ], - }, null, 2)); -} finally { - await rm(stage, { recursive: true, force: true }); -} - -async function assertRecoveryBoundary() { - const compose = await readFile( - predecessorCompose, - "utf8", - ); - for (const fragment of [ - 'DEVICE_DISCOVERY_INGEST_ENABLED: "false"', - 'DEVICE_GATEWAY_LISTEN_ENABLED: "false"', - '"127.0.0.1:18120:18120"', - '"127.0.0.1:18121:18121"', - "name: nodedc-device-plane-postgres-data", - ]) { - if (!compose.includes(fragment)) { - throw new Error( - `device_plane_recovery_compose_boundary_missing:${fragment}`, - ); - } - } - for (const forbidden of [ - "9921:9921", - "0.0.0.0:9921", - 'DEVICE_DISCOVERY_INGEST_ENABLED: "true"', - 'DEVICE_GATEWAY_LISTEN_ENABLED: "true"', - "POSTGRES_PASSWORD:", - ]) { - if (compose.includes(forbidden)) { - throw new Error( - `device_plane_recovery_compose_boundary_violation:${forbidden}`, - ); - } - } - - const descriptor = JSON.parse(await readFile( - resolve( - sourceRoot, - "deployment/device-plane-foundation-recovery-v1.json", - ), - "utf8", - )); - const expected = { - schemaVersion: "nodedc.device-plane.foundation-recovery.v1", - mode: "failed-foundation-live-runtime-adoption", - failedPatchId: "device-plane-foundation-20260725-001", - failedArtifactSha256: - "23d428de547854ad8b1a026671e2f850386ab0be98bde80f016f1e9db631ee24", - backupId: - "device-plane-device-plane-foundation-20260725-001-20260725-223441", - sourceAction: "publish-exact-failed-artifact-source", - runtimeAction: "read-only-acceptance", - preservedServices: [ - "device-control-core", - "device-gateway", - "device-postgres", - ], - databaseVolume: "nodedc-device-plane-postgres-data", - rollback: "source-only-runtime-unchanged", - }; - if (JSON.stringify(descriptor) !== JSON.stringify(expected)) { - throw new Error("device_plane_recovery_descriptor_mismatch"); - } -} - -function canonicalTarScript() { - return [ - "import gzip,io,pathlib,sys,tarfile", - "root=pathlib.Path(sys.argv[2])", - "with open(sys.argv[1],'wb') as out:", - " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", - " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", - " for top in ('manifest.env','files.txt','payload'):", - " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", - " for x in paths:", - " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", - " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", - " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", - ].join("\n"); -} - -async function copySafe(source, destination) { - const sourceStat = await lstat(source); - if (sourceStat.isSymbolicLink()) { - throw new Error( - `source_symlink_rejected:${relative(sourceRoot, source)}`, - ); - } - if (sourceStat.isFile()) { - await mkdir(dirname(destination), { recursive: true }); - await cp(source, destination, { - force: true, - verbatimSymlinks: true, - }); - return; - } - if (!sourceStat.isDirectory()) { - throw new Error(`source_type_rejected:${source}`); - } - - await mkdir(destination, { recursive: true }); - for (const entry of await readdir(source, { withFileTypes: true })) { - if ( - ignoredBasenames.has(entry.name) - || entry.name.startsWith(".env") - || ( - entry.isDirectory() - && ignoredDirectoryNames.has(entry.name) - ) - ) { - continue; - } - const childSource = join(source, entry.name); - const childDestination = join(destination, entry.name); - if (entry.isSymbolicLink()) { - throw new Error( - `source_symlink_rejected:${relative(sourceRoot, childSource)}`, - ); - } - await copySafe(childSource, childDestination); - } -} diff --git a/infra/deploy-runner/build-device-plane-postgres-bootstrap-artifact.mjs b/infra/deploy-runner/build-device-plane-postgres-bootstrap-artifact.mjs deleted file mode 100644 index d56c228..0000000 --- a/infra/deploy-runner/build-device-plane-postgres-bootstrap-artifact.mjs +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env node -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { - cp, - lstat, - mkdir, - mkdtemp, - readFile, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const platformRoot = resolve(scriptDir, "../.."); -const sourceRoot = resolve(platformRoot, "device-plane"); -const artifactDir = resolve( - process.env.NODEDC_DEPLOY_ARTIFACT_DIR - || resolve(scriptDir, "../deploy-artifacts"), -); -const [patchId = "device-plane-postgres-bootstrap-20260725-001", ...extra] = - process.argv.slice(2); - -if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { - throw new Error( - "usage: build-device-plane-postgres-bootstrap-artifact.mjs [patch-id]", - ); -} - -const files = [ - "docker-compose.device-plane.yml", - "deployment/device-postgres-bootstrap-v1.json", -]; -const stage = await mkdtemp( - join(tmpdir(), "nodedc-device-plane-postgres-bootstrap-"), -); -const payload = join(stage, "payload"); -const target = join( - artifactDir, - `nodedc-device-plane-${patchId}.tgz`, -); - -await assertSourceBoundary(); - -try { - await mkdir(payload, { recursive: true }); - for (const relativePath of files) { - const source = resolve(sourceRoot, relativePath); - const sourceStat = await lstat(source); - if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) { - throw new Error(`bootstrap_source_file_required:${relativePath}`); - } - const destination = join(payload, relativePath); - await mkdir(dirname(destination), { recursive: true }); - await cp(source, destination, { - force: true, - verbatimSymlinks: false, - }); - } - await writeFile( - join(stage, "manifest.env"), - `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, - "utf8", - ); - await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8"); - await mkdir(artifactDir, { recursive: true }); - - const tar = spawnSync( - "python3", - ["-c", canonicalTarScript(), target, stage], - { - encoding: "utf8", - maxBuffer: 128 * 1024 * 1024, - }, - ); - if (tar.status !== 0) { - throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); - } - - const sha256 = createHash("sha256") - .update(await readFile(target)) - .digest("hex"); - console.log(JSON.stringify({ - ok: true, - patchId, - artifact: target, - sha256, - component: "device-plane", - entries: files, - services: ["device-postgres"], - mode: "create-if-absent", - rollbackVolumePolicy: "preserve", - }, null, 2)); -} finally { - await rm(stage, { recursive: true, force: true }); -} - -async function assertSourceBoundary() { - const descriptor = JSON.parse( - await readFile( - resolve( - sourceRoot, - "deployment/device-postgres-bootstrap-v1.json", - ), - "utf8", - ), - ); - const expected = { - schemaVersion: "nodedc.device-plane.postgres-bootstrap.v1", - service: "device-postgres", - volume: "nodedc-device-plane-postgres-data", - mode: "create-if-absent", - ordinaryApplicationSelection: "forbidden", - rollbackVolumePolicy: "preserve", - }; - if (JSON.stringify(descriptor) !== JSON.stringify(expected)) { - throw new Error("device_plane_postgres_bootstrap_descriptor_mismatch"); - } - - const compose = await readFile( - resolve(sourceRoot, "docker-compose.device-plane.yml"), - "utf8", - ); - for (const required of [ - "device-postgres:", - "name: nodedc-device-plane-postgres-data", - "POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password", - "create_host_path: false", - ]) { - if (!compose.includes(required)) { - throw new Error(`device_plane_postgres_boundary_missing:${required}`); - } - } - const postgresStart = compose.indexOf(" device-postgres:"); - const postgresEnd = compose.indexOf("\n device-control-core:"); - if ( - postgresStart < 0 - || postgresEnd <= postgresStart - || compose.slice(postgresStart, postgresEnd).includes("\n ports:") - ) { - throw new Error("device_plane_postgres_host_port_forbidden"); - } -} - -function canonicalTarScript() { - return [ - "import gzip,io,pathlib,sys,tarfile", - "root=pathlib.Path(sys.argv[2])", - "with open(sys.argv[1],'wb') as out:", - " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", - " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", - " for top in ('manifest.env','files.txt','payload'):", - " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", - " for x in paths:", - " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", - " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", - " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", - ].join("\n"); -} diff --git a/infra/deploy-runner/fixtures/device-plane-foundation-internal-only-v1.yml b/infra/deploy-runner/fixtures/device-plane-foundation-internal-only-v1.yml deleted file mode 100644 index 15c444c..0000000 --- a/infra/deploy-runner/fixtures/device-plane-foundation-internal-only-v1.yml +++ /dev/null @@ -1,118 +0,0 @@ -services: - device-postgres: - image: postgres:16-alpine - pull_policy: missing - restart: unless-stopped - environment: - POSTGRES_DB: device_plane - POSTGRES_USER: device_plane - POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password - volumes: - - type: volume - source: device-plane-postgres-data - target: /var/lib/postgresql/data - - type: bind - source: /volume1/docker/nodedc-device-plane/secrets/postgres-password - target: /run/nodedc-secrets/postgres-password - read_only: true - bind: - create_host_path: false - networks: - - device-plane-private - healthcheck: - test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"] - interval: 10s - timeout: 5s - retries: 12 - start_period: 20s - - device-control-core: - image: nodedc/device-control-core:local - pull_policy: never - restart: unless-stopped - user: "1000:1000" - read_only: true - tmpfs: - - /tmp:size=16m,mode=1777 - environment: - HOST: 0.0.0.0 - PORT: "18120" - DEVICE_DATABASE_HOST: device-postgres - DEVICE_DATABASE_PORT: "5432" - DEVICE_DATABASE_NAME: device_plane - DEVICE_DATABASE_USER: device_plane - DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password - DEVICE_DATABASE_POOL_SIZE: "10" - DEVICE_DISCOVERY_INGEST_ENABLED: "false" - volumes: - - type: bind - source: /volume1/docker/nodedc-device-plane/secrets/postgres-password - target: /run/nodedc-secrets/postgres-password - read_only: true - bind: - create_host_path: false - ports: - - "127.0.0.1:18120:18120" - networks: - - device-plane-private - depends_on: - device-postgres: - condition: service_healthy - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - healthcheck: - test: - - CMD - - node - - -e - - fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 10s - timeout: 5s - retries: 12 - start_period: 20s - - device-gateway: - image: nodedc/device-gateway:local - pull_policy: never - restart: unless-stopped - user: "1000:1000" - read_only: true - tmpfs: - - /tmp:size=16m,mode=1777 - environment: - DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0 - DEVICE_GATEWAY_HEALTH_PORT: "18121" - DEVICE_GATEWAY_LISTEN_ENABLED: "false" - DEVICE_GATEWAY_TCP_HOST: 127.0.0.1 - DEVICE_GATEWAY_TCP_PORT: "9921" - DEVICE_GATEWAY_MAX_SESSIONS: "100" - DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000" - ports: - - "127.0.0.1:18121:18121" - networks: - - device-plane-private - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - healthcheck: - test: - - CMD - - node - - -e - - fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 10s - timeout: 5s - retries: 12 - start_period: 10s - -networks: - device-plane-private: - name: nodedc-device-plane-private - internal: true - -volumes: - device-plane-postgres-data: - name: nodedc-device-plane-postgres-data diff --git a/infra/deploy-runner/fixtures/device-plane-foundation-network-publication-v1.yml b/infra/deploy-runner/fixtures/device-plane-foundation-network-publication-v1.yml deleted file mode 100644 index 4943189..0000000 --- a/infra/deploy-runner/fixtures/device-plane-foundation-network-publication-v1.yml +++ /dev/null @@ -1,126 +0,0 @@ -services: - device-postgres: - image: postgres:16-alpine - pull_policy: missing - restart: unless-stopped - environment: - POSTGRES_DB: device_plane - POSTGRES_USER: device_plane - POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password - volumes: - - type: volume - source: device-plane-postgres-data - target: /var/lib/postgresql/data - - type: bind - source: /volume1/docker/nodedc-device-plane/secrets/postgres-password - target: /run/nodedc-secrets/postgres-password - read_only: true - bind: - create_host_path: false - networks: - - device-plane-private - healthcheck: - test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"] - interval: 10s - timeout: 5s - retries: 12 - start_period: 20s - - device-control-core: - image: nodedc/device-control-core:local - pull_policy: never - restart: unless-stopped - user: "1000:1000" - read_only: true - tmpfs: - - /tmp:size=16m,mode=1777 - environment: - HOST: 0.0.0.0 - PORT: "18120" - DEVICE_DATABASE_HOST: device-postgres - DEVICE_DATABASE_PORT: "5432" - DEVICE_DATABASE_NAME: device_plane - DEVICE_DATABASE_USER: device_plane - DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password - DEVICE_DATABASE_POOL_SIZE: "10" - DEVICE_DISCOVERY_INGEST_ENABLED: "false" - volumes: - - type: bind - source: /volume1/docker/nodedc-device-plane/secrets/postgres-password - target: /run/nodedc-secrets/postgres-password - read_only: true - bind: - create_host_path: false - ports: - - "127.0.0.1:18120:18120" - networks: - - device-plane-private - - device-plane-control - depends_on: - device-postgres: - condition: service_healthy - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - healthcheck: - test: - - CMD - - node - - -e - - fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 10s - timeout: 5s - retries: 12 - start_period: 20s - - device-gateway: - image: nodedc/device-gateway:local - pull_policy: never - restart: unless-stopped - user: "1000:1000" - read_only: true - tmpfs: - - /tmp:size=16m,mode=1777 - environment: - DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0 - DEVICE_GATEWAY_HEALTH_PORT: "18121" - DEVICE_GATEWAY_LISTEN_ENABLED: "false" - DEVICE_GATEWAY_TCP_HOST: 127.0.0.1 - DEVICE_GATEWAY_TCP_PORT: "9921" - DEVICE_GATEWAY_MAX_SESSIONS: "100" - DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000" - ports: - - "127.0.0.1:18121:18121" - networks: - - device-plane-private - - device-plane-control - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - healthcheck: - test: - - CMD - - node - - -e - - fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 10s - timeout: 5s - retries: 12 - start_period: 10s - -networks: - device-plane-private: - name: nodedc-device-plane-private - internal: true - device-plane-control: - name: nodedc-device-plane-control - driver: bridge - internal: false - driver_opts: - com.docker.network.bridge.enable_ip_masquerade: "false" - -volumes: - device-plane-postgres-data: - name: nodedc-device-plane-postgres-data diff --git a/infra/deploy-runner/nodedc-b2-vps-deploy b/infra/deploy-runner/nodedc-b2-vps-deploy deleted file mode 100755 index d7eb0ed..0000000 --- a/infra/deploy-runner/nodedc-b2-vps-deploy +++ /dev/null @@ -1,2106 +0,0 @@ -#!/usr/bin/env python3 -"""Canonical data-only deploy runner for the Robot2B public Device Edge VPS.""" - -from __future__ import annotations - -import hashlib -import json -import os -import pwd -import re -import shutil -import socket -import subprocess -import sys -import tarfile -import tempfile -import time -import urllib.request -from datetime import datetime, timezone -from pathlib import Path, PurePosixPath - - -RUNNER_PATH = Path("/usr/local/sbin/nodedc-b2-vps-deploy") -LIVE_ROOT = Path("/opt/nodedc-b2-vps") -INBOX_ROOT = Path("/var/lib/nodedc-b2-vps-deploy/inbox") -STATE_ROOT = Path("/var/lib/nodedc-b2-vps-deploy") -APPLIED_ROOT = STATE_ROOT / "applied" -FAILED_ROOT = STATE_ROOT / "failed" -BACKUP_ROOT = STATE_ROOT / "backups" -APPLIED_JOURNAL = STATE_ROOT / "state/applied.jsonl" -FAILED_JOURNAL = STATE_ROOT / "state/failed.jsonl" -DEPLOY_LOCK = STATE_ROOT / "state/deploy.lock" - -COMPONENT = "device-edge-vps" -ARTIFACT_TYPE = "app-overlay" -PATCH_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,96}$") -MAX_ARTIFACT_BYTES = 128 * 1024 * 1024 - -RUNTIME_HOST = "koffyvngij" -PUBLIC_IPV4 = "155.212.211.15" -SERVICE_USER = "nodedc-edge" -SERVICE_GROUP = "nodedc-edge" -BACKHAUL_USER = "nodedc-backhaul" -BACKHAUL_GROUP = "nodedc-backhaul" -RELAY_USER = "nodedc-relay" -RELAY_GROUP = "nodedc-relay" -CHANNEL_USER = "nodedc-channel" -CHANNEL_GROUP = "nodedc-channel" -TAILSCALE_REQUIRED_TAG = "tag:device-edge-vps" -MANAGEMENT_KEY_FINGERPRINT = ( - "SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw" -) -SERVER_HOST_KEY_FINGERPRINT = ( - "SHA256:mhqNn2S6zstkYL7VFdvt3SYHv1nLjB4J7/s57RrKG6w" -) - -NODE_VERSION = "22.23.2" -NODE_ARCHIVE = "node-v22.23.2-linux-x64.tar.xz" -NODE_ARCHIVE_SHA256 = ( - "d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307" -) -TAILSCALE_VERSION = "1.102.2" -TAILSCALE_ARCHIVE = "tailscale_1.102.2_amd64.tgz" -TAILSCALE_ARCHIVE_SHA256 = ( - "ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8" -) -NODE_BIN_SHA256 = "3517c2df0b2f8cd7f422b4b8450ef81c6889f08eb03e281d6de9079b15e6a327" -TAILSCALE_BIN_SHA256 = "58b0fa0907677ea6afe0d3022cc3e99b1a03f39a7ed60144843ed38252e00c80" -TAILSCALED_BIN_SHA256 = "5f17b092bac92326325f6c4ffd9991fad3c073975abe412d02ee68721a500394" - -NODE_BIN = LIVE_ROOT / "runtime/node/bin/node" -TAILSCALE_BIN = LIVE_ROOT / "runtime/tailscale/tailscale" -TAILSCALED_BIN = LIVE_ROOT / "runtime/tailscale/tailscaled" -TAILSCALE_SOCKET = Path("/run/nodedc-b2-vps/tailscaled.sock") -TAILSCALE_STATE = Path("/var/lib/nodedc-b2-vps/tailscale/tailscaled.state") -TRUST_ROOT = Path("/var/lib/nodedc-b2-vps/trust") -BACKHAUL_PRIVATE_KEY = TRUST_ROOT / "backhaul_ed25519" -BACKHAUL_PUBLIC_KEY = TRUST_ROOT / "backhaul_ed25519.pub" -BACKHAUL_KNOWN_HOSTS = TRUST_ROOT / "backhaul_known_hosts" -BACKHAUL_KEY_COMMENT = "nodedc-device-edge-vps-backhaul" - -BACKHAUL_TARGET_IP = "100.109.216.21" -BACKHAUL_TARGET_PORT = 2222 -BACKHAUL_TARGET_HOST_KEY = ( - "ssh-ed25519 " - "AAAAC3NzaC1lZDI1NTE5AAAAIJsmoyS+0Tbhz9VXxrSxXwNMFpfbdTckCilObOnKdlEc" -) -BACKHAUL_TARGET_FINGERPRINT = ( - "SHA256:QERJ5CIUXRj0nLChGT6HMtoX+WTaeaEY5ZgaWqT8d30" -) - -SSHD_DROPIN = Path("/etc/ssh/sshd_config.d/00-nodedc-b2-vps.conf") -NFTABLES_CONFIG = Path("/etc/nftables.conf") -TAILSCALE_UNIT = Path("/etc/systemd/system/nodedc-b2-tailscaled.service") -BACKHAUL_UNIT = Path("/etc/systemd/system/nodedc-b2-backhaul.service") -RELAY_UNIT = Path("/etc/systemd/system/nodedc-b2-relay.service") -CHANNEL_UNIT = Path("/etc/systemd/system/nodedc-device-edge-channel.service") -CHANNEL_TRUST_ROOT = Path("/var/lib/nodedc-b2-vps/channel-trust") -CHANNEL_PRIVATE_KEY = CHANNEL_TRUST_ROOT / "edge-private-key.pem" -CHANNEL_CERTIFICATE = CHANNEL_TRUST_ROOT / "edge-certificate.pem" -CHANNEL_CORE_CERTIFICATE = CHANNEL_TRUST_ROOT / "core-certificate.pem" -CHANNEL_RUNTIME_CONFIG = CHANNEL_TRUST_ROOT / "runtime.json" -CHANNEL_HEALTH_PORT = 18222 -CHANNEL_PUBLIC_PORT = 443 -CORE_CHANNEL_ACCEPTED_PATCH = "device-edge-vps-core-channel-20260812-010" -CORE_CHANNEL_ACCEPTED_SHA256 = ( - "c8ef3c4bb45850cad32e881eba081bc4c891c2886e5500d02cb94616d82353f3" -) -TAILSCALE_RETIREMENT_ACCEPTED_PATCH = ( - "device-edge-vps-tailscale-retirement-20260812-011" -) -TAILSCALE_RETIREMENT_ACCEPTED_SHA256 = ( - "e7b61ec9c83122fa5631467010eff871b98935746df6a1326b6ff6bb9713d877" -) -TRACKER_INGRESS_ACCEPTED_PATCH = "device-edge-vps-tracker-ingress-20260812-012" -TRACKER_INGRESS_ACCEPTED_SHA256 = ( - "290acef118839c6b0c31aac864c47da1832a289537366af9322d4624a1dd81ec" -) - -FOUNDATION_ENTRIES = ( - "vps/config/00-nodedc-b2-vps.conf", - "vps/config/nftables-foundation.conf", - "vps/systemd/nodedc-b2-tailscaled.service", - "deployment/device-edge-vps-foundation-v1.json", - f"vendor/{NODE_ARCHIVE}", - f"vendor/{TAILSCALE_ARCHIVE}", -) -RUNTIME_RECONCILIATION_ENTRIES = ( - "deployment/device-edge-vps-runtime-reconciliation-v1.json", -) -BACKHAUL_ENTRIES = ( - "vps/config/backhaul_ssh_config", - "vps/systemd/nodedc-b2-backhaul.service", - "deployment/device-edge-vps-backhaul-v1.json", -) -RELAY_ENTRIES = ( - "vps/config/nftables-relay.conf", - "vps/systemd/nodedc-b2-relay.service", - "services/device-edge-relay/src", - "deployment/device-edge-vps-relay-v1.json", -) -CORE_CHANNEL_ENTRIES = ( - "packages/device-protocol-contract/package.json", - "packages/device-protocol-contract/src", - "packages/device-edge-channel-contract/package.json", - "packages/device-edge-channel-contract/src", - "services/device-edge-channel/package.json", - "services/device-edge-channel/src", - "vps/config/nftables-core-channel.conf", - "vps/systemd/nodedc-device-edge-channel.service", - "deployment/device-edge-vps-core-channel-v1.json", -) -TAILSCALE_RETIREMENT_ENTRIES = ( - "deployment/device-edge-vps-tailscale-retirement-v1.json", -) -TRACKER_INGRESS_ENTRIES = ( - "packages/device-adapter-runtime/package.json", - "packages/device-adapter-runtime/src", - "packages/device-adapter-catalog/package.json", - "packages/device-adapter-catalog/src", - "packages/arusnavi-b2-adapter/package.json", - "packages/arusnavi-b2-adapter/src", - "services/device-gateway/src/runtime.mjs", - "vps/edge-process/device-edge-runtime.mjs", - "vps/config/nftables-tracker-ingress.conf", - "vps/systemd/nodedc-device-edge-runtime.service", - "deployment/device-edge-vps-tracker-ingress-v1.json", -) -COMMAND_TRANSPORT_ENTRIES = ( - "packages/device-edge-channel-contract/package.json", - "packages/device-edge-channel-contract/src", - "services/device-edge-channel/package.json", - "services/device-edge-channel/src", - "packages/device-adapter-runtime/package.json", - "packages/device-adapter-runtime/src", - "packages/device-adapter-catalog/package.json", - "packages/device-adapter-catalog/src", - "packages/arusnavi-b2-adapter/package.json", - "packages/arusnavi-b2-adapter/src", - "services/device-gateway/src/runtime.mjs", - "vps/edge-process/device-edge-runtime.mjs", - "deployment/device-edge-vps-command-transport-v1.json", -) - -PHASE_ENTRIES = { - "foundation": FOUNDATION_ENTRIES, - "runtime-reconciliation": RUNTIME_RECONCILIATION_ENTRIES, - "backhaul": BACKHAUL_ENTRIES, - "relay": RELAY_ENTRIES, - "core-channel": CORE_CHANNEL_ENTRIES, - "tailscale-retirement": TAILSCALE_RETIREMENT_ENTRIES, - "tracker-ingress": TRACKER_INGRESS_ENTRIES, - "command-transport": COMMAND_TRANSPORT_ENTRIES, -} - -SUPERSEDED_TRANSPORT_PHASES = frozenset({"backhaul", "relay"}) - -PHASE_FILE_SHA256 = { - "foundation": { - "vps/config/00-nodedc-b2-vps.conf": - "2079748f48b2297ecb067e16ae46248e3a981b26331aa0998dc14fdf7454cd4a", - "vps/config/nftables-foundation.conf": - "bce5b8c6e2226d47d8d322f8ee9e7158f2d7a553c5e2c4bc2a722d6067f46e28", - "vps/systemd/nodedc-b2-tailscaled.service": - "de147d29bc1759f56533d31058993df55e1200973f2899903bdbf3d13ff579da", - "deployment/device-edge-vps-foundation-v1.json": - "5ae007195b17d6cfaeb564abbed6dc9234eb36bf62db23bf2b9ace539c398898", - f"vendor/{NODE_ARCHIVE}": NODE_ARCHIVE_SHA256, - f"vendor/{TAILSCALE_ARCHIVE}": TAILSCALE_ARCHIVE_SHA256, - }, - "runtime-reconciliation": { - "deployment/device-edge-vps-runtime-reconciliation-v1.json": - "edf7e05918efa9c9ab6d759ecdcc493ec6a2c92ed5b486772b3629cd5fa981ab", - }, - "backhaul": { - "vps/config/backhaul_ssh_config": - "d0df8b70dda025b7c1c3fecd2dafffe60a5bb753650d3bc37db65db626cfc1af", - "vps/systemd/nodedc-b2-backhaul.service": - "64c26cad21cc17675c67ae4a57fc43b129065a8d22fda894648340b310d3aa8c", - "deployment/device-edge-vps-backhaul-v1.json": - "d7bac0132f7940cf8d4c59f1ae0d627c4921b63fa4728c31d2f95cfa35f21cf5", - }, - "relay": { - "vps/config/nftables-relay.conf": - "497485a2fb1b79faa95eb67ca30a89a3b65c3d4d8ddcb14cd1dc9acfeb2bb6f2", - "vps/systemd/nodedc-b2-relay.service": - "12a927a4cb42016229ac438f6e75969e1bf015037f2b5b0a60ab0591bdf424d2", - "services/device-edge-relay/src/runtime.mjs": - "21e83678980aa61127bf9f3d77982dd485c4aaae208c43818db7bb1cc150b83a", - "services/device-edge-relay/src/server.mjs": - "1b99ec944f1d3fbadded045b159f08624e2829620cec39a97f6b4b8cdcd2be22", - "deployment/device-edge-vps-relay-v1.json": - "cb3c2fff4878021783efef4f4a4d1ec31c8e3d8e6325fe33657fec22bec20165", - }, - "core-channel": { - "packages/device-protocol-contract/package.json": - "19d0d07da0341e8c2e8d3485400767566b18f6270245f0e9a9681c95013ba3b7", - "packages/device-protocol-contract/src/index.mjs": - "21a8b2b85a807899f946387c7976eaffcdc438b7db5e3d41fc3930f4437f0ec7", - "packages/device-edge-channel-contract/package.json": - "57d5349b5dcef2cacd4f3e4fad010359a65d59f5f903eff07d89f67c497f97c0", - "packages/device-edge-channel-contract/src/index.mjs": - "09d45e6104779212605fed650544794aafe1c2b1ab66f33f07bef4d92f430c80", - "services/device-edge-channel/package.json": - "bdf502be43b62bdd6db05b022a532d93ba954277ac5143d6058d2f27f6a2e9d2", - "services/device-edge-channel/src/runtime.mjs": - "74f8f8dbaf144fed8443e83af1ae116f1465e5378f839e92d8b25f55655893ec", - "services/device-edge-channel/src/server.mjs": - "a82057218bb368ab926404f90a19cc17c0359b57dc890f38a1324ab8c497c17b", - "vps/config/nftables-core-channel.conf": - "075b47ad2972161cac4d91cf77afb82eb078067ef50a5f9a933a13159af8bf48", - "vps/systemd/nodedc-device-edge-channel.service": - "a677598a1d1fa66cdc577ef8bb862756abd7725e62b662b2106f01780f59a767", - "deployment/device-edge-vps-core-channel-v1.json": - "ecbd66404d197f6d939736bbc1c28cc1e7091781639ffb78d2199a8db2b3ccd2", - }, - "tailscale-retirement": { - "deployment/device-edge-vps-tailscale-retirement-v1.json": - "bbe11e8cf4103f44ae7888b4d3f3dde7015eeaef8ea5a7c4c00427a7b85f8e33", - }, - "tracker-ingress": { - "packages/device-adapter-runtime/package.json": - "293ee6010f255c511df40b34cf8019fcd0d1b06babe30761c2a842c7da7af778", - "packages/device-adapter-runtime/src/index.mjs": - "199fcdd775d1ea15ddc9f85ab24fc2c165bb7757008de26749f1b7e84481dfeb", - "packages/device-adapter-catalog/package.json": - "60dbfc551ca49719e0851755058c159968411c490621eac0bf96ca76bf8b78d1", - "packages/device-adapter-catalog/src/index.mjs": - "614749e763c0624bf0de7905343632d3edb4e026316f427a2f52ce12c4225dca", - "packages/arusnavi-b2-adapter/package.json": - "136fc997f5d6757ba81d370f03037e0a93fd4750dc624def1af593e7ba8b9098", - "packages/arusnavi-b2-adapter/src/index.mjs": - "c5d95391438dfff1f1c12396b2e919effca9003a4a4a3eb3b828be42790f6173", - "services/device-gateway/src/runtime.mjs": - "bf0697674ed150d5e043e19e31fa99a242bb5dcc7d184e7adaccd883a22c5e5e", - "vps/edge-process/device-edge-runtime.mjs": - "7616e894e55b2579de7c2652589baf3cd3804dc0ec8af69661eadac1098979e9", - "vps/config/nftables-tracker-ingress.conf": - "6a6a76a02e5908118104a9a54a9e41b5a7e04476418efdb1485e39f0ee7c995c", - "vps/systemd/nodedc-device-edge-runtime.service": - "7e639efb66eee891da85b2976353715d942bd8aa381ddc1383446debf279817b", - "deployment/device-edge-vps-tracker-ingress-v1.json": - "faecfa0317ad0e3d102c3636c0547b65257b930e9b48f34d8679c00ceb3d323b", - }, - "command-transport": { - "packages/device-edge-channel-contract/package.json": - "57d5349b5dcef2cacd4f3e4fad010359a65d59f5f903eff07d89f67c497f97c0", - "packages/device-edge-channel-contract/src/index.mjs": - "a49bfe31476a08f8a912c10c82b982fae28a29e2c0eb5828ced390b01f3a10bc", - "services/device-edge-channel/package.json": - "bdf502be43b62bdd6db05b022a532d93ba954277ac5143d6058d2f27f6a2e9d2", - "services/device-edge-channel/src/runtime.mjs": - "39579d85edf50be07573873a1f59045f62a74469c85588db1fd4e5151b3b778d", - "services/device-edge-channel/src/server.mjs": - "a82057218bb368ab926404f90a19cc17c0359b57dc890f38a1324ab8c497c17b", - "packages/device-adapter-runtime/package.json": - "293ee6010f255c511df40b34cf8019fcd0d1b06babe30761c2a842c7da7af778", - "packages/device-adapter-runtime/src/index.mjs": - "199fcdd775d1ea15ddc9f85ab24fc2c165bb7757008de26749f1b7e84481dfeb", - "packages/device-adapter-catalog/package.json": - "60dbfc551ca49719e0851755058c159968411c490621eac0bf96ca76bf8b78d1", - "packages/device-adapter-catalog/src/index.mjs": - "614749e763c0624bf0de7905343632d3edb4e026316f427a2f52ce12c4225dca", - "packages/arusnavi-b2-adapter/package.json": - "136fc997f5d6757ba81d370f03037e0a93fd4750dc624def1af593e7ba8b9098", - "packages/arusnavi-b2-adapter/src/index.mjs": - "790060720639e3835bbf60fb2bdc67172a7dac79a8f8e4bf1558ab51ed130c91", - "services/device-gateway/src/runtime.mjs": - "f1898baef2a964ee04adc55f7e26ae8a637fcc61701cf28d7cf4b3bda1579b25", - "vps/edge-process/device-edge-runtime.mjs": - "4443876d4652de5d953840896acccec93119f9ec642b5d5cdaeaac19a2022017", - "deployment/device-edge-vps-command-transport-v1.json": - "971166143fe954b9c5043cce9a464d17efbc87933da1405b2517a4693a7bed09", - }, -} - -# Exact immutable baselines from terminally accepted predecessor artifacts. -# This is intentionally keyed by both patch id and artifact digest so a later -# artifact cannot inherit compatibility from a merely similar deployment. -ACCEPTED_PREDECESSOR_FILE_SHA256 = { - "foundation": { - ( - "device-edge-vps-foundation-20260806-003", - "1be852f144e9f0fea32af70bebd07a2607b6a1818825094bd4c1b4062064716a", - ): { - "vps/config/00-nodedc-b2-vps.conf": - "cc94d0579f85d0af9746b9ce760bc72980f4a22fb59027e1f5f9c7bf3aaebd64", - "vps/config/nftables-foundation.conf": - "4d44f902d8d98d1aa8506fca9d9582e700f6424def2b1d667ab2cd5a5ee84934", - "deployment/device-edge-vps-foundation-v1.json": - "317c98b42520fff3238275908482de7aa611b4ee41c6b1f8062abd2730ab072a", - }, - }, -} - - -class DeployError(RuntimeError): - pass - - -def die(message: str) -> None: - raise DeployError(message) - - -def run(command, *, check=True, capture=True, timeout=180, cwd=None): - result = subprocess.run( - [str(value) for value in command], - check=False, - capture_output=capture, - text=True, - timeout=timeout, - cwd=str(cwd) if cwd else None, - ) - if check and result.returncode != 0: - detail = (result.stderr or result.stdout or "command failed").strip() - die(f"command failed: {command[0]}: {detail}") - return result - - -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def assert_root(): - if os.geteuid() != 0: - die("nodedc-b2-vps-deploy must run as root") - - -def assert_regular_nonsymlink(path: Path, label: str): - try: - path_stat = path.lstat() - except FileNotFoundError: - die(f"{label} is missing") - if path.is_symlink() or not path.is_file(): - die(f"{label} must be a regular non-symlink file") - return path_stat - - -def assert_directory_nonsymlink(path: Path, label: str): - try: - path_stat = path.lstat() - except FileNotFoundError: - die(f"{label} is missing") - if path.is_symlink() or not path.is_dir(): - die(f"{label} must be a non-symlink directory") - return path_stat - - -def assert_executable_command_path(path: Path, label: str): - try: - resolved = path.resolve(strict=True) - resolved_stat = resolved.stat() - except (FileNotFoundError, OSError, RuntimeError): - die(f"{label} is missing or has an invalid symlink chain") - if not resolved.is_file(): - die(f"{label} must resolve to a regular file") - if not (resolved_stat.st_mode & 0o111): - die(f"{label} is not executable") - return resolved - - -def parse_manifest(raw: str): - values = {} - for line in raw.splitlines(): - if not line or "=" not in line: - die("artifact manifest is malformed") - key, value = line.split("=", 1) - if key in values or key not in {"id", "component", "type"}: - die("artifact manifest key set is invalid") - values[key] = value - if set(values) != {"id", "component", "type"}: - die("artifact manifest key set is incomplete") - if not PATCH_ID_RE.fullmatch(values["id"]): - die("artifact patch id is invalid") - if values["component"] != COMPONENT or values["type"] != ARTIFACT_TYPE: - die("artifact component/type mismatch") - return values - - -def safe_tar_member(member: tarfile.TarInfo): - path = PurePosixPath(member.name) - if path.is_absolute() or ".." in path.parts or not path.parts: - die("artifact contains an unsafe path") - if not (member.isfile() or member.isdir()): - die("artifact contains a non-file/non-directory member") - lowered = {part.lower() for part in path.parts} - if any( - part.startswith(".env") - or part in { - ".git", - "node_modules", - "secrets", - "keys", - "trust", - "runtime", - "logs", - "uploads", - } - for part in lowered - ): - die("artifact contains a forbidden boundary") - if any(part.startswith("._") for part in path.parts): - die("artifact contains AppleDouble metadata") - - -def phase_from_entries(entries): - for phase, expected in PHASE_ENTRIES.items(): - if tuple(entries) == expected: - return phase - die("VPS artifact file selection mismatch") - - -def validate_payload(payload: Path, phase: str): - actual = { - path.relative_to(payload).as_posix(): sha256_file(path) - for path in payload.rglob("*") - if path.is_file() - } - if actual != PHASE_FILE_SHA256[phase]: - die(f"VPS {phase} payload digest set mismatch") - descriptor = json.loads( - ( - payload - / f"deployment/device-edge-vps-{phase}-v1.json" - ).read_text(encoding="utf-8") - ) - if ( - descriptor.get("component") != COMPONENT - or descriptor.get("runtimeHost") != RUNTIME_HOST - or descriptor.get("commandTransport") - != ("typed-service-ping-v1" if phase == "command-transport" else "disabled") - or not str(descriptor.get("gelios", "")).startswith("untouched") - or not descriptor.get("rollback") - ): - die(f"VPS {phase} descriptor mismatch") - return descriptor - - -def load_artifact(artifact: Path, extraction_root: Path): - artifact = artifact.resolve(strict=True) - if artifact.parent != INBOX_ROOT.resolve(strict=True): - die("artifact must be an explicit file in the VPS inbox") - assert_regular_nonsymlink(artifact, "artifact") - if artifact.suffix != ".tgz" or artifact.stat().st_size > MAX_ARTIFACT_BYTES: - die("artifact extension/size rejected") - - seen = set() - with tarfile.open(artifact, "r:gz") as archive: - for member in archive.getmembers(): - safe_tar_member(member) - if member.name in seen: - die("artifact contains duplicate members") - seen.add(member.name) - required = {"manifest.env", "files.txt", "payload"} - if not required.issubset(seen): - die("artifact top-level contract is incomplete") - if any(name.split("/", 1)[0] not in required for name in seen): - die("artifact contains an unexpected top-level member") - archive.extractall(extraction_root, filter="data") - - manifest = parse_manifest( - (extraction_root / "manifest.env").read_text(encoding="utf-8") - ) - entries = tuple( - line - for line in (extraction_root / "files.txt") - .read_text(encoding="utf-8") - .splitlines() - if line - ) - if len(entries) != len(set(entries)): - die("artifact files list contains duplicates") - phase = phase_from_entries(entries) - payload = extraction_root / "payload" - descriptor = validate_payload(payload, phase) - return { - "manifest": manifest, - "entries": entries, - "phase": phase, - "payload": payload, - "descriptor": descriptor, - "sha256": sha256_file(artifact), - "artifact": artifact, - } - - -def journal_records(path: Path): - if not path.exists(): - return [] - records = [] - for line in path.read_text(encoding="utf-8").splitlines(): - if not line: - continue - try: - records.append(json.loads(line)) - except json.JSONDecodeError: - die(f"journal is malformed: {path}") - return records - - -def assert_new_identity(patch_id: str, artifact_sha256: str): - records = journal_records(APPLIED_JOURNAL) + journal_records(FAILED_JOURNAL) - if any(record.get("patch") == patch_id for record in records): - die("VPS patch id is terminally recorded") - if any(record.get("sha256") == artifact_sha256 for record in records): - die("VPS artifact digest is terminally recorded") - - -def applied_phase_record(phase: str): - matches = [ - record - for record in journal_records(APPLIED_JOURNAL) - if record.get("phase") == phase and record.get("status") == "ok" - ] - if len(matches) != 1: - die(f"exactly one accepted {phase} predecessor is required") - return matches[0] - - -def assert_host_identity(): - if socket.gethostname() != RUNTIME_HOST: - die("VPS runtime hostname mismatch") - os_release = Path("/etc/os-release").read_text(encoding="utf-8") - if 'VERSION_ID="24.04"' not in os_release or 'ID=ubuntu' not in os_release: - die("VPS operating system predecessor mismatch") - address = run(["/usr/sbin/ip", "-4", "-brief", "address", "show", "dev", "eth0"]).stdout - if f"{PUBLIC_IPV4}/32" not in address or "UP" not in address: - die("VPS public interface predecessor mismatch") - - -def port_is_open(host: str, port: int, timeout=1.5): - try: - connection = socket.create_connection((host, port), timeout=timeout) - except OSError: - return False - connection.close() - return True - - -def assert_port_closed(port: int): - if port_is_open("127.0.0.1", port): - die(f"unexpected loopback listener is open: {port}") - - -def assert_management_key(): - result = run(["/usr/bin/ssh-keygen", "-lf", "/root/.ssh/authorized_keys"]) - if MANAGEMENT_KEY_FINGERPRINT not in result.stdout: - die("verified Mac management key is missing") - - -def phase_file_sha256(phase: str): - expected = dict(PHASE_FILE_SHA256[phase]) - accepted = [ - record - for record in journal_records(APPLIED_JOURNAL) - if record.get("phase") == phase and record.get("status") == "ok" - ] - if len(accepted) == 1: - record = accepted[0] - identity = (record.get("patch"), record.get("sha256")) - expected.update( - ACCEPTED_PREDECESSOR_FILE_SHA256.get(phase, {}).get(identity, {}) - ) - return expected - - -def source_file_state(phase: str): - expected = phase_file_sha256(phase) - actual = {} - for relative, digest in expected.items(): - path = LIVE_ROOT / relative - assert_regular_nonsymlink(path, f"installed {phase} file {relative}") - actual[relative] = sha256_file(path) - if actual[relative] != digest: - die(f"installed {phase} source drift: {relative}") - return actual - - -def assert_runtime_reconciliation_predecessor(foundation_record): - if ( - foundation_record.get("patch") != "device-edge-vps-foundation-20260806-003" - or foundation_record.get("sha256") - != "1be852f144e9f0fea32af70bebd07a2607b6a1818825094bd4c1b4062064716a" - ): - die("runtime reconciliation foundation predecessor mismatch") - failed = [ - record - for record in journal_records(FAILED_JOURNAL) - if ( - record.get("patch") == "device-edge-vps-core-channel-20260812-001" - and record.get("phase") == "core-channel" - and record.get("sha256") - == "c199980e5754cf3e874a09f42e408fc88885cdbf7c872eac36c0ab768a7bab00" - and record.get("status") == "failed" - and record.get("rollback") == "ok" - and record.get("error") == "PermissionError" - ) - ] - if len(failed) != 1: - die("runtime reconciliation failed Core channel predecessor mismatch") - for path, digest in ( - (NODE_BIN, NODE_BIN_SHA256), - (TAILSCALE_BIN, TAILSCALE_BIN_SHA256), - (TAILSCALED_BIN, TAILSCALED_BIN_SHA256), - ): - state = assert_regular_nonsymlink(path, f"runtime reconciliation {path.name}") - if ( - state.st_uid != 0 - or state.st_gid != 0 - or (state.st_mode & 0o777) != 0o644 - or sha256_file(path) != digest - ): - die(f"runtime reconciliation binary predecessor mismatch: {path.name}") - if not service_active("nodedc-b2-tailscaled.service"): - die("runtime reconciliation Tailscale predecessor is inactive") - if service_active("nodedc-device-edge-channel.service") or user_exists(CHANNEL_USER): - die("runtime reconciliation Core channel predecessor is not absent") - assert_channel_trust(require_runtime_owner=False) - for port in (CHANNEL_HEALTH_PORT, CHANNEL_PUBLIC_PORT, 9921): - assert_port_closed(port) - nft = run(["/usr/sbin/nft", "list", "table", "inet", "nodedc_b2_vps"]).stdout - if ( - "policy drop" not in nft - or "tcp dport 22" not in nft - or "tcp dport 443" in nft - or "tcp dport 9921" in nft - ): - die("runtime reconciliation firewall predecessor mismatch") - - -def systemctl(*args, check=True): - return run(["/usr/bin/systemctl", *args], check=check) - - -def service_active(name: str): - return systemctl("is-active", name, check=False).returncode == 0 - - -def tailscale_status(): - result = run( - [str(TAILSCALE_BIN), f"--socket={TAILSCALE_SOCKET}", "status", "--json"], - check=False, - ) - if result.returncode != 0: - return {"BackendState": "Unavailable", "Error": (result.stderr or result.stdout).strip()} - try: - return json.loads(result.stdout) - except json.JSONDecodeError: - die("Tailscale status JSON is invalid") - - -def current_phase_preflight(phase: str): - assert_host_identity() - assert_management_key() - if phase == "foundation": - if LIVE_ROOT.exists() or TRUST_ROOT.exists(): - die("VPS foundation live/runtime root already exists") - if any(path.exists() for path in (SSHD_DROPIN, TAILSCALE_UNIT, BACKHAUL_UNIT, RELAY_UNIT)): - die("VPS foundation system path already exists") - for port in (1055, 18221, CHANNEL_HEALTH_PORT, 19921, 9921, CHANNEL_PUBLIC_PORT): - assert_port_closed(port) - return {"predecessor": "clean-ubuntu-24.04.4"} - - foundation_record = applied_phase_record("foundation") - source_file_state("foundation") - if phase == "runtime-reconciliation": - assert_runtime_reconciliation_predecessor(foundation_record) - return { - "predecessor": "failed-core-channel-001-rollback-runtime-mode-drift", - } - if phase == "tracker-ingress": - retirement_record = applied_phase_record("tailscale-retirement") - if ( - retirement_record.get("patch") - != TAILSCALE_RETIREMENT_ACCEPTED_PATCH - or retirement_record.get("sha256") - != TAILSCALE_RETIREMENT_ACCEPTED_SHA256 - ): - die("VPS tracker ingress Tailscale retirement predecessor mismatch") - validate_tailscale_retirement_runtime() - if (LIVE_ROOT / TRACKER_INGRESS_ENTRIES[-1]).exists(): - die("VPS tracker ingress target path already exists") - return {"predecessor": "accepted-tailscale-retirement-011"} - if phase == "command-transport": - tracker_record = applied_phase_record("tracker-ingress") - if ( - tracker_record.get("patch") != TRACKER_INGRESS_ACCEPTED_PATCH - or tracker_record.get("sha256") != TRACKER_INGRESS_ACCEPTED_SHA256 - ): - die("VPS command transport tracker ingress predecessor mismatch") - source_file_state("tracker-ingress") - validate_tracker_ingress_runtime() - if (LIVE_ROOT / COMMAND_TRANSPORT_ENTRIES[-1]).exists(): - die("VPS command transport target path already exists") - return {"predecessor": "accepted-tracker-ingress-012"} - validate_foundation_runtime( - require_running_tailnet=phase in {"backhaul", "relay"}, - expected_key_user=BACKHAUL_USER if phase == "relay" else SERVICE_USER, - ) - if phase == "core-channel": - if service_active("nodedc-b2-backhaul.service"): - die("frozen VPS backhaul service must remain inactive") - if service_active("nodedc-b2-relay.service"): - die("frozen VPS relay service must remain inactive") - if CHANNEL_UNIT.exists() or (LIVE_ROOT / CORE_CHANNEL_ENTRIES[-1]).exists(): - die("VPS Core channel target path already exists") - if user_exists(CHANNEL_USER): - die("VPS Core channel runtime user already exists") - assert_channel_trust(require_runtime_owner=False) - for port in (CHANNEL_HEALTH_PORT, CHANNEL_PUBLIC_PORT, 9921): - assert_port_closed(port) - return {"predecessor": "accepted-foundation-closed-channel"} - if phase == "tailscale-retirement": - core_record = applied_phase_record("core-channel") - if ( - core_record.get("patch") != CORE_CHANNEL_ACCEPTED_PATCH - or core_record.get("sha256") != CORE_CHANNEL_ACCEPTED_SHA256 - ): - die("VPS Tailscale retirement Core channel predecessor mismatch") - source_file_state("core-channel") - validate_core_channel_runtime() - if (LIVE_ROOT / TAILSCALE_RETIREMENT_ENTRIES[0]).exists(): - die("VPS Tailscale retirement target path already exists") - return {"predecessor": "accepted-core-channel-010-with-live-tailnet"} - if phase == "backhaul": - for tool in (Path("/usr/bin/ssh"), Path("/usr/bin/nc")): - assert_executable_command_path( - tool, - f"VPS backhaul prerequisite {tool}", - ) - if BACKHAUL_UNIT.exists() or (LIVE_ROOT / BACKHAUL_ENTRIES[-1]).exists(): - die("VPS backhaul target path already exists") - if user_exists(BACKHAUL_USER): - die("VPS backhaul runtime user already exists") - assert_port_closed(19921) - return {"predecessor": "accepted-foundation"} - - applied_phase_record("backhaul") - source_file_state("backhaul") - validate_backhaul_runtime() - if RELAY_UNIT.exists() or (LIVE_ROOT / RELAY_ENTRIES[-1]).exists(): - die("VPS relay target path already exists") - if user_exists(RELAY_USER): - die("VPS relay runtime user already exists") - assert_port_closed(9921) - assert_port_closed(18221) - return {"predecessor": "accepted-backhaul"} - - -def preflight(loaded): - if loaded["phase"] in SUPERSEDED_TRANSPORT_PHASES: - die("vps_initiated_transport_frozen:ADR-0001") - assert_new_identity(loaded["manifest"]["id"], loaded["sha256"]) - return current_phase_preflight(loaded["phase"]) - - -def ensure_state_directories(): - for path in ( - INBOX_ROOT, - APPLIED_ROOT, - FAILED_ROOT, - BACKUP_ROOT, - APPLIED_JOURNAL.parent, - ): - path.mkdir(parents=True, exist_ok=True, mode=0o750) - os.chmod(path, 0o750) - - -def acquire_lock(): - ensure_state_directories() - try: - descriptor = os.open(DEPLOY_LOCK, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - except FileExistsError: - die("VPS deploy lock is present") - os.write(descriptor, f"pid={os.getpid()}\n".encode()) - os.close(descriptor) - - -def release_lock(): - try: - DEPLOY_LOCK.unlink() - except FileNotFoundError: - pass - - -def backup_targets_for_phase(phase: str): - common = [LIVE_ROOT / entry for entry in PHASE_ENTRIES[phase]] - if phase == "foundation": - return common + [SSHD_DROPIN, NFTABLES_CONFIG, TAILSCALE_UNIT] - if phase == "runtime-reconciliation": - return common + [NODE_BIN, TAILSCALE_BIN, TAILSCALED_BIN] - if phase == "backhaul": - return common + [BACKHAUL_UNIT, BACKHAUL_KNOWN_HOSTS] - if phase == "core-channel": - return common + [CHANNEL_UNIT, NFTABLES_CONFIG, CHANNEL_TRUST_ROOT] - if phase == "tailscale-retirement": - return common + [ - TAILSCALE_UNIT, - TAILSCALE_STATE.parent, - TAILSCALE_BIN.parent, - TRUST_ROOT, - ] - if phase == "tracker-ingress": - return common + [CHANNEL_UNIT, NFTABLES_CONFIG] - if phase == "command-transport": - return common + [CHANNEL_UNIT, NFTABLES_CONFIG] - return common + [RELAY_UNIT, NFTABLES_CONFIG] - - -def path_backup_relative(path: Path): - return path.as_posix().lstrip("/") - - -def create_backup(patch_id: str, phase: str): - timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") - backup_id = f"{patch_id}-{timestamp}" - backup = BACKUP_ROOT / backup_id - backup.mkdir(parents=False, mode=0o750) - present = [] - absent = [] - for source in backup_targets_for_phase(phase): - relative = path_backup_relative(source) - target = backup / "filesystem" / relative - if not source.exists() and not source.is_symlink(): - absent.append(relative) - continue - if source.is_symlink(): - die(f"backup target symlink rejected: {source}") - present.append(relative) - target.parent.mkdir(parents=True, exist_ok=True) - if source.is_dir(): - shutil.copytree(source, target, symlinks=False) - else: - shutil.copy2(source, target, follow_symlinks=False) - nft_rules = run(["/usr/sbin/nft", "list", "ruleset"], check=False).stdout - (backup / "nft-ruleset-before.nft").write_text(nft_rules, encoding="utf-8") - metadata = { - "schemaVersion": "nodedc.device-edge-vps.backup.v1", - "patch": patch_id, - "phase": phase, - "present": present, - "absent": absent, - "serviceUserExisted": user_exists(), - "serviceUsersExisted": { - name: user_exists(name) - for name in (SERVICE_USER, BACKHAUL_USER, RELAY_USER, CHANNEL_USER) - }, - "services": { - name: { - "active": service_active(name), - "enabled": systemctl("is-enabled", name, check=False).returncode == 0, - } - for name in ( - "nftables.service", - "ufw.service", - "fail2ban.service", - "nodedc-b2-tailscaled.service", - "nodedc-b2-backhaul.service", - "nodedc-b2-relay.service", - "nodedc-device-edge-channel.service", - ) - }, - } - (backup / "backup.json").write_text( - json.dumps(metadata, sort_keys=True, indent=2) + "\n", - encoding="utf-8", - ) - return backup_id, backup - - -def publish_payload(payload: Path, entries): - for relative in entries: - source = payload / relative - target = LIVE_ROOT / relative - if target.exists() or target.is_symlink(): - if target.is_symlink(): - die(f"live payload target symlink rejected: {target}") - if target.is_dir(): - shutil.rmtree(target) - else: - target.unlink() - target.parent.mkdir(parents=True, exist_ok=True) - if source.is_dir(): - shutil.copytree(source, target, symlinks=False) - else: - shutil.copy2(source, target, follow_symlinks=False) - published = [target] - if target.is_dir(): - published.extend(target.rglob("*")) - for path in published: - if path.is_symlink(): - die(f"published live source contains symlink: {path}") - if path.is_dir(): - os.chmod(path, 0o755) - else: - os.chmod(path, 0o644) - os.chown(LIVE_ROOT, 0, 0) - - -def restore_backup(backup: Path, phase: str): - metadata = json.loads((backup / "backup.json").read_text(encoding="utf-8")) - if metadata.get("phase") != phase: - die("rollback backup phase mismatch") - targets = backup_targets_for_phase(phase) - for target in targets: - if target.exists() or target.is_symlink(): - if target.is_symlink() or target.is_file(): - target.unlink() - else: - shutil.rmtree(target) - for relative in metadata["present"]: - source = backup / "filesystem" / relative - target = Path("/") / relative - target.parent.mkdir(parents=True, exist_ok=True) - if source.is_dir(): - shutil.copytree(source, target, symlinks=False) - else: - shutil.copy2(source, target, follow_symlinks=False) - - -def install_file(source: Path, target: Path, mode=0o644): - assert_regular_nonsymlink(source, f"install source {source}") - target.parent.mkdir(parents=True, exist_ok=True) - temporary = target.with_name(f".{target.name}.installing") - if temporary.exists() or temporary.is_symlink(): - die(f"install staging path exists: {temporary}") - shutil.copy2(source, temporary, follow_symlinks=False) - os.chown(temporary, 0, 0) - os.chmod(temporary, mode) - os.replace(temporary, target) - - -def user_exists(name=SERVICE_USER): - try: - pwd.getpwnam(name) - return True - except KeyError: - return False - - -def ensure_service_user(name=SERVICE_USER, home_dir="/var/lib/nodedc-b2-vps"): - if not user_exists(name): - run([ - "/usr/sbin/useradd", - "--system", - "--user-group", - "--home-dir", - home_dir, - "--shell", - "/usr/sbin/nologin", - name, - ]) - account = pwd.getpwnam(name) - if account.pw_shell != "/usr/sbin/nologin": - die("VPS service user shell mismatch") - return account - - -def extract_vendor_binary(archive: Path, member_name: str, target: Path, mode=0o755): - with tarfile.open(archive, "r:*") as package: - try: - member = package.getmember(member_name) - except KeyError: - die(f"vendor binary member missing: {member_name}") - if not member.isfile() or member.issym() or member.islnk(): - die(f"vendor binary member unsafe: {member_name}") - source = package.extractfile(member) - if source is None: - die(f"vendor binary cannot be read: {member_name}") - target.parent.mkdir(parents=True, exist_ok=True) - temporary = target.with_name(f".{target.name}.installing") - with temporary.open("wb") as handle: - shutil.copyfileobj(source, handle) - os.chown(temporary, 0, 0) - os.chmod(temporary, mode) - os.replace(temporary, target) - - -def ensure_backhaul_key(account): - TRUST_ROOT.mkdir(parents=True, exist_ok=True, mode=0o700) - os.chown(TRUST_ROOT, account.pw_uid, account.pw_gid) - os.chmod(TRUST_ROOT, 0o700) - if not BACKHAUL_PRIVATE_KEY.exists(): - run([ - "/usr/bin/ssh-keygen", - "-q", - "-t", - "ed25519", - "-N", - "", - "-C", - BACKHAUL_KEY_COMMENT, - "-f", - str(BACKHAUL_PRIVATE_KEY), - ]) - for path, mode in ((BACKHAUL_PRIVATE_KEY, 0o400), (BACKHAUL_PUBLIC_KEY, 0o444)): - stat_result = assert_regular_nonsymlink(path, f"backhaul key {path.name}") - if stat_result.st_size > 2048: - die("backhaul key file is unexpectedly large") - os.chown(path, account.pw_uid, account.pw_gid) - os.chmod(path, mode) - fingerprint = run(["/usr/bin/ssh-keygen", "-lf", str(BACKHAUL_PUBLIC_KEY)]).stdout.strip() - if "ED25519" not in fingerprint or BACKHAUL_KEY_COMMENT not in fingerprint: - die("backhaul public key fingerprint mismatch") - return fingerprint - - -def assign_backhaul_trust(account): - assert_directory_nonsymlink(TRUST_ROOT, "backhaul trust root") - os.chown(TRUST_ROOT, account.pw_uid, account.pw_gid) - os.chmod(TRUST_ROOT, 0o700) - for path, mode in ((BACKHAUL_PRIVATE_KEY, 0o400), (BACKHAUL_PUBLIC_KEY, 0o444)): - assert_regular_nonsymlink(path, f"backhaul trust {path.name}") - os.chown(path, account.pw_uid, account.pw_gid) - os.chmod(path, mode) - if BACKHAUL_KNOWN_HOSTS.exists(): - assert_regular_nonsymlink(BACKHAUL_KNOWN_HOSTS, "backhaul known_hosts") - os.chown(BACKHAUL_KNOWN_HOSTS, account.pw_uid, account.pw_gid) - os.chmod(BACKHAUL_KNOWN_HOSTS, 0o444) - - -def certificate_fingerprint(path: Path): - output = run([ - "/usr/bin/openssl", - "x509", - "-in", - str(path), - "-noout", - "-fingerprint", - "-sha256", - ]).stdout.strip() - prefix = "sha256 Fingerprint=" - if not output.lower().startswith(prefix.lower()): - die("VPS certificate fingerprint output is invalid") - fingerprint = output.split("=", 1)[1].upper() - if not re.fullmatch(r"(?:[A-F0-9]{2}:){31}[A-F0-9]{2}", fingerprint): - die("VPS certificate fingerprint is invalid") - return fingerprint - - -def assert_channel_trust(*, require_runtime_owner: bool): - directory = assert_directory_nonsymlink( - CHANNEL_TRUST_ROOT, - "Core channel trust root", - ) - expected_uid = 0 - expected_gid = 0 - if require_runtime_owner: - account = pwd.getpwnam(CHANNEL_USER) - expected_uid = account.pw_uid - expected_gid = account.pw_gid - if ( - directory.st_uid != expected_uid - or directory.st_gid != expected_gid - or (directory.st_mode & 0o777) != 0o700 - ): - die("Core channel trust root ownership/mode mismatch") - - for path, maximum, mode in ( - (CHANNEL_PRIVATE_KEY, 32 * 1024, 0o400), - (CHANNEL_CERTIFICATE, 32 * 1024, 0o444), - (CHANNEL_CORE_CERTIFICATE, 32 * 1024, 0o444), - (CHANNEL_RUNTIME_CONFIG, 32 * 1024, 0o444), - ): - state = assert_regular_nonsymlink(path, f"Core channel trust {path.name}") - if state.st_size < 1 or state.st_size > maximum: - die(f"Core channel trust file size mismatch: {path.name}") - if ( - state.st_uid != expected_uid - or state.st_gid != expected_gid - or (state.st_mode & 0o777) != mode - ): - die(f"Core channel trust file ownership/mode mismatch: {path.name}") - - private_text = CHANNEL_PRIVATE_KEY.read_text(encoding="ascii") - public_text = ( - CHANNEL_CERTIFICATE.read_text(encoding="ascii") - + CHANNEL_CORE_CERTIFICATE.read_text(encoding="ascii") - ) - if "PRIVATE KEY" not in private_text or "PRIVATE KEY" in public_text: - die("Core channel private/public trust boundary mismatch") - for path in ( - CHANNEL_CERTIFICATE, - CHANNEL_CORE_CERTIFICATE, - ): - if path.read_text(encoding="ascii").count("-----BEGIN CERTIFICATE-----") != 1: - die(f"Core channel certificate cardinality mismatch: {path.name}") - - run(["/usr/bin/openssl", "pkey", "-in", str(CHANNEL_PRIVATE_KEY), "-check", "-noout"]) - run(["/usr/bin/openssl", "x509", "-in", str(CHANNEL_CERTIFICATE), "-noout"]) - run(["/usr/bin/openssl", "x509", "-in", str(CHANNEL_CORE_CERTIFICATE), "-noout"]) - run([ - "/usr/bin/openssl", "verify", "-purpose", "sslserver", - "-CAfile", str(CHANNEL_CERTIFICATE), str(CHANNEL_CERTIFICATE), - ]) - run([ - "/usr/bin/openssl", "verify", "-purpose", "sslclient", - "-CAfile", str(CHANNEL_CORE_CERTIFICATE), str(CHANNEL_CORE_CERTIFICATE), - ]) - run([ - "/usr/bin/openssl", "x509", "-in", str(CHANNEL_CERTIFICATE), - "-noout", "-checkip", PUBLIC_IPV4, - ]) - certificate_key = run([ - "/usr/bin/openssl", "x509", "-in", str(CHANNEL_CERTIFICATE), "-pubkey", "-noout", - ]).stdout.strip() - private_key = run([ - "/usr/bin/openssl", "pkey", "-in", str(CHANNEL_PRIVATE_KEY), "-pubout", - ]).stdout.strip() - if certificate_key != private_key: - die("Core channel Edge certificate/private key mismatch") - - try: - document = json.loads(CHANNEL_RUNTIME_CONFIG.read_text(encoding="utf-8")) - except json.JSONDecodeError: - die("Core channel runtime configuration is invalid JSON") - if set(document) != { - "schemaVersion", - "edgeRegistrationId", - "channelGeneration", - "trustGeneration", - "allowedCoreFingerprints", - }: - die("Core channel runtime configuration key set mismatch") - if document.get("schemaVersion") != "nodedc.device-edge.channel-runtime.v1": - die("Core channel runtime configuration schema mismatch") - for key in ("edgeRegistrationId", "channelGeneration", "trustGeneration"): - if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", str(document.get(key, ""))): - die(f"Core channel runtime configuration ref mismatch: {key}") - fingerprints = document.get("allowedCoreFingerprints") - if ( - not isinstance(fingerprints, list) - or not 1 <= len(fingerprints) <= 2 - or len(fingerprints) != len(set(fingerprints)) - or any( - not isinstance(value, str) - or not re.fullmatch(r"(?:[A-F0-9]{2}:){31}[A-F0-9]{2}", value) - for value in fingerprints - ) - ): - die("Core channel Core identity allowlist mismatch") - if certificate_fingerprint(CHANNEL_CORE_CERTIFICATE) not in fingerprints: - die("Core channel Core trust fingerprint mismatch") - return document - - -def assign_channel_trust(account): - assert_channel_trust(require_runtime_owner=False) - os.chown(CHANNEL_TRUST_ROOT, account.pw_uid, account.pw_gid) - os.chmod(CHANNEL_TRUST_ROOT, 0o700) - for path, mode in ( - (CHANNEL_PRIVATE_KEY, 0o400), - (CHANNEL_CERTIFICATE, 0o444), - (CHANNEL_CORE_CERTIFICATE, 0o444), - (CHANNEL_RUNTIME_CONFIG, 0o444), - ): - os.chown(path, account.pw_uid, account.pw_gid) - os.chmod(path, mode) - return assert_channel_trust(require_runtime_owner=True) - - -def apply_nftables(source: Path): - install_file(source, NFTABLES_CONFIG, 0o644) - run(["/usr/sbin/nft", "-c", "-f", str(NFTABLES_CONFIG)]) - run(["/usr/sbin/nft", "-f", str(NFTABLES_CONFIG)]) - systemctl("enable", "nftables.service") - systemctl("disable", "ufw.service", check=False) - if systemctl("is-enabled", "fail2ban.service", check=False).returncode == 0: - systemctl("restart", "fail2ban.service") - - -def apply_foundation(payload: Path): - account = ensure_service_user() - extract_vendor_binary( - LIVE_ROOT / f"vendor/{NODE_ARCHIVE}", - f"node-v{NODE_VERSION}-linux-x64/bin/node", - NODE_BIN, - ) - extract_vendor_binary( - LIVE_ROOT / f"vendor/{TAILSCALE_ARCHIVE}", - f"tailscale_{TAILSCALE_VERSION}_amd64/tailscale", - TAILSCALE_BIN, - ) - extract_vendor_binary( - LIVE_ROOT / f"vendor/{TAILSCALE_ARCHIVE}", - f"tailscale_{TAILSCALE_VERSION}_amd64/tailscaled", - TAILSCALED_BIN, - ) - ensure_backhaul_key(account) - install_file(LIVE_ROOT / FOUNDATION_ENTRIES[0], SSHD_DROPIN, 0o644) - install_file(LIVE_ROOT / FOUNDATION_ENTRIES[2], TAILSCALE_UNIT, 0o644) - run(["/usr/sbin/sshd", "-t"]) - apply_nftables(LIVE_ROOT / FOUNDATION_ENTRIES[1]) - systemctl("daemon-reload") - systemctl("enable", "--now", "nodedc-b2-tailscaled.service") - systemctl("reload", "ssh.service") - validate_foundation_runtime(require_running_tailnet=False) - - -def apply_runtime_reconciliation(_payload: Path): - for path in (NODE_BIN, TAILSCALE_BIN, TAILSCALED_BIN): - os.chown(path, 0, 0) - os.chmod(path, 0o755) - validate_runtime_reconciliation() - - -def materialize_known_hosts(account): - line = ( - f"[{BACKHAUL_TARGET_IP}]:{BACKHAUL_TARGET_PORT} " - f"{BACKHAUL_TARGET_HOST_KEY}\n" - ) - TRUST_ROOT.mkdir(parents=True, exist_ok=True, mode=0o700) - temporary = BACKHAUL_KNOWN_HOSTS.with_name(".backhaul_known_hosts.installing") - temporary.write_text(line, encoding="ascii") - os.chown(temporary, account.pw_uid, account.pw_gid) - os.chmod(temporary, 0o444) - os.replace(temporary, BACKHAUL_KNOWN_HOSTS) - fingerprint = run(["/usr/bin/ssh-keygen", "-lf", str(BACKHAUL_KNOWN_HOSTS)]).stdout - if BACKHAUL_TARGET_FINGERPRINT not in fingerprint: - die("backhaul target host key fingerprint mismatch") - - -def apply_backhaul(payload: Path): - account = ensure_service_user(BACKHAUL_USER, "/var/lib/nodedc-b2-vps/backhaul") - assign_backhaul_trust(account) - materialize_known_hosts(account) - install_file(LIVE_ROOT / BACKHAUL_ENTRIES[1], BACKHAUL_UNIT, 0o644) - systemctl("daemon-reload") - systemctl("enable", "--now", "nodedc-b2-backhaul.service") - validate_backhaul_runtime() - - -def apply_relay(payload: Path): - ensure_service_user(RELAY_USER, "/var/lib/nodedc-b2-vps/relay") - install_file(LIVE_ROOT / RELAY_ENTRIES[1], RELAY_UNIT, 0o644) - apply_nftables(LIVE_ROOT / RELAY_ENTRIES[0]) - systemctl("daemon-reload") - systemctl("enable", "--now", "nodedc-b2-relay.service") - validate_relay_runtime() - - -def apply_core_channel(payload: Path): - account = ensure_service_user( - CHANNEL_USER, - "/var/lib/nodedc-b2-vps/channel-runtime", - ) - assign_channel_trust(account) - install_file(LIVE_ROOT / CORE_CHANNEL_ENTRIES[7], CHANNEL_UNIT, 0o644) - apply_nftables(LIVE_ROOT / CORE_CHANNEL_ENTRIES[6]) - systemctl("daemon-reload") - systemctl("enable", "--now", "nodedc-device-edge-channel.service") - validate_core_channel_runtime() - - -def apply_tailscale_retirement(_payload: Path): - # Remote machine deletion is intentionally post-deploy: doing a logout - # inside apply would invalidate the exact local state needed by automatic - # rollback. The accepted local boundary is nevertheless closed completely. - systemctl("disable", "--now", "nodedc-b2-tailscaled.service", check=False) - for path in ( - TAILSCALE_UNIT, - TAILSCALE_STATE.parent, - TAILSCALE_BIN.parent, - TRUST_ROOT, - ): - if path.is_symlink(): - die(f"VPS Tailscale retirement target symlink rejected: {path}") - if path.is_dir(): - shutil.rmtree(path) - elif path.exists(): - path.unlink() - systemctl("daemon-reload") - systemctl("reset-failed", "nodedc-b2-tailscaled.service", check=False) - validate_tailscale_retirement_runtime() - - -def apply_tracker_ingress(_payload: Path): - install_file( - LIVE_ROOT / TRACKER_INGRESS_ENTRIES[9], - CHANNEL_UNIT, - 0o644, - ) - apply_nftables(LIVE_ROOT / TRACKER_INGRESS_ENTRIES[8]) - systemctl("daemon-reload") - systemctl("restart", "nodedc-device-edge-channel.service") - validate_tracker_ingress_runtime() - - -def apply_command_transport(_payload: Path): - systemctl("daemon-reload") - systemctl("restart", "nodedc-device-edge-channel.service") - validate_command_transport_runtime() - - -def sshd_effective(): - return run(["/usr/sbin/sshd", "-T"]).stdout.lower() - - -def validate_foundation_runtime(*, require_running_tailnet: bool, expected_key_user=SERVICE_USER): - source_file_state("foundation") - if run([str(NODE_BIN), "--version"]).stdout.strip() != f"v{NODE_VERSION}": - die("Node runtime version mismatch") - tailscale_version = run([str(TAILSCALE_BIN), "version"]).stdout.splitlines()[0].strip() - if tailscale_version != TAILSCALE_VERSION: - die("Tailscale runtime version mismatch") - if not service_active("nodedc-b2-tailscaled.service"): - die("VPS tailscaled service is not active") - effective = sshd_effective() - for required in ( - "permitrootlogin without-password", - "passwordauthentication no", - "kbdinteractiveauthentication no", - "pubkeyauthentication yes", - "x11forwarding no", - "allowagentforwarding no", - "allowtcpforwarding no", - "gatewayports no", - "permittunnel no", - "permituserenvironment no", - "maxauthtries 3", - "logingracetime 20", - ): - if required not in effective: - die(f"VPS effective SSH boundary mismatch: {required}") - nft = run(["/usr/sbin/nft", "list", "table", "inet", "nodedc_b2_vps"]).stdout - if "policy drop" not in nft or "tcp dport 22" not in nft: - die("VPS foundation firewall contract mismatch") - if "tcp dport 9921" in nft: - die("VPS foundation unexpectedly opens B2 ingress") - for port in (18221, 19921, 9921): - assert_port_closed(port) - tailscale_account = pwd.getpwnam(SERVICE_USER) - if tailscale_account.pw_shell != "/usr/sbin/nologin": - die("VPS service user runtime mismatch") - account = pwd.getpwnam(expected_key_user) - if account.pw_shell != "/usr/sbin/nologin": - die("VPS backhaul credential owner shell mismatch") - trust_stat = assert_directory_nonsymlink( - TRUST_ROOT, - "runtime trust root", - ) - if trust_stat.st_uid != account.pw_uid or (trust_stat.st_mode & 0o777) != 0o700: - die("VPS backhaul trust root ownership/mode mismatch") - for path, mode in ((BACKHAUL_PRIVATE_KEY, 0o400), (BACKHAUL_PUBLIC_KEY, 0o444)): - path_stat = assert_regular_nonsymlink(path, f"runtime trust {path.name}") - if path_stat.st_uid != account.pw_uid or (path_stat.st_mode & 0o777) != mode: - die("VPS backhaul key ownership/mode mismatch") - status = tailscale_status() - state = status.get("BackendState") - if require_running_tailnet: - if state != "Running": - die(f"VPS Tailscale node is not enrolled/running: {state}") - self_state = status.get("Self") or {} - if self_state.get("HostName") != "nodedc-b2-vps": - die("VPS Tailscale node name mismatch") - if self_state.get("Online") is not True: - die("VPS Tailscale node is not online") - if sorted(self_state.get("Tags") or []) != [TAILSCALE_REQUIRED_TAG]: - die("VPS Tailscale service tag mismatch") - elif state not in {"NeedsLogin", "Stopped", "Running", "NoState", "Starting"}: - die(f"VPS Tailscale foundation state is unexpected: {state}") - return status - - -def validate_runtime_reconciliation(): - source_file_state("runtime-reconciliation") - for path, digest in ( - (NODE_BIN, NODE_BIN_SHA256), - (TAILSCALE_BIN, TAILSCALE_BIN_SHA256), - (TAILSCALED_BIN, TAILSCALED_BIN_SHA256), - ): - state = assert_regular_nonsymlink(path, f"reconciled runtime {path.name}") - if ( - state.st_uid != 0 - or state.st_gid != 0 - or (state.st_mode & 0o777) != 0o755 - or sha256_file(path) != digest - ): - die(f"reconciled runtime binary mismatch: {path.name}") - validate_foundation_runtime(require_running_tailnet=False) - for port in (CHANNEL_HEALTH_PORT, CHANNEL_PUBLIC_PORT, 9921): - assert_port_closed(port) - - -def validate_backhaul_runtime(): - validate_foundation_runtime( - require_running_tailnet=True, - expected_key_user=BACKHAUL_USER, - ) - source_file_state("backhaul") - if not service_active("nodedc-b2-backhaul.service"): - die("VPS backhaul service is not active") - if not port_is_open("127.0.0.1", 19921, timeout=5): - die("VPS backhaul local forward is unavailable") - assert_port_closed(9921) - assert_port_closed(18221) - account = pwd.getpwnam(BACKHAUL_USER) - tailscale_account = pwd.getpwnam(SERVICE_USER) - if account.pw_uid == tailscale_account.pw_uid: - die("backhaul and Tailscale runtime identities are not isolated") - known = assert_regular_nonsymlink(BACKHAUL_KNOWN_HOSTS, "backhaul known_hosts") - if known.st_uid != account.pw_uid or (known.st_mode & 0o777) != 0o444: - die("backhaul known_hosts ownership/mode mismatch") - return True - - -def relay_health(): - last_error = None - for _attempt in range(60): - try: - with urllib.request.urlopen("http://127.0.0.1:18221/healthz", timeout=3) as response: - payload = json.loads(response.read(65537).decode("utf-8")) - if response.status == 200: - return payload - except Exception as error: - last_error = str(error) - time.sleep(2) - die(f"VPS relay health timeout: {last_error}") - - -def core_channel_health(*, require_accepted: bool): - last_error = None - for _attempt in range(60): - try: - with urllib.request.urlopen( - f"http://127.0.0.1:{CHANNEL_HEALTH_PORT}/healthz", - timeout=3, - ) as response: - payload = json.loads(response.read(65537).decode("utf-8")) - if ( - response.status == 200 - and payload.get("ok") is True - and ( - not require_accepted - or payload.get("channel") == "accepted" - ) - ): - return payload - last_error = f"channel={payload.get('channel')}" - except Exception as error: - last_error = str(error) - time.sleep(2) - die(f"VPS Core channel health timeout: {last_error}") - - -def validate_core_channel_runtime(): - validate_foundation_runtime( - require_running_tailnet=False, - expected_key_user=SERVICE_USER, - ) - source_file_state("core-channel") - assert_channel_trust(require_runtime_owner=True) - if service_active("nodedc-b2-backhaul.service"): - die("frozen VPS backhaul service became active") - if service_active("nodedc-b2-relay.service"): - die("frozen VPS relay service became active") - if not service_active("nodedc-device-edge-channel.service"): - die("VPS Core channel service is not active") - health = core_channel_health(require_accepted=True) - expected = { - "ok": True, - "service": "nodedc-device-edge-channel", - "channel": "accepted", - "trackerIngress": "disabled", - "commandTransport": "disabled", - } - for key, value in expected.items(): - if health.get(key) != value: - die(f"VPS Core channel health contract mismatch: {key}") - if not port_is_open(PUBLIC_IPV4, CHANNEL_PUBLIC_PORT, timeout=5): - die("VPS public Core channel listener is unavailable") - assert_port_closed(9921) - nft = run(["/usr/sbin/nft", "list", "table", "inet", "nodedc_b2_vps"]).stdout - if ( - "policy drop" not in nft - or "tcp dport 443" not in nft - or "tcp dport 9921" in nft - ): - die("VPS Core channel firewall contract mismatch") - unit = run([ - "/usr/bin/systemctl", - "show", - "nodedc-device-edge-channel.service", - "--property=User,Group,NoNewPrivileges,CapabilityBoundingSet,AmbientCapabilities,MemoryMax,MemorySwapMax,CPUQuotaPerSecUSec,TasksMax,LimitNOFILE", - ]).stdout - for required in ( - "User=nodedc-channel", - "Group=nodedc-channel", - "NoNewPrivileges=yes", - "CapabilityBoundingSet=cap_net_bind_service", - "AmbientCapabilities=cap_net_bind_service", - "MemoryMax=134217728", - "MemorySwapMax=0", - "TasksMax=64", - "LimitNOFILE=1024", - ): - if required not in unit: - die(f"VPS Core channel resource boundary mismatch: {required}") - return health - - -def validate_tailscale_retirement_runtime(): - source_file_state("foundation") - source_file_state("core-channel") - source_file_state("tailscale-retirement") - core_record = applied_phase_record("core-channel") - if ( - core_record.get("patch") != CORE_CHANNEL_ACCEPTED_PATCH - or core_record.get("sha256") != CORE_CHANNEL_ACCEPTED_SHA256 - ): - die("retired Tailscale runtime Core channel identity mismatch") - if run([str(NODE_BIN), "--version"]).stdout.strip() != f"v{NODE_VERSION}": - die("retired Tailscale runtime Node version mismatch") - if service_active("nodedc-b2-tailscaled.service"): - die("retired Tailscale service remains active") - if systemctl("is-enabled", "nodedc-b2-tailscaled.service", check=False).returncode == 0: - die("retired Tailscale service remains enabled") - for path in ( - TAILSCALE_UNIT, - TAILSCALE_SOCKET, - TAILSCALE_STATE.parent, - TAILSCALE_BIN.parent, - TRUST_ROOT, - ): - if path.exists() or path.is_symlink(): - die(f"retired Tailscale boundary remains present: {path}") - if run(["/usr/bin/pgrep", "-x", "tailscaled"], check=False).returncode == 0: - die("retired Tailscale process remains running") - assert_management_key() - if not port_is_open(PUBLIC_IPV4, 22, timeout=5): - die("management SSH became unavailable after Tailscale retirement") - if port_is_open("127.0.0.1", 1055): - die("retired Tailscale SOCKS listener remains open") - source_file_state("core-channel") - assert_channel_trust(require_runtime_owner=True) - if not service_active("nodedc-device-edge-channel.service"): - die("Core channel became inactive after Tailscale retirement") - health = core_channel_health(require_accepted=True) - expected = { - "ok": True, - "service": "nodedc-device-edge-channel", - "channel": "accepted", - "trackerIngress": "disabled", - "commandTransport": "disabled", - } - for key, value in expected.items(): - if health.get(key) != value: - die(f"retired Tailscale Core channel contract mismatch: {key}") - if not port_is_open(PUBLIC_IPV4, CHANNEL_PUBLIC_PORT, timeout=5): - die("Core channel listener became unavailable after Tailscale retirement") - assert_port_closed(9921) - nft = run(["/usr/sbin/nft", "list", "table", "inet", "nodedc_b2_vps"]).stdout - if ( - "policy drop" not in nft - or "tcp dport 22" not in nft - or "tcp dport 443" not in nft - or "tcp dport 9921" in nft - ): - die("retired Tailscale firewall contract mismatch") - return health - - -def validate_tracker_ingress_runtime(): - source_file_state("foundation") - source_file_state("core-channel") - source_file_state("tailscale-retirement") - source_file_state("tracker-ingress") - retirement_record = applied_phase_record("tailscale-retirement") - if ( - retirement_record.get("patch") != TAILSCALE_RETIREMENT_ACCEPTED_PATCH - or retirement_record.get("sha256") - != TAILSCALE_RETIREMENT_ACCEPTED_SHA256 - ): - die("tracker ingress retirement identity mismatch") - if run([str(NODE_BIN), "--version"]).stdout.strip() != f"v{NODE_VERSION}": - die("tracker ingress Node version mismatch") - for path in ( - TAILSCALE_UNIT, - TAILSCALE_SOCKET, - TAILSCALE_STATE.parent, - TAILSCALE_BIN.parent, - TRUST_ROOT, - ): - if path.exists() or path.is_symlink(): - die(f"tracker ingress superseded trust boundary present: {path}") - if service_active("nodedc-b2-tailscaled.service"): - die("tracker ingress Tailscale service became active") - if service_active("nodedc-b2-backhaul.service"): - die("tracker ingress frozen backhaul service became active") - if service_active("nodedc-b2-relay.service"): - die("tracker ingress frozen relay service became active") - assert_management_key() - assert_channel_trust(require_runtime_owner=True) - if not service_active("nodedc-device-edge-channel.service"): - die("VPS Device Edge runtime is not active") - health = core_channel_health(require_accepted=True) - expected = { - "ok": True, - "service": "nodedc-device-edge-runtime", - "channel": "accepted", - "trackerIngress": "telemetry-ingest", - "commandTransport": "disabled", - } - for key, value in expected.items(): - if health.get(key) != value: - die(f"VPS tracker ingress health contract mismatch: {key}") - tracker = health.get("tracker") or {} - for key, value in { - "adapter": "arusnavi-b2", - "protocolProfile": "arusnavi.b2.internal.v1", - "framing": "verified-read-only", - "publicIngress": "telemetry-ingest", - "commandTransport": "disabled", - }.items(): - if tracker.get(key) != value: - die(f"VPS tracker adapter health contract mismatch: {key}") - for port in (22, CHANNEL_PUBLIC_PORT, 9921): - if not port_is_open(PUBLIC_IPV4, port, timeout=5): - die(f"VPS required public listener is unavailable: {port}") - if not port_is_open("127.0.0.1", 18221, timeout=5): - die("VPS tracker adapter health listener is unavailable") - if port_is_open("127.0.0.1", 1055): - die("VPS retired Tailscale SOCKS listener returned") - nft = run(["/usr/sbin/nft", "list", "table", "inet", "nodedc_b2_vps"]).stdout - for required in ("policy drop", "tcp dport 22", "tcp dport 443", "tcp dport 9921"): - if required not in nft: - die(f"VPS tracker ingress firewall contract mismatch: {required}") - unit = run([ - "/usr/bin/systemctl", - "show", - "nodedc-device-edge-channel.service", - "--property=User,Group,NoNewPrivileges,CapabilityBoundingSet,AmbientCapabilities,MemoryMax,MemorySwapMax,CPUQuotaPerSecUSec,TasksMax,LimitNOFILE", - ]).stdout - for required in ( - "User=nodedc-channel", - "Group=nodedc-channel", - "NoNewPrivileges=yes", - "CapabilityBoundingSet=cap_net_bind_service", - "AmbientCapabilities=cap_net_bind_service", - "MemoryMax=201326592", - "MemorySwapMax=0", - "TasksMax=128", - "LimitNOFILE=1024", - ): - if required not in unit: - die(f"VPS tracker ingress resource boundary mismatch: {required}") - return health - - -def validate_command_transport_runtime(): - source_file_state("foundation") - source_file_state("tailscale-retirement") - source_file_state("command-transport") - tracker_record = applied_phase_record("tracker-ingress") - if ( - tracker_record.get("patch") != TRACKER_INGRESS_ACCEPTED_PATCH - or tracker_record.get("sha256") != TRACKER_INGRESS_ACCEPTED_SHA256 - ): - die("command transport tracker ingress identity mismatch") - if run([str(NODE_BIN), "--version"]).stdout.strip() != f"v{NODE_VERSION}": - die("command transport Node version mismatch") - for path in ( - TAILSCALE_UNIT, - TAILSCALE_SOCKET, - TAILSCALE_STATE.parent, - TAILSCALE_BIN.parent, - TRUST_ROOT, - ): - if path.exists() or path.is_symlink(): - die(f"command transport superseded trust boundary present: {path}") - for service in ( - "nodedc-b2-tailscaled.service", - "nodedc-b2-backhaul.service", - "nodedc-b2-relay.service", - ): - if service_active(service): - die(f"command transport forbidden service became active: {service}") - assert_management_key() - assert_channel_trust(require_runtime_owner=True) - if not service_active("nodedc-device-edge-channel.service"): - die("VPS Device Edge command runtime is not active") - health = core_channel_health(require_accepted=True) - expected = { - "ok": True, - "service": "nodedc-device-edge-runtime", - "channel": "accepted", - "trackerIngress": "telemetry-ingest", - "commandTransport": "typed-service-ping-v1", - } - for key, value in expected.items(): - if health.get(key) != value: - die(f"VPS command transport health contract mismatch: {key}") - tracker = health.get("tracker") or {} - for key, value in { - "adapter": "arusnavi-b2", - "protocolProfile": "arusnavi.b2.internal.v1", - "framing": "verified-read-only", - "publicIngress": "telemetry-ingest", - "commandTransport": "typed-service-ping-v1", - }.items(): - if tracker.get(key) != value: - die(f"VPS command adapter health contract mismatch: {key}") - for port in (22, CHANNEL_PUBLIC_PORT, 9921): - if not port_is_open(PUBLIC_IPV4, port, timeout=5): - die(f"VPS command transport required listener unavailable: {port}") - if not port_is_open("127.0.0.1", 18221, timeout=5): - die("VPS command transport adapter health unavailable") - if port_is_open("127.0.0.1", 1055): - die("VPS retired Tailscale SOCKS listener returned") - nft = run(["/usr/sbin/nft", "list", "table", "inet", "nodedc_b2_vps"]).stdout - for required in ("policy drop", "tcp dport 22", "tcp dport 443", "tcp dport 9921"): - if required not in nft: - die(f"VPS command transport firewall contract mismatch: {required}") - unit = run([ - "/usr/bin/systemctl", - "show", - "nodedc-device-edge-channel.service", - "--property=User,Group,NoNewPrivileges,CapabilityBoundingSet,AmbientCapabilities,MemoryMax,MemorySwapMax,CPUQuotaPerSecUSec,TasksMax,LimitNOFILE", - ]).stdout - for required in ( - "User=nodedc-channel", - "Group=nodedc-channel", - "NoNewPrivileges=yes", - "CapabilityBoundingSet=cap_net_bind_service", - "AmbientCapabilities=cap_net_bind_service", - "MemoryMax=201326592", - "MemorySwapMax=0", - "TasksMax=128", - "LimitNOFILE=1024", - ): - if required not in unit: - die(f"VPS command transport resource boundary mismatch: {required}") - return health - - -def validate_relay_runtime(): - validate_backhaul_runtime() - source_file_state("relay") - if not service_active("nodedc-b2-relay.service"): - die("VPS relay service is not active") - relay_account = pwd.getpwnam(RELAY_USER) - backhaul_account = pwd.getpwnam(BACKHAUL_USER) - tailscale_account = pwd.getpwnam(SERVICE_USER) - if len({relay_account.pw_uid, backhaul_account.pw_uid, tailscale_account.pw_uid}) != 3: - die("relay, backhaul, and Tailscale runtime identities are not isolated") - health = relay_health() - expected = { - "ok": True, - "service": "nodedc-device-edge-relay", - "ingress": "relay-only", - "protocolInspection": "disabled", - "commandTransport": "disabled", - "sourceAdmission": "public-ipv4-only", - } - for key, value in expected.items(): - if health.get(key) != value: - die(f"VPS relay health contract mismatch: {key}") - if not port_is_open(PUBLIC_IPV4, 9921, timeout=5): - die("VPS public B2 listener is unavailable") - nft = run(["/usr/sbin/nft", "list", "table", "inet", "nodedc_b2_vps"]).stdout - if "policy drop" not in nft or "tcp dport 9921" not in nft: - die("VPS relay firewall contract mismatch") - return health - - -def write_journal(path: Path, record): - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("a", encoding="utf-8") as handle: - handle.write(json.dumps(record, sort_keys=True) + "\n") - - -def archive_artifact(artifact: Path, destination_root: Path): - destination = destination_root / artifact.name - if destination.exists(): - die("VPS artifact archive collision") - os.replace(artifact, destination) - return destination - - -def restore_service_enablement(metadata): - for name, state in metadata.get("services", {}).items(): - if state.get("enabled"): - systemctl("enable", name, check=False) - else: - systemctl("disable", name, check=False) - if state.get("active"): - systemctl("start", name, check=False) - else: - systemctl("stop", name, check=False) - - -def rollback(backup: Path, phase: str): - for service in ( - "nodedc-b2-relay.service", - "nodedc-b2-backhaul.service", - "nodedc-device-edge-channel.service", - "nodedc-b2-tailscaled.service", - ): - if phase == "foundation" or service != "nodedc-b2-tailscaled.service": - systemctl("disable", "--now", service, check=False) - restore_backup(backup, phase) - metadata = json.loads((backup / "backup.json").read_text(encoding="utf-8")) - systemctl("daemon-reload", check=False) - if NFTABLES_CONFIG.exists(): - run(["/usr/sbin/nft", "-f", str(NFTABLES_CONFIG)], check=False) - else: - rules = backup / "nft-ruleset-before.nft" - if rules.exists() and rules.stat().st_size: - run(["/usr/sbin/nft", "-f", str(rules)], check=False) - restore_service_enablement(metadata) - systemctl("restart", "fail2ban.service", check=False) - run(["/usr/sbin/sshd", "-t"], check=False) - systemctl("reload", "ssh.service", check=False) - users_before = metadata.get("serviceUsersExisted", {}) - if phase == "backhaul": - assign_backhaul_trust(pwd.getpwnam(SERVICE_USER)) - if not users_before.get(BACKHAUL_USER, False) and user_exists(BACKHAUL_USER): - run(["/usr/sbin/userdel", BACKHAUL_USER], check=False) - if phase == "relay": - if not users_before.get(RELAY_USER, False) and user_exists(RELAY_USER): - run(["/usr/sbin/userdel", RELAY_USER], check=False) - if phase == "core-channel": - if not users_before.get(CHANNEL_USER, False) and user_exists(CHANNEL_USER): - run(["/usr/sbin/userdel", CHANNEL_USER], check=False) - if phase == "foundation" and not metadata.get("serviceUserExisted"): - runtime_state_root = Path("/var/lib/nodedc-b2-vps") - if LIVE_ROOT.exists() and not LIVE_ROOT.is_symlink(): - shutil.rmtree(LIVE_ROOT) - if runtime_state_root.exists() and not runtime_state_root.is_symlink(): - shutil.rmtree(runtime_state_root) - if user_exists(): - run(["/usr/sbin/userdel", SERVICE_USER], check=False) - - -def plan_artifact(artifact_argument: str): - assert_root() - artifact = Path(artifact_argument) - with tempfile.TemporaryDirectory(prefix="nodedc-b2-vps-plan-") as directory: - loaded = load_artifact(artifact, Path(directory)) - evidence = preflight(loaded) - phase = loaded["phase"] - print("== plan ==") - print(f"artifact={loaded['artifact'].name}") - print(f"sha256={loaded['sha256']}") - print(f"id={loaded['manifest']['id']}") - print(f"component={COMPONENT}") - print(f"type={ARTIFACT_TYPE}") - print(f"phase={phase}") - print(f"predecessor={evidence['predecessor']}") - print(f"payload_root={LIVE_ROOT}") - print(f"runtime_host={RUNTIME_HOST}") - print(f"public_ipv4={PUBLIC_IPV4}") - print("management_ssh=root-key-only:tcp/22") - print(f"management_key_fingerprint={MANAGEMENT_KEY_FINGERPRINT}") - print(f"server_host_key_fingerprint={SERVER_HOST_KEY_FINGERPRINT}") - if phase == "foundation": - print(f"node_runtime={NODE_VERSION}:sha256:{NODE_ARCHIVE_SHA256}") - print(f"tailscale_runtime={TAILSCALE_VERSION}:sha256:{TAILSCALE_ARCHIVE_SHA256}") - print("firewall=default-deny:public-tcp/22-only") - print("tailscale=enrollment-required-after-deploy-ok") - print("backhaul_key=runner-managed-new-ed25519") - print("public_b2_ingress=disabled") - print("services=nodedc-b2-tailscaled") - print(f"tailscale_runtime_identity={SERVICE_USER}") - elif phase == "runtime-reconciliation": - print("runtime_reconciliation=exact-known-binaries:0644=>0755") - print("failed_patch=device-edge-vps-core-channel-20260812-001") - print("public_core_channel=disabled") - print("public_b2_ingress=disabled") - print("tracker_tcp_9921=closed") - print("services=preserved:nodedc-b2-tailscaled") - elif phase == "backhaul": - print(f"target={BACKHAUL_TARGET_IP}:{BACKHAUL_TARGET_PORT}") - print(f"target_host_key_fingerprint={BACKHAUL_TARGET_FINGERPRINT}") - print("local_forward=127.0.0.1:19921=>127.0.0.1:9921") - print("proxy=tailscale-userspace-socks5:127.0.0.1:1055") - print("public_b2_ingress=disabled") - print("services=nodedc-b2-backhaul") - print(f"backhaul_runtime_identity={BACKHAUL_USER}:private-key-owner") - elif phase == "relay": - print("public_b2_ingress=155.212.211.15:9921/tcp") - print("health=127.0.0.1:18221") - print("private_upstream=127.0.0.1:19921") - print("source_admission=public-ipv4-only") - print("services=nodedc-b2-relay") - print(f"relay_runtime_identity={RELAY_USER}:no-credentials") - elif phase == "core-channel": - print("public_core_channel=155.212.211.15:443/tcp:tls13-mtls-h2") - print(f"health=127.0.0.1:{CHANNEL_HEALTH_PORT}") - print("public_b2_ingress=disabled") - print("tracker_tcp_9921=closed") - print("services=nodedc-device-edge-channel") - print(f"channel_runtime_identity={CHANNEL_USER}:host-local-private-key") - print("peer_trust=preprovisioned-pinned-self-signed-core-certificate+fingerprint") - elif phase == "tailscale-retirement": - print("public_core_channel=preserved:155.212.211.15:443/tcp:tls13-mtls-h2") - print(f"health=preserved:127.0.0.1:{CHANNEL_HEALTH_PORT}") - print("tailscale=stop+disable+destroy-local-runtime-state") - print("tailscale_socks_1055=removed") - print("superseded_backhaul_private_key=removed") - print("public_b2_ingress=disabled") - print("tracker_tcp_9921=closed") - print("services=preserved:nodedc-device-edge-channel") - print("external_tailnet_machine_cleanup=required-after-deploy-ok") - elif phase == "tracker-ingress": - print("public_core_channel=preserved:155.212.211.15:443/tcp:tls13-mtls-h2") - print(f"health=127.0.0.1:{CHANNEL_HEALTH_PORT}:combined-edge-runtime") - print("public_b2_ingress=155.212.211.15:9921/tcp:telemetry-only") - print("tracker_adapter=arusnavi-b2:profile=arusnavi.b2.internal.v1") - print("tracker_ack=after-core-durable-acceptance-only") - print("runtime_composition=single-non-root-process:core-channel+universal-gateway") - print("tailscale=preserved:absent") - print("services=recreate:nodedc-device-edge-channel") - else: - print("public_core_channel=preserved:155.212.211.15:443/tcp:tls13-mtls-h2") - print(f"health=127.0.0.1:{CHANNEL_HEALTH_PORT}:combined-edge-runtime") - print("public_b2_ingress=155.212.211.15:9921/tcp:bidirectional-session") - print("tracker_adapter=arusnavi-b2:profile=arusnavi.b2.internal.v1") - print("tracker_ack=after-core-durable-acceptance-only") - print("runtime_composition=single-non-root-process:core-channel+universal-gateway") - print("tailscale=preserved:absent") - print("services=recreate:nodedc-device-edge-channel") - print( - "command_transport=typed-service-ping-v1:allowlisted-adapter-only" - if phase == "command-transport" - else "command_transport=disabled" - ) - if phase == "command-transport": - print("command_catalog=allowlisted-adapter-typed-commands-only") - print( - "gelios=untouched-legacy-only" - if phase == "command-transport" - else "gelios=untouched" - ) - print("dns=unchanged") - print("b2_routes=unchanged") - print("state=new") - print("== files ==") - for entry in loaded["entries"]: - print(f" {entry}") - - -def apply_artifact(artifact_argument: str): - assert_root() - acquire_lock() - loaded = None - backup_id = None - backup = None - try: - with tempfile.TemporaryDirectory(prefix="nodedc-b2-vps-apply-") as directory: - loaded = load_artifact(Path(artifact_argument), Path(directory)) - preflight(loaded) - backup_id, backup = create_backup( - loaded["manifest"]["id"], - loaded["phase"], - ) - publish_payload(loaded["payload"], loaded["entries"]) - if loaded["phase"] == "foundation": - apply_foundation(loaded["payload"]) - elif loaded["phase"] == "runtime-reconciliation": - apply_runtime_reconciliation(loaded["payload"]) - elif loaded["phase"] == "backhaul": - apply_backhaul(loaded["payload"]) - elif loaded["phase"] == "relay": - apply_relay(loaded["payload"]) - elif loaded["phase"] == "core-channel": - apply_core_channel(loaded["payload"]) - elif loaded["phase"] == "tailscale-retirement": - apply_tailscale_retirement(loaded["payload"]) - elif loaded["phase"] == "tracker-ingress": - apply_tracker_ingress(loaded["payload"]) - else: - apply_command_transport(loaded["payload"]) - - archived = archive_artifact(loaded["artifact"], APPLIED_ROOT) - record = { - "status": "ok", - "patch": loaded["manifest"]["id"], - "component": COMPONENT, - "phase": loaded["phase"], - "sha256": loaded["sha256"], - "artifact": archived.name, - "backup": backup_id, - "appliedAt": datetime.now(timezone.utc).isoformat(), - } - write_journal(APPLIED_JOURNAL, record) - print( - f"deploy-ok patch={record['patch']} component={COMPONENT} " - f"backup={backup_id}" - ) - except Exception as error: - rollback_status = "not-required" - if backup is not None and loaded is not None: - try: - rollback(backup, loaded["phase"]) - rollback_status = "ok" - except Exception as rollback_error: - rollback_status = f"failed:{type(rollback_error).__name__}" - if loaded is not None and loaded["artifact"].exists(): - destination = FAILED_ROOT / ( - f"{loaded['artifact'].name}." - f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}" - ) - os.replace(loaded["artifact"], destination) - if loaded is not None: - write_journal(FAILED_JOURNAL, { - "status": "failed", - "patch": loaded["manifest"]["id"], - "component": COMPONENT, - "phase": loaded["phase"], - "sha256": loaded["sha256"], - "backup": backup_id, - "rollback": rollback_status, - "error": type(error).__name__, - "failedAt": datetime.now(timezone.utc).isoformat(), - }) - if rollback_status.startswith("failed"): - die(f"apply failed and rollback failed: {error}") - die(f"apply failed; automatic rollback={rollback_status}: {error}") - finally: - release_lock() - - -def verify_install(): - assert_root() - path = RUNNER_PATH if RUNNER_PATH.exists() else Path(__file__).resolve() - assert_regular_nonsymlink(path, "runner") - print(f"path={path}") - print(f"sha256={sha256_file(path)}") - print(f"python={sys.version.split()[0]}") - print(f"runtime_host={socket.gethostname()}") - print(f"component={COMPONENT}") - print(f"live_root={LIVE_ROOT}") - print(f"inbox_root={INBOX_ROOT}") - print(f"node_runtime={'present' if NODE_BIN.exists() else 'absent'}") - print(f"tailscale_runtime={'present' if TAILSCALE_BIN.exists() else 'absent'}") - print("verify-install-ok") - - -def main(arguments): - if len(arguments) == 1 and arguments[0] == "verify-install": - verify_install() - return 0 - if len(arguments) == 2 and arguments[0] == "plan": - plan_artifact(arguments[1]) - return 0 - if len(arguments) == 2 and arguments[0] == "apply": - apply_artifact(arguments[1]) - return 0 - print( - "usage: nodedc-b2-vps-deploy verify-install | plan | apply ", - file=sys.stderr, - ) - return 2 - - -if __name__ == "__main__": - try: - raise SystemExit(main(sys.argv[1:])) - except DeployError as error: - print(f"ERROR: {error}", file=sys.stderr) - raise SystemExit(1) diff --git a/infra/deploy-runner/nodedc-edge-deploy b/infra/deploy-runner/nodedc-edge-deploy deleted file mode 100644 index 74366e7..0000000 --- a/infra/deploy-runner/nodedc-edge-deploy +++ /dev/null @@ -1,868 +0,0 @@ -#!/usr/bin/env python3 -"""Canonical data-only deploy runner for the dedicated NODE.DC Device Edge.""" - -from __future__ import annotations - -import hashlib -import json -import os -import re -import select -import shutil -import socket -import struct -import subprocess -import sys -import tarfile -import tempfile -import time -from datetime import datetime, timezone -from pathlib import Path, PurePosixPath - - -RUNNER_PATH = Path("/usr/local/sbin/nodedc-edge-deploy") -LIVE_ROOT = Path("/home/ndcsudo/nodedc-device-edge/source") -INBOX_ROOT = Path("/home/ndcsudo/nodedc-device-edge/deploy/inbox") -STATE_ROOT = Path("/var/lib/nodedc-edge-deploy") -APPLIED_ROOT = STATE_ROOT / "applied" -FAILED_ROOT = STATE_ROOT / "failed" -BACKUP_ROOT = STATE_ROOT / "backups" -APPLIED_JOURNAL = STATE_ROOT / "state/applied.jsonl" -FAILED_JOURNAL = STATE_ROOT / "state/failed.jsonl" -DEPLOY_LOCK = STATE_ROOT / "state/deploy.lock" - -DOCKER = "/usr/bin/docker" -COMPONENT = "device-edge" -ARTIFACT_TYPE = "app-overlay" -PATCH_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,96}$") -MAX_ARTIFACT_BYTES = 16 * 1024 * 1024 - -COMPOSE_PROJECT = "nodedc-device-edge" -BASE_COMPOSE = LIVE_ROOT / "docker-compose.device-edge.yml" -INGRESS_COMPOSE = LIVE_ROOT / "docker-compose.device-edge.ingress.yml" -RELAY_SERVICE = "device-edge-relay" -RELAY_CONTAINER = "nodedc-device-edge-device-edge-relay-1" -BACKHAUL_CONTAINER = "nodedc-device-edge-device-edge-backhaul-1" -TAILNET_CONTAINER = "nodedc-device-edge-tailnet-1" -RELAY_IMAGE = "nodedc/device-edge-relay:local" - -INGRESS_PARENT = "enp1s0f0" -INGRESS_SUBNET = "192.168.68.0/22" -INGRESS_GATEWAY = "192.168.68.1" -INGRESS_IPV4 = "192.168.71.253" -INGRESS_PORT = 9921 -INGRESS_NETWORK = "nodedc-device-edge-ingress" -INGRESS_IPV4_APPROVED = True -INGRESS_IPV4_APPROVAL = "approved-outside-dhcp-pool" - -ENTRIES = ( - "docker-compose.device-edge.yml", - "docker-compose.device-edge.ingress.yml", - "services/device-edge-relay/Dockerfile", - "services/device-edge-relay/src", - "deployment/device-edge-admission-gate-v1.json", -) - -PAYLOAD_FILE_SHA256 = { - "docker-compose.device-edge.yml": - "666945ffd9512355e610ecd36a9df96936477315150555def93e0243e8ff1e22", - "docker-compose.device-edge.ingress.yml": - "11bedfd7fdea749ca1bdb3b35b9c136c86b330f51a9001f0b38c4618f6f96108", - "services/device-edge-relay/Dockerfile": - "f2f15b7618ac2ab3a1d4dd40041d06d1e9a695edcfc168d8835f9560b4897e70", - "services/device-edge-relay/src/runtime.mjs": - "21e83678980aa61127bf9f3d77982dd485c4aaae208c43818db7bb1cc150b83a", - "services/device-edge-relay/src/server.mjs": - "1b99ec944f1d3fbadded045b159f08624e2829620cec39a97f6b4b8cdcd2be22", - "deployment/device-edge-admission-gate-v1.json": - "e6c1f21ff297b451c42b6746bc2063484874435dfa9f1614410a7cbe84f0ce6f", -} - -PREDECESSOR_FILE_SHA256 = { - "docker-compose.device-edge.yml": - "7f13c11d6d4d541964053c0a8cf791e401947d34c42e0f7c26f9f9df26fa00b5", - "docker-compose.device-edge.ingress.yml": - "a4afd04755530fc3b9be64d1a65f0f7282a9539bcc1985bfd880aa904e1c4d8f", - "services/device-edge-relay/Dockerfile": - "f2f15b7618ac2ab3a1d4dd40041d06d1e9a695edcfc168d8835f9560b4897e70", - "services/device-edge-relay/src/runtime.mjs": - "ae8bf8b55603bab266b6fa6e9bc65c9f310a9d94a54db04e2130704e38622ffc", - "services/device-edge-relay/src/server.mjs": - "e4b051b74f934bd37322440e6a013fb6774a76607da08f9cc1e844fc109c83c1", - "deployment/device-edge-ingress-ipvlan-v1.json": - "b9ce402db0c059a76f07a8d4a34297aff2250fd1c0d1aed9970b3a88f4e75d7f", -} - -PREDECESSOR_ABSENT = { - "deployment/device-edge-admission-gate-v1.json", -} - - -class DeployError(RuntimeError): - pass - - -def die(message: str) -> None: - raise DeployError(message) - - -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def run(command, *, check=True, capture=True, cwd=None, timeout=180): - result = subprocess.run( - [str(value) for value in command], - check=False, - capture_output=capture, - text=True, - cwd=str(cwd) if cwd else None, - timeout=timeout, - ) - if check and result.returncode != 0: - detail = (result.stderr or result.stdout or "command failed").strip() - die(f"command failed: {command[0]}: {detail}") - return result - - -def docker_json(*args): - result = run([DOCKER, *args]) - try: - return json.loads(result.stdout) - except json.JSONDecodeError as error: - die(f"Docker JSON response invalid: {error}") - - -def expected_descriptor(): - return { - "schemaVersion": "nodedc.device-edge.admission-gate.v1", - "mode": "single-nic-ipvlan-b2-relay-only", - "runtimeHost": "ndcmini12", - "component": COMPONENT, - "selectedServices": [RELAY_SERVICE], - "preservedServices": ["device-edge-backhaul", "tailnet"], - "composeProject": COMPOSE_PROJECT, - "composeFiles": [ - "docker-compose.device-edge.yml", - "docker-compose.device-edge.ingress.yml", - ], - "parentInterface": INGRESS_PARENT, - "lanSubnet": INGRESS_SUBNET, - "lanGateway": INGRESS_GATEWAY, - "ingressIpv4": INGRESS_IPV4, - "ingressIpv4Approval": INGRESS_IPV4_APPROVAL, - "ingressNetwork": INGRESS_NETWORK, - "deviceTcpListen": f"{INGRESS_IPV4}:{INGRESS_PORT}", - "hostPortPublication": "disabled", - "healthPublication": "disabled", - "privateUpstream": "device-edge-backhaul:19921", - "sourceAdmission": "public-ipv4-only", - "maxTrackedSourceAddresses": 2048, - "maxBytesPerDirection": 262144, - "protocolInspection": "gateway-owned", - "identityTrust": "claimed-not-ownership-proof", - "discoveryLifecycle": "quarantine", - "commandTransport": "disabled", - "gelios": "untouched", - "amneziaHostFullTunnel": "preserved", - "routerNatFirewall": "separate-manual-gate", - "rollback": "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation", - } - - -def assert_root(): - if os.geteuid() != 0: - die("nodedc-edge-deploy must run as root") - - -def assert_regular_nonsymlink(path: Path, label: str): - if not path.exists() or path.is_symlink() or not path.is_file(): - die(f"{label} must be a regular non-symlink file") - - -def parse_manifest(raw: str): - values = {} - for line in raw.splitlines(): - if not line or "=" not in line: - die("artifact manifest is malformed") - key, value = line.split("=", 1) - if key in values or key not in {"id", "component", "type"}: - die("artifact manifest key set is invalid") - values[key] = value - if set(values) != {"id", "component", "type"}: - die("artifact manifest key set is incomplete") - if not PATCH_ID_RE.fullmatch(values["id"]): - die("artifact patch id is invalid") - if values["component"] != COMPONENT or values["type"] != ARTIFACT_TYPE: - die("artifact component/type mismatch") - return values - - -def safe_tar_member(member: tarfile.TarInfo): - path = PurePosixPath(member.name) - if path.is_absolute() or ".." in path.parts or not path.parts: - die("artifact contains an unsafe path") - if not (member.isfile() or member.isdir()): - die("artifact contains a non-file/non-directory member") - lowered = {part.lower() for part in path.parts} - if any( - part.startswith(".env") - or part in { - ".git", - "node_modules", - "secrets", - "keys", - "trust", - "runtime", - "logs", - "uploads", - } - for part in lowered - ): - die("artifact contains a forbidden boundary") - if any(part.startswith("._") for part in path.parts): - die("artifact contains AppleDouble metadata") - - -def load_artifact(artifact: Path, extraction_root: Path): - artifact = artifact.resolve(strict=True) - if artifact.parent != INBOX_ROOT.resolve(strict=True): - die("artifact must be an explicit file in the Device Edge inbox") - assert_regular_nonsymlink(artifact, "artifact") - if artifact.suffix != ".tgz" or artifact.stat().st_size > MAX_ARTIFACT_BYTES: - die("artifact extension/size rejected") - - seen = set() - with tarfile.open(artifact, "r:gz") as archive: - for member in archive.getmembers(): - safe_tar_member(member) - if member.name in seen: - die("artifact contains duplicate members") - seen.add(member.name) - required = {"manifest.env", "files.txt", "payload"} - if not required.issubset(seen): - die("artifact top-level contract is incomplete") - if any(name.split("/", 1)[0] not in required for name in seen): - die("artifact contains an unexpected top-level member") - archive.extractall(extraction_root, filter="data") - - manifest = parse_manifest( - (extraction_root / "manifest.env").read_text(encoding="utf-8") - ) - entries = tuple( - line for line in - (extraction_root / "files.txt").read_text(encoding="utf-8").splitlines() - if line - ) - if entries != ENTRIES or len(entries) != len(set(entries)): - die("Device Edge artifact file selection mismatch") - payload = extraction_root / "payload" - validate_payload(payload) - return manifest, entries, payload, sha256_file(artifact), artifact - - -def validate_payload(payload: Path): - actual_files = { - path.relative_to(payload).as_posix(): sha256_file(path) - for path in payload.rglob("*") - if path.is_file() - } - if actual_files != PAYLOAD_FILE_SHA256: - die("Device Edge artifact payload digest set mismatch") - descriptor = json.loads( - (payload / "deployment/device-edge-admission-gate-v1.json") - .read_text(encoding="utf-8") - ) - if descriptor != expected_descriptor(): - die("Device Edge ingress descriptor mismatch") - - -def journal_records(path: Path): - if not path.exists(): - return [] - records = [] - for line in path.read_text(encoding="utf-8").splitlines(): - if not line: - continue - try: - records.append(json.loads(line)) - except json.JSONDecodeError: - die(f"journal is malformed: {path}") - return records - - -def assert_new_identity(patch_id: str, artifact_sha256: str): - records = journal_records(APPLIED_JOURNAL) + journal_records(FAILED_JOURNAL) - if any(record.get("patch") == patch_id for record in records): - die("Device Edge patch id is terminally recorded") - if any(record.get("sha256") == artifact_sha256 for record in records): - die("Device Edge artifact digest is terminally recorded") - - -def current_source_state(): - state = {} - for relative, expected in PREDECESSOR_FILE_SHA256.items(): - path = LIVE_ROOT / relative - assert_regular_nonsymlink(path, f"predecessor {relative}") - state[relative] = sha256_file(path) - if state[relative] != expected: - die(f"Device Edge predecessor drift: {relative}") - for relative in PREDECESSOR_ABSENT: - if (LIVE_ROOT / relative).exists(): - die(f"Device Edge predecessor unexpected path: {relative}") - return state - - -def inspect_container(name: str): - response = docker_json("inspect", name) - if len(response) != 1: - die(f"container inspect cardinality mismatch: {name}") - return response[0] - - -def container_health(container): - health = container.get("State", {}).get("Health") - return health.get("Status") if health else None - - -def preserved_runtime_snapshot(): - snapshot = {} - for name in (BACKHAUL_CONTAINER, TAILNET_CONTAINER): - container = inspect_container(name) - if container.get("State", {}).get("Status") != "running": - die(f"preserved Device Edge service is not running: {name}") - if name == BACKHAUL_CONTAINER and container_health(container) != "healthy": - die("Device Edge backhaul is not healthy") - snapshot[name] = { - "Id": container.get("Id"), - "Image": container.get("Image"), - "StartedAt": container.get("State", {}).get("StartedAt"), - "RestartCount": container.get("RestartCount"), - "PortBindings": container.get("HostConfig", {}).get("PortBindings"), - } - return snapshot - - -def assert_preserved_runtime(snapshot): - current_snapshot = preserved_runtime_snapshot() - for name, expected in snapshot.items(): - current = current_snapshot[name] - if current != expected: - die(f"preserved Device Edge runtime changed: {name}") - - -def validate_predecessor_runtime(): - relay = inspect_container(RELAY_CONTAINER) - if relay.get("State", {}).get("Status") != "running": - die("Device Edge IPvlan predecessor relay is not running") - if container_health(relay) != "healthy": - die("Device Edge IPvlan predecessor relay is not healthy") - environment = set(relay.get("Config", {}).get("Env") or []) - required = { - "DEVICE_EDGE_RELAY_HEALTH_HOST=127.0.0.1", - "DEVICE_EDGE_RELAY_INGRESS_ENABLED=true", - "DEVICE_EDGE_RELAY_TCP_HOST=0.0.0.0", - "DEVICE_EDGE_RELAY_TCP_PORT=9921", - "DEVICE_EDGE_RELAY_UPSTREAM_HOST=device-edge-backhaul", - "DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921", - } - if not required.issubset(environment): - die("Device Edge IPvlan predecessor environment mismatch") - bindings = relay.get("HostConfig", {}).get("PortBindings") or {} - if bindings not in ({}, None): - die("Device Edge IPvlan predecessor host publication mismatch") - validate_network_runtime(relay) - - -def validate_host_network_boundary(): - if socket.gethostname() != "ndcmini12": - die("Device Edge runtime host mismatch") - route = run(["/usr/sbin/ip", "-4", "route", "show"]).stdout - for line in ( - "0.0.0.0/1 dev amn0 metric 1", - "128.0.0.0/1 dev amn0 metric 1", - "default via 192.168.68.1 dev enp1s0f0", - "192.168.68.0/22 dev enp1s0f0", - ): - if line not in route: - die(f"Device Edge host route boundary mismatch: {line}") - if run(["/usr/bin/systemctl", "is-active", "AmneziaVPN.service"]).stdout.strip() != "active": - die("AmneziaVPN must remain active for this transition") - interface = run([ - "/usr/sbin/ip", "-4", "-brief", "address", "show", "dev", INGRESS_PARENT, - ]).stdout - if "192.168.68.54/22" not in interface or "UP" not in interface: - die("Device Edge physical interface boundary mismatch") - - -def arp_duplicate_detected(target_ip: str, interface: str, attempts=3): - protocol = 0x0806 - raw = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(protocol)) - try: - raw.bind((interface, 0)) - source_mac = raw.getsockname()[4] - target = socket.inet_aton(target_ip) - ethernet = b"\xff" * 6 + source_mac + struct.pack("!H", protocol) - arp = struct.pack( - "!HHBBH6s4s6s4s", - 1, - 0x0800, - 6, - 4, - 1, - source_mac, - b"\x00" * 4, - b"\x00" * 6, - target, - ) - raw.setblocking(False) - for _ in range(attempts): - raw.send(ethernet + arp) - deadline = time.monotonic() + 0.7 - while time.monotonic() < deadline: - ready, _, _ = select.select([raw], [], [], deadline - time.monotonic()) - if not ready: - break - packet = raw.recv(2048) - if len(packet) < 42 or packet[12:14] != b"\x08\x06": - continue - if packet[28:32] == target and packet[22:28] != source_mac: - return True - return False - finally: - raw.close() - - -def preflight(manifest, artifact_sha256): - if not INGRESS_IPV4_APPROVED: - die("Device Edge ingress IPv4 approval is not granted") - if INGRESS_IPV4_APPROVAL != "approved-outside-dhcp-pool": - die("Device Edge ingress IPv4 approval contract mismatch") - assert_new_identity(manifest["id"], artifact_sha256) - current_source_state() - validate_predecessor_runtime() - preserved = preserved_runtime_snapshot() - validate_host_network_boundary() - return preserved - - -def compose_command(*args, baseline=False): - command = [ - DOCKER, - "compose", - "--project-name", - COMPOSE_PROJECT, - "--file", - str(BASE_COMPOSE), - ] - if not baseline: - command.extend(["--file", str(INGRESS_COMPOSE)]) - command.extend(args) - return command - - -def ensure_state_directories(): - for path in ( - APPLIED_ROOT, - FAILED_ROOT, - BACKUP_ROOT, - APPLIED_JOURNAL.parent, - ): - path.mkdir(parents=True, exist_ok=True, mode=0o750) - os.chmod(path, 0o750) - - -def acquire_lock(): - ensure_state_directories() - try: - descriptor = os.open( - DEPLOY_LOCK, - os.O_WRONLY | os.O_CREAT | os.O_EXCL, - 0o600, - ) - except FileExistsError: - die("Device Edge deploy lock is present") - os.write(descriptor, f"pid={os.getpid()}\n".encode()) - os.close(descriptor) - - -def release_lock(): - try: - DEPLOY_LOCK.unlink() - except FileNotFoundError: - pass - - -def create_backup(patch_id: str): - timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") - backup_id = f"{patch_id}-{timestamp}" - backup = BACKUP_ROOT / backup_id - backup.mkdir(parents=False, mode=0o750) - present = [] - absent = [] - for relative in ENTRIES: - source = LIVE_ROOT / relative - target = backup / "payload" / relative - if not source.exists(): - absent.append(relative) - continue - present.append(relative) - target.parent.mkdir(parents=True, exist_ok=True) - if source.is_dir(): - shutil.copytree(source, target, symlinks=False) - else: - shutil.copy2(source, target, follow_symlinks=False) - (backup / "backup.json").write_text(json.dumps({ - "schemaVersion": "nodedc.device-edge.backup.v1", - "patch": patch_id, - "present": present, - "absent": absent, - }, sort_keys=True, indent=2) + "\n", encoding="utf-8") - return backup_id, backup - - -def publish_payload(payload: Path): - for relative in ENTRIES: - source = payload / relative - target = LIVE_ROOT / relative - if target.exists(): - if target.is_dir(): - shutil.rmtree(target) - else: - target.unlink() - target.parent.mkdir(parents=True, exist_ok=True) - if source.is_dir(): - shutil.copytree(source, target, symlinks=False) - else: - shutil.copy2(source, target, follow_symlinks=False) - - -def restore_backup(backup: Path): - descriptor = json.loads((backup / "backup.json").read_text(encoding="utf-8")) - for relative in ENTRIES: - target = LIVE_ROOT / relative - if target.exists(): - if target.is_dir(): - shutil.rmtree(target) - else: - target.unlink() - for relative in descriptor["present"]: - source = backup / "payload" / relative - target = LIVE_ROOT / relative - target.parent.mkdir(parents=True, exist_ok=True) - if source.is_dir(): - shutil.copytree(source, target, symlinks=False) - else: - shutil.copy2(source, target, follow_symlinks=False) - - -def build_relay(): - run([ - DOCKER, - "build", - "--no-cache", - "--network=host", - "--file", - "services/device-edge-relay/Dockerfile", - "--tag", - RELAY_IMAGE, - ".", - ], cwd=LIVE_ROOT, timeout=900, capture=False) - - -def wait_healthy(name: str, timeout_seconds=150): - deadline = time.monotonic() + timeout_seconds - while time.monotonic() < deadline: - try: - container = inspect_container(name) - except DeployError: - time.sleep(2) - continue - if ( - container.get("State", {}).get("Status") == "running" - and container_health(container) == "healthy" - ): - return container - if container.get("State", {}).get("Status") in {"exited", "dead"}: - die(f"container stopped before health acceptance: {name}") - time.sleep(2) - die(f"container health timeout: {name}") - - -def validate_network_runtime(relay): - networks = relay.get("NetworkSettings", {}).get("Networks") or {} - if set(networks) != {"nodedc-device-edge-private", INGRESS_NETWORK}: - die("Device Edge relay network set mismatch") - if networks[INGRESS_NETWORK].get("IPAddress") != INGRESS_IPV4: - die("Device Edge relay IPvlan address mismatch") - response = docker_json("network", "inspect", INGRESS_NETWORK) - if len(response) != 1: - die("Device Edge ingress network cardinality mismatch") - network = response[0] - if network.get("Driver") != "ipvlan" or network.get("Internal") is True: - die("Device Edge ingress network driver mismatch") - options = network.get("Options") or {} - if options.get("parent") != INGRESS_PARENT or options.get("ipvlan_mode") != "l2": - die("Device Edge ingress network option mismatch") - configs = network.get("IPAM", {}).get("Config") or [] - if len(configs) != 1: - die("Device Edge ingress IPAM cardinality mismatch") - if configs[0].get("Subnet") != INGRESS_SUBNET or configs[0].get("Gateway") != INGRESS_GATEWAY: - die("Device Edge ingress IPAM mismatch") - - -def validate_relay_runtime(preserved): - relay = wait_healthy(RELAY_CONTAINER) - if relay.get("Config", {}).get("User") != "1000:1000": - die("Device Edge relay user mismatch") - host = relay.get("HostConfig", {}) - if host.get("ReadonlyRootfs") is not True or host.get("Privileged") is not False: - die("Device Edge relay filesystem/privilege mismatch") - if set(host.get("CapDrop") or []) != {"ALL"}: - die("Device Edge relay capability mismatch") - if host.get("PortBindings") not in ({}, None): - die("Device Edge relay host port publication detected") - environment = set(relay.get("Config", {}).get("Env") or []) - required = { - "DEVICE_EDGE_RELAY_HEALTH_HOST=127.0.0.1", - "DEVICE_EDGE_RELAY_INGRESS_ENABLED=true", - "DEVICE_EDGE_RELAY_TCP_HOST=0.0.0.0", - "DEVICE_EDGE_RELAY_TCP_PORT=9921", - "DEVICE_EDGE_RELAY_UPSTREAM_HOST=device-edge-backhaul", - "DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921", - "DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only", - "DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES=2048", - "DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION=262144", - } - if not required.issubset(environment): - die("Device Edge relay environment mismatch") - validate_network_runtime(relay) - health_result = run([ - DOCKER, - "exec", - RELAY_CONTAINER, - "node", - "-e", - "fetch('http://127.0.0.1:18221/healthz').then(async r=>{if(!r.ok)process.exit(2);console.log(await r.text())}).catch(()=>process.exit(3))", - ]) - try: - health = json.loads(health_result.stdout) - except json.JSONDecodeError: - die("Device Edge relay health JSON invalid") - expected_health = { - "ok": True, - "service": "nodedc-device-edge-relay", - "ingress": "relay-only", - "protocolInspection": "disabled", - "commandTransport": "disabled", - "sourceAdmission": "public-ipv4-only", - } - for key, expected in expected_health.items(): - if health.get(key) != expected: - die(f"Device Edge relay health contract mismatch: {key}") - run([ - DOCKER, - "exec", - RELAY_CONTAINER, - "node", - "-e", - "const n=require('node:net');const s=n.connect({host:'device-edge-backhaul',port:19921});s.setTimeout(5000);s.once('connect',()=>{s.destroy();process.exit(0)});s.once('timeout',()=>process.exit(2));s.once('error',()=>process.exit(3))", - ]) - validate_host_network_boundary() - assert_preserved_runtime(preserved) - - -def write_journal(path: Path, record): - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("a", encoding="utf-8") as handle: - handle.write(json.dumps(record, sort_keys=True) + "\n") - - -def archive_artifact(artifact: Path, destination_root: Path): - destination = destination_root / artifact.name - if destination.exists(): - die("Device Edge artifact archive collision") - os.replace(artifact, destination) - return destination - - -def rollback(backup: Path, preserved): - restore_backup(backup) - run(compose_command( - "up", - "--detach", - "--no-deps", - "--force-recreate", - "--pull", - "never", - RELAY_SERVICE, - ), cwd=LIVE_ROOT, timeout=300, capture=False) - wait_healthy(RELAY_CONTAINER) - current_source_state() - validate_predecessor_runtime() - assert_preserved_runtime(preserved) - - -def plan_artifact(artifact_argument: str): - assert_root() - artifact = Path(artifact_argument) - with tempfile.TemporaryDirectory(prefix="nodedc-edge-plan-") as directory: - manifest, entries, _payload, digest, resolved = load_artifact( - artifact, - Path(directory), - ) - preflight(manifest, digest) - print("== plan ==") - print(f"artifact={resolved.name}") - print(f"sha256={digest}") - print(f"id={manifest['id']}") - print(f"component={COMPONENT}") - print(f"type={ARTIFACT_TYPE}") - print(f"payload_root={LIVE_ROOT}") - print(f"compose_root={LIVE_ROOT}") - print(f"compose_project={COMPOSE_PROJECT}") - print("compose_files=docker-compose.device-edge.yml docker-compose.device-edge.ingress.yml") - print("build=/usr/bin/docker build --no-cache --network=host -f services/device-edge-relay/Dockerfile -t nodedc/device-edge-relay:local .") - print("services=device-edge-relay") - print("preserved_services=device-edge-backhaul tailnet") - print(f"device_edge_ingress=ipvlan:l2:{INGRESS_PARENT}:{INGRESS_IPV4}:{INGRESS_PORT}/tcp") - print(f"device_edge_lan={INGRESS_SUBNET}:gateway:{INGRESS_GATEWAY}") - print(f"device_edge_ingress_ipv4_approval={INGRESS_IPV4_APPROVAL}") - print("device_edge_host_port_publication=disabled") - print("device_edge_health_publication=disabled") - print("device_edge_private_upstream=device-edge-backhaul:19921") - print("device_edge_source_admission=public-ipv4-only") - print("device_edge_source_table_limit=2048") - print("device_edge_byte_limit_per_direction=262144") - print("device_edge_command_transport=disabled") - print("device_edge_discovery_lifecycle=quarantine") - print("device_edge_gelios=untouched") - print("device_edge_amnezia=preserved:active:host-full-tunnel") - print("device_edge_router_nat_firewall=unchanged") - print("device_edge_rollback=restore-reviewed-ipvlan-predecessor-no-router-mutation") - print("state=new") - print("== files ==") - for entry in entries: - print(f" {entry}") - - -def apply_artifact(artifact_argument: str): - assert_root() - artifact = Path(artifact_argument) - acquire_lock() - manifest = None - digest = None - resolved = None - backup_id = None - backup = None - preserved = None - try: - with tempfile.TemporaryDirectory(prefix="nodedc-edge-apply-") as directory: - manifest, _entries, payload, digest, resolved = load_artifact( - artifact, - Path(directory), - ) - preserved = preflight(manifest, digest) - backup_id, backup = create_backup(manifest["id"]) - publish_payload(payload) - build_relay() - run(compose_command( - "up", - "--detach", - "--no-deps", - "--force-recreate", - "--pull", - "never", - RELAY_SERVICE, - ), cwd=LIVE_ROOT, timeout=300, capture=False) - validate_relay_runtime(preserved) - archived = archive_artifact(resolved, APPLIED_ROOT) - write_journal(APPLIED_JOURNAL, { - "status": "ok", - "patch": manifest["id"], - "component": COMPONENT, - "sha256": digest, - "artifact": archived.name, - "backup": backup_id, - "appliedAt": datetime.now(timezone.utc).isoformat(), - }) - print( - f"deploy-ok patch={manifest['id']} component={COMPONENT} " - f"backup={backup_id}" - ) - except Exception as error: - rollback_status = "not-started" - if backup is not None and preserved is not None: - try: - rollback(backup, preserved) - rollback_status = "ok" - except Exception as rollback_error: - rollback_status = f"failed:{type(rollback_error).__name__}" - if resolved is not None and resolved.exists(): - failed_name = ( - FAILED_ROOT - / f"{resolved.name}.{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}" - ) - os.replace(resolved, failed_name) - if manifest is not None and digest is not None: - write_journal(FAILED_JOURNAL, { - "status": "failed", - "patch": manifest["id"], - "component": COMPONENT, - "sha256": digest, - "backup": backup_id, - "rollback": rollback_status, - "error": type(error).__name__, - "failedAt": datetime.now(timezone.utc).isoformat(), - }) - if rollback_status.startswith("failed"): - die(f"apply failed and rollback failed: {error}") - die(f"apply failed; automatic rollback={rollback_status}: {error}") - finally: - release_lock() - - -def verify_install(): - assert_root() - path = RUNNER_PATH if RUNNER_PATH.exists() else Path(__file__).resolve() - assert_regular_nonsymlink(path, "runner") - docker_version = run([DOCKER, "version", "--format", "{{.Server.Version}}"]).stdout.strip() - compose_version = run([DOCKER, "compose", "version", "--short"]).stdout.strip() - print(f"path={path}") - print(f"sha256={sha256_file(path)}") - print(f"python={sys.version.split()[0]}") - print(f"docker={docker_version}") - print(f"compose={compose_version}") - print(f"device_edge_ingress_ipv4={INGRESS_IPV4}") - print(f"device_edge_ingress_ipv4_approval={INGRESS_IPV4_APPROVAL}") - print("device_edge_source_admission=public-ipv4-only") - print("verify-install-ok") - - -def main(arguments): - if len(arguments) == 1 and arguments[0] == "verify-install": - verify_install() - return 0 - if len(arguments) == 2 and arguments[0] == "plan": - plan_artifact(arguments[1]) - return 0 - if len(arguments) == 2 and arguments[0] == "apply": - apply_artifact(arguments[1]) - return 0 - print( - "usage: nodedc-edge-deploy verify-install | plan | apply ", - file=sys.stderr, - ) - return 2 - - -if __name__ == "__main__": - try: - raise SystemExit(main(sys.argv[1:])) - except DeployError as error: - print(f"ERROR: {error}", file=sys.stderr) - raise SystemExit(1) diff --git a/infra/deploy-runner/test_device_edge_core_channel_bootstrap.py b/infra/deploy-runner/test_device_edge_core_channel_bootstrap.py deleted file mode 100644 index 0a4e9c0..0000000 --- a/infra/deploy-runner/test_device_edge_core_channel_bootstrap.py +++ /dev/null @@ -1,274 +0,0 @@ -#!/usr/bin/env python3 -import importlib.machinery -import importlib.util -import os -import shutil -import tempfile -import unittest -from pathlib import Path -from unittest import mock - - -SCRIPT_DIR = Path(__file__).resolve().parent -RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" - - -def load_runner(): - loader = importlib.machinery.SourceFileLoader( - "nodedc_device_edge_core_channel_deploy_under_test", - str(RUNNER_PATH), - ) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -RUNNER = load_runner() - - -class DeviceEdgeCoreChannelBootstrapTest(unittest.TestCase): - def test_identity_generation_ignores_synology_global_ca_extensions(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-edge-openssl-", - ) as directory: - root = Path(directory) - malicious = root / "synology-openssl.cnf" - malicious.write_text( - """[ req ] -prompt = no -distinguished_name = dn -x509_extensions = v3_ca - -[ dn ] -CN = synology-global-default - -[ v3_ca ] -basicConstraints = critical,CA:TRUE -keyUsage = critical,keyCertSign,cRLSign -""", - encoding="ascii", - ) - private_key = root / "core-private-key.pem" - certificate = root / "core-certificate.pem" - with ( - mock.patch.dict( - os.environ, - {"OPENSSL_CONF": str(malicious)}, - ), - mock.patch.object( - RUNNER, - "resolve_openssl_binary", - return_value=Path(shutil.which("openssl")), - ), - ): - RUNNER.generate_device_edge_channel_core_identity( - private_key, - certificate, - ) - self.assertEqual( - RUNNER.validate_device_edge_channel_certificate_extensions( - certificate - ), - "exact-clientAuth", - ) - text = RUNNER.device_edge_channel_certificate_text(certificate) - self.assertEqual( - text.count("X509v3 Basic Constraints: critical"), - 1, - ) - self.assertIn("CA:FALSE", text) - self.assertNotIn("CA:TRUE", text) - RUNNER.run_openssl( - [ - "verify", - "-purpose", - "sslclient", - "-CAfile", - str(certificate), - str(certificate), - ], - "unit Device Edge client certificate", - ) - - def test_bootstrap_acceptance_is_core_only_and_preserves_manager(self): - entries = RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_ENTRIES - services = ("device-control-core",) - with ( - mock.patch.object( - RUNNER, - "healthcheck_compose_service_with_grace", - ) as service_health, - mock.patch.object(RUNNER, "healthcheck_url") as url_health, - mock.patch.object( - RUNNER, - "validate_device_manager_control_plane_runtime", - ) as runtime_acceptance, - ): - RUNNER.run_healthchecks("device-plane", entries, services) - self.assertEqual( - [call.args for call in service_health.call_args_list], - [ - ("device-plane", "device-control-core"), - ("device-plane", "device-manager"), - ("device-plane", "device-gateway"), - ("device-plane", "device-postgres"), - ], - ) - url_health.assert_called_once_with( - RUNNER.component_healthchecks( - "device-plane", - entries, - services, - )[0] - ) - runtime_acceptance.assert_called_once_with( - require_edge_channel=True - ) - - def test_exact_failed_016_recovery_is_required_for_replacement(self): - with ( - mock.patch.object( - RUNNER, - "device_edge_channel_invalid_identity_is_exact_recoverable", - return_value=False, - ), - ): - with self.assertRaisesRegex( - RUNNER.DeployError, - "does not match the exact unexported failed-016", - ): - RUNNER.recover_invalid_device_edge_channel_core_identity() - - def test_failed_016_recovery_accepts_actual_synology_constraint_shape(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-edge-failed-016-", - ) as directory: - root = Path(directory) - private_key = root / "core-private-key.pem" - certificate = root / "core-certificate.pem" - peers = root / "peers" - private_key.write_text("private-placeholder\n", encoding="ascii") - certificate.write_text("certificate-placeholder\n", encoding="ascii") - peers.mkdir() - with ( - mock.patch.object( - RUNNER, - "DEVICE_PLANE_EDGE_CHANNEL_CORE_PRIVATE_KEY_FILE", - private_key, - ), - mock.patch.object( - RUNNER, - "DEVICE_PLANE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE", - certificate, - ), - mock.patch.object( - RUNNER, - "DEVICE_PLANE_EDGE_CHANNEL_PEER_TRUST_DIR", - peers, - ), - mock.patch.object( - RUNNER, - "DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_CERTIFICATE_FILE", - root / "exported-certificate.pem", - ), - mock.patch.object( - RUNNER, - "DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_FINGERPRINT_FILE", - root / "exported-fingerprint.txt", - ), - mock.patch.object( - RUNNER, - "device_edge_channel_certificate_fingerprint", - return_value=( - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_INVALID_CERTIFICATE_FINGERPRINT - ), - ), - mock.patch.object( - RUNNER, - "device_edge_channel_certificate_text", - return_value=""" -X509v3 Basic Constraints: - CA:TRUE -X509v3 Basic Constraints: critical - CA:FALSE -X509v3 Key Usage: critical - Digital Signature -X509v3 Extended Key Usage: - TLS Web Client Authentication -""", - ), - mock.patch.object( - RUNNER, - "capture_openssl", - side_effect=[b"same-public-key", b"same-public-key"], - ), - ): - self.assertTrue( - RUNNER.device_edge_channel_invalid_identity_is_exact_recoverable() - ) - - def test_failed_016_recovery_rejects_ambiguous_constraint_shape(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-edge-ambiguous-", - ) as directory: - root = Path(directory) - private_key = root / "core-private-key.pem" - certificate = root / "core-certificate.pem" - peers = root / "peers" - private_key.write_text("private-placeholder\n", encoding="ascii") - certificate.write_text("certificate-placeholder\n", encoding="ascii") - peers.mkdir() - with ( - mock.patch.object( - RUNNER, - "DEVICE_PLANE_EDGE_CHANNEL_CORE_PRIVATE_KEY_FILE", - private_key, - ), - mock.patch.object( - RUNNER, - "DEVICE_PLANE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE", - certificate, - ), - mock.patch.object( - RUNNER, - "DEVICE_PLANE_EDGE_CHANNEL_PEER_TRUST_DIR", - peers, - ), - mock.patch.object( - RUNNER, - "DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_CERTIFICATE_FILE", - root / "exported-certificate.pem", - ), - mock.patch.object( - RUNNER, - "DEVICE_PLANE_EDGE_CHANNEL_EXPORTED_CORE_FINGERPRINT_FILE", - root / "exported-fingerprint.txt", - ), - mock.patch.object( - RUNNER, - "device_edge_channel_certificate_fingerprint", - return_value=( - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_INVALID_CERTIFICATE_FINGERPRINT - ), - ), - mock.patch.object( - RUNNER, - "device_edge_channel_certificate_text", - return_value=""" -X509v3 Basic Constraints: - CA:TRUE -X509v3 Basic Constraints: critical - CA:FALSE -X509v3 Basic Constraints: critical - CA:FALSE -""", - ), - ): - self.assertFalse( - RUNNER.device_edge_channel_invalid_identity_is_exact_recoverable() - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/infra/deploy-runner/test_device_edge_ingress_artifact.py b/infra/deploy-runner/test_device_edge_ingress_artifact.py deleted file mode 100644 index b222f00..0000000 --- a/infra/deploy-runner/test_device_edge_ingress_artifact.py +++ /dev/null @@ -1,323 +0,0 @@ -#!/usr/bin/env python3 -import hashlib -import importlib.machinery -import importlib.util -import json -import os -import subprocess -import tarfile -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - - -SCRIPT_DIR = Path(__file__).resolve().parent -BUILDER = SCRIPT_DIR / "build-device-edge-ingress-artifact.mjs" -RUNNER_PATH = SCRIPT_DIR / "nodedc-edge-deploy" - - -def load_runner(): - loader = importlib.machinery.SourceFileLoader( - "nodedc_edge_runner_under_test", - str(RUNNER_PATH), - ) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -RUNNER = load_runner() - - -class DeviceEdgeIngressArtifactTest(unittest.TestCase): - def build(self, artifact_dir, patch_id): - environment = os.environ.copy() - environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) - return subprocess.run( - ["node", str(BUILDER), patch_id], - check=False, - capture_output=True, - text=True, - env=environment, - ) - - def test_builder_is_deterministic_narrow_and_secret_free(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-edge-artifact-", - ) as directory: - artifact_dir = Path(directory) - patch_id = "device-edge-ingress-ipvlan-unit-001" - first_result = self.build(artifact_dir, patch_id) - self.assertEqual(first_result.returncode, 0, first_result.stderr) - first = json.loads(first_result.stdout) - first_bytes = Path(first["artifact"]).read_bytes() - second_result = self.build(artifact_dir, patch_id) - self.assertEqual(second_result.returncode, 0, second_result.stderr) - second = json.loads(second_result.stdout) - second_bytes = Path(second["artifact"]).read_bytes() - - self.assertEqual(first_bytes, second_bytes) - self.assertEqual(first["sha256"], second["sha256"]) - self.assertEqual( - first["sha256"], - hashlib.sha256(first_bytes).hexdigest(), - ) - self.assertEqual(first["component"], "device-edge") - self.assertEqual(first["entries"], list(RUNNER.ENTRIES)) - self.assertEqual(first["services"], ["device-edge-relay"]) - self.assertEqual( - first["ingress"]["ipv4Approval"], - "approved-outside-dhcp-pool", - ) - - with tarfile.open(first["artifact"], "r:gz") as archive: - members = archive.getmembers() - names = {member.name for member in members} - manifest = archive.extractfile("manifest.env").read().decode() - files = archive.extractfile("files.txt").read().decode().splitlines() - payload_bytes = b"\n".join( - archive.extractfile(member).read() - for member in members - if member.isfile() - ) - - self.assertEqual( - manifest, - f"id={patch_id}\ncomponent=device-edge\ntype=app-overlay\n", - ) - self.assertEqual(files, list(RUNNER.ENTRIES)) - self.assertIn( - "payload/docker-compose.device-edge.ingress.yml", - names, - ) - self.assertNotIn(b"PRIVATE KEY", payload_bytes) - self.assertFalse(any( - "/test/" in name - or "/secrets/" in name - or "/keys/" in name - or "/trust/" in name - or "/node_modules/" in name - or Path(name).name.startswith(".env") - for name in names - )) - - def test_production_builder_accepts_the_explicitly_approved_address(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-edge-address-gate-", - ) as directory: - result = self.build( - Path(directory), - "device-edge-admission-gate-20260804-002", - ) - self.assertEqual(result.returncode, 0, result.stderr) - built = json.loads(result.stdout) - self.assertEqual( - built["ingress"]["ipv4Approval"], - "approved-outside-dhcp-pool", - ) - self.assertTrue(Path(built["artifact"]).is_file()) - - def test_runner_loads_exact_artifact_and_enters_runtime_preflight(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-edge-runner-load-", - ) as directory: - workspace = Path(directory) - inbox = workspace / "inbox" - inbox.mkdir() - result = self.build( - inbox, - "device-edge-admission-gate-20260804-003", - ) - self.assertEqual(result.returncode, 0, result.stderr) - artifact = Path(json.loads(result.stdout)["artifact"]) - extracted = workspace / "extracted" - extracted.mkdir() - - old_inbox = RUNNER.INBOX_ROOT - RUNNER.INBOX_ROOT = inbox - try: - manifest, entries, payload, digest, resolved = ( - RUNNER.load_artifact(artifact, extracted) - ) - finally: - RUNNER.INBOX_ROOT = old_inbox - - self.assertEqual(manifest["component"], "device-edge") - self.assertEqual(entries, RUNNER.ENTRIES) - self.assertEqual(resolved, artifact.resolve()) - self.assertEqual(digest, hashlib.sha256(artifact.read_bytes()).hexdigest()) - self.assertEqual( - json.loads( - (payload / "deployment/device-edge-admission-gate-v1.json") - .read_text(encoding="utf-8") - ), - RUNNER.expected_descriptor(), - ) - preserved = { - RUNNER.BACKHAUL_CONTAINER: {"Id": "backhaul"}, - RUNNER.TAILNET_CONTAINER: {"Id": "tailnet"}, - } - with patch.object(RUNNER, "assert_new_identity"), patch.object( - RUNNER, - "current_source_state", - ), patch.object(RUNNER, "validate_predecessor_runtime"), patch.object( - RUNNER, - "preserved_runtime_snapshot", - return_value=preserved, - ), patch.object(RUNNER, "validate_host_network_boundary"), patch.object( - RUNNER, - "arp_duplicate_detected", - return_value=False, - ), patch.object( - RUNNER, - "run", - return_value=subprocess.CompletedProcess([], 1, "", ""), - ): - self.assertEqual(RUNNER.preflight(manifest, digest), preserved) - - def test_backup_restore_preserves_exact_predecessor_partition(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-edge-backup-", - ) as directory: - workspace = Path(directory) - live = workspace / "live" - backups = workspace / "backups" - live.mkdir() - backups.mkdir() - for relative in RUNNER.ENTRIES: - if relative in RUNNER.PREDECESSOR_ABSENT: - continue - target = live / relative - if relative.endswith("/src"): - target.mkdir(parents=True) - (target / "server.mjs").write_text("old\n", encoding="utf-8") - else: - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(f"old:{relative}\n", encoding="utf-8") - - old_live = RUNNER.LIVE_ROOT - old_backups = RUNNER.BACKUP_ROOT - RUNNER.LIVE_ROOT = live - RUNNER.BACKUP_ROOT = backups - try: - _backup_id, backup = RUNNER.create_backup("unit-backup") - for relative in RUNNER.ENTRIES: - target = live / relative - if target.exists(): - if target.is_dir(): - import shutil - shutil.rmtree(target) - else: - target.unlink() - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text("candidate\n", encoding="utf-8") - RUNNER.restore_backup(backup) - finally: - RUNNER.LIVE_ROOT = old_live - RUNNER.BACKUP_ROOT = old_backups - - for relative in RUNNER.PREDECESSOR_ABSENT: - self.assertFalse((live / relative).exists()) - self.assertEqual( - (live / "docker-compose.device-edge.yml").read_text(), - "old:docker-compose.device-edge.yml\n", - ) - self.assertEqual( - (live / "services/device-edge-relay/src/server.mjs").read_text(), - "old\n", - ) - - def test_runner_selection_and_compose_commands_are_exact(self): - self.assertTrue(RUNNER.INGRESS_IPV4_APPROVED) - self.assertEqual( - RUNNER.INGRESS_IPV4_APPROVAL, - "approved-outside-dhcp-pool", - ) - self.assertEqual(RUNNER.RELAY_SERVICE, "device-edge-relay") - self.assertEqual( - RUNNER.expected_descriptor()["preservedServices"], - ["device-edge-backhaul", "tailnet"], - ) - self.assertEqual( - RUNNER.compose_command( - "up", - "--detach", - "--no-deps", - "--force-recreate", - "--pull", - "never", - RUNNER.RELAY_SERVICE, - ), - [ - RUNNER.DOCKER, - "compose", - "--project-name", - RUNNER.COMPOSE_PROJECT, - "--file", - str(RUNNER.BASE_COMPOSE), - "--file", - str(RUNNER.INGRESS_COMPOSE), - "up", - "--detach", - "--no-deps", - "--force-recreate", - "--pull", - "never", - RUNNER.RELAY_SERVICE, - ], - ) - self.assertNotIn("down", RUNNER_PATH.read_text(encoding="utf-8")) - - def test_preserved_runtime_is_compared_from_one_atomic_snapshot(self): - expected = { - RUNNER.BACKHAUL_CONTAINER: {"Id": "backhaul"}, - RUNNER.TAILNET_CONTAINER: {"Id": "tailnet"}, - } - with patch.object( - RUNNER, - "preserved_runtime_snapshot", - return_value=expected, - ) as snapshot: - RUNNER.assert_preserved_runtime(expected) - snapshot.assert_called_once_with() - - def test_network_acceptance_rejects_any_non_ipvlan_substitution(self): - relay = { - "NetworkSettings": { - "Networks": { - "nodedc-device-edge-private": {"IPAddress": "172.18.0.4"}, - RUNNER.INGRESS_NETWORK: {"IPAddress": RUNNER.INGRESS_IPV4}, - }, - }, - } - accepted_network = [{ - "Driver": "ipvlan", - "Internal": False, - "Options": { - "parent": RUNNER.INGRESS_PARENT, - "ipvlan_mode": "l2", - }, - "IPAM": { - "Config": [{ - "Subnet": RUNNER.INGRESS_SUBNET, - "Gateway": RUNNER.INGRESS_GATEWAY, - }], - }, - }] - with patch.object(RUNNER, "docker_json", return_value=accepted_network): - RUNNER.validate_network_runtime(relay) - - rejected_network = json.loads(json.dumps(accepted_network)) - rejected_network[0]["Driver"] = "bridge" - with patch.object(RUNNER, "docker_json", return_value=rejected_network): - with self.assertRaisesRegex( - RUNNER.DeployError, - "ingress network driver mismatch", - ): - RUNNER.validate_network_runtime(relay) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_device_edge_vps_artifact.py b/infra/deploy-runner/test_device_edge_vps_artifact.py deleted file mode 100644 index 0c54a94..0000000 --- a/infra/deploy-runner/test_device_edge_vps_artifact.py +++ /dev/null @@ -1,765 +0,0 @@ -#!/usr/bin/env python3 -import hashlib -import importlib.machinery -import importlib.util -import json -import os -import subprocess -import tarfile -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - - -SCRIPT_DIR = Path(__file__).resolve().parent -BUILDER = SCRIPT_DIR / "build-device-edge-vps-artifact.mjs" -RUNNER_PATH = SCRIPT_DIR / "nodedc-b2-vps-deploy" -DEFAULT_RUNTIME_CACHE = Path( - os.environ.get("NODEDC_DEVICE_EDGE_VPS_RUNTIME_DIR", "/tmp") -) - - -def load_runner(): - loader = importlib.machinery.SourceFileLoader( - "nodedc_b2_vps_runner_under_test", - str(RUNNER_PATH), - ) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -RUNNER = load_runner() - - -class DeviceEdgeVpsArtifactTest(unittest.TestCase): - def build(self, artifact_dir, phase, patch_id, runtime_cache=None): - environment = os.environ.copy() - environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) - environment["NODEDC_DEVICE_EDGE_VPS_RUNTIME_DIR"] = str( - runtime_cache or DEFAULT_RUNTIME_CACHE - ) - if phase in {"backhaul", "relay"}: - environment["NODEDC_ALLOW_SUPERSEDED_TRANSPORT"] = "test-only" - return subprocess.run( - ["node", str(BUILDER), phase, patch_id], - check=False, - capture_output=True, - text=True, - env=environment, - ) - - def test_superseded_transport_builds_fail_closed_by_default(self): - environment = os.environ.copy() - environment.pop("NODEDC_ALLOW_SUPERSEDED_TRANSPORT", None) - with tempfile.TemporaryDirectory(prefix="nodedc-vps-frozen-") as directory: - environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = directory - for phase in ("backhaul", "relay"): - with self.subTest(phase=phase): - result = subprocess.run( - [ - "node", - str(BUILDER), - phase, - f"device-edge-vps-{phase}-frozen-001", - ], - check=False, - capture_output=True, - text=True, - env=environment, - ) - self.assertNotEqual(result.returncode, 0) - self.assertIn( - "vps_initiated_transport_frozen:ADR-0001", - result.stderr, - ) - - def test_runner_rejects_superseded_transport_before_host_preflight(self): - for phase in ("backhaul", "relay"): - with self.subTest(phase=phase), self.assertRaises(RUNNER.DeployError): - RUNNER.preflight({"phase": phase}) - - def test_accepted_shared_source_phases_cannot_be_rebuilt(self): - for phase in ("core-channel", "tracker-ingress"): - with self.subTest(phase=phase), tempfile.TemporaryDirectory( - prefix=f"nodedc-vps-frozen-{phase}-" - ) as directory: - result = self.build( - Path(directory), - phase, - f"device-edge-vps-{phase}-frozen-001", - ) - self.assertNotEqual(result.returncode, 0) - self.assertIn( - f"accepted_vps_phase_rebuild_frozen:{phase}:ADR-0001", - result.stderr, - ) - - def require_runtime_cache(self): - for name, digest in ( - (RUNNER.NODE_ARCHIVE, RUNNER.NODE_ARCHIVE_SHA256), - (RUNNER.TAILSCALE_ARCHIVE, RUNNER.TAILSCALE_ARCHIVE_SHA256), - ): - path = DEFAULT_RUNTIME_CACHE / name - self.assertTrue(path.is_file(), f"missing runtime fixture: {path}") - self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), digest) - - def test_builders_are_deterministic_narrow_and_secret_free(self): - self.require_runtime_cache() - for phase in ( - "command-transport", - ): - with self.subTest(phase=phase), tempfile.TemporaryDirectory( - prefix=f"nodedc-vps-{phase}-" - ) as directory: - root = Path(directory) - patch_id = f"device-edge-vps-{phase}-unit-001" - first = self.build(root, phase, patch_id) - self.assertEqual(first.returncode, 0, first.stderr) - first_result = json.loads(first.stdout) - first_bytes = Path(first_result["artifact"]).read_bytes() - second = self.build(root, phase, patch_id) - self.assertEqual(second.returncode, 0, second.stderr) - second_result = json.loads(second.stdout) - second_bytes = Path(second_result["artifact"]).read_bytes() - - self.assertEqual(first_bytes, second_bytes) - self.assertEqual(first_result["sha256"], second_result["sha256"]) - self.assertEqual( - first_result["sha256"], - hashlib.sha256(first_bytes).hexdigest(), - ) - self.assertEqual(first_result["entries"], list(RUNNER.PHASE_ENTRIES[phase])) - - with tarfile.open(first_result["artifact"], "r:gz") as archive: - members = archive.getmembers() - names = {member.name for member in members} - payload = b"\n".join( - archive.extractfile(member).read() - for member in members - if member.isfile() and member.size < 2 * 1024 * 1024 - ) - self.assertIn("manifest.env", names) - self.assertIn("files.txt", names) - self.assertFalse(any( - "/secrets/" in name - or "/keys/" in name - or "/trust/" in name - or "/runtime/" in name - or "/node_modules/" in name - or Path(name).name.startswith(".env") - for name in names - )) - self.assertNotIn(b"PRIVATE KEY", payload) - self.assertNotIn(b"TS_AUTHKEY", payload) - - def test_foundation_builder_rejects_modified_runtime_archive(self): - self.require_runtime_cache() - with tempfile.TemporaryDirectory(prefix="nodedc-vps-corrupt-") as directory: - cache = Path(directory) / "cache" - artifacts = Path(directory) / "artifacts" - cache.mkdir() - for name in (RUNNER.NODE_ARCHIVE, RUNNER.TAILSCALE_ARCHIVE): - (cache / name).write_bytes((DEFAULT_RUNTIME_CACHE / name).read_bytes()) - with (cache / RUNNER.NODE_ARCHIVE).open("ab") as handle: - handle.write(b"corrupt") - result = self.build( - artifacts, - "foundation", - "device-edge-vps-foundation-corrupt-001", - runtime_cache=cache, - ) - self.assertNotEqual(result.returncode, 0) - self.assertIn("runtime_digest_mismatch", result.stderr) - - def test_runner_loads_each_exact_phase(self): - self.require_runtime_cache() - with tempfile.TemporaryDirectory(prefix="nodedc-vps-load-") as directory: - inbox = Path(directory) / "inbox" - inbox.mkdir() - old_inbox = RUNNER.INBOX_ROOT - RUNNER.INBOX_ROOT = inbox - try: - for phase in ( - "command-transport", - ): - result = self.build( - inbox, - phase, - f"device-edge-vps-{phase}-load-001", - ) - self.assertEqual(result.returncode, 0, result.stderr) - artifact = Path(json.loads(result.stdout)["artifact"]) - extraction = Path(directory) / f"extract-{phase}" - extraction.mkdir() - loaded = RUNNER.load_artifact(artifact, extraction) - self.assertEqual(loaded["phase"], phase) - self.assertEqual(loaded["entries"], RUNNER.PHASE_ENTRIES[phase]) - self.assertEqual( - loaded["sha256"], - hashlib.sha256(artifact.read_bytes()).hexdigest(), - ) - finally: - RUNNER.INBOX_ROOT = old_inbox - - def test_plan_is_exact_and_never_claims_dns_or_b2_mutation(self): - self.require_runtime_cache() - with tempfile.TemporaryDirectory(prefix="nodedc-vps-plan-") as directory: - inbox = Path(directory) / "inbox" - inbox.mkdir() - result = self.build( - inbox, - "foundation", - "device-edge-vps-foundation-plan-001", - ) - self.assertEqual(result.returncode, 0, result.stderr) - artifact = Path(json.loads(result.stdout)["artifact"]) - old_inbox = RUNNER.INBOX_ROOT - RUNNER.INBOX_ROOT = inbox - try: - with patch.object(RUNNER, "assert_root"), patch.object( - RUNNER, - "preflight", - return_value={"predecessor": "unit-predecessor"}, - ), patch("builtins.print") as output: - RUNNER.plan_artifact(str(artifact)) - finally: - RUNNER.INBOX_ROOT = old_inbox - rendered = "\n".join( - " ".join(str(arg) for arg in call.args) - for call in output.call_args_list - ) - self.assertIn("phase=foundation", rendered) - self.assertIn("public_b2_ingress=disabled", rendered) - self.assertIn("dns=unchanged", rendered) - self.assertIn("b2_routes=unchanged", rendered) - self.assertIn("command_transport=disabled", rendered) - - @unittest.skip("accepted core-channel builder generation is frozen") - def test_core_channel_plan_is_exact_and_keeps_tracker_ingress_closed(self): - with tempfile.TemporaryDirectory(prefix="nodedc-vps-channel-plan-") as directory: - inbox = Path(directory) / "inbox" - inbox.mkdir() - result = self.build( - inbox, - "core-channel", - "device-edge-vps-core-channel-plan-001", - ) - self.assertEqual(result.returncode, 0, result.stderr) - artifact = Path(json.loads(result.stdout)["artifact"]) - old_inbox = RUNNER.INBOX_ROOT - RUNNER.INBOX_ROOT = inbox - try: - with patch.object(RUNNER, "assert_root"), patch.object( - RUNNER, - "preflight", - return_value={"predecessor": "accepted-foundation-closed-channel"}, - ), patch("builtins.print") as output: - RUNNER.plan_artifact(str(artifact)) - finally: - RUNNER.INBOX_ROOT = old_inbox - rendered = "\n".join( - " ".join(str(arg) for arg in call.args) - for call in output.call_args_list - ) - self.assertIn("phase=core-channel", rendered) - self.assertIn( - "predecessor=accepted-foundation-closed-channel", - rendered, - ) - self.assertIn( - "public_core_channel=155.212.211.15:443/tcp:tls13-mtls-h2", - rendered, - ) - self.assertIn("tracker_tcp_9921=closed", rendered) - self.assertIn("public_b2_ingress=disabled", rendered) - self.assertIn( - "peer_trust=preprovisioned-pinned-self-signed-core-certificate+fingerprint", - rendered, - ) - self.assertIn("command_transport=disabled", rendered) - self.assertIn("gelios=untouched", rendered) - - def test_runtime_reconciliation_plan_is_exact_and_opens_no_port(self): - with tempfile.TemporaryDirectory(prefix="nodedc-vps-reconcile-plan-") as directory: - inbox = Path(directory) / "inbox" - inbox.mkdir() - result = self.build( - inbox, - "runtime-reconciliation", - "device-edge-vps-runtime-reconciliation-plan-001", - ) - self.assertEqual(result.returncode, 0, result.stderr) - artifact = Path(json.loads(result.stdout)["artifact"]) - old_inbox = RUNNER.INBOX_ROOT - RUNNER.INBOX_ROOT = inbox - try: - with patch.object(RUNNER, "assert_root"), patch.object( - RUNNER, - "preflight", - return_value={ - "predecessor": ( - "failed-core-channel-001-rollback-runtime-mode-drift" - ), - }, - ), patch("builtins.print") as output: - RUNNER.plan_artifact(str(artifact)) - finally: - RUNNER.INBOX_ROOT = old_inbox - rendered = "\n".join( - " ".join(str(arg) for arg in call.args) - for call in output.call_args_list - ) - self.assertIn("phase=runtime-reconciliation", rendered) - self.assertIn( - "runtime_reconciliation=exact-known-binaries:0644=>0755", - rendered, - ) - self.assertIn("public_core_channel=disabled", rendered) - self.assertIn("tracker_tcp_9921=closed", rendered) - - def test_tailscale_retirement_plan_preserves_channel_and_opens_no_tracker_port(self): - with tempfile.TemporaryDirectory(prefix="nodedc-vps-retirement-plan-") as directory: - inbox = Path(directory) / "inbox" - inbox.mkdir() - result = self.build( - inbox, - "tailscale-retirement", - "device-edge-vps-tailscale-retirement-plan-001", - ) - self.assertEqual(result.returncode, 0, result.stderr) - artifact = Path(json.loads(result.stdout)["artifact"]) - old_inbox = RUNNER.INBOX_ROOT - RUNNER.INBOX_ROOT = inbox - try: - with patch.object(RUNNER, "assert_root"), patch.object( - RUNNER, - "preflight", - return_value={ - "predecessor": "accepted-core-channel-010-with-live-tailnet", - }, - ), patch("builtins.print") as output: - RUNNER.plan_artifact(str(artifact)) - finally: - RUNNER.INBOX_ROOT = old_inbox - rendered = "\n".join( - " ".join(str(arg) for arg in call.args) - for call in output.call_args_list - ) - self.assertIn("phase=tailscale-retirement", rendered) - self.assertIn( - "predecessor=accepted-core-channel-010-with-live-tailnet", - rendered, - ) - self.assertIn( - "public_core_channel=preserved:155.212.211.15:443/tcp:tls13-mtls-h2", - rendered, - ) - self.assertIn( - "tailscale=stop+disable+destroy-local-runtime-state", - rendered, - ) - self.assertIn("tailscale_socks_1055=removed", rendered) - self.assertIn("superseded_backhaul_private_key=removed", rendered) - self.assertIn("tracker_tcp_9921=closed", rendered) - self.assertIn("external_tailnet_machine_cleanup=required-after-deploy-ok", rendered) - self.assertIn("command_transport=disabled", rendered) - self.assertIn("gelios=untouched", rendered) - - @unittest.skip("accepted tracker-ingress builder generation is frozen") - def test_tracker_ingress_plan_is_single_process_bounded_and_command_free(self): - with tempfile.TemporaryDirectory(prefix="nodedc-vps-ingress-plan-") as directory: - inbox = Path(directory) / "inbox" - inbox.mkdir() - result = self.build( - inbox, - "tracker-ingress", - "device-edge-vps-tracker-ingress-plan-001", - ) - self.assertEqual(result.returncode, 0, result.stderr) - artifact = Path(json.loads(result.stdout)["artifact"]) - old_inbox = RUNNER.INBOX_ROOT - RUNNER.INBOX_ROOT = inbox - try: - with patch.object(RUNNER, "assert_root"), patch.object( - RUNNER, - "preflight", - return_value={"predecessor": "accepted-tailscale-retirement-011"}, - ), patch("builtins.print") as output: - RUNNER.plan_artifact(str(artifact)) - finally: - RUNNER.INBOX_ROOT = old_inbox - rendered = "\n".join( - " ".join(str(arg) for arg in call.args) - for call in output.call_args_list - ) - self.assertIn("phase=tracker-ingress", rendered) - self.assertIn("predecessor=accepted-tailscale-retirement-011", rendered) - self.assertIn( - "public_b2_ingress=155.212.211.15:9921/tcp:telemetry-only", - rendered, - ) - self.assertIn("tracker_adapter=arusnavi-b2", rendered) - self.assertIn("tracker_ack=after-core-durable-acceptance-only", rendered) - self.assertIn("runtime_composition=single-non-root-process", rendered) - self.assertIn("tailscale=preserved:absent", rendered) - self.assertIn("command_transport=disabled", rendered) - self.assertIn("gelios=untouched", rendered) - - def test_command_transport_plan_is_typed_single_process_and_bounded(self): - with tempfile.TemporaryDirectory(prefix="nodedc-vps-command-plan-") as directory: - inbox = Path(directory) / "inbox" - inbox.mkdir() - result = self.build( - inbox, - "command-transport", - "device-edge-vps-command-transport-plan-001", - ) - self.assertEqual(result.returncode, 0, result.stderr) - artifact = Path(json.loads(result.stdout)["artifact"]) - old_inbox = RUNNER.INBOX_ROOT - RUNNER.INBOX_ROOT = inbox - try: - with patch.object(RUNNER, "assert_root"), patch.object( - RUNNER, - "preflight", - return_value={"predecessor": "accepted-tracker-ingress-012"}, - ), patch("builtins.print") as output: - RUNNER.plan_artifact(str(artifact)) - finally: - RUNNER.INBOX_ROOT = old_inbox - rendered = "\n".join( - " ".join(str(arg) for arg in call.args) - for call in output.call_args_list - ) - self.assertIn("phase=command-transport", rendered) - self.assertIn("predecessor=accepted-tracker-ingress-012", rendered) - self.assertIn("command_transport=typed-service-ping-v1", rendered) - self.assertIn("command_catalog=allowlisted-adapter-typed-commands-only", rendered) - self.assertIn("runtime_composition=single-non-root-process", rendered) - self.assertIn("public_b2_ingress=155.212.211.15:9921/tcp:bidirectional-session", rendered) - self.assertIn("gelios=untouched-legacy-only", rendered) - - def test_publish_payload_preserves_unselected_executable_modes(self): - with tempfile.TemporaryDirectory(prefix="nodedc-vps-publish-scope-") as directory: - root = Path(directory) - live = root / "live" - payload = root / "payload" - runtime = live / "runtime/node/bin/node" - marker = payload / "deployment/reconciliation.json" - runtime.parent.mkdir(parents=True) - marker.parent.mkdir(parents=True) - runtime.write_bytes(b"runtime-binary") - runtime.chmod(0o755) - marker.write_text("{}\n", encoding="utf-8") - old_live = RUNNER.LIVE_ROOT - RUNNER.LIVE_ROOT = live - try: - with patch.object(RUNNER.os, "chown"): - RUNNER.publish_payload(payload, ("deployment/reconciliation.json",)) - finally: - RUNNER.LIVE_ROOT = old_live - self.assertEqual(runtime.stat().st_mode & 0o777, 0o755) - self.assertEqual( - (live / "deployment/reconciliation.json").stat().st_mode & 0o777, - 0o644, - ) - - def test_source_baseline_is_pinned_to_the_exact_accepted_predecessor(self): - with tempfile.TemporaryDirectory(prefix="nodedc-vps-baseline-") as directory: - journal = Path(directory) / "applied.jsonl" - old_journal = RUNNER.APPLIED_JOURNAL - RUNNER.APPLIED_JOURNAL = journal - try: - journal.write_text(json.dumps({ - "patch": "device-edge-vps-foundation-20260806-003", - "phase": "foundation", - "sha256": ( - "1be852f144e9f0fea32af70bebd07a2607b6a1818825094bd4c1b4062064716a" - ), - "status": "ok", - }) + "\n", encoding="utf-8") - expected = RUNNER.phase_file_sha256("foundation") - self.assertEqual( - expected["vps/config/00-nodedc-b2-vps.conf"], - "cc94d0579f85d0af9746b9ce760bc72980f4a22fb59027e1f5f9c7bf3aaebd64", - ) - self.assertEqual( - expected["vps/config/nftables-foundation.conf"], - "4d44f902d8d98d1aa8506fca9d9582e700f6424def2b1d667ab2cd5a5ee84934", - ) - self.assertEqual( - expected["deployment/device-edge-vps-foundation-v1.json"], - "317c98b42520fff3238275908482de7aa611b4ee41c6b1f8062abd2730ab072a", - ) - - journal.write_text(json.dumps({ - "patch": "device-edge-vps-foundation-20260806-003", - "phase": "foundation", - "sha256": "0" * 64, - "status": "ok", - }) + "\n", encoding="utf-8") - unexpected = RUNNER.phase_file_sha256("foundation") - self.assertEqual( - unexpected["vps/config/00-nodedc-b2-vps.conf"], - RUNNER.PHASE_FILE_SHA256[ - "foundation" - ]["vps/config/00-nodedc-b2-vps.conf"], - ) - finally: - RUNNER.APPLIED_JOURNAL = old_journal - - def test_units_and_firewalls_keep_the_required_boundaries(self): - source_root = SCRIPT_DIR.parent.parent / "device-plane" - foundation = (source_root / "vps/config/nftables-foundation.conf").read_text() - relay = (source_root / "vps/config/nftables-relay.conf").read_text() - channel = (source_root / "vps/config/nftables-core-channel.conf").read_text() - tracker = (source_root / "vps/config/nftables-tracker-ingress.conf").read_text() - sshd = (source_root / "vps/config/00-nodedc-b2-vps.conf").read_text() - backhaul = (source_root / "vps/config/backhaul_ssh_config").read_text() - tailscale_unit = ( - source_root / "vps/systemd/nodedc-b2-tailscaled.service" - ).read_text() - relay_unit = (source_root / "vps/systemd/nodedc-b2-relay.service").read_text() - backhaul_unit = ( - source_root / "vps/systemd/nodedc-b2-backhaul.service" - ).read_text() - channel_unit = ( - source_root / "vps/systemd/nodedc-device-edge-channel.service" - ).read_text() - tracker_unit = ( - source_root / "vps/systemd/nodedc-device-edge-runtime.service" - ).read_text() - - self.assertIn("policy drop", foundation) - self.assertIn("tcp dport 22", foundation) - self.assertNotIn("tcp dport 9921", foundation) - self.assertIn("tcp dport 9921", relay) - self.assertIn("tcp dport 443", channel) - self.assertNotIn("tcp dport 9921", channel) - self.assertIn("tcp dport 443", tracker) - self.assertIn("tcp dport 9921", tracker) - self.assertIn("PasswordAuthentication no", sshd) - self.assertIn("AllowTcpForwarding no", sshd) - self.assertIn("StrictHostKeyChecking yes", backhaul) - self.assertIn("ProxyCommand /usr/bin/nc -X 5 -x 127.0.0.1:1055", backhaul) - self.assertIn("AF_NETLINK", tailscale_unit) - self.assertIn("User=nodedc-edge", tailscale_unit) - self.assertIn("StateDirectoryMode=0700", tailscale_unit) - self.assertIn("User=nodedc-backhaul", backhaul_unit) - self.assertNotIn("User=nodedc-edge", backhaul_unit) - self.assertIn("User=nodedc-relay", relay_unit) - self.assertNotIn("User=nodedc-edge", relay_unit) - self.assertIn("DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only", relay_unit) - self.assertIn("MemoryMax=192M", relay_unit) - self.assertIn("User=nodedc-channel", channel_unit) - self.assertIn( - "ExecStart=/opt/nodedc-b2-vps/runtime/node/bin/node " - "/opt/nodedc-b2-vps/services/device-edge-channel/src/server.mjs", - channel_unit, - ) - self.assertIn("MemoryDenyWriteExecute=no", channel_unit) - self.assertIn( - "CapabilityBoundingSet=CAP_NET_BIND_SERVICE", - channel_unit, - ) - self.assertIn( - "AmbientCapabilities=CAP_NET_BIND_SERVICE", - channel_unit, - ) - self.assertNotIn("--jitless", channel_unit) - self.assertIn("MemoryMax=128M", channel_unit) - self.assertIn("MemorySwapMax=0", channel_unit) - self.assertIn("CPUQuota=50%", channel_unit) - self.assertIn("TasksMax=64", channel_unit) - self.assertIn("LimitNOFILE=1024", channel_unit) - self.assertNotIn("LocalForward", channel_unit) - self.assertNotIn("DEVICE_EDGE_RELAY_UPSTREAM", channel_unit) - self.assertIn("User=nodedc-channel", tracker_unit) - self.assertIn("vps/edge-process/device-edge-runtime.mjs", tracker_unit) - self.assertIn("DEVICE_GATEWAY_TCP_PORT=9921", tracker_unit) - self.assertIn("DEVICE_GATEWAY_MAX_SESSIONS=128", tracker_unit) - self.assertIn("MemoryMax=192M", tracker_unit) - self.assertIn("MemorySwapMax=0", tracker_unit) - self.assertIn("CPUQuota=75%", tracker_unit) - self.assertIn("TasksMax=128", tracker_unit) - self.assertIn("LimitNOFILE=1024", tracker_unit) - self.assertNotIn("LocalForward", tracker_unit) - self.assertNotIn("DEVICE_EDGE_RELAY_UPSTREAM", tracker_unit) - - def test_runner_has_registered_rollback_and_no_generic_latest(self): - source = RUNNER_PATH.read_text(encoding="utf-8") - self.assertIn("def rollback(", source) - self.assertIn('TAILSCALE_REQUIRED_TAG = "tag:device-edge-vps"', source) - self.assertIn("assign_backhaul_trust", source) - self.assertIn("deploy-ok patch=", source) - self.assertNotIn("apply-latest", source) - self.assertNotIn("compose down", source) - self.assertNotIn("docker system prune", source) - - def test_executable_preflight_accepts_a_valid_alternatives_symlink(self): - with tempfile.TemporaryDirectory(prefix="nodedc-vps-tool-") as directory: - root = Path(directory) - target = root / "netcat.openbsd" - target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - target.chmod(0o755) - command = root / "nc" - command.symlink_to(target.name) - - self.assertEqual( - RUNNER.assert_executable_command_path(command, "test command"), - target.resolve(), - ) - - def test_executable_preflight_rejects_a_broken_symlink(self): - with tempfile.TemporaryDirectory(prefix="nodedc-vps-tool-") as directory: - command = Path(directory) / "nc" - command.symlink_to("missing-netcat") - with self.assertRaises(RUNNER.DeployError): - RUNNER.assert_executable_command_path(command, "test command") - - def test_backup_restore_preserves_the_exact_relay_partition(self): - with tempfile.TemporaryDirectory(prefix="nodedc-vps-backup-") as directory: - root = Path(directory) - live = root / "live" - backups = root / "backups" - nft = root / "etc/nftables.conf" - relay_unit = root / "etc/nodedc-b2-relay.service" - backups.mkdir() - nft.parent.mkdir(parents=True) - nft.write_text("foundation-firewall\n", encoding="utf-8") - relay_unit.write_text("old-unit\n", encoding="utf-8") - for relative in RUNNER.RELAY_ENTRIES: - target = live / relative - if relative.endswith("/src"): - target.mkdir(parents=True) - (target / "server.mjs").write_text("old-source\n", encoding="utf-8") - else: - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(f"old:{relative}\n", encoding="utf-8") - - old_live = RUNNER.LIVE_ROOT - old_backups = RUNNER.BACKUP_ROOT - old_nft = RUNNER.NFTABLES_CONFIG - old_relay_unit = RUNNER.RELAY_UNIT - RUNNER.LIVE_ROOT = live - RUNNER.BACKUP_ROOT = backups - RUNNER.NFTABLES_CONFIG = nft - RUNNER.RELAY_UNIT = relay_unit - completed = subprocess.CompletedProcess([], 0, "table inet old {}\n", "") - try: - with patch.object(RUNNER, "run", return_value=completed), patch.object( - RUNNER, - "service_active", - return_value=False, - ), patch.object( - RUNNER, - "systemctl", - return_value=completed, - ), patch.object( - RUNNER, - "user_exists", - return_value=False, - ): - _backup_id, backup = RUNNER.create_backup("relay-unit", "relay") - nft.write_text("candidate-firewall\n", encoding="utf-8") - relay_unit.write_text("candidate-unit\n", encoding="utf-8") - (live / "services/device-edge-relay/src/server.mjs").write_text( - "candidate-source\n", - encoding="utf-8", - ) - RUNNER.restore_backup(backup, "relay") - finally: - RUNNER.LIVE_ROOT = old_live - RUNNER.BACKUP_ROOT = old_backups - RUNNER.NFTABLES_CONFIG = old_nft - RUNNER.RELAY_UNIT = old_relay_unit - - self.assertEqual(nft.read_text(), "foundation-firewall\n") - self.assertEqual(relay_unit.read_text(), "old-unit\n") - self.assertEqual( - (live / "services/device-edge-relay/src/server.mjs").read_text(), - "old-source\n", - ) - - def test_backup_restore_preserves_core_channel_source_trust_and_firewall(self): - with tempfile.TemporaryDirectory(prefix="nodedc-vps-channel-backup-") as directory: - root = Path(directory) - live = root / "live" - backups = root / "backups" - nft = root / "etc/nftables.conf" - channel_unit = root / "etc/nodedc-device-edge-channel.service" - trust = root / "state/channel-trust" - backups.mkdir() - nft.parent.mkdir(parents=True) - trust.mkdir(parents=True) - nft.write_text("foundation-firewall\n", encoding="utf-8") - channel_unit.parent.mkdir(parents=True, exist_ok=True) - channel_unit.write_text("old-channel-unit\n", encoding="utf-8") - (trust / "runtime.json").write_text("old-runtime\n", encoding="utf-8") - for relative in RUNNER.CORE_CHANNEL_ENTRIES: - target = live / relative - if relative.endswith("/src"): - target.mkdir(parents=True) - (target / "server.mjs").write_text("old-channel-source\n", encoding="utf-8") - else: - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(f"old:{relative}\n", encoding="utf-8") - - old_live = RUNNER.LIVE_ROOT - old_backups = RUNNER.BACKUP_ROOT - old_nft = RUNNER.NFTABLES_CONFIG - old_unit = RUNNER.CHANNEL_UNIT - old_trust = RUNNER.CHANNEL_TRUST_ROOT - RUNNER.LIVE_ROOT = live - RUNNER.BACKUP_ROOT = backups - RUNNER.NFTABLES_CONFIG = nft - RUNNER.CHANNEL_UNIT = channel_unit - RUNNER.CHANNEL_TRUST_ROOT = trust - completed = subprocess.CompletedProcess([], 0, "table inet old {}\n", "") - try: - with patch.object(RUNNER, "run", return_value=completed), patch.object( - RUNNER, - "service_active", - return_value=False, - ), patch.object( - RUNNER, - "systemctl", - return_value=completed, - ), patch.object( - RUNNER, - "user_exists", - return_value=False, - ): - _backup_id, backup = RUNNER.create_backup( - "channel-unit", - "core-channel", - ) - nft.write_text("candidate-firewall\n", encoding="utf-8") - channel_unit.write_text("candidate-channel-unit\n", encoding="utf-8") - (trust / "runtime.json").write_text("candidate-runtime\n", encoding="utf-8") - (live / "services/device-edge-channel/src/server.mjs").write_text( - "candidate-channel-source\n", - encoding="utf-8", - ) - RUNNER.restore_backup(backup, "core-channel") - finally: - RUNNER.LIVE_ROOT = old_live - RUNNER.BACKUP_ROOT = old_backups - RUNNER.NFTABLES_CONFIG = old_nft - RUNNER.CHANNEL_UNIT = old_unit - RUNNER.CHANNEL_TRUST_ROOT = old_trust - - self.assertEqual(nft.read_text(), "foundation-firewall\n") - self.assertEqual(channel_unit.read_text(), "old-channel-unit\n") - self.assertEqual((trust / "runtime.json").read_text(), "old-runtime\n") - self.assertEqual( - (live / "services/device-edge-channel/src/server.mjs").read_text(), - "old-channel-source\n", - ) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_device_manager_control_plane_artifacts.py b/infra/deploy-runner/test_device_manager_control_plane_artifacts.py deleted file mode 100644 index b93a0d7..0000000 --- a/infra/deploy-runner/test_device_manager_control_plane_artifacts.py +++ /dev/null @@ -1,2157 +0,0 @@ -#!/usr/bin/env python3 -import hashlib -import importlib.machinery -import importlib.util -import json -import os -import subprocess -import tarfile -import tempfile -import unittest -from pathlib import Path -from unittest import mock - - -SCRIPT_DIR = Path(__file__).resolve().parent -PLATFORM_ROOT = SCRIPT_DIR.parent.parent -RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" - - -def load_runner(): - loader = importlib.machinery.SourceFileLoader( - "nodedc_device_manager_deploy_under_test", - str(RUNNER_PATH), - ) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -RUNNER = load_runner() -LAUNCHER_HUB_SERVICE_TRUST_UI_ENTRIES = ( - "src/app/LauncherApp.tsx", - "src/styles/globals.css", - "src/widgets/admin-overlay/AdminOverlay.tsx", -) - - -def healthy_device_plane_inventory(*, include_manager=False): - services = [ - ("device-control-core", "a"), - ("device-gateway", "b"), - ("device-postgres", "c"), - ] - if include_manager: - services.append(("device-manager", "d")) - return { - "schemaVersion": "nodedc.device-plane.runtime-inventory.v1", - "composeProject": "nodedc-device-plane", - "services": [ - { - "service": service, - "containerId": character * 64, - "imageId": f"sha256:{character * 64}", - "status": "running", - "running": True, - "health": "healthy", - "restartCount": 0, - } - for service, character in services - ], - } - - -def device_manager_release_descriptor( - release_id="device-manager-release-unit-001", - *, - action="activate", - predecessor_kind="reconciliation", - predecessor_patch="device-manager-reconciliation-unit-001", - predecessor_sha="a" * 64, -): - return { - "schemaVersion": "nodedc.device-plane.device-manager-release.v2", - "releaseId": release_id, - "action": action, - "predecessor": { - "kind": predecessor_kind, - "patchId": predecessor_patch, - "artifactSha256": predecessor_sha, - }, - **RUNNER.expected_device_plane_manager_release_v2_boundaries(), - } - - -def device_manager_release_v1_descriptor( - release_id="device-manager-release-v1-unit-001", - *, - action="upgrade", - predecessor_kind="release", - predecessor_patch="device-manager-release-v1-unit-000", - predecessor_sha="a" * 64, -): - return { - "schemaVersion": "nodedc.device-plane.device-manager-release.v1", - "releaseId": release_id, - "action": action, - "predecessor": { - "kind": predecessor_kind, - "patchId": predecessor_patch, - "artifactSha256": predecessor_sha, - }, - **RUNNER.expected_device_plane_manager_release_v1_boundaries(), - } - - -def device_manager_release_v3_descriptor( - release_id="device-manager-release-v3-unit-001", - *, - predecessor_patch="device-manager-release-20260811-010", - predecessor_sha="d" * 64, -): - return { - "schemaVersion": "nodedc.device-plane.device-manager-release.v3", - "releaseId": release_id, - "action": "upgrade", - "predecessor": { - "kind": "release", - "patchId": predecessor_patch, - "artifactSha256": predecessor_sha, - }, - **RUNNER.expected_device_plane_manager_release_v3_boundaries(), - } - - -class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): - def build(self, script, patch_id, artifact_dir): - environment = os.environ.copy() - environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) - completed = subprocess.run( - ["node", str(SCRIPT_DIR / script), patch_id], - cwd=PLATFORM_ROOT, - env=environment, - check=True, - capture_output=True, - text=True, - ) - return json.loads(completed.stdout) - - def assert_deterministic_artifact(self, script, patch_id, expected_entries): - with tempfile.TemporaryDirectory(prefix="nodedc-device-manager-artifact-") as directory: - root = Path(directory) - first = self.build(script, patch_id, root / "first") - second = self.build(script, patch_id, root / "second") - first_artifact = Path(first["artifact"]) - second_artifact = Path(second["artifact"]) - self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes()) - self.assertEqual( - first["sha256"], - hashlib.sha256(first_artifact.read_bytes()).hexdigest(), - ) - self.assertEqual(tuple(first["entries"]), tuple(expected_entries)) - extracted = root / "extracted" - extracted.mkdir() - manifest, entries, payload = RUNNER.load_artifact(first_artifact, extracted) - self.assertEqual(tuple(entries), tuple(expected_entries)) - with tarfile.open(first_artifact, "r:gz") as archive: - members = archive.getmembers() - names = [member.name for member in members] - bytes_joined = b"\n".join( - archive.extractfile(member).read() - for member in members - if member.isfile() - ) - self.assertFalse(any(Path(name).name.startswith("._") for name in names)) - self.assertFalse(any("/node_modules/" in name or "/.git/" in name for name in names)) - self.assertNotIn(b"-----BEGIN PRIVATE KEY-----", bytes_joined) - return manifest, entries, names, first - - def test_platform_hub_trust_artifact_is_exact_and_build_free(self): - manifest, entries, _names, result = self.assert_deterministic_artifact( - "build-platform-device-core-hub-trust-artifact.mjs", - "platform-device-core-hub-trust-unit-001", - RUNNER.PLATFORM_DEVICE_CORE_HUB_TRUST_ENTRIES, - ) - self.assertEqual(manifest["component"], "platform") - self.assertEqual(RUNNER.component_services("platform", entries), ("launcher",)) - self.assertEqual(RUNNER.component_builds("platform", entries), ()) - self.assertEqual(result["services"], ["launcher"]) - - def test_launcher_session_artifact_is_exact(self): - manifest, entries, _names, result = self.assert_deterministic_artifact( - "build-launcher-device-core-artifact.mjs", - "launcher-device-core-session-unit-001", - RUNNER.LAUNCHER_DEVICE_CORE_SESSION_ENTRIES, - ) - self.assertEqual(manifest["component"], "launcher") - self.assertEqual(RUNNER.component_services("launcher", entries), ("launcher",)) - self.assertEqual(len(RUNNER.component_builds("launcher", entries)), 1) - self.assertEqual(result["services"], ["launcher"]) - - def test_launcher_hub_service_trust_ui_artifact_is_exact(self): - manifest, entries, _names, result = self.assert_deterministic_artifact( - "build-launcher-hub-service-trust-ui-artifact.mjs", - "launcher-hub-service-trust-ui-unit-001", - LAUNCHER_HUB_SERVICE_TRUST_UI_ENTRIES, - ) - self.assertEqual(manifest["component"], "launcher") - self.assertEqual(RUNNER.component_services("launcher", entries), ("launcher",)) - self.assertEqual(len(RUNNER.component_builds("launcher", entries)), 1) - self.assertEqual(result["services"], ["launcher"]) - - def test_device_manager_artifact_selects_only_core_and_manager(self): - manifest, entries, names, result = self.assert_deterministic_artifact( - "build-device-manager-control-plane-artifact.mjs", - "device-manager-control-plane-unit-001", - RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V1_SUCCESSOR_ENTRIES, - ) - self.assertEqual(manifest["component"], "device-plane") - self.assertEqual( - RUNNER.component_services("device-plane", entries), - ("device-control-core", "device-manager"), - ) - builds = RUNNER.component_builds("device-plane", entries) - self.assertEqual(len(builds), 2) - self.assertIn(RUNNER.DEVICE_PLANE_CONTROL_CORE_IMAGE, builds[0][1]) - self.assertIn(RUNNER.DEVICE_PLANE_MANAGER_IMAGE, builds[1][1]) - self.assertIn("payload/services/device-manager/dist/index.html", names) - self.assertIn( - "payload/services/device-manager/server/device-manager-server.mjs", - names, - ) - self.assertIn( - "payload/services/device-control-core/src/credential-reference.mjs", - names, - ) - self.assertIn( - "payload/packages/device-edge-channel-contract/src/index.mjs", - names, - ) - self.assertIn("packages/device-edge-channel-contract", entries) - self.assertIn( - "payload/services/device-control-core/package-lock.json", - names, - ) - self.assertIn( - "payload/services/device-control-core/src/device-gateway-core-runtime.mjs", - names, - ) - self.assertFalse(any( - name.startswith("payload/packages/device-adapter-runtime/") - or name.startswith("payload/packages/device-adapter-catalog/") - or name.startswith("payload/services/device-gateway-core/") - for name in names - )) - core_dockerfile = ( - PLATFORM_ROOT / "device-plane/services/device-control-core/Dockerfile" - ).read_text(encoding="utf-8") - self.assertIn( - "COPY services/device-control-core/package.json " - "services/device-control-core/package-lock.json ./", - core_dockerfile, - ) - self.assertNotIn("COPY package.json package-lock.json ./", core_dockerfile) - self.assertNotIn("COPY services/device-gateway-core", core_dockerfile) - self.assertFalse(any(name.endswith(".test.mjs") for name in names)) - self.assertEqual(result["services"], ["device-control-core", "device-manager"]) - self.assertNotIn("device-postgres", result["services"]) - self.assertIn("docker-compose.device-manager.yml", entries) - self.assertNotIn("docker-compose.device-plane.yml", entries) - compose = ( - PLATFORM_ROOT / "device-plane/docker-compose.device-manager.yml" - ).read_text(encoding="utf-8") - for forbidden in ( - "DEVICE_EDGE_CHANNEL_", - "device-edge-channel/", - "name: nodedc-device-plane-egress", - ): - self.assertNotIn(forbidden, compose) - self.assertNotIn("PRIVATE KEY", compose) - checks = RUNNER.component_healthchecks("device-plane", entries, tuple(result["services"])) - self.assertEqual(checks[0]["expected_json"]["managementApi"], "enabled") - self.assertEqual(checks[0]["expected_json"]["discoveryIngest"], "enabled") - - def test_device_manager_release_v3_is_exact_typed_and_secret_free(self): - patch_id = "device-manager-release-v3-unit-001" - manifest, entries, names, result = self.assert_deterministic_artifact( - "build-device-manager-control-plane-artifact.mjs", - patch_id, - RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V3_ENTRIES, - ) - self.assertEqual(manifest["component"], "device-plane") - self.assertEqual( - RUNNER.component_services("device-plane", entries), - ("device-manager",), - ) - self.assertEqual(result["services"], ["device-manager"]) - self.assertIn( - "payload/deployment/device-manager-release-v3.json", - names, - ) - self.assertFalse( - any(name.startswith("payload/services/device-control-core/") for name in names) - ) - self.assertFalse( - any(name.startswith("payload/packages/") for name in names) - ) - self.assertFalse(any(name.endswith(".test.mjs") for name in names)) - descriptor = device_manager_release_v3_descriptor(patch_id) - self.assertIs( - RUNNER.validate_device_plane_manager_release_descriptor( - descriptor, - schema_version=( - "nodedc.device-plane.device-manager-release.v3" - ), - boundaries=( - RUNNER.expected_device_plane_manager_release_v3_boundaries() - ), - expected_release_id=patch_id, - ), - descriptor, - ) - self.assertEqual(descriptor["commandTransport"], "typed-service-ping-v1") - self.assertEqual( - descriptor["commandCatalog"], - "allowlisted-adapter-typed-commands-only", - ) - self.assertEqual(descriptor["gelios"], "untouched-legacy-only") - self.assertTrue( - RUNNER.is_device_plane_manager_release_v3_slice( - "device-plane", - entries, - ) - ) - - def test_edge_core_channel_bootstrap_is_core_only_and_secret_free(self): - manifest, entries, names, result = self.assert_deterministic_artifact( - "build-device-edge-core-channel-bootstrap-artifact.mjs", - "device-edge-core-channel-bootstrap-unit-001", - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_ENTRIES, - ) - self.assertEqual(manifest["component"], "device-plane") - self.assertEqual( - RUNNER.component_services("device-plane", entries), - ("device-control-core",), - ) - builds = RUNNER.component_builds("device-plane", entries) - self.assertEqual(len(builds), 1) - self.assertIn(RUNNER.DEVICE_PLANE_CONTROL_CORE_IMAGE, builds[0][1]) - self.assertEqual(result["services"], ["device-control-core"]) - self.assertFalse(any( - name.startswith("payload/services/device-manager/") - or name.startswith("payload/services/device-gateway/") - for name in names - )) - self.assertIn( - "payload/packages/device-edge-channel-contract/src/index.mjs", - names, - ) - self.assertIn( - "payload/docker-compose.device-edge-core-channel.yml", - names, - ) - checks = RUNNER.component_healthchecks( - "device-plane", - entries, - tuple(result["services"]), - ) - self.assertEqual(checks[0]["expected_json"]["managementApi"], "enabled") - self.assertEqual(checks[0]["expected_json"]["commandTransport"], "disabled") - - def test_edge_core_channel_upgrade_is_core_only_and_pins_bootstrap_018(self): - manifest, entries, names, result = self.assert_deterministic_artifact( - "build-device-edge-core-channel-bootstrap-artifact.mjs", - "device-edge-core-channel-upgrade-unit-001", - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_ENTRIES, - ) - self.assertEqual(manifest["component"], "device-plane") - self.assertEqual( - RUNNER.component_services("device-plane", entries), - ("device-control-core",), - ) - self.assertEqual(result["services"], ["device-control-core"]) - self.assertIn( - "payload/deployment/device-edge-core-channel-upgrade-v1.json", - names, - ) - descriptor = RUNNER.expected_device_plane_edge_core_channel_upgrade_descriptor( - "device-edge-core-channel-upgrade-unit-001" - ) - self.assertEqual(descriptor["action"], "upgrade") - self.assertEqual( - descriptor["bootstrapPredecessor"]["patchId"], - "device-edge-core-channel-bootstrap-20260812-018", - ) - self.assertEqual( - descriptor["endpointPolicy"], - "public-ipv4-standard-https-tcp-443-only", - ) - - def test_edge_core_channel_upgrade_v2_is_core_only_and_pins_upgrade_019(self): - patch_id = "device-edge-core-channel-upgrade-v2-unit-001" - manifest, entries, names, result = self.assert_deterministic_artifact( - "build-device-edge-core-channel-bootstrap-artifact.mjs", - patch_id, - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES, - ) - self.assertEqual(manifest["component"], "device-plane") - self.assertEqual( - RUNNER.component_services("device-plane", entries), - ("device-control-core",), - ) - self.assertEqual(len(RUNNER.component_builds("device-plane", entries)), 1) - self.assertEqual(result["services"], ["device-control-core"]) - self.assertIn( - "payload/deployment/device-edge-core-channel-upgrade-v2.json", - names, - ) - descriptor = ( - RUNNER.expected_device_plane_edge_core_channel_upgrade_v2_descriptor( - patch_id - ) - ) - self.assertEqual(descriptor["action"], "upgrade") - self.assertEqual( - descriptor["upgradePredecessor"]["patchId"], - "device-edge-core-channel-upgrade-20260812-019", - ) - self.assertEqual( - descriptor["upgradePredecessor"]["artifactSha256"], - "8e9a220275959f378c1c4b00be5c7192e79afe2134eaab808a64e515870a8438", - ) - self.assertEqual(descriptor["edgeRegistrations"], "preserved") - self.assertTrue( - RUNNER.is_device_plane_edge_core_channel_upgrade_slice( - "device-plane", - entries, - ) - ) - self.assertTrue( - RUNNER.is_device_plane_edge_core_channel_upgrade_v2_slice( - "device-plane", - entries, - ) - ) - - def test_edge_core_channel_upgrade_v4_is_core_only_and_pins_upgrade_021(self): - patch_id = "device-edge-core-channel-upgrade-v4-unit-001" - manifest, entries, names, result = self.assert_deterministic_artifact( - "build-device-edge-core-channel-bootstrap-artifact.mjs", - patch_id, - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_ENTRIES, - ) - self.assertEqual(manifest["component"], "device-plane") - self.assertEqual( - RUNNER.component_services("device-plane", entries), - ("device-control-core",), - ) - self.assertEqual(len(RUNNER.component_builds("device-plane", entries)), 1) - self.assertEqual(result["services"], ["device-control-core"]) - self.assertIn( - "payload/deployment/device-edge-core-channel-upgrade-v4.json", - names, - ) - self.assertIn( - "payload/docker-compose.device-plane.yml", - names, - ) - descriptor = ( - RUNNER.expected_device_plane_edge_core_channel_upgrade_v4_descriptor( - patch_id - ) - ) - self.assertEqual( - descriptor["upgradePredecessor"]["patchId"], - "device-edge-core-channel-upgrade-v2-20260812-021", - ) - self.assertEqual( - descriptor["upgradePredecessor"]["artifactSha256"], - "e40a6fd24edfecac09e42cd82635a77850541bcf047788db3e9c55d2b9e58867", - ) - self.assertEqual( - descriptor["failedAttempt"]["patchId"], - "device-edge-core-channel-upgrade-v3-20260812-022", - ) - self.assertEqual( - descriptor["failedAttempt"]["artifactSha256"], - "9e2b409a4b2d19711db434e90d03ac8e3db77bd74949f83cace7949f33caf613", - ) - self.assertEqual( - descriptor["coreNetworks"], - ["device-plane-private", "device-plane-egress"], - ) - self.assertEqual( - descriptor["removedCoreNetwork"], - "device-plane-control", - ) - self.assertEqual( - descriptor["composeCompatibility"], - "synology-compose-v2.20-no-gw-priority", - ) - self.assertTrue( - RUNNER.is_device_plane_edge_core_channel_upgrade_v4_slice( - "device-plane", - entries, - ) - ) - - def test_edge_core_channel_upgrade_v4_rejects_installed_marker(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-edge-upgrade-v4-installed-", - ) as directory: - root = Path(directory) - marker = root / RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL - marker.parent.mkdir(parents=True) - marker.write_text("{}\n", encoding="utf-8") - descriptor = ( - RUNNER.expected_device_plane_edge_core_channel_upgrade_v4_descriptor( - "device-edge-core-channel-upgrade-v4-unit-002" - ) - ) - with ( - mock.patch.object(RUNNER, "DEVICE_PLANE_ROOT", root), - mock.patch.object( - RUNNER, - "validate_device_plane_edge_core_channel_upgrade_v4_payload", - return_value=descriptor, - ), - ): - with self.assertRaisesRegex( - RUNNER.DeployError, - "upgrade v4 is already installed", - ): - RUNNER.validate_device_plane_edge_core_channel_upgrade_v4_predecessor( - root / "payload" - ) - - def test_control_core_release_is_repeatable_core_only_and_compose_free(self): - patch_id = "device-control-core-release-unit-001" - manifest, entries, names, result = self.assert_deterministic_artifact( - "build-device-control-core-release-artifact.mjs", - patch_id, - RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_ENTRIES, - ) - self.assertEqual(manifest["component"], "device-plane") - self.assertEqual( - RUNNER.component_services("device-plane", entries), - ("device-control-core",), - ) - self.assertEqual(len(RUNNER.component_builds("device-plane", entries)), 1) - self.assertEqual(result["services"], ["device-control-core"]) - self.assertIn( - "payload/deployment/device-control-core-release-v1.json", - names, - ) - self.assertFalse(any("docker-compose" in name for name in names)) - descriptor = RUNNER.expected_device_plane_control_core_release_descriptor( - patch_id, - { - "kind": "edge-core-channel-upgrade-v4", - "patchId": ( - RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_FIRST_PREDECESSOR_PATCH_ID - ), - "artifactSha256": ( - RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_FIRST_PREDECESSOR_ARTIFACT_SHA256 - ), - }, - ) - self.assertEqual( - descriptor["coreNetworks"], - ["device-plane-private", "device-plane-egress"], - ) - self.assertEqual(descriptor["edgeRegistrations"], "preserved") - - def test_control_core_release_apply_gate_checks_preserved_runtime(self): - entries = RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_ENTRIES - services = ("device-control-core",) - with ( - mock.patch.object( - RUNNER, - "healthcheck_compose_service_with_grace", - ) as service_health, - mock.patch.object(RUNNER, "healthcheck_url") as url_health, - mock.patch.object( - RUNNER, - "validate_device_manager_control_plane_runtime", - ) as runtime_acceptance, - ): - RUNNER.run_healthchecks("device-plane", entries, services) - - self.assertEqual( - [call.args for call in service_health.call_args_list], - [ - ("device-plane", "device-control-core"), - ("device-plane", "device-manager"), - ("device-plane", "device-gateway"), - ("device-plane", "device-postgres"), - ], - ) - url_health.assert_called_once_with( - RUNNER.component_healthchecks( - "device-plane", - entries, - services, - )[0] - ) - runtime_acceptance.assert_called_once_with( - require_edge_channel=True, - core_network_mode="private-egress", - ) - - def test_control_core_release_v2_is_typed_core_only(self): - patch_id = "device-control-core-release-v2-unit-001" - manifest, entries, names, result = self.assert_deterministic_artifact( - "build-device-control-core-release-artifact.mjs", - patch_id, - RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_ENTRIES, - ) - self.assertEqual(manifest["component"], "device-plane") - self.assertEqual(result["services"], ["device-control-core"]) - self.assertIn( - "payload/deployment/device-control-core-release-v2.json", - names, - ) - self.assertFalse(any("docker-compose" in name for name in names)) - self.assertEqual( - RUNNER.component_services("device-plane", entries), - ("device-control-core",), - ) - descriptor = RUNNER.expected_device_plane_control_core_release_descriptor( - patch_id, - { - "kind": "release", - "patchId": "device-control-core-release-20260812-024", - "artifactSha256": ( - "a289e909283109642e6bba3d9822a31f63423cfe0bbcd52705979681bd2bc793" - ), - }, - schema_version="v2", - ) - self.assertEqual(descriptor["commandTransport"], "typed-service-ping-v1") - self.assertEqual( - descriptor["commandCatalog"], - "allowlisted-adapter-typed-commands-only", - ) - self.assertEqual(descriptor["gelios"], "untouched-legacy-only") - - def test_control_core_release_builder_supports_release_predecessor(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-control-core-successor-", - ) as directory: - result = self.build( - "build-device-control-core-release-artifact.mjs", - "device-control-core-release-unit-004", - Path(directory), - ) - first_artifact = Path(result["artifact"]) - # Rebuild through the CLI's repeatable-release predecessor form. - environment = os.environ.copy() - successor_dir = Path(directory) / "successor" - environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(successor_dir) - predecessor_id = "device-control-core-release-unit-004" - predecessor_sha = hashlib.sha256( - first_artifact.read_bytes() - ).hexdigest() - completed = subprocess.run( - [ - "node", - str( - SCRIPT_DIR - / "build-device-control-core-release-artifact.mjs" - ), - "device-control-core-release-unit-005", - predecessor_id, - predecessor_sha, - ], - cwd=PLATFORM_ROOT, - env=environment, - check=True, - capture_output=True, - text=True, - ) - successor = json.loads(completed.stdout) - extracted = Path(directory) / "extracted-successor" - extracted.mkdir() - _manifest, _entries, payload = RUNNER.load_artifact( - Path(successor["artifact"]), - extracted, - ) - descriptor = json.loads( - ( - payload / RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_REL - ).read_text(encoding="utf-8") - ) - self.assertEqual( - descriptor["predecessor"], - { - "kind": "release", - "patchId": predecessor_id, - "artifactSha256": predecessor_sha, - }, - ) - - def test_control_core_release_v2_builder_supports_v2_release_predecessor(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-control-core-v2-successor-", - ) as directory: - artifact_dir = Path(directory) / "successor" - environment = os.environ.copy() - environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) - predecessor_id = "device-control-core-release-v2-unit-001" - predecessor_sha = "b" * 64 - completed = subprocess.run( - [ - "node", - str( - SCRIPT_DIR - / "build-device-control-core-release-artifact.mjs" - ), - "device-control-core-release-v2-unit-002", - predecessor_id, - predecessor_sha, - ], - cwd=PLATFORM_ROOT, - env=environment, - check=True, - capture_output=True, - text=True, - ) - successor = json.loads(completed.stdout) - extracted = Path(directory) / "extracted-successor" - extracted.mkdir() - _manifest, entries, payload = RUNNER.load_artifact( - Path(successor["artifact"]), - extracted, - ) - self.assertEqual( - tuple(entries), - RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_ENTRIES, - ) - descriptor = json.loads( - ( - payload / RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_REL - ).read_text(encoding="utf-8") - ) - self.assertEqual( - descriptor["predecessor"], - { - "kind": "release", - "patchId": predecessor_id, - "artifactSha256": predecessor_sha, - }, - ) - - def test_control_core_release_v2_accepts_current_v2_release_predecessor(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-control-core-v2-predecessor-", - ) as directory: - root = Path(directory) - applied = root / "applied" - temporary = root / "tmp" - device_plane = root / "device-plane" - for path in (applied, temporary, device_plane / "deployment"): - path.mkdir(parents=True) - predecessor_id = "device-control-core-release-v2-unit-001" - predecessor_sha = "b" * 64 - artifact_name = f"nodedc-device-plane-{predecessor_id}.tgz" - (applied / artifact_name).write_bytes(b"reviewed-v2-predecessor") - predecessor = { - "kind": "release", - "patchId": predecessor_id, - "artifactSha256": predecessor_sha, - } - predecessor_descriptor = ( - RUNNER.expected_device_plane_control_core_release_descriptor( - predecessor_id, - { - "kind": "release", - "patchId": "device-control-core-release-20260812-024", - "artifactSha256": "a" * 64, - }, - schema_version="v2", - ) - ) - candidate_descriptor = ( - RUNNER.expected_device_plane_control_core_release_descriptor( - "device-control-core-release-v2-unit-002", - predecessor, - schema_version="v2", - ) - ) - installed = ( - device_plane / RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_REL - ) - installed.write_text( - json.dumps(predecessor_descriptor), - encoding="utf-8", - ) - with ( - mock.patch.object(RUNNER, "APPLIED_DIR", applied), - mock.patch.object(RUNNER, "TMP_DIR", temporary), - mock.patch.object(RUNNER, "DEVICE_PLANE_ROOT", device_plane), - mock.patch.object( - RUNNER, - "validate_device_plane_control_core_release_payload", - side_effect=(candidate_descriptor, predecessor_descriptor), - ), - mock.patch.object( - RUNNER, - "load_artifact", - return_value=( - { - "id": predecessor_id, - "component": "device-plane", - "type": "app-overlay", - }, - RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_V2_ENTRIES, - root / "predecessor-payload", - ), - ), - mock.patch.object( - RUNNER, - "load_state", - return_value=[{ - "id": predecessor_id, - "artifact": artifact_name, - "component": "device-plane", - "sha256": predecessor_sha, - "status": "ok", - }], - ), - mock.patch.object( - RUNNER, - "sha256_file", - return_value=predecessor_sha, - ), - mock.patch.object( - RUNNER, - "collect_exact_files", - return_value={"same": "source"}, - ), - mock.patch.object(RUNNER, "component_compose_files"), - mock.patch.object( - RUNNER, - "inspect_device_edge_channel_core_identity_state", - return_value="valid-reuse-at-apply", - ), - mock.patch.object(RUNNER, "healthcheck_compose_service"), - mock.patch.object( - RUNNER, - "validate_device_manager_control_plane_runtime", - ), - ): - result = ( - RUNNER.validate_device_plane_control_core_release_predecessor( - root / "candidate-payload" - ) - ) - - self.assertEqual( - result["mode"], - "active-device-control-core-forward-release", - ) - - def test_control_core_release_rejects_direct_legacy_predecessor(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-control-core-release-invalid-", - ) as directory: - payload = Path(directory) - descriptor_path = ( - payload / RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_REL - ) - descriptor_path.parent.mkdir(parents=True) - predecessor = { - "kind": "release", - "patchId": "device-edge-core-channel-upgrade-v4-20260812-023", - "artifactSha256": "a" * 64, - } - descriptor = RUNNER.expected_device_plane_control_core_release_descriptor( - "device-control-core-release-unit-002", - predecessor, - ) - descriptor_path.write_text( - json.dumps(descriptor), - encoding="utf-8", - ) - with self.assertRaisesRegex( - RUNNER.DeployError, - "release descriptor mismatch", - ): - RUNNER.validate_device_plane_control_core_release_payload( - payload - ) - - def test_edge_core_channel_upgrade_v2_rejects_installed_marker(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-edge-upgrade-v2-installed-", - ) as directory: - root = Path(directory) - marker = root / RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL - marker.parent.mkdir(parents=True) - marker.write_text("{}\n", encoding="utf-8") - descriptor = ( - RUNNER.expected_device_plane_edge_core_channel_upgrade_v2_descriptor( - "device-edge-core-channel-upgrade-v2-unit-002" - ) - ) - with ( - mock.patch.object(RUNNER, "DEVICE_PLANE_ROOT", root), - mock.patch.object( - RUNNER, - "validate_device_plane_edge_core_channel_upgrade_v2_payload", - return_value=descriptor, - ), - ): - with self.assertRaisesRegex( - RUNNER.DeployError, - "upgrade v2 is already installed", - ): - RUNNER.validate_device_plane_edge_core_channel_upgrade_v2_predecessor( - root / "payload" - ) - - def test_edge_core_channel_upgrade_v2_accepts_exact_applied_019(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-edge-upgrade-v2-predecessor-", - ) as directory: - root = Path(directory) - applied = root / "applied" - temporary = root / "tmp" - device_plane = root / "device-plane" - for path in (applied, temporary, device_plane / "deployment"): - path.mkdir(parents=True) - predecessor_patch = ( - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_PREDECESSOR_PATCH_ID - ) - predecessor_sha = ( - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_PREDECESSOR_ARTIFACT_SHA256 - ) - artifact_name = f"nodedc-device-plane-{predecessor_patch}.tgz" - artifact = applied / artifact_name - artifact.write_bytes(b"reviewed-upgrade-019") - state_file = root / "applied.jsonl" - state_file.write_text( - json.dumps({ - "id": predecessor_patch, - "artifact": artifact_name, - "component": "device-plane", - "sha256": predecessor_sha, - "status": "ok", - }) + "\n", - encoding="utf-8", - ) - bootstrap = ( - RUNNER.expected_device_plane_edge_core_channel_bootstrap_descriptor( - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_PREDECESSOR_PATCH_ID - ) - ) - descriptor = ( - RUNNER.expected_device_plane_edge_core_channel_upgrade_v2_descriptor( - "device-edge-core-channel-upgrade-v2-unit-003" - ) - ) - expected_source = {"source": "upgrade-019"} - with ( - mock.patch.object(RUNNER, "APPLIED_DIR", applied), - mock.patch.object(RUNNER, "TMP_DIR", temporary), - mock.patch.object(RUNNER, "STATE_FILE", state_file), - mock.patch.object(RUNNER, "DEVICE_PLANE_ROOT", device_plane), - mock.patch.object( - RUNNER, - "validate_device_plane_edge_core_channel_upgrade_v2_payload", - return_value=descriptor, - ), - mock.patch.object( - RUNNER, - "sha256_file", - return_value=predecessor_sha, - ), - mock.patch.object( - RUNNER, - "load_artifact", - return_value=( - { - "id": predecessor_patch, - "component": "device-plane", - "type": "app-overlay", - }, - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_ENTRIES, - root / "predecessor-payload", - ), - ), - mock.patch.object( - RUNNER, - "validate_device_plane_edge_core_channel_upgrade_payload", - ) as validate_predecessor, - mock.patch.object( - RUNNER, - "collect_exact_files", - side_effect=(expected_source, expected_source), - ), - mock.patch.object( - RUNNER, - "read_strict_json", - return_value=bootstrap, - ), - mock.patch.object( - RUNNER, - "inspect_device_edge_channel_core_identity_state", - return_value="valid-reuse-at-apply", - ), - mock.patch.object( - RUNNER, - "healthcheck_compose_service", - ) as health, - ): - result = ( - RUNNER.validate_device_plane_edge_core_channel_upgrade_v2_predecessor( - root / "payload" - ) - ) - - self.assertEqual(result["mode"], "edge-core-channel-forward-upgrade-v2") - self.assertEqual(result["upgradeArtifact"], artifact) - validate_predecessor.assert_called_once_with( - root / "predecessor-payload", - expected_transition_id=predecessor_patch, - ) - self.assertEqual( - [call.args for call in health.call_args_list], - [ - ("device-plane", "device-control-core"), - ("device-plane", "device-manager"), - ("device-plane", "device-gateway"), - ("device-plane", "device-postgres"), - ], - ) - - def test_edge_core_channel_upgrade_v4_accepts_exact_applied_021(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-edge-upgrade-v4-predecessor-", - ) as directory: - root = Path(directory) - applied = root / "applied" - failed = root / "failed" - backups = root / "backups" - temporary = root / "tmp" - device_plane = root / "device-plane" - for path in ( - applied, - failed, - backups, - temporary, - device_plane / "deployment", - ): - path.mkdir(parents=True) - predecessor_patch = ( - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_PREDECESSOR_PATCH_ID - ) - predecessor_sha = ( - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_PREDECESSOR_ARTIFACT_SHA256 - ) - artifact_name = f"nodedc-device-plane-{predecessor_patch}.tgz" - artifact = applied / artifact_name - artifact.write_bytes(b"reviewed-upgrade-021") - installed_base = device_plane / "docker-compose.device-plane.yml" - installed_base.write_text("services: {}\n", encoding="utf-8") - state_file = root / "applied.jsonl" - state_file.write_text( - json.dumps({ - "id": predecessor_patch, - "artifact": artifact_name, - "component": "device-plane", - "sha256": predecessor_sha, - "status": "ok", - }) + "\n", - encoding="utf-8", - ) - failed_artifact = ( - failed - / RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_ARTIFACT - ) - failed_artifact.write_bytes(b"reviewed-failed-upgrade-022") - failed_backup = ( - backups - / RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_BACKUP_ID - ) - failed_backup.mkdir() - failed_state_file = root / "failed.jsonl" - failed_state_file.write_text( - json.dumps({ - "id": RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_PATCH_ID, - "artifact": failed_artifact.name, - "backup_id": failed_backup.name, - "component": "device-plane", - "sha256": RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_ARTIFACT_SHA256, - "status": "failed", - "started_apply": True, - "rollback_status": "ok:device-plane-overlay:source+runtime-restored:8", - "message": "Device Control Core Edge channel network boundary mismatch", - }) + "\n", - encoding="utf-8", - ) - descriptor = ( - RUNNER.expected_device_plane_edge_core_channel_upgrade_v4_descriptor( - "device-edge-core-channel-upgrade-v4-unit-003" - ) - ) - expected_source = {"source": "upgrade-021"} - with ( - mock.patch.object(RUNNER, "APPLIED_DIR", applied), - mock.patch.object(RUNNER, "FAILED_DIR", failed), - mock.patch.object(RUNNER, "BACKUPS_DIR", backups), - mock.patch.object(RUNNER, "TMP_DIR", temporary), - mock.patch.object(RUNNER, "STATE_FILE", state_file), - mock.patch.object( - RUNNER, - "FAILED_STATE_FILE", - failed_state_file, - ), - mock.patch.object(RUNNER, "DEVICE_PLANE_ROOT", device_plane), - mock.patch.object( - RUNNER, - "validate_device_plane_edge_core_channel_upgrade_v4_payload", - return_value=descriptor, - ), - mock.patch.object( - RUNNER, - "sha256_file", - side_effect=lambda path: ( - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_PREDECESSOR_BASE_COMPOSE_SHA256 - if Path(path) == installed_base - else RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_FAILED_ARTIFACT_SHA256 - if Path(path) == failed_artifact - else predecessor_sha - ), - ), - mock.patch.object( - RUNNER, - "load_artifact", - return_value=( - { - "id": predecessor_patch, - "component": "device-plane", - "type": "app-overlay", - }, - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES, - root / "predecessor-payload", - ), - ), - mock.patch.object( - RUNNER, - "validate_device_plane_edge_core_channel_upgrade_v2_payload", - ) as validate_predecessor, - mock.patch.object( - RUNNER, - "collect_exact_files", - side_effect=(expected_source, expected_source), - ), - mock.patch.object( - RUNNER, - "inspect_device_edge_channel_core_identity_state", - return_value="valid-reuse-at-apply", - ), - mock.patch.object( - RUNNER, - "healthcheck_compose_service", - ) as health, - ): - result = ( - RUNNER.validate_device_plane_edge_core_channel_upgrade_v4_predecessor( - root / "payload" - ) - ) - - self.assertEqual( - result["mode"], - "edge-core-channel-private-plus-egress-upgrade-v4", - ) - self.assertEqual(result["upgradeArtifact"], artifact) - validate_predecessor.assert_called_once_with( - root / "predecessor-payload", - expected_transition_id=predecessor_patch, - ) - self.assertEqual( - [call.args for call in health.call_args_list], - [ - ("device-plane", "device-control-core"), - ("device-plane", "device-manager"), - ("device-plane", "device-gateway"), - ("device-plane", "device-postgres"), - ], - ) - - def test_release_v2_keeps_release_v1_predecessor_contract_immutable(self): - predecessor = device_manager_release_v1_descriptor( - release_id="device-manager-release-20260811-010", - ) - candidate = device_manager_release_descriptor( - release_id="device-manager-release-20260811-012", - action="upgrade", - predecessor_kind="release", - predecessor_patch="device-manager-release-20260811-010", - predecessor_sha="d" * 64, - ) - self.assertIs( - RUNNER.validate_device_plane_manager_release_descriptor( - predecessor, - schema_version=( - "nodedc.device-plane.device-manager-release.v1" - ), - boundaries=( - RUNNER.expected_device_plane_manager_release_v1_boundaries() - ), - expected_release_id="device-manager-release-20260811-010", - ), - predecessor, - ) - self.assertIs( - RUNNER.validate_device_plane_manager_release_descriptor( - candidate, - schema_version=( - "nodedc.device-plane.device-manager-release.v2" - ), - boundaries=( - RUNNER.expected_device_plane_manager_release_v2_boundaries() - ), - expected_release_id="device-manager-release-20260811-012", - ), - candidate, - ) - with self.assertRaisesRegex( - RUNNER.DeployError, - r"schema=.*release\.v2 missing=edgeChannel,edgeChannelEgress," - r"edgeChannelIdentity extra=none", - ): - RUNNER.validate_device_plane_manager_release_descriptor( - predecessor, - schema_version=( - "nodedc.device-plane.device-manager-release.v2" - ), - boundaries=( - RUNNER.expected_device_plane_manager_release_v2_boundaries() - ), - ) - - def test_installed_manager_compose_follows_release_generation(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-manager-compose-generation-", - ) as directory: - root = Path(directory) - deployment = root / "deployment" - deployment.mkdir() - compose = root / RUNNER.DEVICE_PLANE_MANAGER_COMPOSE_REL - compose.write_text("services: {}\n", encoding="utf-8") - v1 = root / RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL - v2 = root / RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V2_REL - v1.write_text( - json.dumps(device_manager_release_v1_descriptor()), - encoding="utf-8", - ) - - with ( - mock.patch.object(RUNNER, "DEVICE_PLANE_ROOT", root), - mock.patch.dict( - RUNNER.COMPONENTS["device-plane"], - {"compose_files": ()}, - ), - mock.patch.object( - RUNNER, - "sha256_file", - return_value=( - RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V1_COMPOSE_SHA256 - ), - ), - ): - self.assertEqual( - RUNNER.component_compose_files("device-plane"), - (compose,), - ) - - v2.write_text( - json.dumps(device_manager_release_descriptor()), - encoding="utf-8", - ) - with ( - mock.patch.object(RUNNER, "DEVICE_PLANE_ROOT", root), - mock.patch.dict( - RUNNER.COMPONENTS["device-plane"], - {"compose_files": ()}, - ), - mock.patch.object( - RUNNER, - "sha256_file", - return_value=( - RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V2_COMPOSE_SHA256 - ), - ), - ): - self.assertEqual( - RUNNER.component_compose_files("device-plane"), - (compose,), - ) - - with ( - mock.patch.object(RUNNER, "DEVICE_PLANE_ROOT", root), - mock.patch.dict( - RUNNER.COMPONENTS["device-plane"], - {"compose_files": ()}, - ), - mock.patch.object( - RUNNER, - "sha256_file", - return_value="0" * 64, - ), - ): - with self.assertRaisesRegex( - RUNNER.DeployError, - "installed Device Manager Compose drift detected", - ): - RUNNER.component_compose_files("device-plane") - - def test_public_route_artifact_is_last_and_proxy_only(self): - manifest, entries, _names, result = self.assert_deterministic_artifact( - "build-platform-device-manager-route-artifact.mjs", - "platform-device-manager-route-unit-001", - RUNNER.PLATFORM_DEVICE_MANAGER_PUBLIC_ROUTE_ENTRIES, - ) - self.assertEqual(manifest["component"], "platform") - self.assertEqual(RUNNER.component_services("platform", entries), ("reverse-proxy",)) - self.assertEqual(RUNNER.component_builds("platform", entries), ()) - self.assertEqual(result["services"], ["reverse-proxy"]) - - def test_runner_creates_only_file_backed_runtime_secrets(self): - with mock.patch.object(RUNNER, "ensure_platform_runtime_secret") as ensure: - RUNNER.prepare_component_runtime( - "platform", - RUNNER.PLATFORM_DEVICE_CORE_HUB_TRUST_ENTRIES, - ) - self.assertEqual( - [call.args[0] for call in ensure.call_args_list], - [RUNNER.PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE], - ) - - with ( - mock.patch.object( - RUNNER, - "ensure_platform_runtime_secret", - ) as ensure, - mock.patch.object( - RUNNER, - "ensure_device_edge_channel_core_identity", - ) as ensure_edge_identity, - ): - RUNNER.prepare_component_runtime( - "device-plane", - RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V1_SUCCESSOR_ENTRIES, - ) - self.assertEqual( - [call.args[0] for call in ensure.call_args_list], - [ - RUNNER.DEVICE_PLANE_POSTGRES_PASSWORD_FILE, - RUNNER.DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE, - RUNNER.DEVICE_PLANE_IDENTIFIER_PEPPER_FILE, - RUNNER.DEVICE_PLANE_MANAGEMENT_CORE_TOKEN_FILE, - RUNNER.PLATFORM_DEVICE_CORE_INTERNAL_TOKEN_FILE, - ], - ) - ensure_edge_identity.assert_not_called() - - with ( - mock.patch.object( - RUNNER, - "ensure_platform_runtime_secret", - ) as ensure, - mock.patch.object( - RUNNER, - "ensure_device_edge_channel_core_identity", - ) as ensure_edge_identity, - ): - RUNNER.prepare_component_runtime( - "device-plane", - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_BOOTSTRAP_ENTRIES, - ) - self.assertEqual( - [call.args[0] for call in ensure.call_args_list], - [ - RUNNER.DEVICE_PLANE_POSTGRES_PASSWORD_FILE, - RUNNER.DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE, - RUNNER.DEVICE_PLANE_IDENTIFIER_PEPPER_FILE, - RUNNER.DEVICE_PLANE_MANAGEMENT_CORE_TOKEN_FILE, - ], - ) - ensure_edge_identity.assert_called_once_with( - allow_invalid_unexported_recovery=True - ) - - with ( - mock.patch.object( - RUNNER, - "ensure_platform_runtime_secret", - ), - mock.patch.object( - RUNNER, - "ensure_device_edge_channel_core_identity", - ) as ensure_edge_identity, - ): - RUNNER.prepare_component_runtime( - "device-plane", - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_ENTRIES, - ) - ensure_edge_identity.assert_called_once_with( - allow_invalid_unexported_recovery=False - ) - - with ( - mock.patch.object( - RUNNER, - "ensure_platform_runtime_secret", - ), - mock.patch.object( - RUNNER, - "ensure_device_edge_channel_core_identity", - ) as ensure_edge_identity, - ): - RUNNER.prepare_component_runtime( - "device-plane", - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES, - ) - ensure_edge_identity.assert_called_once_with( - allow_invalid_unexported_recovery=False - ) - - with ( - mock.patch.object( - RUNNER, - "ensure_platform_runtime_secret", - ), - mock.patch.object( - RUNNER, - "ensure_device_edge_channel_core_identity", - ) as ensure_edge_identity, - ): - RUNNER.prepare_component_runtime( - "device-plane", - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_ENTRIES, - ) - ensure_edge_identity.assert_called_once_with( - allow_invalid_unexported_recovery=False - ) - - def test_apply_gate_checks_exact_services_core_contract_and_runtime_boundary(self): - entries = RUNNER.DEVICE_PLANE_MANAGER_RELEASE_V1_SUCCESSOR_ENTRIES - services = ("device-control-core", "device-manager") - with ( - mock.patch.object( - RUNNER, - "healthcheck_compose_service_with_grace", - ) as service_health, - mock.patch.object(RUNNER, "healthcheck_url") as url_health, - mock.patch.object( - RUNNER, - "validate_device_manager_control_plane_runtime", - ) as runtime_acceptance, - ): - RUNNER.run_healthchecks("device-plane", entries, services) - - self.assertEqual( - [call.args for call in service_health.call_args_list], - [ - ("device-plane", "device-control-core"), - ("device-plane", "device-manager"), - ], - ) - url_health.assert_called_once_with( - RUNNER.component_healthchecks( - "device-plane", - entries, - services, - )[0] - ) - runtime_acceptance.assert_called_once_with() - - def test_upgrade_v2_apply_gate_checks_preserved_runtime_and_edge_contract(self): - entries = RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES - services = ("device-control-core",) - with ( - mock.patch.object( - RUNNER, - "healthcheck_compose_service_with_grace", - ) as service_health, - mock.patch.object(RUNNER, "healthcheck_url") as url_health, - mock.patch.object( - RUNNER, - "validate_device_manager_control_plane_runtime", - ) as runtime_acceptance, - ): - RUNNER.run_healthchecks("device-plane", entries, services) - - self.assertEqual( - [call.args for call in service_health.call_args_list], - [ - ("device-plane", "device-control-core"), - ("device-plane", "device-manager"), - ("device-plane", "device-gateway"), - ("device-plane", "device-postgres"), - ], - ) - url_health.assert_called_once_with( - RUNNER.component_healthchecks( - "device-plane", - entries, - services, - )[0] - ) - runtime_acceptance.assert_called_once_with( - require_edge_channel=True - ) - - def test_upgrade_v4_apply_gate_checks_preserved_runtime_and_edge_contract(self): - entries = RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_ENTRIES - services = ("device-control-core",) - with ( - mock.patch.object( - RUNNER, - "healthcheck_compose_service_with_grace", - ) as service_health, - mock.patch.object(RUNNER, "healthcheck_url") as url_health, - mock.patch.object( - RUNNER, - "validate_device_manager_control_plane_runtime", - ) as runtime_acceptance, - ): - RUNNER.run_healthchecks("device-plane", entries, services) - - self.assertEqual( - [call.args for call in service_health.call_args_list], - [ - ("device-plane", "device-control-core"), - ("device-plane", "device-manager"), - ("device-plane", "device-gateway"), - ("device-plane", "device-postgres"), - ], - ) - url_health.assert_called_once_with( - RUNNER.component_healthchecks( - "device-plane", - entries, - services, - )[0] - ) - runtime_acceptance.assert_called_once_with( - require_edge_channel=True, - core_network_mode="private-egress", - ) - - def test_activation_resolves_predecessor_from_descriptor_and_journal(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-manager-release-predecessor-", - ) as directory: - root = Path(directory) - applied = root / "applied" - backups = root / "backups" - temporary = root / "tmp" - device_plane = root / "device-plane" - for path in (applied, backups, temporary, device_plane / "deployment"): - path.mkdir(parents=True) - predecessor_patch = "device-manager-reconciliation-unit-004" - artifact_name = f"nodedc-device-plane-{predecessor_patch}.tgz" - artifact = applied / artifact_name - artifact_bytes = b"reviewed-reconciliation-artifact" - artifact.write_bytes(artifact_bytes) - artifact_sha = hashlib.sha256(artifact_bytes).hexdigest() - descriptor = device_manager_release_descriptor( - predecessor_patch=predecessor_patch, - predecessor_sha=artifact_sha, - ) - state_file = root / "applied.jsonl" - state_file.write_text( - json.dumps({ - "id": predecessor_patch, - "artifact": artifact_name, - "backup_id": "predecessor-backup", - "component": "device-plane", - "sha256": artifact_sha, - "status": "ok", - }) + "\n", - encoding="utf-8", - ) - (backups / "predecessor-backup").mkdir() - baseline_backup = root / "failed-backup" - predecessor_descriptor = {"marker": "exact"} - marker = ( - device_plane - / RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_REL - ) - marker.write_text( - json.dumps(predecessor_descriptor), - encoding="utf-8", - ) - with ( - mock.patch.object(RUNNER, "APPLIED_DIR", applied), - mock.patch.object(RUNNER, "BACKUPS_DIR", backups), - mock.patch.object(RUNNER, "TMP_DIR", temporary), - mock.patch.object(RUNNER, "STATE_FILE", state_file), - mock.patch.object( - RUNNER, - "component_root", - return_value=device_plane, - ), - mock.patch.object( - RUNNER, - "validate_device_plane_manager_release_payload", - return_value=descriptor, - ) as payload, - mock.patch.object( - RUNNER, - "load_artifact", - return_value=( - { - "id": predecessor_patch, - "component": "device-plane", - "type": "app-overlay", - }, - RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_ENTRIES, - root / "predecessor-payload", - ), - ), - mock.patch.object( - RUNNER, - "validate_device_plane_manager_v2_reconciliation_payload", - return_value=predecessor_descriptor, - ), - mock.patch.object( - RUNNER, - "validate_device_plane_manager_v2_reconciliation_backup", - return_value=baseline_backup, - ), - mock.patch.object( - RUNNER, - "validate_device_plane_manager_v2_reconciled_baseline", - return_value={"accepted": True}, - ) as baseline, - ): - result = ( - RUNNER.validate_device_plane_manager_activation_predecessor( - root / "payload" - ) - ) - - self.assertEqual( - result["mode"], - "reconciled-manager-forward-activation", - ) - payload.assert_called_once_with(root / "payload") - baseline.assert_called_once_with( - baseline_backup, - marker_installed=True, - ) - - def test_failed_patch_id_and_sha_are_globally_non_replayable(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-manager-failed-replay-", - ) as directory: - failed_state = Path(directory) / "failed.jsonl" - failed_state.write_text( - json.dumps({ - "id": "device-manager-release-failed-001", - "sha256": "b" * 64, - "status": "failed", - }) + "\n", - encoding="utf-8", - ) - with mock.patch.object( - RUNNER, - "FAILED_STATE_FILE", - failed_state, - ): - with self.assertRaisesRegex( - RUNNER.DeployError, - "SHA is terminal failed", - ): - RUNNER.reject_failed_artifact_replay( - {"id": "different"}, - "b" * 64, - ) - with self.assertRaisesRegex( - RUNNER.DeployError, - "patch id is terminal failed", - ): - RUNNER.reject_failed_artifact_replay( - {"id": "device-manager-release-failed-001"}, - "c" * 64, - ) - - def test_upgrade_accepts_any_current_release_without_runner_patch_ids(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-manager-release-upgrade-", - ) as directory: - root = Path(directory) - applied = root / "applied" - backups = root / "backups" - temporary = root / "tmp" - device_plane = root / "device-plane" - for path in (applied, backups, temporary, device_plane / "deployment"): - path.mkdir(parents=True) - predecessor_patch = "device-manager-release-arbitrary-041" - artifact_name = f"nodedc-device-plane-{predecessor_patch}.tgz" - artifact = applied / artifact_name - artifact_bytes = b"arbitrary-reviewed-device-manager-release" - artifact.write_bytes(artifact_bytes) - artifact_sha = hashlib.sha256(artifact_bytes).hexdigest() - predecessor_descriptor = device_manager_release_v1_descriptor( - release_id=predecessor_patch, - ) - candidate_descriptor = device_manager_release_descriptor( - release_id="device-manager-release-arbitrary-042", - action="upgrade", - predecessor_kind="release", - predecessor_patch=predecessor_patch, - predecessor_sha=artifact_sha, - ) - installed = ( - device_plane / RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL - ) - installed.write_text( - json.dumps(predecessor_descriptor), - encoding="utf-8", - ) - state_file = root / "applied.jsonl" - state_file.write_text( - json.dumps({ - "id": predecessor_patch, - "artifact": artifact_name, - "backup_id": "arbitrary-release-backup", - "component": "device-plane", - "sha256": artifact_sha, - "status": "ok", - }) + "\n", - encoding="utf-8", - ) - (backups / "arbitrary-release-backup").mkdir() - with ( - mock.patch.object(RUNNER, "APPLIED_DIR", applied), - mock.patch.object(RUNNER, "BACKUPS_DIR", backups), - mock.patch.object(RUNNER, "TMP_DIR", temporary), - mock.patch.object(RUNNER, "STATE_FILE", state_file), - mock.patch.object( - RUNNER, - "component_root", - return_value=device_plane, - ), - mock.patch.object( - RUNNER, - "validate_device_plane_manager_release_payload", - side_effect=( - candidate_descriptor, - predecessor_descriptor, - ), - ), - mock.patch.object( - RUNNER, - "load_artifact", - return_value=( - { - "id": predecessor_patch, - "component": "device-plane", - "type": "app-overlay", - }, - RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES, - root / "predecessor-payload", - ), - ), - mock.patch.object( - RUNNER, - "healthcheck_compose_service", - ) as health, - ): - result = ( - RUNNER.validate_device_plane_manager_activation_predecessor( - root / "candidate-payload" - ) - ) - - self.assertEqual(result["mode"], "active-manager-forward-upgrade") - self.assertEqual( - [call.args for call in health.call_args_list], - [ - ("device-plane", "device-control-core"), - ("device-plane", "device-manager"), - ("device-plane", "device-postgres"), - ], - ) - - def test_initial_install_rollback_removes_manager_and_restores_core_only(self): - entries = RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES - missing = { - RUNNER.DEVICE_PLANE_MANAGER_COMPOSE_REL, - "services/device-manager", - RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, - } - existing = [entry for entry in entries if entry not in missing] - with tempfile.TemporaryDirectory( - prefix="nodedc-device-manager-rollback-", - ) as directory: - backup = Path(directory) / "backup" - backup.mkdir() - (backup / "existing-files.txt").write_text( - "\n".join(existing) + "\n", - encoding="utf-8", - ) - (backup / "missing-files.txt").write_text( - "\n".join(entry for entry in entries if entry in missing) - + "\n", - encoding="utf-8", - ) - (backup / "runtime-before.json").write_text( - json.dumps(healthy_device_plane_inventory()), - encoding="utf-8", - ) - with ( - mock.patch.object( - RUNNER, - "stop_and_remove_compose_services", - ) as stop, - mock.patch.object( - RUNNER, - "restore_platform_overlay", - return_value=len(entries), - ) as restore, - mock.patch.object( - RUNNER, - "run_component_runtime", - ) as restore_runtime, - mock.patch.object( - RUNNER, - "healthcheck_compose_service_with_grace", - ) as restore_health, - mock.patch.object( - RUNNER, - "component_healthchecks", - return_value=("core-health",), - ), - mock.patch.object( - RUNNER, - "healthcheck_url", - ) as restore_url_health, - mock.patch.object( - RUNNER, - "run_healthchecks", - ) as generic_health, - ): - result = RUNNER.rollback_device_plane_apply( - Path(directory) / "live", - backup, - entries, - "test-stamp", - True, - ("device-control-core", "device-manager"), - ) - - stop.assert_called_once_with("device-plane", ("device-manager",)) - restore.assert_called_once() - restore_runtime.assert_called_once_with( - "device-plane", - existing, - ("device-control-core",), - ) - restore_health.assert_called_once_with( - "device-plane", - "device-control-core", - ) - restore_url_health.assert_called_once_with("core-health") - generic_health.assert_not_called() - self.assertEqual( - result, - f"source+runtime-restored:{len(entries)}", - ) - - def test_upgrade_rollback_restores_previous_core_and_manager_release(self): - entries = RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES - with tempfile.TemporaryDirectory( - prefix="nodedc-device-manager-upgrade-rollback-", - ) as directory: - backup = Path(directory) / "backup" - backup.mkdir() - (backup / "existing-files.txt").write_text( - "\n".join(entries) + "\n", - encoding="utf-8", - ) - (backup / "missing-files.txt").write_text("", encoding="utf-8") - (backup / "runtime-before.json").write_text( - json.dumps(healthy_device_plane_inventory(include_manager=True)), - encoding="utf-8", - ) - with ( - mock.patch.object( - RUNNER, - "stop_and_remove_compose_services", - ) as stop, - mock.patch.object( - RUNNER, - "restore_platform_overlay", - return_value=len(entries), - ), - mock.patch.object( - RUNNER, - "run_component_runtime", - ) as restore_runtime, - mock.patch.object( - RUNNER, - "healthcheck_compose_service_with_grace", - ) as restore_health, - mock.patch.object( - RUNNER, - "component_healthchecks", - return_value=(), - ), - ): - result = RUNNER.rollback_device_plane_apply( - Path(directory) / "live", - backup, - entries, - "test-stamp", - True, - ("device-control-core", "device-manager"), - ) - - stop.assert_not_called() - restore_runtime.assert_called_once_with( - "device-plane", - list(entries), - ("device-control-core", "device-manager"), - ) - self.assertEqual( - [call.args for call in restore_health.call_args_list], - [ - ("device-plane", "device-control-core"), - ("device-plane", "device-manager"), - ], - ) - self.assertEqual( - result, - f"source+runtime-restored:{len(entries)}", - ) - - def test_edge_upgrade_v2_rollback_restores_upgrade_019_core_runtime(self): - entries = RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_ENTRIES - missing = {RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL} - existing = [entry for entry in entries if entry not in missing] - with tempfile.TemporaryDirectory( - prefix="nodedc-device-edge-upgrade-v2-rollback-", - ) as directory: - backup = Path(directory) / "backup" - backup.mkdir() - (backup / "existing-files.txt").write_text( - "\n".join(existing) + "\n", - encoding="utf-8", - ) - (backup / "missing-files.txt").write_text( - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V2_REL + "\n", - encoding="utf-8", - ) - (backup / "runtime-before.json").write_text( - json.dumps(healthy_device_plane_inventory(include_manager=True)), - encoding="utf-8", - ) - with ( - mock.patch.object( - RUNNER, - "stop_and_remove_compose_services", - ) as stop, - mock.patch.object( - RUNNER, - "restore_platform_overlay", - return_value=len(entries), - ), - mock.patch.object( - RUNNER, - "run_component_runtime", - ) as restore_runtime, - mock.patch.object( - RUNNER, - "healthcheck_compose_service_with_grace", - ) as restore_health, - mock.patch.object( - RUNNER, - "validate_device_manager_control_plane_runtime", - ) as runtime_acceptance, - ): - result = RUNNER.rollback_device_plane_apply( - Path(directory) / "live", - backup, - entries, - "test-stamp", - True, - ("device-control-core",), - ) - - stop.assert_not_called() - restore_runtime.assert_called_once_with( - "device-plane", - existing, - ("device-control-core",), - ) - self.assertEqual( - [call.args for call in restore_health.call_args_list], - [ - ("device-plane", "device-control-core"), - ("device-plane", "device-manager"), - ("device-plane", "device-gateway"), - ("device-plane", "device-postgres"), - ], - ) - runtime_acceptance.assert_called_once_with(require_edge_channel=True) - self.assertEqual(result, f"source+runtime-restored:{len(entries)}") - - def test_edge_upgrade_v4_rollback_restores_upgrade_021_core_runtime(self): - entries = RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_ENTRIES - missing = { - RUNNER.DEVICE_PLANE_EDGE_CORE_CHANNEL_UPGRADE_V4_REL, - } - existing = [entry for entry in entries if entry not in missing] - with tempfile.TemporaryDirectory( - prefix="nodedc-device-edge-upgrade-v4-rollback-", - ) as directory: - backup = Path(directory) / "backup" - backup.mkdir() - (backup / "existing-files.txt").write_text( - "\n".join(existing) + "\n", - encoding="utf-8", - ) - (backup / "missing-files.txt").write_text( - "\n".join(sorted(missing)) + "\n", - encoding="utf-8", - ) - (backup / "runtime-before.json").write_text( - json.dumps(healthy_device_plane_inventory(include_manager=True)), - encoding="utf-8", - ) - with ( - mock.patch.object( - RUNNER, - "stop_and_remove_compose_services", - ) as stop, - mock.patch.object( - RUNNER, - "restore_platform_overlay", - return_value=len(entries), - ), - mock.patch.object( - RUNNER, - "run_component_runtime", - ) as restore_runtime, - mock.patch.object( - RUNNER, - "healthcheck_compose_service_with_grace", - ) as restore_health, - mock.patch.object( - RUNNER, - "validate_device_manager_control_plane_runtime", - ) as runtime_acceptance, - ): - result = RUNNER.rollback_device_plane_apply( - Path(directory) / "live", - backup, - entries, - "test-stamp", - True, - ("device-control-core",), - ) - - stop.assert_not_called() - restore_runtime.assert_called_once_with( - "device-plane", - existing, - ("device-control-core",), - ) - self.assertEqual( - [call.args for call in restore_health.call_args_list], - [ - ("device-plane", "device-control-core"), - ("device-plane", "device-manager"), - ("device-plane", "device-gateway"), - ("device-plane", "device-postgres"), - ], - ) - runtime_acceptance.assert_called_once_with(require_edge_channel=True) - self.assertEqual(result, f"source+runtime-restored:{len(entries)}") - - def test_control_core_release_rollback_restores_only_core_on_v4_topology(self): - entries = RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_ENTRIES - missing = {RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_REL} - existing = [entry for entry in entries if entry not in missing] - with tempfile.TemporaryDirectory( - prefix="nodedc-control-core-release-rollback-", - ) as directory: - backup = Path(directory) / "backup" - backup.mkdir() - (backup / "existing-files.txt").write_text( - "\n".join(existing) + "\n", - encoding="utf-8", - ) - (backup / "missing-files.txt").write_text( - RUNNER.DEVICE_PLANE_CONTROL_CORE_RELEASE_REL + "\n", - encoding="utf-8", - ) - (backup / "runtime-before.json").write_text( - json.dumps(healthy_device_plane_inventory(include_manager=True)), - encoding="utf-8", - ) - with ( - mock.patch.object( - RUNNER, - "stop_and_remove_compose_services", - ) as stop, - mock.patch.object( - RUNNER, - "restore_platform_overlay", - return_value=len(entries), - ), - mock.patch.object( - RUNNER, - "run_component_runtime", - ) as restore_runtime, - mock.patch.object( - RUNNER, - "healthcheck_compose_service_with_grace", - ) as restore_health, - mock.patch.object( - RUNNER, - "validate_device_manager_control_plane_runtime", - ) as runtime_acceptance, - ): - result = RUNNER.rollback_device_plane_apply( - Path(directory) / "live", - backup, - entries, - "test-stamp", - True, - ("device-control-core",), - ) - - stop.assert_not_called() - restore_runtime.assert_called_once_with( - "device-plane", - existing, - ("device-control-core",), - ) - self.assertEqual( - [call.args for call in restore_health.call_args_list], - [ - ("device-plane", "device-control-core"), - ("device-plane", "device-manager"), - ("device-plane", "device-gateway"), - ("device-plane", "device-postgres"), - ], - ) - runtime_acceptance.assert_called_once_with( - require_edge_channel=True, - core_network_mode="private-egress", - ) - self.assertEqual(result, f"source+runtime-restored:{len(entries)}") - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_device_manager_control_plane_reconciliation_artifact.py b/infra/deploy-runner/test_device_manager_control_plane_reconciliation_artifact.py deleted file mode 100644 index 79d03bb..0000000 --- a/infra/deploy-runner/test_device_manager_control_plane_reconciliation_artifact.py +++ /dev/null @@ -1,376 +0,0 @@ -#!/usr/bin/env python3 -import hashlib -import importlib.machinery -import importlib.util -import json -import os -import subprocess -import tarfile -import tempfile -import unittest -from pathlib import Path -from unittest import mock - - -SCRIPT_DIR = Path(__file__).resolve().parent -RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" -BUILDER = ( - SCRIPT_DIR - / "build-device-manager-control-plane-reconciliation-artifact.mjs" -) -V2_BUILDER = ( - SCRIPT_DIR - / "build-device-manager-control-plane-v2-reconciliation-artifact.mjs" -) - - -def load_runner(): - loader = importlib.machinery.SourceFileLoader( - "nodedc_device_manager_reconciliation_runner_under_test", - str(RUNNER_PATH), - ) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -RUNNER = load_runner() - - -class DeviceManagerControlPlaneReconciliationArtifactTest(unittest.TestCase): - def build(self, artifact_dir, patch_id, builder=BUILDER): - environment = os.environ.copy() - environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) - result = subprocess.run( - ["node", str(builder), patch_id], - check=True, - capture_output=True, - text=True, - env=environment, - ) - return json.loads(result.stdout) - - def test_artifact_is_marker_only_exact_and_deterministic(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-manager-reconciliation-artifact-", - ) as directory: - artifact_dir = Path(directory) - first = self.build( - artifact_dir, - "device-manager-control-plane-reconciliation-unit-002", - ) - first_bytes = Path(first["artifact"]).read_bytes() - second = self.build( - artifact_dir, - "device-manager-control-plane-reconciliation-unit-002", - ) - second_bytes = Path(second["artifact"]).read_bytes() - - self.assertEqual(first_bytes, second_bytes) - self.assertEqual( - first["sha256"], - hashlib.sha256(first_bytes).hexdigest(), - ) - self.assertEqual(first["component"], "device-plane") - self.assertEqual( - first["entries"], - list(RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_ENTRIES), - ) - self.assertEqual(first["build"], []) - self.assertEqual(first["services"], []) - self.assertEqual( - first["runtimeAction"], - "read-only-acceptance", - ) - - with tarfile.open(first["artifact"], "r:gz") as archive: - names = [member.name for member in archive.getmembers()] - descriptor = json.loads( - archive.extractfile( - "payload/" - + RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_REL - ).read().decode("utf-8") - ) - self.assertEqual( - descriptor, - RUNNER.expected_device_plane_manager_reconciliation_descriptor(), - ) - self.assertEqual( - names, - [ - "manifest.env", - "files.txt", - "payload", - "payload/deployment", - "payload/" - + RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_REL, - ], - ) - self.assertEqual( - RUNNER.component_services( - "device-plane", - RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_ENTRIES, - ), - (), - ) - self.assertEqual( - RUNNER.component_builds( - "device-plane", - RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_ENTRIES, - ), - (), - ) - - def test_failed_control_plane_is_terminal(self): - with self.assertRaisesRegex( - RUNNER.DeployError, - "exact v2 activation successor", - ): - RUNNER.reject_terminal_device_plane_manager_artifact( - {"id": RUNNER.DEVICE_PLANE_MANAGER_FAILED_PATCH_ID}, - "0" * 64, - ) - with self.assertRaisesRegex( - RUNNER.DeployError, - "exact v2 activation successor", - ): - RUNNER.reject_terminal_device_plane_manager_artifact( - {"id": "different"}, - RUNNER.DEVICE_PLANE_MANAGER_FAILED_ARTIFACT_SHA256, - ) - with self.assertRaisesRegex( - RUNNER.DeployError, - "exact v2 activation successor", - ): - RUNNER.reject_terminal_device_plane_manager_artifact( - {"id": "different", "component": "device-plane"}, - "1" * 64, - RUNNER.DEVICE_PLANE_MANAGER_FAILED_CONTROL_PLANE_ENTRIES, - ) - - def test_v2_reconciliation_is_marker_only_exact_and_deterministic(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-manager-v2-reconciliation-artifact-", - ) as directory: - artifact_dir = Path(directory) - first = self.build( - artifact_dir, - "device-manager-control-plane-v2-reconciliation-unit-004", - V2_BUILDER, - ) - first_bytes = Path(first["artifact"]).read_bytes() - second = self.build( - artifact_dir, - "device-manager-control-plane-v2-reconciliation-unit-004", - V2_BUILDER, - ) - second_bytes = Path(second["artifact"]).read_bytes() - - self.assertEqual(first_bytes, second_bytes) - self.assertEqual( - first["sha256"], - hashlib.sha256(first_bytes).hexdigest(), - ) - self.assertEqual( - first["entries"], - list(RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_ENTRIES), - ) - self.assertEqual(first["build"], []) - self.assertEqual(first["services"], []) - with tarfile.open(first["artifact"], "r:gz") as archive: - descriptor = json.loads( - archive.extractfile( - "payload/" - + RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_REL - ).read().decode("utf-8") - ) - self.assertEqual( - descriptor, - RUNNER.expected_device_plane_manager_v2_reconciliation_descriptor(), - ) - self.assertEqual( - RUNNER.component_services( - "device-plane", - RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_ENTRIES, - ), - (), - ) - self.assertEqual( - RUNNER.component_builds( - "device-plane", - RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_ENTRIES, - ), - (), - ) - - def test_failed_v2_control_plane_is_terminal(self): - with self.assertRaisesRegex( - RUNNER.DeployError, - "exact v2 reconciliation successor", - ): - RUNNER.reject_terminal_device_plane_manager_artifact( - {"id": RUNNER.DEVICE_PLANE_MANAGER_V2_FAILED_PATCH_ID}, - "0" * 64, - ) - with self.assertRaisesRegex( - RUNNER.DeployError, - "exact v2 reconciliation successor", - ): - RUNNER.reject_terminal_device_plane_manager_artifact( - {"id": "different"}, - RUNNER.DEVICE_PLANE_MANAGER_V2_FAILED_ARTIFACT_SHA256, - ) - with self.assertRaisesRegex( - RUNNER.DeployError, - "exact v2 reconciliation successor", - ): - RUNNER.reject_terminal_device_plane_manager_artifact( - {"id": "different", "component": "device-plane"}, - "1" * 64, - RUNNER.DEVICE_PLANE_MANAGER_V2_CONTROL_PLANE_ENTRIES, - ) - - def test_health_grace_waits_through_unhealthy_and_exited(self): - results = [ - mock.Mock(stdout="unhealthy\n", stderr="", returncode=0), - mock.Mock(stdout="exited\n", stderr="", returncode=0), - mock.Mock(stdout="starting\n", stderr="", returncode=0), - mock.Mock(stdout="healthy\n", stderr="", returncode=0), - ] - with ( - mock.patch.object( - RUNNER.subprocess, - "run", - side_effect=results, - ) as inspect, - mock.patch.object(RUNNER.time, "sleep") as sleep, - ): - RUNNER.healthcheck_container_with_grace("container-id") - self.assertEqual(inspect.call_count, 4) - self.assertEqual(sleep.call_count, 3) - - def test_manager_rollback_uses_bounded_grace_for_restored_core(self): - entries = RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES - runtime_before = { - "schemaVersion": "nodedc.device-plane.runtime-inventory.v1", - "composeProject": "nodedc-device-plane", - "services": [ - { - "service": "device-control-core", - "containerId": "a" * 64, - "imageId": "sha256:" + "b" * 64, - "status": "running", - "running": True, - "health": "healthy", - "restartCount": 0, - }, - { - "service": "device-gateway", - "containerId": "c" * 64, - "imageId": "sha256:" + "d" * 64, - "status": "running", - "running": True, - "health": "healthy", - "restartCount": 0, - }, - { - "service": "device-postgres", - "containerId": "e" * 64, - "imageId": "sha256:" + "f" * 64, - "status": "running", - "running": True, - "health": "healthy", - "restartCount": 0, - }, - ], - } - missing_set = { - RUNNER.DEVICE_PLANE_MANAGER_COMPOSE_REL, - "services/device-manager", - RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL, - } - existing = [entry for entry in entries if entry not in missing_set] - missing = [entry for entry in entries if entry in missing_set] - with ( - mock.patch.object( - RUNNER, - "read_backup_path_list", - side_effect=[existing, missing], - ), - mock.patch.object( - RUNNER, - "read_strict_json", - return_value=runtime_before, - ), - mock.patch.object( - RUNNER, - "stop_and_remove_compose_services", - ) as stop, - mock.patch.object( - RUNNER, - "restore_platform_overlay", - return_value=len(entries), - ), - mock.patch.object(RUNNER, "run_component_runtime") as runtime, - mock.patch.object( - RUNNER, - "healthcheck_compose_service_with_grace", - ) as health, - mock.patch.object( - RUNNER, - "component_healthchecks", - return_value=("core",), - ), - mock.patch.object(RUNNER, "healthcheck_url") as url_health, - mock.patch.object(RUNNER, "run_healthchecks") as generic_health, - ): - restored = RUNNER.rollback_device_plane_apply( - Path("/live"), - Path("/backup"), - entries, - "stamp", - True, - ("device-control-core", "device-manager"), - ) - - self.assertEqual( - restored, - f"source+runtime-restored:{len(entries)}", - ) - stop.assert_called_once_with("device-plane", ("device-manager",)) - runtime.assert_called_once_with( - "device-plane", - existing, - ("device-control-core",), - ) - health.assert_called_once_with( - "device-plane", - "device-control-core", - ) - url_health.assert_called_once_with("core") - generic_health.assert_not_called() - - def test_reconciliation_runtime_phase_is_read_only(self): - for entries in ( - RUNNER.DEVICE_PLANE_MANAGER_RECONCILIATION_ENTRIES, - RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_ENTRIES, - ): - with ( - mock.patch.object(RUNNER, "run_build") as build, - mock.patch.object(RUNNER, "prepare_component_runtime") as prepare, - mock.patch.object(RUNNER, "run_compose") as compose, - ): - RUNNER.run_component_runtime("device-plane", entries, ()) - RUNNER.run_device_plane_runtime_for_apply( - entries, - (), - mock.Mock(), - ) - build.assert_not_called() - prepare.assert_not_called() - compose.assert_not_called() - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_device_plane_artifact.py b/infra/deploy-runner/test_device_plane_artifact.py deleted file mode 100644 index 1676d11..0000000 --- a/infra/deploy-runner/test_device_plane_artifact.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python3 -import hashlib -import importlib.machinery -import importlib.util -import json -import os -import subprocess -import tarfile -import tempfile -import unittest -from pathlib import Path - - -SCRIPT_DIR = Path(__file__).resolve().parent -BUILDER = SCRIPT_DIR / "build-device-plane-artifact.mjs" -RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" -EXPECTED_ENTRIES = [ - ".dockerignore", - "package.json", - "package-lock.json", - "docker-compose.device-plane.yml", - "packages/device-protocol-contract", - "packages/arusnavi-b2-adapter", - "services/device-control-core", - "services/device-gateway", -] - - -def load_runner(): - loader = importlib.machinery.SourceFileLoader( - "nodedc_device_plane_artifact_runner_under_test", - str(RUNNER_PATH), - ) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -RUNNER = load_runner() - - -class DevicePlaneArtifactTest(unittest.TestCase): - def build(self, artifact_dir, patch_id): - environment = os.environ.copy() - environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) - result = subprocess.run( - ["node", str(BUILDER), patch_id], - check=True, - capture_output=True, - text=True, - env=environment, - ) - return json.loads(result.stdout) - - def test_artifact_is_narrow_safe_and_deterministic(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-plane-artifact-", - ) as directory: - artifact_dir = Path(directory) - first = self.build( - artifact_dir, - "device-plane-foundation-unit-001", - ) - artifact = Path(first["artifact"]) - first_bytes = artifact.read_bytes() - second = self.build( - artifact_dir, - "device-plane-foundation-unit-001", - ) - second_bytes = Path(second["artifact"]).read_bytes() - - self.assertEqual(first["component"], "device-plane") - self.assertEqual(first["entries"], EXPECTED_ENTRIES) - self.assertEqual( - first["services"], - ["device-control-core", "device-gateway"], - ) - self.assertEqual( - first["sha256"], - hashlib.sha256(first_bytes).hexdigest(), - ) - self.assertEqual(first["sha256"], second["sha256"]) - self.assertEqual(first_bytes, second_bytes) - - with tarfile.open(artifact, "r:gz") as archive: - members = archive.getmembers() - names = {member.name for member in members} - files = ( - archive.extractfile("files.txt") - .read() - .decode("utf-8") - .splitlines() - ) - manifest = ( - archive.extractfile("manifest.env") - .read() - .decode("utf-8") - ) - compose = ( - archive.extractfile( - "payload/docker-compose.device-plane.yml", - ) - .read() - .decode("utf-8") - ) - regular_payloads = [ - archive.extractfile(member).read() - for member in members - if member.isfile() - ] - - self.assertEqual(files, EXPECTED_ENTRIES) - self.assertEqual( - manifest, - "id=device-plane-foundation-unit-001\n" - "component=device-plane\n" - "type=app-overlay\n", - ) - self.assertIn( - "payload/services/device-control-core/Dockerfile", - names, - ) - self.assertIn( - "payload/services/device-gateway/Dockerfile", - names, - ) - self.assertFalse(any( - "/test/" in name - or "/node_modules/" in name - or Path(name).name.startswith(".env") - or name.startswith("payload/docs/") - or name.startswith("payload/runtime/") - or name.startswith("payload/secrets/") - for name in names - )) - self.assertNotIn("9921:9921", compose) - self.assertIn('DEVICE_GATEWAY_LISTEN_ENABLED: "false"', compose) - self.assertIn('DEVICE_DISCOVERY_INGEST_ENABLED: "false"', compose) - self.assertNotIn(b"-----BEGIN PRIVATE KEY-----", b"\n".join( - regular_payloads, - )) - - with tempfile.TemporaryDirectory( - prefix="nodedc-device-plane-runner-load-", - ) as work_directory: - manifest_loaded, entries_loaded, payload_loaded = ( - RUNNER.load_artifact(artifact, Path(work_directory)) - ) - self.assertEqual(manifest_loaded["component"], "device-plane") - self.assertEqual(entries_loaded, EXPECTED_ENTRIES) - self.assertEqual(payload_loaded.name, "payload") - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_device_plane_b2_discovery_ingress_artifact.py b/infra/deploy-runner/test_device_plane_b2_discovery_ingress_artifact.py deleted file mode 100644 index 217b24a..0000000 --- a/infra/deploy-runner/test_device_plane_b2_discovery_ingress_artifact.py +++ /dev/null @@ -1,477 +0,0 @@ -#!/usr/bin/env python3 -import hashlib -import importlib.machinery -import importlib.util -import json -import os -import subprocess -import tarfile -import tempfile -import unittest -from pathlib import Path -from unittest import mock - - -SCRIPT_DIR = Path(__file__).resolve().parent -BUILDER = ( - SCRIPT_DIR / "build-device-plane-b2-discovery-ingress-artifact.mjs" -) -RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" -COMPOSE = ( - SCRIPT_DIR.parent.parent - / "device-plane/docker-compose.device-plane.yml" -) - - -def load_runner(): - loader = importlib.machinery.SourceFileLoader( - "nodedc_device_plane_b2_ingress_runner_under_test", - str(RUNNER_PATH), - ) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -RUNNER = load_runner() - - -class DevicePlaneB2DiscoveryIngressArtifactTest(unittest.TestCase): - def build(self, artifact_dir, patch_id): - environment = os.environ.copy() - environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) - result = subprocess.run( - ["node", str(BUILDER), patch_id], - check=True, - capture_output=True, - text=True, - env=environment, - ) - return json.loads(result.stdout) - - def test_artifact_is_exact_deterministic_and_database_free(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-plane-b2-ingress-", - ) as directory: - artifact_dir = Path(directory) - patch_id = "device-plane-b2-discovery-ingress-unit-001" - first = self.build(artifact_dir, patch_id) - first_bytes = Path(first["artifact"]).read_bytes() - second = self.build(artifact_dir, patch_id) - second_bytes = Path(second["artifact"]).read_bytes() - - self.assertEqual(first_bytes, second_bytes) - self.assertEqual( - first["sha256"], - hashlib.sha256(first_bytes).hexdigest(), - ) - self.assertEqual( - first["transition"], - "verified-b2-loopback-discovery-only", - ) - self.assertEqual( - first["services"], - ["device-control-core", "device-gateway"], - ) - self.assertNotIn("device-postgres", first["services"]) - self.assertEqual( - first["entries"], - list(RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES), - ) - - with tarfile.open(first["artifact"], "r:gz") as archive: - files = ( - archive.extractfile("files.txt") - .read() - .decode("utf-8") - .splitlines() - ) - compose = archive.extractfile( - "payload/docker-compose.device-plane.yml" - ).read() - descriptor = json.loads( - archive.extractfile( - "payload/deployment/" - "device-plane-b2-discovery-ingress-v1.json" - ) - .read() - .decode("utf-8") - ) - edge_manifest = json.loads( - archive.extractfile( - "payload/services/device-edge-relay/package.json" - ) - .read() - .decode("utf-8") - ) - dockerignore = archive.extractfile( - "payload/.dockerignore" - ).read().decode("utf-8") - core_dockerfile = archive.extractfile( - "payload/services/device-control-core/Dockerfile" - ).read().decode("utf-8") - gateway_dockerfile = archive.extractfile( - "payload/services/device-gateway/Dockerfile" - ).read().decode("utf-8") - - self.assertEqual(files, first["entries"]) - self.assertEqual( - hashlib.sha256(compose).hexdigest(), - RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_COMPOSE_SHA256, - ) - self.assertEqual( - descriptor, - RUNNER.expected_device_plane_b2_discovery_ingress_descriptor(), - ) - self.assertEqual( - edge_manifest["name"], - "@nodedc/device-edge-relay", - ) - self.assertIn("**/*.prev-*", dockerignore.splitlines()) - self.assertIn("**/*.next-*", dockerignore.splitlines()) - for dockerfile in (core_dockerfile, gateway_dockerfile): - self.assertNotIn("COPY packages ./packages", dockerfile) - self.assertIn( - "COPY packages/device-protocol-contract " - "./packages/device-protocol-contract", - dockerfile, - ) - self.assertIn( - "COPY packages/arusnavi-b2-adapter " - "./packages/arusnavi-b2-adapter", - dockerfile, - ) - - def test_compose_opens_only_discovery_tcp_and_preserves_database(self): - compose = COMPOSE.read_text(encoding="utf-8") - postgres, stateless = compose.split(" device-control-core:", 1) - self.assertNotIn("9921", postgres) - self.assertNotIn("device-plane-control", postgres) - self.assertIn( - 'DEVICE_DISCOVERY_INGEST_ENABLED: "true"', - stateless, - ) - self.assertIn( - 'DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false"', - stateless, - ) - self.assertIn('"127.0.0.1:9921:9921"', stateless) - self.assertNotIn('"0.0.0.0:9921:9921"', stateless) - self.assertNotIn("DEVICE_GATEWAY_COMMAND", compose) - self.assertNotIn("POSTGRES_PASSWORD:", compose) - - def test_runner_builds_only_stateless_services_and_accepts_new_health(self): - entries = RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES - self.assertEqual( - RUNNER.component_services("device-plane", entries), - ("device-control-core", "device-gateway"), - ) - builds = RUNNER.component_builds("device-plane", entries) - self.assertEqual(len(builds), 2) - checks = RUNNER.component_healthchecks( - "device-plane", - entries, - ("device-control-core", "device-gateway"), - ) - self.assertEqual( - checks[0]["expected_json"]["discoveryIngest"], - "enabled", - ) - self.assertEqual( - checks[1]["expected_json"]["publicIngress"], - "disabled", - ) - self.assertEqual( - checks[1]["expected_json"]["commandTransport"], - "disabled", - ) - - def test_ingress_preflight_requires_terminal_recovery_marker(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-plane-b2-recovery-barrier-", - ) as directory: - workspace = Path(directory) - built = self.build( - workspace, - "device-plane-b2-discovery-ingress-unit-recovery-barrier", - ) - extracted = workspace / "extracted" - extracted.mkdir() - _manifest, _entries, payload = RUNNER.load_artifact( - Path(built["artifact"]), - extracted, - ) - - def has_patch(patch_id): - return ( - patch_id - == RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_PATCH_ID - ) - - with ( - mock.patch.object( - RUNNER, - "state_has_patch_id", - side_effect=has_patch, - ), - mock.patch.object( - RUNNER, - "state_has_sha", - return_value=True, - ), - ): - with self.assertRaisesRegex( - RUNNER.DeployError, - "rollback recovery patch is not applied", - ): - RUNNER.validate_device_plane_b2_discovery_ingress_evidence( - payload - ) - - def test_runtime_acceptance_preserves_postgres_and_replaces_stateless(self): - before = { - "schemaVersion": "nodedc.device-plane.runtime-inventory.v1", - "composeProject": "nodedc-device-plane", - "services": [ - runtime_item("device-control-core", "1", "a"), - runtime_item("device-gateway", "2", "b"), - runtime_item("device-postgres", "3", "c"), - ], - } - containers = { - "core": stateless_container( - service="device-control-core", - container_id="4" * 64, - image_id="sha256:" + "d" * 64, - image=RUNNER.DEVICE_PLANE_CONTROL_CORE_IMAGE, - ports={ - "18120/tcp": [{ - "HostIp": "127.0.0.1", - "HostPort": "18120", - }], - }, - environment={ - "DEVICE_DISCOVERY_INGEST_ENABLED": "true", - "DEVICE_GATEWAY_CORE_TOKEN_FILE": - "/run/nodedc-secrets/gateway-core-token", - "DEVICE_IDENTIFIER_PEPPER_FILE": - "/run/nodedc-secrets/identifier-pepper", - }, - mounts=[ - secret_mount( - RUNNER.DEVICE_PLANE_POSTGRES_PASSWORD_FILE, - "/run/nodedc-secrets/postgres-password", - ), - secret_mount( - RUNNER.DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE, - "/run/nodedc-secrets/gateway-core-token", - ), - secret_mount( - RUNNER.DEVICE_PLANE_IDENTIFIER_PEPPER_FILE, - "/run/nodedc-secrets/identifier-pepper", - ), - ], - ), - "gateway": stateless_container( - service="device-gateway", - container_id="5" * 64, - image_id="sha256:" + "e" * 64, - image=RUNNER.DEVICE_PLANE_GATEWAY_IMAGE, - ports={ - "18121/tcp": [{ - "HostIp": "127.0.0.1", - "HostPort": "18121", - }], - "9921/tcp": [{ - "HostIp": "127.0.0.1", - "HostPort": "9921", - }], - }, - environment={ - "DEVICE_GATEWAY_LISTEN_ENABLED": "true", - "DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED": "false", - "DEVICE_GATEWAY_TCP_HOST": "127.0.0.1", - "DEVICE_GATEWAY_TCP_PORT": "9921", - "DEVICE_GATEWAY_CORE_URL": - "http://device-control-core:18120", - "DEVICE_GATEWAY_CORE_TOKEN_FILE": - "/run/nodedc-secrets/gateway-core-token", - "DEVICE_GATEWAY_CORE_TIMEOUT_MS": "5000", - "DEVICE_GATEWAY_MAX_BUFFERED_BYTES": "65536", - "DEVICE_GATEWAY_MAX_SESSIONS": "100", - "DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS": "10", - "DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS": - "30", - "DEVICE_GATEWAY_SESSION_TIMEOUT_MS": "10000", - }, - mounts=[ - secret_mount( - RUNNER.DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE, - "/run/nodedc-secrets/gateway-core-token", - ), - ], - ), - "postgres": postgres_container(), - } - service_ids = { - "device-control-core": ("core",), - "device-gateway": ("gateway",), - "device-postgres": ("postgres",), - } - with ( - mock.patch.object( - RUNNER, - "validate_device_plane_runtime_secret_metadata", - ), - mock.patch.object( - RUNNER, - "device_plane_service_container_ids", - side_effect=lambda service: service_ids[service], - ), - mock.patch.object( - RUNNER, - "inspect_device_plane_container", - side_effect=lambda container_id: containers[container_id], - ), - mock.patch.object( - RUNNER, - "validate_device_plane_network_contract", - ) as network, - mock.patch.object( - RUNNER, - "assert_loopback_tcp_port_open", - ) as port_open, - ): - accepted = ( - RUNNER.validate_device_plane_b2_discovery_ingress_runtime( - before - ) - ) - self.assertEqual( - accepted["device-postgres"]["containerId"], - "3" * 64, - ) - self.assertEqual(network.call_count, 2) - port_open.assert_called_once_with(9921) - - -def runtime_item(service, container_digit, image_digit): - return { - "service": service, - "containerId": container_digit * 64, - "imageId": "sha256:" + image_digit * 64, - "status": "running", - "running": True, - "health": "healthy", - "restartCount": 0, - } - - -def secret_mount(source, destination): - return { - "Type": "bind", - "Source": str(source), - "Destination": destination, - "RW": False, - } - - -def stateless_container( - *, - service, - container_id, - image_id, - image, - ports, - environment, - mounts, -): - return { - "Id": container_id, - "Image": image_id, - "RestartCount": 0, - "State": { - "Status": "running", - "Running": True, - "Restarting": False, - "ExitCode": 0, - "Error": "", - "Health": {"Status": "healthy"}, - }, - "Config": { - "Image": image, - "User": "1000:1000", - "Labels": { - "com.docker.compose.project": "nodedc-device-plane", - "com.docker.compose.service": service, - }, - "Env": [f"{key}={value}" for key, value in environment.items()], - }, - "HostConfig": { - "PortBindings": ports, - "RestartPolicy": {"Name": "unless-stopped"}, - "ReadonlyRootfs": True, - "CapDrop": ["ALL"], - "SecurityOpt": ["no-new-privileges:true"], - }, - "NetworkSettings": { - "Ports": ports, - "Networks": { - RUNNER.DEVICE_PLANE_PRIVATE_NETWORK: {}, - RUNNER.DEVICE_PLANE_CONTROL_NETWORK: {}, - }, - }, - "Mounts": mounts, - } - - -def postgres_container(): - return { - "Id": "3" * 64, - "Image": "sha256:" + "c" * 64, - "RestartCount": 0, - "State": { - "Status": "running", - "Running": True, - "Restarting": False, - "ExitCode": 0, - "Error": "", - "Health": {"Status": "healthy"}, - }, - "Config": { - "Image": "postgres:16-alpine", - "User": "", - "Labels": { - "com.docker.compose.project": "nodedc-device-plane", - "com.docker.compose.service": "device-postgres", - }, - "Env": [], - }, - "HostConfig": { - "PortBindings": {}, - "RestartPolicy": {"Name": "unless-stopped"}, - }, - "NetworkSettings": { - "Ports": {}, - "Networks": { - RUNNER.DEVICE_PLANE_PRIVATE_NETWORK: {}, - }, - }, - "Mounts": [ - secret_mount( - RUNNER.DEVICE_PLANE_POSTGRES_PASSWORD_FILE, - "/run/nodedc-secrets/postgres-password", - ), - { - "Type": "volume", - "Name": RUNNER.DEVICE_PLANE_POSTGRES_VOLUME, - "Destination": "/var/lib/postgresql/data", - "RW": True, - }, - ], - } - - -if __name__ == "__main__": - unittest.main() diff --git a/infra/deploy-runner/test_device_plane_b2_discovery_loopback_recovery_artifact.py b/infra/deploy-runner/test_device_plane_b2_discovery_loopback_recovery_artifact.py deleted file mode 100644 index d67a394..0000000 --- a/infra/deploy-runner/test_device_plane_b2_discovery_loopback_recovery_artifact.py +++ /dev/null @@ -1,323 +0,0 @@ -#!/usr/bin/env python3 -import hashlib -import importlib.machinery -import importlib.util -import json -import os -import subprocess -import tarfile -import tempfile -import unittest -from pathlib import Path -from unittest import mock - - -SCRIPT_DIR = Path(__file__).resolve().parent -BUILDER = ( - SCRIPT_DIR - / "build-device-plane-b2-discovery-loopback-recovery-artifact.mjs" -) -RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" - - -def load_runner(): - loader = importlib.machinery.SourceFileLoader( - "nodedc_device_plane_b2_recovery_runner_under_test", - str(RUNNER_PATH), - ) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -RUNNER = load_runner() - - -class DevicePlaneB2DiscoveryLoopbackRecoveryArtifactTest( - unittest.TestCase, -): - def build(self, artifact_dir, patch_id): - environment = os.environ.copy() - environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) - result = subprocess.run( - ["node", str(BUILDER), patch_id], - check=True, - capture_output=True, - text=True, - env=environment, - ) - return json.loads(result.stdout) - - def test_artifact_is_marker_only_exact_and_deterministic(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-plane-b2-recovery-artifact-", - ) as directory: - artifact_dir = Path(directory) - patch_id = RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_PATCH_ID - first = self.build(artifact_dir, patch_id) - first_bytes = Path(first["artifact"]).read_bytes() - second = self.build(artifact_dir, patch_id) - second_bytes = Path(second["artifact"]).read_bytes() - - self.assertEqual(first_bytes, second_bytes) - self.assertEqual( - first["sha256"], - hashlib.sha256(first_bytes).hexdigest(), - ) - self.assertEqual(first["build"], []) - self.assertEqual(first["services"], []) - self.assertEqual( - first["entries"], - list( - RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_ENTRIES - ), - ) - - with tarfile.open(first["artifact"], "r:gz") as archive: - names = { - member.name - for member in archive.getmembers() - if member.isfile() - } - descriptor = json.loads( - archive.extractfile( - "payload/" - + RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_REL - ) - .read() - .decode("utf-8") - ) - self.assertEqual( - names, - { - "manifest.env", - "files.txt", - "payload/" - + RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_REL, - }, - ) - self.assertEqual( - descriptor, - RUNNER.expected_device_plane_b2_discovery_rollback_recovery_descriptor(), - ) - entries = ( - RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_ENTRIES - ) - self.assertEqual( - RUNNER.component_services("device-plane", entries), - (), - ) - self.assertEqual( - RUNNER.component_builds("device-plane", entries), - (), - ) - - def test_recovery_preflight_requires_exact_failed_evidence(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-plane-b2-recovery-preflight-", - ) as directory: - workspace = Path(directory) - artifacts = workspace / "artifacts" - failed_root = workspace / "failed" - backups_root = workspace / "backups" - state_root = workspace / "state" - temp_root = workspace / "tmp" - live_root = workspace / "live" - for path in ( - artifacts, - failed_root, - backups_root, - state_root, - temp_root, - live_root, - ): - path.mkdir(parents=True, exist_ok=True) - - recovery = self.build( - artifacts, - RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_PATCH_ID, - ) - extracted = workspace / "extracted" - extracted.mkdir() - _manifest, entries, payload = RUNNER.load_artifact( - Path(recovery["artifact"]), - extracted, - ) - self.assertEqual( - tuple(entries), - RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_ENTRIES, - ) - - failed_artifact = ( - failed_root / RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_ARTIFACT - ) - failed_artifact.write_bytes(b"failed-b2-artifact-fixture\n") - failed_sha = RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_ARTIFACT_SHA256 - - backup = ( - backups_root - / RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_BACKUP_ID - ) - backup.mkdir() - backup_names = ( - "manifest.env", - "files.txt", - "existing-files.txt", - "missing-files.txt", - "runtime-before.json", - "source-before.tgz", - ) - for name in backup_names: - (backup / name).write_text( - f"fixture:{name}\n", - encoding="utf-8", - ) - backup_hashes = { - name: hashlib.sha256((backup / name).read_bytes()).hexdigest() - for name in backup_names - } - - (state_root / "failed.jsonl").write_text( - json.dumps({ - "artifact": RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_ARTIFACT, - "backup_id": ( - RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_BACKUP_ID - ), - "component": "device-plane", - "id": RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_PATCH_ID, - "message": ( - "Command '['/usr/local/bin/docker', 'build', " - "'--no-cache', '--network=host', '-f', " - "'services/device-control-core/Dockerfile', '-t', " - "'nodedc/device-control-core:local', '.']' returned " - "non-zero exit status 1." - ), - "rollback_status": "failed:CalledProcessError", - "sha256": failed_sha, - "started_apply": True, - "status": "failed", - }) - + "\n", - encoding="utf-8", - ) - - runtime = {"accepted": True} - failed_manifest = { - "id": RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_PATCH_ID, - "component": "device-plane", - "type": "app-overlay", - } - with ( - mock.patch.object(RUNNER, "BACKUPS_DIR", backups_root), - mock.patch.object(RUNNER, "FAILED_DIR", failed_root), - mock.patch.object( - RUNNER, - "FAILED_STATE_FILE", - state_root / "failed.jsonl", - ), - mock.patch.object(RUNNER, "TMP_DIR", temp_root), - mock.patch.object( - RUNNER, - "component_root", - return_value=live_root, - ), - mock.patch.object( - RUNNER, - "DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_BACKUP_SHA256", - backup_hashes, - ), - mock.patch.object( - RUNNER, - "sha256_file", - side_effect=lambda path: ( - failed_sha - if Path(path) == failed_artifact - else hashlib.sha256(Path(path).read_bytes()).hexdigest() - ), - ), - mock.patch.object( - RUNNER, - "load_artifact", - return_value=( - failed_manifest, - list(RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_ENTRIES), - workspace / "unused-payload", - ), - ), - mock.patch.object( - RUNNER, - "validate_device_plane_foundation_network_publication_installed_source", - ), - mock.patch.object( - RUNNER, - "validate_device_plane_foundation_runtime", - return_value=runtime, - ), - mock.patch.object(RUNNER, "assert_loopback_tcp_port_closed"), - ): - evidence = ( - RUNNER.validate_device_plane_b2_discovery_rollback_recovery_evidence( - payload - ) - ) - self.assertEqual( - evidence["mode"], - "failed-b2-loopback-build-reconciliation", - ) - self.assertEqual(evidence["runtime"], runtime) - - def test_build_failure_does_not_mark_runtime_started(self): - marker = mock.Mock() - entries = RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES - services = ("device-control-core", "device-gateway") - failure = subprocess.CalledProcessError(1, ["docker", "build"]) - with ( - mock.patch.object(RUNNER, "run_build", side_effect=failure), - mock.patch.object(RUNNER, "prepare_component_runtime") as prepare, - mock.patch.object(RUNNER, "run_compose") as compose, - ): - with self.assertRaises(subprocess.CalledProcessError): - RUNNER.run_device_plane_runtime_for_apply( - entries, - services, - marker, - ) - marker.assert_not_called() - prepare.assert_not_called() - compose.assert_not_called() - - def test_compose_failure_is_marked_after_build_and_prepare(self): - events = [] - - def mark(): - events.append("mark") - - with ( - mock.patch.object( - RUNNER, - "run_build", - side_effect=lambda *_args: events.append("build"), - ), - mock.patch.object( - RUNNER, - "prepare_component_runtime", - side_effect=lambda *_args: events.append("prepare"), - ), - mock.patch.object( - RUNNER, - "run_compose", - side_effect=RuntimeError("compose failed"), - ), - ): - with self.assertRaisesRegex(RuntimeError, "compose failed"): - RUNNER.run_device_plane_runtime_for_apply( - RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES, - ("device-control-core", "device-gateway"), - mark, - ) - self.assertEqual(events, ["build", "prepare", "mark"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/infra/deploy-runner/test_device_plane_backhaul_target_artifact.py b/infra/deploy-runner/test_device_plane_backhaul_target_artifact.py deleted file mode 100644 index d9b0bb2..0000000 --- a/infra/deploy-runner/test_device_plane_backhaul_target_artifact.py +++ /dev/null @@ -1,649 +0,0 @@ -#!/usr/bin/env python3 -import base64 -import hashlib -import importlib.machinery -import importlib.util -import json -import os -import subprocess -import tarfile -import tempfile -import unittest -from pathlib import Path -from types import SimpleNamespace -from unittest import mock - - -SCRIPT_DIR = Path(__file__).resolve().parent -BUILDER = SCRIPT_DIR / "build-device-plane-backhaul-target-artifact.mjs" -RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" -COMPOSE = ( - SCRIPT_DIR.parent.parent - / "device-plane/docker-compose.device-plane.backhaul-target.yml" -) -PREDECESSOR_COMPOSE = ( - SCRIPT_DIR.parent.parent / "device-plane/docker-compose.device-plane.yml" -) -SSHD_CONFIG = ( - SCRIPT_DIR.parent.parent - / "device-plane/services/device-backhaul-target/sshd_config" -) - - -def load_runner(): - loader = importlib.machinery.SourceFileLoader( - "nodedc_device_plane_backhaul_runner_under_test", - str(RUNNER_PATH), - ) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -RUNNER = load_runner() - - -def valid_public_key(comment="nodedc-device-edge-backhaul"): - blob = b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20" + bytes(range(32)) - return f"ssh-ed25519 {base64.b64encode(blob).decode()} {comment}\n" - - -def healthy_inventory(): - return { - "schemaVersion": "nodedc.device-plane.runtime-inventory.v1", - "composeProject": "nodedc-device-plane", - "services": [ - { - "service": service, - "containerId": character * 64, - "imageId": f"sha256:{character * 64}", - "status": "running", - "running": True, - "health": "healthy", - "restartCount": 0, - } - for service, character in ( - ("device-control-core", "a"), - ("device-gateway", "b"), - ("device-postgres", "c"), - ) - ], - } - - -class DevicePlaneBackhaulTargetArtifactTest(unittest.TestCase): - def build(self, artifact_dir, patch_id): - environment = os.environ.copy() - environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) - result = subprocess.run( - ["node", str(BUILDER), patch_id], - check=True, - capture_output=True, - text=True, - env=environment, - ) - return json.loads(result.stdout) - - def test_artifact_is_exact_deterministic_and_contains_no_keys(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-plane-backhaul-artifact-", - ) as directory: - target = Path(directory) - first = self.build(target, "device-plane-backhaul-target-unit-001") - first_bytes = Path(first["artifact"]).read_bytes() - second = self.build(target, "device-plane-backhaul-target-unit-001") - second_bytes = Path(second["artifact"]).read_bytes() - self.assertEqual(first_bytes, second_bytes) - self.assertEqual(first["sha256"], hashlib.sha256(first_bytes).hexdigest()) - self.assertEqual( - first["entries"], - list(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES), - ) - self.assertEqual(first["services"], ["device-backhaul-target"]) - - with tarfile.open(first["artifact"], "r:gz") as archive: - names = archive.getnames() - files = archive.extractfile("files.txt").read().decode().splitlines() - descriptor = json.loads( - archive.extractfile( - "payload/deployment/" - "device-plane-backhaul-target-tailnet-serve-v1.json" - ).read() - ) - self.assertEqual(files, first["entries"]) - self.assertEqual( - descriptor, - RUNNER.expected_device_plane_backhaul_target_descriptor(), - ) - self.assertFalse(any( - name.endswith((".key", ".pem", "authorized_keys")) - for name in names - )) - - def test_registry_selects_only_target_and_preserves_red_boundaries(self): - entries = RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES - self.assertEqual( - RUNNER.component_services("device-plane", entries), - ("device-backhaul-target",), - ) - builds = RUNNER.component_builds("device-plane", entries) - self.assertEqual(len(builds), 1) - self.assertIn("services/device-backhaul-target/Dockerfile", builds[0][1]) - self.assertIn(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_IMAGE, builds[0][1]) - for path in ( - RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL, - "services/device-backhaul-target/Dockerfile", - "services/device-backhaul-target/sshd_config", - RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_REL, - ): - self.assertTrue(RUNNER.allowed_payload_path("device-plane", path)) - - compose = COMPOSE.read_text(encoding="utf-8") - sshd = SSHD_CONFIG.read_text(encoding="utf-8") - self.assertIn("network_mode: host", compose) - self.assertNotIn("0.0.0.0:2222", compose) - self.assertIn('"127.0.0.1", "2222"', compose) - self.assertIn("ListenAddress 127.0.0.1", sshd) - self.assertIn("AllowTcpForwarding local", sshd) - self.assertIn("PermitOpen 127.0.0.1:9921", sshd) - self.assertIn("ForceCommand /bin/false", sshd) - self.assertIn("PasswordAuthentication no", sshd) - - def test_preflight_requires_exact_applied_006_and_enrollment_key(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-plane-backhaul-preflight-", - ) as directory: - work = Path(directory) - built = self.build(work, "device-plane-backhaul-target-unit-002") - extracted = work / "extracted" - extracted.mkdir() - _manifest, _entries, payload = RUNNER.load_artifact( - Path(built["artifact"]), - extracted, - ) - live = work / "live" - live.mkdir() - (live / "docker-compose.device-plane.yml").write_bytes( - PREDECESSOR_COMPOSE.read_bytes() - ) - descriptor = live / RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL - descriptor.parent.mkdir(parents=True) - descriptor.write_text( - json.dumps(RUNNER.expected_device_plane_b2_discovery_ingress_descriptor()), - encoding="utf-8", - ) - enrollment = work / "device-edge-backhaul.pub" - enrollment.write_text(valid_public_key(), encoding="ascii") - - def has_patch(value): - return value == RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_PREDECESSOR_PATCH_ID - - def has_sha(value): - return value == RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_PREDECESSOR_ARTIFACT_SHA256 - - with ( - mock.patch.object(RUNNER, "component_root", return_value=live), - mock.patch.object( - RUNNER, - "DEVICE_PLANE_BACKHAUL_ENROLLMENT_PUBLIC_KEY_FILE", - enrollment, - ), - mock.patch.object(RUNNER, "state_has_patch_id", side_effect=has_patch), - mock.patch.object(RUNNER, "state_has_sha", side_effect=has_sha), - mock.patch.object( - RUNNER, - "device_plane_service_container_ids", - return_value=[], - ), - mock.patch.object( - RUNNER, - "device_plane_runtime_inventory", - return_value=healthy_inventory(), - ), - mock.patch.object(RUNNER, "assert_loopback_tcp_port_open"), - mock.patch.object( - RUNNER, - "validate_device_plane_backhaul_failed_evidence", - return_value={ - "backup": work / "failed-backup", - "failedArtifact": work / "failed-artifact.tgz", - }, - ), - mock.patch.object( - RUNNER, - "validate_device_plane_tailscale_cli", - return_value={ - "binary": str(RUNNER.DEVICE_PLANE_TAILSCALE), - "uid": 1024, - "gid": 1024, - "binarySha256": "d" * 64, - }, - ), - mock.patch.object( - RUNNER, - "validate_device_plane_tailscale_runtime", - return_value={ - "self": { - "Online": True, - "TailscaleIPs": ["100.109.216.21"], - }, - "serve": {}, - }, - ), - ): - accepted = RUNNER.validate_device_plane_backhaul_target_evidence(payload) - self.assertEqual( - accepted["mode"], - "failed-backhaul-target-to-loopback-tailnet-serve", - ) - self.assertRegex(accepted["enrollmentPublicKeySha256"], r"^[a-f0-9]{64}$") - self.assertEqual(accepted["tailscaleServeBefore"], {}) - self.assertEqual(accepted["tailscaleCli"]["uid"], 1024) - - def test_registered_health_gate_checks_preserved_and_target_services(self): - with mock.patch.object(RUNNER, "healthcheck_compose_service") as health: - RUNNER.run_healthchecks( - "device-plane", - RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES, - (RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,), - ) - self.assertEqual( - [call.args[1] for call in health.call_args_list], - [ - "device-control-core", - "device-gateway", - "device-postgres", - "device-backhaul-target", - ], - ) - - def test_candidate_rollback_removes_only_target_and_preserves_runtime(self): - runtime = healthy_inventory() - with ( - mock.patch.object( - RUNNER, - "read_backup_path_list", - side_effect=[[], list(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES)], - ), - mock.patch.object( - RUNNER, - "validate_backup_partition", - return_value=(set(), set(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES)), - ), - mock.patch.object( - RUNNER, - "read_strict_json", - side_effect=[runtime, {}], - ), - mock.patch.object( - RUNNER, - "disable_device_plane_tailscale_serve", - ) as disable_serve, - mock.patch.object(RUNNER, "stop_and_remove_compose_services") as stop, - mock.patch.object(RUNNER, "restore_platform_overlay", return_value=3), - mock.patch.object( - RUNNER, - "device_plane_service_container_ids", - return_value=[], - ), - mock.patch.object( - RUNNER, - "device_plane_runtime_inventory", - return_value=runtime, - ), - mock.patch.object( - RUNNER, - "validate_device_plane_tailscale_runtime", - return_value={"serve": {}}, - ), - mock.patch.object(RUNNER, "assert_loopback_tcp_port_open") as port, - ): - result = RUNNER.rollback_device_plane_apply( - Path("/unused/live"), - Path("/unused/backup"), - RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES, - "20260803-000000", - True, - (RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,), - ) - self.assertEqual( - result, - "tailscale-serve-restored-source-restored-target-removed-" - "preserved-runtime-unchanged:3", - ) - stop.assert_called_once_with( - "device-plane", - (RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,), - ) - disable_serve.assert_called_once_with({}) - port.assert_called_once_with(9921) - - def test_tailscale_serve_port_parser_rejects_funnel_and_nested_collision(self): - clean = {"TCP": {"443": {"HTTPS": True}}} - self.assertEqual( - RUNNER.device_plane_tailscale_handlers_for_port(clean, 2222), - [], - ) - active = { - "TCP": {"2222": {"TCPForward": "127.0.0.1:2222"}}, - "Foreground": { - "session": { - "TCP": {"443": {"HTTPS": True}}, - }, - }, - } - self.assertEqual( - RUNNER.device_plane_tailscale_handlers_for_port(active, 2222), - [((), {"TCPForward": "127.0.0.1:2222"})], - ) - self.assertFalse( - RUNNER.device_plane_tailscale_funnel_uses_port(active, 2222) - ) - active["AllowFunnel"] = {"edge.example.ts.net:2222": True} - self.assertTrue( - RUNNER.device_plane_tailscale_funnel_uses_port(active, 2222) - ) - - def test_runtime_activation_enables_private_tailscale_serve_after_health(self): - calls = [] - with ( - mock.patch.object(RUNNER, "run_build", side_effect=lambda *a: calls.append("build")), - mock.patch.object( - RUNNER, - "prepare_component_runtime", - side_effect=lambda *a: calls.append("prepare"), - ), - mock.patch.object( - RUNNER, - "run_compose", - side_effect=lambda *a: calls.append("compose"), - ), - mock.patch.object( - RUNNER, - "healthcheck_compose_service", - side_effect=lambda *a: calls.append("health"), - ), - mock.patch.object( - RUNNER, - "enable_device_plane_tailscale_serve", - side_effect=lambda *a: calls.append("serve"), - ) as enable, - ): - RUNNER.run_device_plane_runtime_for_apply( - RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES, - (RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,), - lambda: calls.append("started"), - backhaul_serve_before={"TCP": {"443": {"HTTPS": True}}}, - ) - self.assertEqual( - calls, - ["build", "prepare", "started", "compose", "health", "serve"], - ) - enable.assert_called_once_with({"TCP": {"443": {"HTTPS": True}}}) - - def test_tailscale_serve_enable_and_disable_preserve_unrelated_routes(self): - before = {"TCP": {"443": {"HTTPS": True}}} - active = { - "TCP": { - "443": {"HTTPS": True}, - "2222": {"TCPForward": "127.0.0.1:2222"}, - }, - } - with ( - mock.patch.object( - RUNNER, - "validate_device_plane_tailscale_runtime", - side_effect=[{"serve": before}, {"serve": active}], - ), - mock.patch.object(RUNNER, "run_device_plane_tailscale") as run, - ): - result = RUNNER.enable_device_plane_tailscale_serve(before) - self.assertEqual(result, active) - self.assertEqual( - run.call_args.args[0], - [ - "serve", - "--bg", - "--yes", - "--tcp=2222", - "tcp://127.0.0.1:2222", - ], - ) - - with ( - mock.patch.object( - RUNNER, - "read_device_plane_tailscale_json", - return_value=active, - ), - mock.patch.object( - RUNNER, - "validate_device_plane_tailscale_runtime", - return_value={"serve": before}, - ), - mock.patch.object(RUNNER, "run_device_plane_tailscale") as run, - ): - changed = RUNNER.disable_device_plane_tailscale_serve(before) - self.assertTrue(changed) - self.assertEqual( - run.call_args.args[0], - [ - "serve", - "--tcp=2222", - "off", - ], - ) - - def test_tailscale_cli_runs_as_official_package_account(self): - context = { - "binary": "/var/packages/Tailscale/target/bin/tailscale", - "uid": 1051, - "gid": 1051, - "binarySha256": "e" * 64, - } - with ( - mock.patch.object( - RUNNER, - "validate_device_plane_tailscale_cli", - return_value=context, - ), - mock.patch.object(RUNNER.subprocess, "run") as run, - mock.patch.object( - RUNNER, - "device_plane_tailscale_drop_privileges", - return_value="drop-to-package-account", - ) as drop, - ): - RUNNER.run_device_plane_tailscale( - ["status", "--json"], - check=False, - capture_output=True, - text=True, - ) - self.assertEqual( - run.call_args.args[0], - [context["binary"], "status", "--json"], - ) - self.assertEqual( - run.call_args.kwargs["preexec_fn"], - "drop-to-package-account", - ) - drop.assert_called_once_with(1051, 1051) - - def test_tailscale_cli_accepts_package_owned_binary_without_root_execution(self): - privilege_path = mock.MagicMock() - privilege_path.__str__.return_value = ( - "/var/packages/Tailscale/conf/privilege" - ) - privilege_path.lstat.return_value = SimpleNamespace( - st_mode=RUNNER.stat.S_IFREG | 0o644, - st_uid=0, - ) - binary_path = mock.MagicMock() - binary_path.__str__.return_value = ( - "/var/packages/Tailscale/target/bin/tailscale" - ) - binary_path.lstat.return_value = SimpleNamespace( - st_mode=RUNNER.stat.S_IFREG | 0o755, - st_uid=1051, - st_gid=1051, - st_size=32 * 1024 * 1024, - ) - account = SimpleNamespace(pw_uid=1051, pw_gid=1051) - group = SimpleNamespace(gr_gid=1051) - help_result = SimpleNamespace( - stdout="--tcp --bg --yes", - stderr="", - ) - with ( - mock.patch.object( - RUNNER, - "DEVICE_PLANE_TAILSCALE_PRIVILEGE", - privilege_path, - ), - mock.patch.object( - RUNNER, - "DEVICE_PLANE_TAILSCALE", - binary_path, - ), - mock.patch.object( - RUNNER, - "read_strict_json", - return_value={ - "defaults": {"run-as": "package"}, - "username": "tailscale", - "groupname": "tailscale", - }, - ), - mock.patch.object(RUNNER.pwd, "getpwnam", return_value=account), - mock.patch.object(RUNNER.grp, "getgrnam", return_value=group), - mock.patch.object( - RUNNER, - "sha256_file", - return_value="f" * 64, - ), - mock.patch.object( - RUNNER.subprocess, - "run", - return_value=help_result, - ) as run, - ): - context = RUNNER.validate_device_plane_tailscale_cli() - self.assertEqual(context["uid"], 1051) - self.assertEqual(context["gid"], 1051) - self.assertEqual(context["binarySha256"], "f" * 64) - self.assertTrue(callable(run.call_args.kwargs["preexec_fn"])) - - def test_failed_001_evidence_is_exact_and_terminal(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-plane-backhaul-failed-evidence-", - ) as directory: - root = Path(directory) - backups = root / "backups" - failed = root / "failed" - state = root / "state" - tmp = root / "tmp" - for path in (backups, failed, state, tmp): - path.mkdir() - - backup = backups / RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_BACKUP_ID - backup.mkdir() - backup_hashes = {} - for name in ( - "manifest.env", - "files.txt", - "source-before.tgz", - "existing-files.txt", - "missing-files.txt", - "runtime-before.json", - ): - payload = f"fixture:{name}\n".encode() - (backup / name).write_bytes(payload) - backup_hashes[name] = hashlib.sha256(payload).hexdigest() - - stage = root / "failed-stage" - payload = stage / "payload" - service = payload / "services/device-backhaul-target" - deployment = payload / "deployment" - service.mkdir(parents=True) - deployment.mkdir(parents=True) - (stage / "manifest.env").write_text( - "id=device-plane-backhaul-target-20260803-001\n" - "component=device-plane\n" - "type=app-overlay\n", - encoding="utf-8", - ) - (stage / "files.txt").write_text( - "\n".join(RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_TARGET_ENTRIES) - + "\n", - encoding="utf-8", - ) - (payload / RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL).write_text( - "services: {}\n", - encoding="utf-8", - ) - (service / "Dockerfile").write_text( - "FROM scratch\n", - encoding="utf-8", - ) - (service / "sshd_config").write_text( - "PasswordAuthentication no\n", - encoding="utf-8", - ) - (payload / RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_TARGET_REL).write_text( - json.dumps( - RUNNER.expected_failed_device_plane_backhaul_target_descriptor() - ) - + "\n", - encoding="utf-8", - ) - failed_artifact = failed / RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_ARTIFACT - with tarfile.open(failed_artifact, "w:gz") as archive: - for name in ("manifest.env", "files.txt", "payload"): - archive.add(stage / name, arcname=name) - failed_sha = hashlib.sha256(failed_artifact.read_bytes()).hexdigest() - - failed_state = state / "failed.jsonl" - failed_state.write_text( - json.dumps({ - "artifact": RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_ARTIFACT, - "backup_id": RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_BACKUP_ID, - "component": "device-plane", - "id": RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_PATCH_ID, - "message": RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_MESSAGE, - "rollback_status": ( - "ok:device-plane-overlay:source-restored-target-removed-" - "preserved-runtime-unchanged:3" - ), - "sha256": failed_sha, - "started_apply": True, - "status": "failed", - }) - + "\n", - encoding="utf-8", - ) - - with ( - mock.patch.object(RUNNER, "BACKUPS_DIR", backups), - mock.patch.object(RUNNER, "FAILED_DIR", failed), - mock.patch.object(RUNNER, "FAILED_STATE_FILE", failed_state), - mock.patch.object(RUNNER, "TMP_DIR", tmp), - mock.patch.object( - RUNNER, - "DEVICE_PLANE_BACKHAUL_FAILED_BACKUP_SHA256", - backup_hashes, - ), - mock.patch.object( - RUNNER, - "DEVICE_PLANE_BACKHAUL_FAILED_ARTIFACT_SHA256", - failed_sha, - ), - ): - evidence = RUNNER.validate_device_plane_backhaul_failed_evidence() - self.assertEqual(evidence["backup"], backup) - self.assertEqual(evidence["failedArtifact"], failed_artifact) - - -if __name__ == "__main__": - unittest.main() diff --git a/infra/deploy-runner/test_device_plane_backhaul_vps_enrollment_artifact.py b/infra/deploy-runner/test_device_plane_backhaul_vps_enrollment_artifact.py deleted file mode 100644 index b94fccc..0000000 --- a/infra/deploy-runner/test_device_plane_backhaul_vps_enrollment_artifact.py +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env python3 -import base64 -import hashlib -import importlib.machinery -import importlib.util -import json -import os -import subprocess -import tarfile -import tempfile -import unittest -from pathlib import Path -from unittest import mock - - -SCRIPT_DIR = Path(__file__).resolve().parent -BUILDER = ( - SCRIPT_DIR / "build-device-plane-backhaul-vps-enrollment-artifact.mjs" -) -RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" - - -def load_runner(): - loader = importlib.machinery.SourceFileLoader( - "nodedc_device_plane_vps_enrollment_runner_under_test", - str(RUNNER_PATH), - ) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -RUNNER = load_runner() - - -class DevicePlaneBackhaulVpsEnrollmentArtifactTest(unittest.TestCase): - def build(self, artifact_dir, patch_id): - environment = os.environ.copy() - environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) - environment["NODEDC_ALLOW_SUPERSEDED_TRANSPORT"] = "test-only" - return subprocess.run( - ["node", str(BUILDER), patch_id], - check=False, - capture_output=True, - text=True, - env=environment, - ) - - def test_builder_fails_closed_without_test_only_reconstruction(self): - environment = os.environ.copy() - environment.pop("NODEDC_ALLOW_SUPERSEDED_TRANSPORT", None) - with tempfile.TemporaryDirectory( - prefix="nodedc-vps-enrollment-frozen-", - ) as directory: - environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = directory - result = subprocess.run( - ["node", str(BUILDER), "device-plane-vps-enrollment-frozen-001"], - check=False, - capture_output=True, - text=True, - env=environment, - ) - self.assertNotEqual(result.returncode, 0) - self.assertIn( - "vps_initiated_transport_frozen:ADR-0001", - result.stderr, - ) - - def test_runner_rejects_a_prebuilt_superseded_enrollment_artifact(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-vps-enrollment-frozen-runner-", - ) as directory: - root = Path(directory) - result = self.build( - root, - "device-plane-vps-enrollment-frozen-runner-001", - ) - self.assertEqual(result.returncode, 0, result.stderr) - artifact = Path(json.loads(result.stdout)["artifact"]) - extracted = root / "extracted" - extracted.mkdir() - with self.assertRaises(RUNNER.DeployError) as raised: - RUNNER.load_artifact(artifact, extracted) - self.assertIn( - "vps_initiated_transport_frozen:ADR-0001", - str(raised.exception), - ) - - def test_artifact_is_deterministic_marker_only_and_secret_free(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-vps-enrollment-artifact-", - ) as directory: - root = Path(directory) - first = self.build(root, "device-plane-vps-enrollment-unit-001") - self.assertEqual(first.returncode, 0, first.stderr) - first_result = json.loads(first.stdout) - first_bytes = Path(first_result["artifact"]).read_bytes() - second = self.build(root, "device-plane-vps-enrollment-unit-001") - self.assertEqual(second.returncode, 0, second.stderr) - second_result = json.loads(second.stdout) - second_bytes = Path(second_result["artifact"]).read_bytes() - self.assertEqual(first_bytes, second_bytes) - self.assertEqual( - first_result["sha256"], - hashlib.sha256(first_bytes).hexdigest(), - ) - self.assertEqual( - first_result["entries"], - list(RUNNER.DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_ENTRIES), - ) - with tarfile.open(first_result["artifact"], "r:gz") as archive: - names = archive.getnames() - payload = b"\n".join( - archive.extractfile(member).read() - for member in archive.getmembers() - if member.isfile() - ) - self.assertEqual( - set(names), - { - "manifest.env", - "files.txt", - "payload", - "payload/deployment", - "payload/deployment/device-plane-backhaul-vps-enrollment-v1.json", - }, - ) - self.assertNotIn(b"PRIVATE KEY", payload) - self.assertNotIn(b"authorized_keys", payload) - - def test_registry_selects_only_existing_target_without_build(self): - entries = RUNNER.DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_ENTRIES - self.assertTrue( - RUNNER.is_device_plane_backhaul_vps_enrollment_slice( - "device-plane", - entries, - ) - ) - self.assertEqual( - RUNNER.component_services("device-plane", entries), - (RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,), - ) - self.assertEqual(RUNNER.component_builds("device-plane", entries), ()) - - def test_vps_public_key_is_pinned_by_computed_fingerprint(self): - blob = ( - b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20" - + bytes(range(32)) - ) - key = ( - "ssh-ed25519 " - + base64.b64encode(blob).decode("ascii") - + " source-comment\n" - ) - fingerprint = ( - "SHA256:" - + base64.b64encode(hashlib.sha256(blob).digest()) - .decode("ascii") - .rstrip("=") - ) - with tempfile.TemporaryDirectory( - prefix="nodedc-vps-enrollment-key-", - ) as directory: - path = Path(directory) / "device-edge-vps-backhaul.pub" - path.write_text(key, encoding="ascii") - with ( - mock.patch.object( - RUNNER, - "DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PUBLIC_KEY_FILE", - path, - ), - mock.patch.object( - RUNNER, - "DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_FINGERPRINT", - fingerprint, - ), - ): - enrollment = ( - RUNNER.read_device_plane_backhaul_vps_enrollment_public_key() - ) - self.assertEqual(enrollment["fingerprint"], fingerprint) - self.assertTrue( - enrollment["line"].endswith( - " nodedc-device-edge-vps-backhaul" - ) - ) - - def test_runtime_authorized_key_is_external_and_atomic(self): - blob = ( - b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20" - + bytes(reversed(range(32))) - ) - enrollment = { - "line": ( - "ssh-ed25519 " - + base64.b64encode(blob).decode("ascii") - + " nodedc-device-edge-vps-backhaul" - ) - } - with tempfile.TemporaryDirectory( - prefix="nodedc-vps-enrollment-runtime-", - ) as directory: - secret_dir = Path(directory) / "secret" - authorized = secret_dir / "authorized_keys" - with ( - mock.patch.object( - RUNNER, - "DEVICE_PLANE_BACKHAUL_SECRET_DIR", - secret_dir, - ), - mock.patch.object( - RUNNER, - "DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE", - authorized, - ), - mock.patch.object(RUNNER.os, "chown"), - ): - digest = RUNNER.install_device_plane_backhaul_authorized_key( - enrollment - ) - expected = ( - 'restrict,port-forwarding,permitopen="127.0.0.1:9921" ' - + enrollment["line"] - + "\n" - ) - self.assertEqual(authorized.read_text(encoding="ascii"), expected) - self.assertEqual( - digest, - hashlib.sha256(expected.encode("ascii")).hexdigest(), - ) - self.assertEqual(authorized.stat().st_mode & 0o777, 0o444) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_device_plane_foundation_network_publication_artifact.py b/infra/deploy-runner/test_device_plane_foundation_network_publication_artifact.py deleted file mode 100644 index 32adade..0000000 --- a/infra/deploy-runner/test_device_plane_foundation_network_publication_artifact.py +++ /dev/null @@ -1,302 +0,0 @@ -#!/usr/bin/env python3 -import hashlib -import importlib.machinery -import importlib.util -import json -import os -import subprocess -import tarfile -import tempfile -import unittest -from pathlib import Path -from unittest import mock - - -SCRIPT_DIR = Path(__file__).resolve().parent -BUILDER = ( - SCRIPT_DIR - / "build-device-plane-foundation-network-publication-artifact.mjs" -) -RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" -COMPOSE = ( - SCRIPT_DIR - / "fixtures/device-plane-foundation-network-publication-v1.yml" -) -PREDECESSOR_COMPOSE = ( - SCRIPT_DIR - / "fixtures/device-plane-foundation-internal-only-v1.yml" -) - - -def load_runner(): - loader = importlib.machinery.SourceFileLoader( - "nodedc_device_plane_network_publication_runner_under_test", - str(RUNNER_PATH), - ) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -RUNNER = load_runner() - - -class DevicePlaneFoundationNetworkPublicationArtifactTest( - unittest.TestCase -): - def build(self, artifact_dir, patch_id): - environment = os.environ.copy() - environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) - result = subprocess.run( - ["node", str(BUILDER), patch_id], - check=True, - capture_output=True, - text=True, - env=environment, - ) - return json.loads(result.stdout) - - def test_artifact_is_exact_deterministic_and_database_free(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-plane-network-publication-", - ) as directory: - artifact_dir = Path(directory) - patch_id = ( - "device-plane-foundation-network-publication-unit-003" - ) - first = self.build(artifact_dir, patch_id) - first_bytes = Path(first["artifact"]).read_bytes() - second = self.build(artifact_dir, patch_id) - second_bytes = Path(second["artifact"]).read_bytes() - - self.assertEqual(first_bytes, second_bytes) - self.assertEqual( - first["sha256"], - hashlib.sha256(first_bytes).hexdigest(), - ) - self.assertEqual( - first["transition"], - "failed-foundation-network-publication-correction", - ) - self.assertEqual(first["build"], []) - self.assertEqual( - first["services"], - ["device-control-core", "device-gateway"], - ) - self.assertNotIn("device-postgres", first["services"]) - self.assertEqual( - first["entries"], - list( - RUNNER - .DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES - ), - ) - - with tarfile.open(first["artifact"], "r:gz") as archive: - files = ( - archive.extractfile("files.txt") - .read() - .decode("utf-8") - .splitlines() - ) - compose = ( - archive.extractfile( - "payload/docker-compose.device-plane.yml" - ) - .read() - ) - descriptor = json.loads( - archive.extractfile( - "payload/deployment/" - "device-plane-foundation-network-publication-v1.json" - ) - .read() - .decode("utf-8") - ) - - self.assertEqual(files, first["entries"]) - self.assertEqual( - hashlib.sha256(compose).hexdigest(), - RUNNER - .DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_COMPOSE_SHA256, - ) - self.assertEqual( - descriptor, - RUNNER - .expected_device_plane_foundation_network_publication_descriptor(), - ) - - def test_compose_preserves_database_and_adds_control_network_only_to_stateless_services( - self, - ): - compose = COMPOSE.read_text(encoding="utf-8") - predecessor = PREDECESSOR_COMPOSE.read_text(encoding="utf-8") - self.assertEqual( - hashlib.sha256(PREDECESSOR_COMPOSE.read_bytes()).hexdigest(), - RUNNER.DEVICE_PLANE_FOUNDATION_PREDECESSOR_COMPOSE_SHA256, - ) - self.assertEqual( - hashlib.sha256(COMPOSE.read_bytes()).hexdigest(), - RUNNER - .DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_COMPOSE_SHA256, - ) - self.assertIn("name: nodedc-device-plane-control", compose) - self.assertIn( - 'com.docker.network.bridge.enable_ip_masquerade: "false"', - compose, - ) - self.assertNotIn("nodedc-device-plane-control", predecessor) - postgres_section, stateless = compose.split( - " device-control-core:", - 1, - ) - self.assertNotIn("device-plane-control", postgres_section) - self.assertEqual(stateless.count(" - device-plane-control"), 2) - self.assertNotIn("9921:9921", compose) - - def test_runner_selects_exact_services_no_build_and_no_deps(self): - entries = ( - RUNNER.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES - ) - services = RUNNER.component_services("device-plane", entries) - self.assertEqual( - services, - ("device-control-core", "device-gateway"), - ) - self.assertEqual( - RUNNER.component_builds("device-plane", entries), - (), - ) - - with ( - mock.patch.object(RUNNER, "run_build") as run_build, - mock.patch.object( - RUNNER, - "prepare_component_runtime", - ) as prepare, - mock.patch.object(RUNNER, "run_compose") as run_compose, - ): - RUNNER.run_component_runtime( - "device-plane", - entries, - services, - ) - run_build.assert_not_called() - prepare.assert_called_once_with("device-plane", entries) - run_compose.assert_called_once_with( - "device-plane", - services, - entries, - ) - - with mock.patch.object(RUNNER.subprocess, "run") as run: - RUNNER.run_compose("device-plane", services, entries) - command = run.call_args_list[0].args[0] - self.assertIn("--force-recreate", command) - self.assertIn("--no-deps", command) - self.assertEqual( - command[-2:], - ["device-control-core", "device-gateway"], - ) - - def test_terminal_failed_recovery_is_rejected(self): - with self.assertRaisesRegex( - RUNNER.DeployError, - "recovery 002 is terminal failed", - ): - RUNNER.reject_terminal_device_plane_foundation_artifact( - { - "id": - RUNNER - .DEVICE_PLANE_FOUNDATION_RECOVERY_FAILED_PATCH_ID, - }, - "0" * 64, - ) - with self.assertRaisesRegex( - RUNNER.DeployError, - "recovery 002 is terminal failed", - ): - RUNNER.reject_terminal_device_plane_foundation_artifact( - {"id": "unrelated"}, - RUNNER - .DEVICE_PLANE_FOUNDATION_RECOVERY_FAILED_ARTIFACT_SHA256, - ) - - def test_rollback_is_stateless_and_removes_only_control_network(self): - entries = ( - RUNNER.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES - ) - services = ("device-control-core", "device-gateway") - runtime = { - "device-control-core": {"containerId": "1" * 64}, - "device-gateway": {"containerId": "2" * 64}, - "device-postgres": { - "containerId": - RUNNER - .DEVICE_PLANE_FOUNDATION_PREDECESSOR_CONTAINER_IDS[ - "device-postgres" - ], - }, - } - with ( - mock.patch.object( - RUNNER, - "stop_and_remove_compose_services", - ) as stop, - mock.patch.object( - RUNNER, - "restore_platform_overlay", - return_value=9, - ) as restore, - mock.patch.object( - RUNNER, - "remove_device_plane_control_network_if_unused", - return_value="removed", - ) as remove_network, - mock.patch.object(RUNNER, "run_compose") as compose, - mock.patch.object( - RUNNER, - "healthcheck_compose_service", - ) as health, - mock.patch.object( - RUNNER, - "validate_device_plane_foundation_runtime", - return_value=runtime, - ) as validate_runtime, - mock.patch.object( - RUNNER, - "assert_loopback_tcp_port_closed", - ) as closed, - ): - result = ( - RUNNER - .rollback_device_plane_network_publication_apply( - Path("/live"), - Path("/backup"), - entries, - "stamp", - True, - services, - ) - ) - - self.assertEqual(result, "source+internal-runtime-restored:9") - stop.assert_called_once_with("device-plane", services) - restore.assert_called_once() - remove_network.assert_called_once_with() - compose.assert_called_once_with( - "device-plane", - services, - ("docker-compose.device-plane.yml",), - ) - self.assertEqual(health.call_count, 3) - validate_runtime.assert_called_once_with() - self.assertEqual( - [call.args[0] for call in closed.call_args_list], - [18120, 18121, 9921], - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/infra/deploy-runner/test_device_plane_foundation_recovery_artifact.py b/infra/deploy-runner/test_device_plane_foundation_recovery_artifact.py deleted file mode 100644 index 3590a4b..0000000 --- a/infra/deploy-runner/test_device_plane_foundation_recovery_artifact.py +++ /dev/null @@ -1,386 +0,0 @@ -#!/usr/bin/env python3 -import hashlib -import importlib.machinery -import importlib.util -import json -import os -import shutil -import subprocess -import tarfile -import tempfile -import unittest -from pathlib import Path -from unittest import mock - - -SCRIPT_DIR = Path(__file__).resolve().parent -RECOVERY_BUILDER = ( - SCRIPT_DIR / "build-device-plane-foundation-recovery-artifact.mjs" -) -FOUNDATION_BUILDER = SCRIPT_DIR / "build-device-plane-artifact.mjs" -PREDECESSOR_COMPOSE = ( - SCRIPT_DIR - / "fixtures/device-plane-foundation-internal-only-v1.yml" -) -RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" -RECOVERY_ENTRIES = [ - ".dockerignore", - "package.json", - "package-lock.json", - "docker-compose.device-plane.yml", - "packages/device-protocol-contract", - "packages/arusnavi-b2-adapter", - "services/device-control-core", - "services/device-gateway", - "deployment/device-plane-foundation-recovery-v1.json", -] - - -def load_runner(): - loader = importlib.machinery.SourceFileLoader( - "nodedc_device_plane_recovery_runner_under_test", - str(RUNNER_PATH), - ) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -RUNNER = load_runner() - - -class DevicePlaneFoundationRecoveryArtifactTest(unittest.TestCase): - def build(self, builder, artifact_dir, patch_id): - environment = os.environ.copy() - environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) - result = subprocess.run( - ["node", str(builder), patch_id], - check=True, - capture_output=True, - text=True, - env=environment, - ) - return json.loads(result.stdout) - - def test_recovery_artifact_is_source_only_exact_and_deterministic(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-plane-recovery-artifact-", - ) as directory: - artifact_dir = Path(directory) - first = self.build( - RECOVERY_BUILDER, - artifact_dir, - "device-plane-foundation-recovery-unit-002", - ) - artifact = Path(first["artifact"]) - first_bytes = artifact.read_bytes() - second = self.build( - RECOVERY_BUILDER, - artifact_dir, - "device-plane-foundation-recovery-unit-002", - ) - second_bytes = Path(second["artifact"]).read_bytes() - - self.assertEqual(first["component"], "device-plane") - self.assertEqual(first["entries"], RECOVERY_ENTRIES) - self.assertEqual(first["build"], []) - self.assertEqual(first["services"], []) - self.assertEqual( - first["transition"], - "failed-foundation-live-runtime-adoption", - ) - self.assertEqual(first_bytes, second_bytes) - self.assertEqual( - first["sha256"], - hashlib.sha256(first_bytes).hexdigest(), - ) - - with tarfile.open(artifact, "r:gz") as archive: - names = {member.name for member in archive.getmembers()} - files = ( - archive.extractfile("files.txt") - .read() - .decode("utf-8") - .splitlines() - ) - descriptor = json.loads( - archive.extractfile( - "payload/deployment/" - "device-plane-foundation-recovery-v1.json" - ) - .read() - .decode("utf-8") - ) - - self.assertEqual(files, RECOVERY_ENTRIES) - self.assertIn( - "payload/services/device-control-core/src/server.mjs", - names, - ) - self.assertIn( - "payload/services/device-gateway/src/server.mjs", - names, - ) - self.assertFalse(any( - "/test/" in name - or "/node_modules/" in name - or Path(name).name.startswith(".env") - or name.startswith("payload/runtime/") - or name.startswith("payload/secrets/") - for name in names - )) - self.assertEqual( - descriptor, - RUNNER.expected_device_plane_foundation_recovery_descriptor(), - ) - self.assertEqual( - RUNNER.component_services( - "device-plane", - tuple(RECOVERY_ENTRIES), - ), - (), - ) - self.assertEqual( - RUNNER.component_builds( - "device-plane", - tuple(RECOVERY_ENTRIES), - ), - (), - ) - - def test_preflight_requires_exact_failed_evidence_partial_source_and_runtime( - self, - ): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-plane-recovery-preflight-", - ) as directory: - workspace = Path(directory) - artifacts = workspace / "artifacts" - failed_root = workspace / "failed" - backups_root = workspace / "backups" - state_root = workspace / "state" - temp_root = workspace / "tmp" - live_root = workspace / "live" - for path in ( - artifacts, - failed_root, - backups_root, - state_root, - temp_root, - live_root / "deployment", - ): - path.mkdir(parents=True, exist_ok=True) - - failed_build = self.build( - FOUNDATION_BUILDER, - artifacts, - RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID, - ) - self.assertEqual( - failed_build["sha256"], - hashlib.sha256( - Path(failed_build["artifact"]).read_bytes() - ).hexdigest(), - ) - failed_artifact = ( - failed_root / RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT - ) - shutil.copy2(failed_build["artifact"], failed_artifact) - - recovery_build = self.build( - RECOVERY_BUILDER, - artifacts, - "device-plane-foundation-recovery-unit-002", - ) - recovery_extract = workspace / "recovery-extract" - recovery_extract.mkdir() - _manifest, entries, payload = RUNNER.load_artifact( - Path(recovery_build["artifact"]), - recovery_extract, - ) - self.assertEqual(entries, RECOVERY_ENTRIES) - - backup = ( - backups_root - / RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_ID - ) - backup.mkdir() - for name in ( - "manifest.env", - "files.txt", - "existing-files.txt", - "missing-files.txt", - "source-before.tgz", - ): - (backup / name).write_text( - f"fixture:{name}\n", - encoding="utf-8", - ) - backup_hashes = { - name: hashlib.sha256((backup / name).read_bytes()).hexdigest() - for name in ( - "manifest.env", - "files.txt", - "existing-files.txt", - "missing-files.txt", - "source-before.tgz", - ) - } - - (state_root / "failed.jsonl").write_text( - json.dumps({ - "artifact": - RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT, - "backup_id": - RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_ID, - "component": "device-plane", - "id": RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID, - "message": - "healthcheck failed for " - "http://127.0.0.1:18120/healthz: " - "", - "rollback_status": "failed:DeployError", - "sha256": - failed_build["sha256"], - "started_apply": True, - "status": "failed", - }) - + "\n", - encoding="utf-8", - ) - - source_root = SCRIPT_DIR.parent.parent / "device-plane" - shutil.copy2( - PREDECESSOR_COMPOSE, - live_root / "docker-compose.device-plane.yml", - ) - shutil.copy2( - source_root - / "deployment/device-postgres-bootstrap-v1.json", - live_root - / "deployment/device-postgres-bootstrap-v1.json", - ) - runtime = { - service: { - "containerId": service, - "imageId": - RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_IMAGE_IDS[ - service - ], - "health": "healthy", - "restartCount": 0, - } - for service in ( - "device-control-core", - "device-gateway", - "device-postgres", - ) - } - - with ( - mock.patch.object(RUNNER, "BACKUPS_DIR", backups_root), - mock.patch.object(RUNNER, "FAILED_DIR", failed_root), - mock.patch.object( - RUNNER, - "FAILED_STATE_FILE", - state_root / "failed.jsonl", - ), - mock.patch.object(RUNNER, "TMP_DIR", temp_root), - mock.patch.object( - RUNNER, - "component_root", - return_value=live_root, - ), - mock.patch.object( - RUNNER, - "DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256", - failed_build["sha256"], - ), - mock.patch.dict( - RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_SHA256, - backup_hashes, - clear=True, - ), - mock.patch.object( - RUNNER, - "validate_device_plane_foundation_runtime", - return_value=runtime, - ), - ): - result = ( - RUNNER - .validate_device_plane_foundation_recovery_evidence( - payload - ) - ) - - self.assertEqual( - result["mode"], - "failed-foundation-live-runtime-adoption", - ) - self.assertEqual(result["runtime"], runtime) - - def test_recovery_health_acceptance_never_mutates_runtime(self): - entries = tuple(RECOVERY_ENTRIES) - checks = ("core-health", "gateway-health") - with ( - mock.patch.object( - RUNNER, - "healthcheck_compose_service", - ) as compose_health, - mock.patch.object( - RUNNER, - "component_healthchecks", - return_value=checks, - ), - mock.patch.object(RUNNER, "healthcheck_url") as url_health, - mock.patch.object( - RUNNER, - "validate_device_plane_foundation_installed_source", - ) as source_acceptance, - mock.patch.object( - RUNNER, - "validate_device_plane_foundation_runtime", - ) as runtime_acceptance, - mock.patch.object(RUNNER, "run_compose") as compose_mutation, - mock.patch.object(RUNNER, "run_build") as build_mutation, - ): - RUNNER.run_healthchecks("device-plane", entries, ()) - - self.assertEqual( - [call.args for call in compose_health.call_args_list], - [ - ("device-plane", "device-control-core"), - ("device-plane", "device-gateway"), - ("device-plane", "device-postgres"), - ], - ) - self.assertEqual( - [call.args[0] for call in url_health.call_args_list], - list(checks), - ) - source_acceptance.assert_called_once_with() - runtime_acceptance.assert_called_once_with() - compose_mutation.assert_not_called() - build_mutation.assert_not_called() - - def test_recovery_runtime_phase_has_no_runtime_mutation(self): - entries = tuple(RECOVERY_ENTRIES) - with ( - mock.patch.object( - RUNNER, - "prepare_component_runtime", - ) as prepare, - mock.patch.object(RUNNER, "run_compose") as compose_mutation, - mock.patch.object(RUNNER, "run_build") as build_mutation, - ): - RUNNER.run_component_runtime("device-plane", entries, ()) - - prepare.assert_not_called() - compose_mutation.assert_not_called() - build_mutation.assert_not_called() - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_device_plane_postgres_bootstrap.py b/infra/deploy-runner/test_device_plane_postgres_bootstrap.py deleted file mode 100644 index 3899cc1..0000000 --- a/infra/deploy-runner/test_device_plane_postgres_bootstrap.py +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env python3 -import hashlib -import importlib.machinery -import importlib.util -import json -import os -import subprocess -import tarfile -import tempfile -import unittest -from pathlib import Path -from unittest import mock - - -SCRIPT_DIR = Path(__file__).resolve().parent -RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" -BUILDER = ( - SCRIPT_DIR - / "build-device-plane-postgres-bootstrap-artifact.mjs" -) -EXPECTED_ENTRIES = [ - "docker-compose.device-plane.yml", - "deployment/device-postgres-bootstrap-v1.json", -] - - -def load_runner(): - loader = importlib.machinery.SourceFileLoader( - "nodedc_device_plane_postgres_runner_under_test", - str(RUNNER_PATH), - ) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -RUNNER = load_runner() - - -class DevicePlanePostgresBootstrapTest(unittest.TestCase): - def build(self, artifact_dir, patch_id): - environment = os.environ.copy() - environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) - result = subprocess.run( - ["node", str(BUILDER), patch_id], - check=True, - capture_output=True, - text=True, - env=environment, - ) - return json.loads(result.stdout) - - def test_bootstrap_artifact_is_exact_deterministic_and_runner_accepted(self): - with tempfile.TemporaryDirectory( - prefix="nodedc-device-plane-postgres-artifact-", - ) as directory: - artifact_dir = Path(directory) - first = self.build( - artifact_dir, - "device-plane-postgres-bootstrap-unit-001", - ) - artifact = Path(first["artifact"]) - first_bytes = artifact.read_bytes() - second = self.build( - artifact_dir, - "device-plane-postgres-bootstrap-unit-001", - ) - second_bytes = Path(second["artifact"]).read_bytes() - - self.assertEqual(first["entries"], EXPECTED_ENTRIES) - self.assertEqual(first["services"], ["device-postgres"]) - self.assertEqual(first["mode"], "create-if-absent") - self.assertEqual(first["rollbackVolumePolicy"], "preserve") - self.assertEqual( - first["sha256"], - hashlib.sha256(first_bytes).hexdigest(), - ) - self.assertEqual(first_bytes, second_bytes) - - with tarfile.open(artifact, "r:gz") as archive: - names = {member.name for member in archive.getmembers()} - self.assertEqual( - archive.extractfile("files.txt") - .read() - .decode("utf-8") - .splitlines(), - EXPECTED_ENTRIES, - ) - self.assertEqual( - names, - { - "manifest.env", - "files.txt", - "payload", - "payload/docker-compose.device-plane.yml", - "payload/deployment", - "payload/deployment/device-postgres-bootstrap-v1.json", - }, - ) - - with tempfile.TemporaryDirectory( - prefix="nodedc-device-plane-postgres-load-", - ) as work_directory: - manifest, entries, _payload = RUNNER.load_artifact( - artifact, - Path(work_directory), - ) - self.assertEqual(manifest["component"], "device-plane") - self.assertEqual(entries, EXPECTED_ENTRIES) - self.assertTrue( - RUNNER.is_device_plane_postgres_bootstrap_slice( - manifest["component"], - entries, - ), - ) - self.assertEqual( - RUNNER.component_services("device-plane", entries), - ("device-postgres",), - ) - self.assertEqual( - RUNNER.component_builds("device-plane", entries), - (), - ) - - def test_preflight_accepts_only_absent_container_and_volume(self): - absent_container = mock.Mock(returncode=0, stdout="") - absent_volume = mock.Mock(returncode=1) - with mock.patch.object( - RUNNER.subprocess, - "run", - side_effect=[absent_container, absent_volume], - ): - self.assertEqual( - RUNNER.preflight_device_plane_postgres_bootstrap(), - "absent", - ) - - existing_container = mock.Mock( - returncode=0, - stdout="abc123def456\n", - ) - with mock.patch.object( - RUNNER.subprocess, - "run", - return_value=existing_container, - ): - with self.assertRaisesRegex( - RUNNER.DeployError, - "container already exists", - ): - RUNNER.preflight_device_plane_postgres_bootstrap() - - with mock.patch.object( - RUNNER.subprocess, - "run", - side_effect=[ - absent_container, - mock.Mock(returncode=0), - ], - ): - with self.assertRaisesRegex( - RUNNER.DeployError, - "volume already exists", - ): - RUNNER.preflight_device_plane_postgres_bootstrap() - - def test_plan_selection_is_unambiguous_for_bootstrap_and_application(self): - self.assertEqual( - RUNNER.device_plane_postgres_plan_selection(None), - "preserved-prerequisite:not-selected", - ) - self.assertEqual( - RUNNER.device_plane_postgres_plan_selection("absent"), - "bootstrap-selected:create-if-absent", - ) - with self.assertRaisesRegex( - RUNNER.DeployError, - "plan preflight state is invalid", - ): - RUNNER.device_plane_postgres_plan_selection("unknown") - - def test_bootstrap_health_acceptance_is_database_service_only(self): - with mock.patch.object( - RUNNER, - "healthcheck_compose_service", - ) as health: - RUNNER.run_healthchecks( - "device-plane", - EXPECTED_ENTRIES, - ("device-postgres",), - ) - health.assert_called_once_with( - "device-plane", - "device-postgres", - ) - - with self.assertRaisesRegex( - RUNNER.DeployError, - "service set mismatch", - ): - RUNNER.run_healthchecks( - "device-plane", - EXPECTED_ENTRIES, - ("device-control-core",), - ) - - -if __name__ == "__main__": - unittest.main(verbosity=2)