From e2177237844e9dd50144b50e6dda974f79f893ec Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 25 Jul 2026 21:29:05 +0300 Subject: [PATCH] feat(device-plane): add fail-closed deploy foundation --- device-plane/.dockerignore | 10 + device-plane/README.md | 45 ++ .../device-postgres-bootstrap-v1.json | 8 + device-plane/docker-compose.device-plane.yml | 118 +++++ device-plane/docs/IMPLEMENTATION_BASELINE.md | 171 ++++++++ device-plane/docs/TEST_MATRIX.md | 25 ++ device-plane/package-lock.json | 212 +++++++++ device-plane/package.json | 16 + .../packages/arusnavi-b2-adapter/package.json | 15 + .../arusnavi-b2-adapter/src/index.mjs | 82 ++++ .../arusnavi-b2-adapter/test/adapter.test.mjs | 51 +++ .../device-protocol-contract/package.json | 15 + .../device-protocol-contract/src/index.mjs | 239 ++++++++++ .../test/contract.test.mjs | 108 +++++ .../services/device-control-core/Dockerfile | 14 + .../001_device_plane_foundation.sql | 99 +++++ .../services/device-control-core/package.json | 16 + .../services/device-control-core/src/app.mjs | 152 +++++++ .../src/database-config.mjs | 73 ++++ .../src/postgres-repository.mjs | 128 ++++++ .../device-control-core/src/server.mjs | 107 +++++ .../device-control-core/test/app.test.mjs | 143 ++++++ .../test/database-config.test.mjs | 50 +++ .../test/migration.test.mjs | 32 ++ .../services/device-gateway/Dockerfile | 14 + .../services/device-gateway/package.json | 13 + .../services/device-gateway/src/runtime.mjs | 208 +++++++++ .../services/device-gateway/src/server.mjs | 62 +++ .../device-gateway/test/runtime.test.mjs | 95 ++++ infra/deploy-runner/README.md | 55 +++ .../build-device-plane-artifact.mjs | 193 +++++++++ ...vice-plane-postgres-bootstrap-artifact.mjs | 162 +++++++ infra/deploy-runner/nodedc-deploy | 408 ++++++++++++++++++ .../test_device_plane_artifact.py | 156 +++++++ .../test_device_plane_postgres_bootstrap.py | 195 +++++++++ .../test_device_plane_registry.py | 239 ++++++++++ 36 files changed, 3729 insertions(+) create mode 100644 device-plane/.dockerignore create mode 100644 device-plane/README.md create mode 100644 device-plane/deployment/device-postgres-bootstrap-v1.json create mode 100644 device-plane/docker-compose.device-plane.yml create mode 100644 device-plane/docs/IMPLEMENTATION_BASELINE.md create mode 100644 device-plane/docs/TEST_MATRIX.md create mode 100644 device-plane/package-lock.json create mode 100644 device-plane/package.json create mode 100644 device-plane/packages/arusnavi-b2-adapter/package.json create mode 100644 device-plane/packages/arusnavi-b2-adapter/src/index.mjs create mode 100644 device-plane/packages/arusnavi-b2-adapter/test/adapter.test.mjs create mode 100644 device-plane/packages/device-protocol-contract/package.json create mode 100644 device-plane/packages/device-protocol-contract/src/index.mjs create mode 100644 device-plane/packages/device-protocol-contract/test/contract.test.mjs create mode 100644 device-plane/services/device-control-core/Dockerfile create mode 100644 device-plane/services/device-control-core/migrations/001_device_plane_foundation.sql create mode 100644 device-plane/services/device-control-core/package.json create mode 100644 device-plane/services/device-control-core/src/app.mjs create mode 100644 device-plane/services/device-control-core/src/database-config.mjs create mode 100644 device-plane/services/device-control-core/src/postgres-repository.mjs create mode 100644 device-plane/services/device-control-core/src/server.mjs create mode 100644 device-plane/services/device-control-core/test/app.test.mjs create mode 100644 device-plane/services/device-control-core/test/database-config.test.mjs create mode 100644 device-plane/services/device-control-core/test/migration.test.mjs create mode 100644 device-plane/services/device-gateway/Dockerfile create mode 100644 device-plane/services/device-gateway/package.json create mode 100644 device-plane/services/device-gateway/src/runtime.mjs create mode 100644 device-plane/services/device-gateway/src/server.mjs create mode 100644 device-plane/services/device-gateway/test/runtime.test.mjs create mode 100644 infra/deploy-runner/build-device-plane-artifact.mjs create mode 100644 infra/deploy-runner/build-device-plane-postgres-bootstrap-artifact.mjs create mode 100644 infra/deploy-runner/test_device_plane_artifact.py create mode 100644 infra/deploy-runner/test_device_plane_postgres_bootstrap.py create mode 100644 infra/deploy-runner/test_device_plane_registry.py diff --git a/device-plane/.dockerignore b/device-plane/.dockerignore new file mode 100644 index 0000000..f522c9b --- /dev/null +++ b/device-plane/.dockerignore @@ -0,0 +1,10 @@ +.git +.DS_Store +.env +.env.* +docs +node_modules +**/test +**/*.log +runtime +secrets diff --git a/device-plane/README.md b/device-plane/README.md new file mode 100644 index 0000000..a5a48da --- /dev/null +++ b/device-plane/README.md @@ -0,0 +1,45 @@ +# 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. + +Current implementation status: local fail-closed foundation; not deployed. + +- `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. + +The Foundry `Device Manager` is a canonical page template using a server-owned +`device-plane-control` binding. It is not a service in this directory. + +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-postgres-bootstrap-v1.json b/device-plane/deployment/device-postgres-bootstrap-v1.json new file mode 100644 index 0000000..3261b67 --- /dev/null +++ b/device-plane/deployment/device-postgres-bootstrap-v1.json @@ -0,0 +1,8 @@ +{ + "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/docker-compose.device-plane.yml b/device-plane/docker-compose.device-plane.yml new file mode 100644 index 0000000..15c444c --- /dev/null +++ b/device-plane/docker-compose.device-plane.yml @@ -0,0 +1,118 @@ +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/device-plane/docs/IMPLEMENTATION_BASELINE.md b/device-plane/docs/IMPLEMENTATION_BASELINE.md new file mode 100644 index 0000000..87d79b1 --- /dev/null +++ b/device-plane/docs/IMPLEMENTATION_BASELINE.md @@ -0,0 +1,171 @@ +# Device Plane Implementation Baseline + +Status: local fail-closed foundation implemented; no runtime or production +mutation. + +## 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 +``` + +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/ + docker-compose.device-plane.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. + +## Network boundary + +The baseline publishes no device port. The planned pilot ingress is +`device.nodedc.ru:9921/TCP` only after a port-collision check, quarantine/rate +limits and a separate reviewed ingress transition. + +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. + +## 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 public material inspected so far does not define enough exact INTERNAL +packet framing and acknowledgement detail to implement a production decoder. +The B2 adapter therefore hashes bounded evidence and fails closed. It does not +search arbitrary bytes for a 15-digit sequence and call that an IMEI. + +## 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 health endpoint and a loopback-only test listener that records only + a bounded evidence hash, sends no acknowledgement and emits no identifier. +- 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 source slice + +1. Review, hash and promote the canonical runner candidate through the + standalone root administrative gate; do not stage an application artifact + before fresh `verify-install`. +2. Build, plan and accept the separate one-time PostgreSQL prerequisite + bootstrap artifact; ordinary application artifacts must continue to exclude + the database service. +3. Obtain or capture the exact official B2 INTERNAL framing and acknowledgement + contract; keep ingress disabled until its fixtures pass. +4. Add an authenticated internal Core/Gateway discovery boundary and explicit + platform-admin claim operation. +5. Build, audit and stage the first deterministic application artifact only + after the runner extension is installed and verified. diff --git a/device-plane/docs/TEST_MATRIX.md b/device-plane/docs/TEST_MATRIX.md new file mode 100644 index 0000000..f7d0690 --- /dev/null +++ b/device-plane/docs/TEST_MATRIX.md @@ -0,0 +1,25 @@ +# 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 | +| 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 | diff --git a/device-plane/package-lock.json b/device-plane/package-lock.json new file mode 100644 index 0000000..87b6bd1 --- /dev/null +++ b/device-plane/package-lock.json @@ -0,0 +1,212 @@ +{ + "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-control-core": { + "resolved": "services/device-control-core", + "link": true + }, + "node_modules/@nodedc/device-gateway": { + "resolved": "services/device-gateway", + "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-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-gateway": { + "name": "@nodedc/device-gateway", + "version": "0.1.0", + "engines": { + "node": ">=20" + } + } + } +} diff --git a/device-plane/package.json b/device-plane/package.json new file mode 100644 index 0000000..8d2f45f --- /dev/null +++ b/device-plane/package.json @@ -0,0 +1,16 @@ +{ + "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 new file mode 100644 index 0000000..94df5b9 --- /dev/null +++ b/device-plane/packages/arusnavi-b2-adapter/package.json @@ -0,0 +1,15 @@ +{ + "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 new file mode 100644 index 0000000..afc067d --- /dev/null +++ b/device-plane/packages/arusnavi-b2-adapter/src/index.mjs @@ -0,0 +1,82 @@ +import { createHash } from "node:crypto"; + +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: "blocked_pending_official_specification", + maxInitialBytes: 4096, + }, + commandTransport: { + status: "disabled", + exportedCommandBuilders: 0, + }, + routeCompatibility: { + gelios: "parallel-preserved", + automaticCommandFailover: false, + }, +}); + +export function inspectUnverifiedInitialBytes(input) { + if (!Buffer.isBuffer(input)) { + throw new TypeError("b2_initial_bytes_buffer_required"); + } + if (input.length === 0) { + throw new TypeError("b2_initial_bytes_empty"); + } + if (input.length > ARUSNAVI_B2_MODEL_PROFILE.framing.maxInitialBytes) { + throw new TypeError("b2_initial_bytes_limit_exceeded"); + } + + return Object.freeze({ + schemaVersion: "nodedc.device.protocol-evidence.v1", + profileRef: ARUSNAVI_B2_MODEL_PROFILE.profileRef, + status: "official_framing_required", + bytesObserved: input.length, + contentDigest: `sha256:${createHash("sha256").update(input).digest("hex")}`, + identifierExtracted: false, + commandTransport: "disabled", + }); +} + +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.commandTransport.status !== "disabled") { + throw new TypeError("b2_command_transport_must_be_disabled"); + } + if (profile.routeCompatibility.gelios !== "parallel-preserved") { + throw new TypeError("b2_gelios_route_must_be_preserved"); + } + return true; +} + +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 new file mode 100644 index 0000000..dbab78a --- /dev/null +++ b/device-plane/packages/arusnavi-b2-adapter/test/adapter.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + ARUSNAVI_B2_MODEL_PROFILE, + assertB2ProfileInvariant, + inspectUnverifiedInitialBytes, +} from "../src/index.mjs"; + +test("records the official B2 route 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.routeCompatibility.gelios, + "parallel-preserved", + ); +}); + +test("fails closed instead of guessing an IMEI from unverified bytes", () => { + const fakeBytes = Buffer.from( + "unverified-frame-with-fake-identifier-000000000000001", + "utf8", + ); + const evidence = inspectUnverifiedInitialBytes(fakeBytes); + const serialized = JSON.stringify(evidence); + assert.equal(evidence.status, "official_framing_required"); + assert.equal(evidence.identifierExtracted, false); + assert.equal(serialized.includes("000000000000001"), false); + assert.match(evidence.contentDigest, /^sha256:[a-f0-9]{64}$/); +}); + +test("enforces the bounded initial frame evidence window", () => { + assert.throws( + () => inspectUnverifiedInitialBytes(Buffer.alloc(0)), + /b2_initial_bytes_empty/, + ); + assert.throws( + () => inspectUnverifiedInitialBytes(Buffer.alloc(4097)), + /b2_initial_bytes_limit_exceeded/, + ); +}); + +test("exports no command builder and keeps transport disabled", () => { + assert.equal(ARUSNAVI_B2_MODEL_PROFILE.commandTransport.status, "disabled"); + assert.equal( + ARUSNAVI_B2_MODEL_PROFILE.commandTransport.exportedCommandBuilders, + 0, + ); +}); diff --git a/device-plane/packages/device-protocol-contract/package.json b/device-plane/packages/device-protocol-contract/package.json new file mode 100644 index 0000000..d88f5eb --- /dev/null +++ b/device-plane/packages/device-protocol-contract/package.json @@ -0,0 +1,15 @@ +{ + "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 new file mode 100644 index 0000000..038edc5 --- /dev/null +++ b/device-plane/packages/device-protocol-contract/src/index.mjs @@ -0,0 +1,239 @@ +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_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 DIGEST_RE = /^hmac-sha256:[a-f0-9]{64}$/; +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 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, + modelProfileRef, + protocol, + observedAt, + identifier, + evidence, + lifecycleState: "quarantine", + commandTransport: "disabled", + }); +} + +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 } : {}), + modelProfileRef: normalized.modelProfileRef, + protocol: normalized.protocol, + observedAt: normalized.observedAt, + lifecycleState: normalized.lifecycleState, + identifier: Object.freeze({ + 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 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; +} + +function normalizeRestrictedIdentifier(input) { + assertPlainObject(input, "restricted_identifier"); + if (input.kind !== "imei") { + throw new TypeError("restricted_identifier_kind_unsupported"); + } + if (typeof input.value !== "string" || !IMEI_RE.test(input.value)) { + throw new TypeError("restricted_identifier_imei_invalid"); + } + return Object.freeze({ kind: "imei", value: input.value }); +} + +function maskRestrictedIdentifier(identifier) { + if (identifier.kind === "imei") { + return `***********${identifier.value.slice(-4)}`; + } + throw new TypeError("restricted_identifier_kind_unsupported"); +} + +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 normalizeOpaqueRef(value, label) { + if (typeof value !== "string" || !OPAQUE_REF_RE.test(value)) { + throw new TypeError(`${label}_invalid`); + } + return value; +} + +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`); + } +} diff --git a/device-plane/packages/device-protocol-contract/test/contract.test.mjs b/device-plane/packages/device-protocol-contract/test/contract.test.mjs new file mode 100644 index 0000000..9f93d48 --- /dev/null +++ b/device-plane/packages/device-protocol-contract/test/contract.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEVICE_DISCOVERY_SIGNAL_SCHEMA, + DEVICE_PLANE_BINDING_SCHEMA, + assertIdentifierDigest, + assertSafeProjection, + hashRestrictedIdentifier, + normalizeDevicePlaneBinding, + normalizeDiscoverySignal, + toSafeDiscoveryView, +} 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("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("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); +}); diff --git a/device-plane/services/device-control-core/Dockerfile b/device-plane/services/device-control-core/Dockerfile new file mode 100644 index 0000000..6360bb1 --- /dev/null +++ b/device-plane/services/device-control-core/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY packages ./packages +COPY services/device-control-core ./services/device-control-core +COPY services/device-gateway/package.json ./services/device-gateway/package.json + +RUN npm ci --omit=dev --ignore-scripts + +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 new file mode 100644 index 0000000..8e5a134 --- /dev/null +++ b/device-plane/services/device-control-core/migrations/001_device_plane_foundation.sql @@ -0,0 +1,99 @@ +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/package.json b/device-plane/services/device-control-core/package.json new file mode 100644 index 0000000..295e62f --- /dev/null +++ b/device-plane/services/device-control-core/package.json @@ -0,0 +1,16 @@ +{ + "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.18.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 new file mode 100644 index 0000000..da6b6f1 --- /dev/null +++ b/device-plane/services/device-control-core/src/app.mjs @@ -0,0 +1,152 @@ +import { timingSafeEqual } from "node:crypto"; +import { createServer } from "node:http"; + +import { + assertSafeProjection, + hashRestrictedIdentifier, + normalizeDiscoverySignal, + toSafeDiscoveryView, +} from "../../../packages/device-protocol-contract/src/index.mjs"; + +export function createControlCoreApp({ + repository, + gatewayToken = "", + identifierPepper = "", + discoveryIngestEnabled = false, +} = {}) { + 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 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"); + } + } + + 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", + commandTransport: "disabled", + }); + } + + 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 signal = normalizeDiscoverySignal(input); + const identifierDigest = hashRestrictedIdentifier( + signal.identifier, + identifierPepper, + ); + const safeView = assertSafeProjection(toSafeDiscoveryView(signal)); + const discovery = await repository.upsertQuarantineDiscovery({ + identifierDigest, + safeView, + }); + return writeJson(response, discovery.created ? 201 : 200, { + ok: true, + created: discovery.created, + discovery: assertSafeProjection(discovery.value), + }); + } + + 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 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/database-config.mjs b/device-plane/services/device-control-core/src/database-config.mjs new file mode 100644 index 0000000..859dbcd --- /dev/null +++ b/device-plane/services/device-control-core/src/database-config.mjs @@ -0,0 +1,73 @@ +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/postgres-repository.mjs b/device-plane/services/device-control-core/src/postgres-repository.mjs new file mode 100644 index 0000000..36639cc --- /dev/null +++ b/device-plane/services/device-control-core/src/postgres-repository.mjs @@ -0,0 +1,128 @@ +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 { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs"; + +const { Pool } = pg; +const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +export class PostgresDeviceRepository { + constructor({ databaseUrl, poolSize = 10 } = {}) { + if (typeof databaseUrl !== "string" || databaseUrl.trim() === "") { + throw new TypeError("device_database_url_required"); + } + this.pool = new Pool({ + connectionString: databaseUrl, + max: normalizePoolSize(poolSize), + }); + } + + async migrate() { + const sql = await readFile( + resolve(serviceRoot, "migrations/001_device_plane_foundation.sql"), + "utf8", + ); + await this.pool.query(sql); + await this.pool.query( + `insert into device_model_profiles ( + profile_ref, + schema_version, + vendor, + model, + device_type, + protocol, + profile + ) values ($1, $2, $3, $4, $5, $6, $7::jsonb) + on conflict (profile_ref) do update set + schema_version = excluded.schema_version, + profile = excluded.profile, + updated_at = now()`, + [ + ARUSNAVI_B2_MODEL_PROFILE.profileRef, + ARUSNAVI_B2_MODEL_PROFILE.schemaVersion, + ARUSNAVI_B2_MODEL_PROFILE.vendor, + ARUSNAVI_B2_MODEL_PROFILE.model, + ARUSNAVI_B2_MODEL_PROFILE.deviceType, + ARUSNAVI_B2_MODEL_PROFILE.protocol, + JSON.stringify(ARUSNAVI_B2_MODEL_PROFILE), + ], + ); + } + + async health() { + await this.pool.query("select 1"); + return "ready"; + } + + async upsertQuarantineDiscovery({ identifierDigest, safeView }) { + const result = await this.pool.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 + ) values ($1, $2, $3, $4, $5, $6, 'quarantine', $7, $7, $8::jsonb) + on conflict (identifier_kind, identifier_digest, model_profile_ref) + do update set + last_observed_at = greatest( + device_discoveries.last_observed_at, + excluded.last_observed_at + ), + evidence = excluded.evidence, + updated_at = now() + returning id, lifecycle_state, model_profile_ref, protocol, + identifier_kind, identifier_masked, first_observed_at, + last_observed_at, (xmax = 0) as created`, + [ + randomUUID(), + safeView.identifier.kind, + identifierDigest, + safeView.identifier.masked, + safeView.modelProfileRef, + safeView.protocol, + safeView.observedAt, + JSON.stringify(safeView.evidence), + ], + ); + const row = result.rows[0]; + return { + created: row.created === true, + value: { + schemaVersion: "nodedc.device.discovery-view.v1", + discoveryRef: `discovery:${row.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: safeView.evidence, + commandTransport: "disabled", + }, + }; + } + + async close() { + await this.pool.end(); + } +} + +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/server.mjs b/device-plane/services/device-control-core/src/server.mjs new file mode 100644 index 0000000..ef6e92d --- /dev/null +++ b/device-plane/services/device-control-core/src/server.mjs @@ -0,0 +1,107 @@ +import { readFile } from "node:fs/promises"; + +import { createControlCoreApp } from "./app.mjs"; +import { resolveDeviceDatabaseUrl } from "./database-config.mjs"; +import { PostgresDeviceRepository } from "./postgres-repository.mjs"; + +const config = await readConfig(); +const repository = new PostgresDeviceRepository({ + databaseUrl: config.databaseUrl, + poolSize: config.databasePoolSize, +}); + +await repository.migrate(); + +const server = createControlCoreApp({ + repository, + gatewayToken: config.gatewayToken, + identifierPepper: config.identifierPepper, + discoveryIngestEnabled: config.discoveryIngestEnabled, +}); + +server.listen(config.port, config.host, () => { + console.log(JSON.stringify({ + event: "device_control_core_started", + host: config.host, + port: config.port, + discoveryIngest: config.discoveryIngestEnabled, + commandTransport: "disabled", + })); +}); + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + +async function shutdown() { + server.close(async () => { + await repository.close(); + process.exit(0); + }); +} + +async function readConfig() { + const discoveryIngestEnabled = parseBoolean( + process.env.DEVICE_DISCOVERY_INGEST_ENABLED, + false, + ); + 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, + gatewayToken: discoveryIngestEnabled + ? await readRequiredSecretFile( + process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE, + "device_gateway_core_token_file_required", + ) + : "", + identifierPepper: discoveryIngestEnabled + ? await readRequiredSecretFile( + process.env.DEVICE_IDENTIFIER_PEPPER_FILE, + "device_identifier_pepper_file_required", + ) + : "", + }; +} + +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 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/test/app.test.mjs b/device-plane/services/device-control-core/test/app.test.mjs new file mode 100644 index 0000000..d8b0182 --- /dev/null +++ b/device-plane/services/device-control-core/test/app.test.mjs @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + 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 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", + commandTransport: "disabled", + }); + } 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", + }, + }; + }, + }, + }); + 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(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", + }, + }; +} + +async function startTestServer(options) { + const server = createControlCoreApp(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/database-config.test.mjs b/device-plane/services/device-control-core/test/database-config.test.mjs new file mode 100644 index 0000000..0cdff09 --- /dev/null +++ b/device-plane/services/device-control-core/test/database-config.test.mjs @@ -0,0 +1,50 @@ +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/migration.test.mjs b/device-plane/services/device-control-core/test/migration.test.mjs new file mode 100644 index 0000000..391be27 --- /dev/null +++ b/device-plane/services/device-control-core/test/migration.test.mjs @@ -0,0 +1,32 @@ +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-gateway/Dockerfile b/device-plane/services/device-gateway/Dockerfile new file mode 100644 index 0000000..80a15ab --- /dev/null +++ b/device-plane/services/device-gateway/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY packages ./packages +COPY services/device-gateway ./services/device-gateway +COPY services/device-control-core/package.json ./services/device-control-core/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 new file mode 100644 index 0000000..c4cdeb3 --- /dev/null +++ b/device-plane/services/device-gateway/package.json @@ -0,0 +1,13 @@ +{ + "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/runtime.mjs b/device-plane/services/device-gateway/src/runtime.mjs new file mode 100644 index 0000000..a55cb83 --- /dev/null +++ b/device-plane/services/device-gateway/src/runtime.mjs @@ -0,0 +1,208 @@ +import { randomUUID } from "node:crypto"; +import { createServer as createHttpServer } from "node:http"; +import { createServer as createTcpServer } from "node:net"; + +import { + ARUSNAVI_B2_MODEL_PROFILE, + inspectUnverifiedInitialBytes, +} from "../../../packages/arusnavi-b2-adapter/src/index.mjs"; + +export function createDeviceGatewayRuntime(options = {}) { + const config = normalizeConfig(options); + const sessions = new Map(); + let totalAccepted = 0; + let totalRejected = 0; + let totalEvidence = 0; + + const tcpServer = createTcpServer((socket) => { + if (sessions.size >= config.maxConcurrentSessions) { + totalRejected += 1; + socket.destroy(); + return; + } + + const sessionRef = `session:${randomUUID()}`; + const session = { + sessionRef, + bytes: [], + byteLength: 0, + evidenceRecorded: false, + }; + sessions.set(socket, session); + totalAccepted += 1; + socket.setNoDelay(true); + socket.setTimeout(config.sessionTimeoutMs); + + socket.on("data", (chunk) => { + if (session.evidenceRecorded) return; + session.byteLength += chunk.length; + if (session.byteLength > config.maxInitialBytes) { + totalRejected += 1; + socket.destroy(); + return; + } + session.bytes.push(chunk); + const evidence = inspectUnverifiedInitialBytes( + Buffer.concat(session.bytes, session.byteLength), + ); + session.evidenceRecorded = true; + totalEvidence += 1; + config.onEvidence?.({ + sessionRef, + evidence, + }); + // Until exact official framing is implemented, the gateway never sends + // acknowledgement or command bytes and never guesses an identifier. + socket.end(); + }); + socket.on("timeout", () => socket.destroy()); + socket.on("close", () => sessions.delete(socket)); + socket.on("error", () => sessions.delete(socket)); + }); + + 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", + protocolProfile: ARUSNAVI_B2_MODEL_PROFILE.profileRef, + framing: ARUSNAVI_B2_MODEL_PROFILE.framing.status, + tcpListener: config.listenEnabled ? "internal-test-only" : "disabled", + publicIngress: "disabled", + commandTransport: "disabled", + sessions: { + active: sessions.size, + accepted: totalAccepted, + rejected: totalRejected, + evidenceRecorded: totalEvidence, + }, + })}\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 of sessions.keys()) socket.destroy(); + await Promise.all([ + closeServer(healthServer), + config.listenEnabled ? closeServer(tcpServer) : Promise.resolve(), + ]); + }, + status() { + return { + activeSessions: sessions.size, + totalAccepted, + totalRejected, + totalEvidence, + commandTransport: "disabled", + publicIngress: "disabled", + }; + }, + }; +} + +function normalizeConfig(input) { + const listenEnabled = input.listenEnabled === true; + const maxInitialBytes = parseInteger( + input.maxInitialBytes, + ARUSNAVI_B2_MODEL_PROFILE.framing.maxInitialBytes, + 1, + ARUSNAVI_B2_MODEL_PROFILE.framing.maxInitialBytes, + "device_gateway_initial_bytes_invalid", + ); + return { + listenEnabled, + healthHost: normalizeHealthHost(input.healthHost, "127.0.0.1"), + healthPort: parseInteger( + input.healthPort, + 18121, + 0, + 65535, + "device_gateway_health_port_invalid", + ), + tcpHost: normalizeTcpHost(input.tcpHost, "127.0.0.1"), + tcpPort: parseInteger( + input.tcpPort, + 9921, + 0, + 65535, + "device_gateway_tcp_port_invalid", + ), + maxInitialBytes, + maxConcurrentSessions: parseInteger( + input.maxConcurrentSessions, + 100, + 1, + 10000, + "device_gateway_session_limit_invalid", + ), + sessionTimeoutMs: parseInteger( + input.sessionTimeoutMs, + 10000, + 100, + 60000, + "device_gateway_session_timeout_invalid", + ), + onEvidence: typeof input.onEvidence === "function" + ? input.onEvidence + : undefined, + }; +} + +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) { + const normalized = String(value || fallback).trim(); + if (!["127.0.0.1", "::1"].includes(normalized)) { + throw new TypeError("device_gateway_baseline_loopback_only"); + } + return normalized; +} + +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 new file mode 100644 index 0000000..2c97e5f --- /dev/null +++ b/device-plane/services/device-gateway/src/server.mjs @@ -0,0 +1,62 @@ +import { createDeviceGatewayRuntime } from "./runtime.mjs"; + +const listenEnabled = parseBoolean( + process.env.DEVICE_GATEWAY_LISTEN_ENABLED, + false, +); +const runtime = createDeviceGatewayRuntime({ + listenEnabled, + 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 || "127.0.0.1"), + tcpPort: parsePort(process.env.DEVICE_GATEWAY_TCP_PORT, 9921), + maxConcurrentSessions: parsePositiveInt( + process.env.DEVICE_GATEWAY_MAX_SESSIONS, + 100, + ), + sessionTimeoutMs: parsePositiveInt( + process.env.DEVICE_GATEWAY_SESSION_TIMEOUT_MS, + 10000, + ), +}); + +const addresses = await runtime.start(); +console.log(JSON.stringify({ + event: "device_gateway_started", + health: addresses.healthAddress, + tcp: addresses.tcpAddress, + publicIngress: "disabled", + commandTransport: "disabled", +})); + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + +async function shutdown() { + await runtime.stop(); + process.exit(0); +} + +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/runtime.test.mjs b/device-plane/services/device-gateway/test/runtime.test.mjs new file mode 100644 index 0000000..4b9ee97 --- /dev/null +++ b/device-plane/services/device-gateway/test/runtime.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { connect } from "node:net"; +import test from "node:test"; + +import { createDeviceGatewayRuntime } from "../src/runtime.mjs"; + +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("loopback evidence listener emits no acknowledgement or command bytes", async () => { + const captured = []; + const runtime = createDeviceGatewayRuntime({ + healthPort: 0, + tcpPort: 0, + listenEnabled: true, + onEvidence: (value) => captured.push(value), + }); + const addresses = await runtime.start(); + try { + const received = await sendAndCollect( + addresses.tcpAddress.port, + Buffer.from( + "unverified-frame-with-fake-identifier-000000000000001", + "utf8", + ), + ); + assert.equal(received.length, 0); + assert.equal(captured.length, 1); + assert.equal(captured[0].evidence.identifierExtracted, false); + assert.equal( + JSON.stringify(captured).includes("000000000000001"), + false, + ); + assert.equal(runtime.status().totalEvidence, 1); + assert.equal(runtime.status().commandTransport, "disabled"); + } finally { + await runtime.stop(); + } +}); + +test("baseline rejects non-loopback binding", () => { + assert.throws( + () => createDeviceGatewayRuntime({ + listenEnabled: true, + tcpHost: "0.0.0.0", + }), + /device_gateway_baseline_loopback_only/, + ); +}); + +test("container health may bind all interfaces while TCP stays loopback-only", 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 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("end", () => resolve(Buffer.concat(chunks))); + socket.on("error", reject); + }); +} diff --git a/infra/deploy-runner/README.md b/infra/deploy-runner/README.md index d140dd5..9155f8d 100644 --- a/infra/deploy-runner/README.md +++ b/infra/deploy-runner/README.md @@ -30,6 +30,7 @@ Supported components in this source: - `bim-viewer` - `n8n-private-extension` - `module-foundry` +- `device-plane` - `proxy-contur` - `dc-amd-proxy` @@ -429,3 +430,57 @@ Normal service deploys must still use explicit artifacts: sudo /usr/local/sbin/nodedc-deploy plan /volume1/docker/nodedc-deploy/inbox/.tgz sudo /usr/local/sbin/nodedc-deploy apply /volume1/docker/nodedc-deploy/inbox/.tgz ``` + +## Device Plane foundation + +`device-plane` is an additive component rooted at: + +```text +/volume1/docker/nodedc-device-plane +``` + +Its fixed Compose project is `nodedc-device-plane`. Ordinary application +artifacts may select only `device-control-core` and `device-gateway`, always +with `--no-deps`. `device-postgres` and the named +`nodedc-device-plane-postgres-data` volume are durable prerequisites and are +never selected or recreated by an application overlay. + +The sole exception is the exact one-time bootstrap artifact containing only +the reviewed Compose file and +`deployment/device-postgres-bootstrap-v1.json`. Its preflight requires both the +Compose database container and named volume to be absent. It selects only +`device-postgres`; a failed activation may remove that candidate container but +never the volume. Any pre-existing container or volume is an ambiguity and +fails closed. + +The runner creates or validates three root-owned secret files outside the +artifact: the PostgreSQL password, Gateway-to-Core token and restricted +identifier pepper. The manifest cannot choose their paths or values. + +The foundation publishes only loopback health endpoints on `18120` and +`18121`. Raw device ingress `9921`, discovery ingest and outbound command +transport remain disabled. The first application artifact must not be built or +staged until this runner candidate is separately reviewed, promoted and proven +by a fresh `verify-install`. + +Build and test the deterministic data-only artifact contract locally: + +```bash +python3 -m unittest -v \ + infra.deploy-runner.test_device_plane_registry \ + infra.deploy-runner.test_device_plane_artifact + +node infra/deploy-runner/build-device-plane-artifact.mjs \ + device-plane-foundation-YYYYMMDD-NNN +``` + +Any failed first activation removes only candidate Core/Gateway containers, +never volumes, restores the source overlay and retains PostgreSQL state. + +After the runner is promoted and freshly verified, bootstrap the durable +prerequisite with a separate artifact before planning the application: + +```bash +node infra/deploy-runner/build-device-plane-postgres-bootstrap-artifact.mjs \ + device-plane-postgres-bootstrap-YYYYMMDD-NNN +``` diff --git a/infra/deploy-runner/build-device-plane-artifact.mjs b/infra/deploy-runner/build-device-plane-artifact.mjs new file mode 100644 index 0000000..575e54b --- /dev/null +++ b/infra/deploy-runner/build-device-plane-artifact.mjs @@ -0,0 +1,193 @@ +#!/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-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) { + 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", + 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 compose = await readFile( + resolve(sourceRoot, "docker-compose.device-plane.yml"), + "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-postgres-bootstrap-artifact.mjs b/infra/deploy-runner/build-device-plane-postgres-bootstrap-artifact.mjs new file mode 100644 index 0000000..d56c228 --- /dev/null +++ b/infra/deploy-runner/build-device-plane-postgres-bootstrap-artifact.mjs @@ -0,0 +1,162 @@ +#!/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/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index 20b839e..aa30271 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -37,6 +37,21 @@ MAP_GATEWAY_SECRET_FILE = MAP_GATEWAY_SECRET_DIR / "map-gateway-admin-secret" MAP_EGRESS_PROXY_SECRET_FILE = MAP_GATEWAY_SECRET_DIR / "map-egress-proxy-token" PROXY_CONTUR_ENV_FILE = Path("/volume1/docker/proxy-contur/.env") DC_AMD_PROXY_RUNTIME_DIR = Path("/volume1/docker/dc-amd-proxy/runtime") +DEVICE_PLANE_ROOT = Path("/volume1/docker/nodedc-device-plane") +DEVICE_PLANE_SECRET_DIR = DEVICE_PLANE_ROOT / "secrets" +DEVICE_PLANE_POSTGRES_PASSWORD_FILE = DEVICE_PLANE_SECRET_DIR / "postgres-password" +DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE = DEVICE_PLANE_SECRET_DIR / "gateway-core-token" +DEVICE_PLANE_IDENTIFIER_PEPPER_FILE = DEVICE_PLANE_SECRET_DIR / "identifier-pepper" +DEVICE_PLANE_CONTROL_CORE_IMAGE = "nodedc/device-control-core:local" +DEVICE_PLANE_GATEWAY_IMAGE = "nodedc/device-gateway:local" +DEVICE_PLANE_POSTGRES_BOOTSTRAP_REL = ( + "deployment/device-postgres-bootstrap-v1.json" +) +DEVICE_PLANE_POSTGRES_BOOTSTRAP_ENTRIES = ( + "docker-compose.device-plane.yml", + DEVICE_PLANE_POSTGRES_BOOTSTRAP_REL, +) +DEVICE_PLANE_POSTGRES_VOLUME = "nodedc-device-plane-postgres-data" EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR = MAP_GATEWAY_SECRET_DIR / "external-data-plane-provisioner" EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE = EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR / "token" ENGINE_CREDENTIAL_PROVISIONER_PRIVATE_KEY_FILE = EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR / "engine-credential-provisioner-ed25519.pem" @@ -1140,6 +1155,19 @@ COMPONENTS = { "http://172.22.0.222:9920/healthz", ), }, + "device-plane": { + "payload_root": DEVICE_PLANE_ROOT, + "compose_root": DEVICE_PLANE_ROOT, + "compose_project": "nodedc-device-plane", + "compose_files": ( + DEVICE_PLANE_ROOT / "docker-compose.device-plane.yml", + ), + "bootstrap_root": True, + "compose_no_deps": True, + # PostgreSQL is durable state infrastructure. Normal application + # overlays can rebuild/recreate only these two stateless services. + "services": ("device-control-core", "device-gateway"), + }, "proxy-contur": { "payload_root": Path("/volume1/docker/proxy-contur"), "compose_root": Path("/volume1/docker/proxy-contur"), @@ -2297,6 +2325,12 @@ def denied_payload_path(component, rel): "infra/docker-compose.module-foundry.yml", ): pass + elif component == "device-plane" and rel in ( + "docker-compose.device-plane.yml", + "services/device-control-core/Dockerfile", + "services/device-gateway/Dockerfile", + ): + pass elif component == "proxy-contur" and rel in ( "Dockerfile", "docker-compose.yml", @@ -2426,6 +2460,25 @@ def denied_payload_path(component, rel): return "module-foundry env file" if rel.startswith(("Dockerfile.bak", "infra/docker-compose.module-foundry.yml.bak")): return "module-foundry backup file" + elif component == "device-plane": + runtime_prefixes = ( + "runtime", + "secrets", + "data", + "logs", + "backups", + "node_modules", + ) + if rel == ".env" or rel.startswith(".env."): + return "device-plane env file" + if "test" in parts: + return "device-plane test path" + if rel.startswith(( + "docker-compose.device-plane.yml.bak", + "services/device-control-core/Dockerfile.bak", + "services/device-gateway/Dockerfile.bak", + )): + return "device-plane backup file" elif component == "n8n-private-extension": # An extension release is inert data at this boundary. Activation is # Engine-owned and must never be smuggled into the artifact as a script, @@ -2676,6 +2729,27 @@ def allowed_payload_path(component, rel): )): return True + if component == "device-plane": + if rel in ( + ".dockerignore", + "package.json", + "package-lock.json", + "docker-compose.device-plane.yml", + DEVICE_PLANE_POSTGRES_BOOTSTRAP_REL, + "packages/device-protocol-contract", + "packages/arusnavi-b2-adapter", + "services/device-control-core", + "services/device-gateway", + ): + return True + if rel.startswith(( + "packages/device-protocol-contract/", + "packages/arusnavi-b2-adapter/", + "services/device-control-core/", + "services/device-gateway/", + )): + return True + if component == "n8n-private-extension": parts = PurePosixPath(rel).parts if ( @@ -7681,6 +7755,11 @@ def load_artifact(artifact, work_dir): die(f"files.txt entry missing in payload: {rel}") validate_payload_tree(manifest["component"], payload_dir, entries) + if is_device_plane_postgres_bootstrap_slice( + manifest["component"], + entries, + ): + validate_device_plane_postgres_bootstrap_payload(payload_dir) if manifest["component"] == "n8n-private-extension": validate_n8n_private_extension_release(payload_dir, entries) if manifest["component"] == "engine": @@ -7972,6 +8051,79 @@ def component_root(component): return COMPONENTS[component]["payload_root"] +def is_device_plane_postgres_bootstrap_slice(component, entries): + return ( + component == "device-plane" + and entries is not None + and tuple(entries) == DEVICE_PLANE_POSTGRES_BOOTSTRAP_ENTRIES + ) + + +def validate_device_plane_postgres_bootstrap_payload(payload_dir): + descriptor = read_strict_json( + payload_dir / DEVICE_PLANE_POSTGRES_BOOTSTRAP_REL, + "Device Plane PostgreSQL bootstrap descriptor", + max_bytes=8 * 1024, + ) + expected = { + "schemaVersion": "nodedc.device-plane.postgres-bootstrap.v1", + "service": "device-postgres", + "volume": DEVICE_PLANE_POSTGRES_VOLUME, + "mode": "create-if-absent", + "ordinaryApplicationSelection": "forbidden", + "rollbackVolumePolicy": "preserve", + } + if descriptor != expected: + die("Device Plane PostgreSQL bootstrap descriptor mismatch") + return descriptor + + +def preflight_device_plane_postgres_bootstrap(): + container_result = subprocess.run( + [ + str(DOCKER), + "container", + "ls", + "-a", + "--filter", + "label=com.docker.compose.project=nodedc-device-plane", + "--filter", + "label=com.docker.compose.service=device-postgres", + "--format", + "{{.ID}}", + ], + check=False, + capture_output=True, + text=True, + ) + if container_result.returncode != 0: + die("Device Plane PostgreSQL container preflight failed") + container_ids = [ + line.strip() + for line in container_result.stdout.splitlines() + if line.strip() + ] + if container_ids: + die("Device Plane PostgreSQL container already exists") + + volume_result = subprocess.run( + [ + str(DOCKER), + "volume", + "inspect", + DEVICE_PLANE_POSTGRES_VOLUME, + ], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if volume_result.returncode == 0: + die("Device Plane PostgreSQL volume already exists") + if volume_result.returncode != 1: + die("Device Plane PostgreSQL volume preflight failed") + return "absent" + + def component_compose_root(component): return COMPONENTS[component].get("compose_root", component_root(component)) @@ -9491,6 +9643,13 @@ def is_platform_provider_catalog_only(entries): def component_services(component, entries=None): + if is_device_plane_postgres_bootstrap_slice(component, entries): + # This exact one-time transition is the only Device Plane artifact that + # may select durable state. Its preflight requires both container and + # named volume to be absent, so --force-recreate cannot touch an + # installed database. + return ("device-postgres",) + if is_engine_l2_closed_loop_slice(component, entries): # The failed 030 apply published both the backend source and the built # UI before Compose rejected the descriptor/source mismatch. The exact @@ -9549,6 +9708,45 @@ def component_services(component, entries=None): # authorization normalization inside the already-active backend. return ("nodedc-backend",) + if component == "device-plane" and entries is not None: + selected = [] + + def add(*services): + for service in services: + if service not in selected: + selected.append(service) + + touches_common = any( + rel in ( + ".dockerignore", + "package.json", + "package-lock.json", + "docker-compose.device-plane.yml", + "packages/device-protocol-contract", + "packages/arusnavi-b2-adapter", + ) + or rel.startswith(( + "packages/device-protocol-contract/", + "packages/arusnavi-b2-adapter/", + )) + for rel in entries + ) + touches_core = any( + rel == "services/device-control-core" + or rel.startswith("services/device-control-core/") + for rel in entries + ) + touches_gateway = any( + rel == "services/device-gateway" + or rel.startswith("services/device-gateway/") + for rel in entries + ) + if touches_common or touches_core: + add("device-control-core") + if touches_common or touches_gateway: + add("device-gateway") + return tuple(selected) + if component == "dc-cms" and entries is not None: selected = [] @@ -9783,6 +9981,42 @@ def component_build_args(component, entries=None): def component_builds(component, entries=None): + if is_device_plane_postgres_bootstrap_slice(component, entries): + return () + + if component == "device-plane" and entries is not None: + selected_services = component_services(component, entries) + builds = [] + if "device-control-core" in selected_services: + builds.append(( + DEVICE_PLANE_ROOT, + ( + "build", + "--no-cache", + "--network=host", + "-f", + "services/device-control-core/Dockerfile", + "-t", + DEVICE_PLANE_CONTROL_CORE_IMAGE, + ".", + ), + )) + if "device-gateway" in selected_services: + builds.append(( + DEVICE_PLANE_ROOT, + ( + "build", + "--no-cache", + "--network=host", + "-f", + "services/device-gateway/Dockerfile", + "-t", + DEVICE_PLANE_GATEWAY_IMAGE, + ".", + ), + )) + return tuple(builds) + if component == "platform" and entries is not None: touches_compose = any(rel == "platform/docker-compose.platform-http.yml" for rel in entries) touches_notification = any(rel == "platform/notification-core" or rel.startswith("platform/notification-core/") for rel in entries) @@ -11739,6 +11973,7 @@ def plan_artifact(artifact): mcp_registered_execution_profiles_preflight = None mcp_gelios_units_items_preflight = None provider_catalog_preflight = None + device_plane_postgres_preflight = None with tempfile.TemporaryDirectory(prefix="plan-", dir=TMP_DIR) as tmp: manifest, entries, payload_dir = load_artifact(artifact, Path(tmp)) reject_terminal_engine_l2_failed_artifact(manifest, sha) @@ -11875,6 +12110,13 @@ def plan_artifact(artifact): ) if is_engine_provider_security_catalog_slice(manifest["component"], entries): provider_catalog_preflight = preflight_engine_provider_security_catalog_predecessor() + if is_device_plane_postgres_bootstrap_slice( + manifest["component"], + entries, + ): + device_plane_postgres_preflight = ( + preflight_device_plane_postgres_bootstrap() + ) component = manifest["component"] root = component_root(component) @@ -13246,6 +13488,22 @@ def plan_artifact(artifact): print(f"runtime_grants=runner-managed:{FOUNDRY_BINDING_GRANTS_DIR}") print(f"runtime_private_key=runner-managed:{FOUNDRY_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE}") print(f"runtime_public_trust=runner-managed:{FOUNDRY_EDP_MANAGED_PROVISIONER_PUBLIC_KEY_FILE}") + if component == "device-plane": + print(f"runtime_secret=runner-managed:{DEVICE_PLANE_POSTGRES_PASSWORD_FILE}") + print(f"runtime_secret=runner-managed:{DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE}") + print(f"runtime_secret=runner-managed:{DEVICE_PLANE_IDENTIFIER_PEPPER_FILE}") + print("device_postgres=preserved-prerequisite:not-selected") + print("device_postgres_volume=preserved:nodedc-device-plane-postgres-data") + print("device_gateway_public_ingress=disabled") + print("device_gateway_command_transport=disabled") + print("gelios=untouched") + if device_plane_postgres_preflight is not None: + print( + "device_postgres_bootstrap=" + f"{device_plane_postgres_preflight}" + ) + print("device_postgres_bootstrap_mode=create-if-absent") + print("device_postgres_rollback_volume=preserve") if touches_external_data_plane: print(f"runtime_secret=runner-managed:{EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE}") print(f"runtime_grants=runner-managed:{EXTERNAL_DATA_PLANE_READER_GRANTS_DIR}") @@ -13527,6 +13785,71 @@ def rollback_platform_apply(root, backup_dir, entries, current_stamp, runtime_st return f"source+runtime-restored:{restored_count}" +def rollback_device_plane_apply( + root, + backup_dir, + entries, + current_stamp, + runtime_started, + applied_services, +): + existing = read_backup_path_list(backup_dir / "existing-files.txt") + missing = read_backup_path_list(backup_dir / "missing-files.txt") + existing_set, _missing_set = validate_backup_partition( + entries, + existing, + missing, + "Device Plane runtime rollback", + ) + baseline_entries = [rel for rel in entries if rel in existing_set] + compose_was_installed = "docker-compose.device-plane.yml" in existing_set + baseline_services = ( + component_services("device-plane", baseline_entries) + if compose_was_installed + else () + ) + candidate_only_services = tuple( + service + for service in applied_services + if service not in baseline_services + ) + candidate_cleanup_failed = False + if runtime_started and candidate_only_services: + # Remove only candidate services while the candidate Compose file is + # still present. PostgreSQL can appear only in the exact one-time + # bootstrap slice; volume flags are deliberately never used. + try: + stop_and_remove_compose_services( + "device-plane", + candidate_only_services, + ) + except Exception: + candidate_cleanup_failed = True + + restored_count = restore_platform_overlay( + root, + backup_dir, + entries, + current_stamp, + ) + if candidate_cleanup_failed: + die("Device Plane candidate-only runtime cleanup failed after source restore") + if not runtime_started or not baseline_services: + return f"source-restored-runtime-unchanged:{restored_count}" + + run_component_runtime( + "device-plane", + baseline_entries, + baseline_services, + ) + run_healthchecks( + "device-plane", + baseline_entries, + baseline_services, + ) + return f"source+runtime-restored:{restored_count}" + + def rollback_engine_apply(root, backup_dir, entries, current_stamp, runtime_started, applied_services): existing = read_backup_path_list(backup_dir / "existing-files.txt") missing = read_backup_path_list(backup_dir / "missing-files.txt") @@ -14130,6 +14453,24 @@ def prepare_component_runtime(component, entries=None): ) return + if component == "device-plane": + ensure_platform_runtime_secret( + DEVICE_PLANE_POSTGRES_PASSWORD_FILE, + MAP_GATEWAY_SECRET_RE, + "device plane PostgreSQL", + ) + ensure_platform_runtime_secret( + DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE, + MAP_GATEWAY_SECRET_RE, + "device plane Gateway to Core", + ) + ensure_platform_runtime_secret( + DEVICE_PLANE_IDENTIFIER_PEPPER_FILE, + MAP_GATEWAY_SECRET_RE, + "device plane identifier pepper", + ) + return + if component == "proxy-contur": sync_map_egress_proxy_secret() return @@ -14309,6 +14650,8 @@ def module_foundry_healthcheck(): def component_healthchecks(component, entries=None, services=None): + if is_device_plane_postgres_bootstrap_slice(component, entries): + return () if is_engine_n8n_transition(component, entries): # The transition does not restart the Engine UI/backend generation. # Its own acceptance below verifies n8n readiness, image, mount, logs, @@ -14323,6 +14666,36 @@ def component_healthchecks(component, entries=None, services=None): return () if component == "module-foundry": return (module_foundry_healthcheck(),) + if component == "device-plane": + selected_services = ( + tuple(services) + if services is not None + else component_services(component, entries) + ) + checks = [] + if "device-control-core" in selected_services: + checks.append({ + "url": "http://127.0.0.1:18120/healthz", + "expected_json": { + "ok": True, + "service": "nodedc-device-control-core", + "database": "ready", + "discoveryIngest": "disabled", + "commandTransport": "disabled", + }, + }) + if "device-gateway" in selected_services: + checks.append({ + "url": "http://127.0.0.1:18121/healthz", + "expected_json": { + "ok": True, + "service": "nodedc-device-gateway", + "tcpListener": "disabled", + "publicIngress": "disabled", + "commandTransport": "disabled", + }, + }) + return tuple(checks) if ( ( is_engine_data_product_publish_grant_slice(component, entries) @@ -14712,6 +15085,11 @@ process.stdout.write('engine-l2-closed-loop:0.7.0:cas+safe-profile+external-plan def run_healthchecks(component, entries=None, services=None): + if is_device_plane_postgres_bootstrap_slice(component, entries): + if tuple(services or ()) != ("device-postgres",): + die("Device Plane PostgreSQL bootstrap service set mismatch") + healthcheck_compose_service("device-plane", "device-postgres") + return if component == "platform" and entries is not None and is_platform_provider_catalog_only(entries): return if is_engine_l2_closed_loop_slice(component, entries): @@ -15504,6 +15882,11 @@ def apply_artifact(artifact): die(f"artifact sha already applied: {sha}") if state_has_patch_id(patch_id): die(f"patch id already applied: {patch_id}") + if is_device_plane_postgres_bootstrap_slice( + component, + entries, + ): + preflight_device_plane_postgres_bootstrap() if not root.is_dir(): if bootstrap_root: root.mkdir(parents=True, exist_ok=True) @@ -16064,6 +16447,31 @@ def apply_artifact(artifact): except Exception as rollback_exc: rollback_status = f"failed:{type(rollback_exc).__name__}" print("platform-automatic-rollback=failed", file=sys.stderr) + elif ( + component == "device-plane" + and entries is not None + and services is not None + ): + try: + restored_state = rollback_device_plane_apply( + root, + backup_dir, + entries, + current_stamp, + runtime_started, + services, + ) + rollback_status = f"ok:device-plane-overlay:{restored_state}" + print( + f"device-plane-automatic-rollback={rollback_status}", + file=sys.stderr, + ) + except Exception as rollback_exc: + rollback_status = f"failed:{type(rollback_exc).__name__}" + print( + "device-plane-automatic-rollback=failed", + file=sys.stderr, + ) failed_path = None if artifact.exists() and rollback_status != "deferred:reconciliation-required": try: diff --git a/infra/deploy-runner/test_device_plane_artifact.py b/infra/deploy-runner/test_device_plane_artifact.py new file mode 100644 index 0000000..1676d11 --- /dev/null +++ b/infra/deploy-runner/test_device_plane_artifact.py @@ -0,0 +1,156 @@ +#!/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_postgres_bootstrap.py b/infra/deploy-runner/test_device_plane_postgres_bootstrap.py new file mode 100644 index 0000000..d420e80 --- /dev/null +++ b/infra/deploy-runner/test_device_plane_postgres_bootstrap.py @@ -0,0 +1,195 @@ +#!/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_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) diff --git a/infra/deploy-runner/test_device_plane_registry.py b/infra/deploy-runner/test_device_plane_registry.py new file mode 100644 index 0000000..067f908 --- /dev/null +++ b/infra/deploy-runner/test_device_plane_registry.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +import importlib.machinery +import importlib.util +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_plane_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 DevicePlaneRegistryTest(unittest.TestCase): + def test_registry_has_exact_roots_project_and_stateless_services(self): + component = RUNNER.COMPONENTS["device-plane"] + root = Path("/volume1/docker/nodedc-device-plane") + self.assertEqual(component["payload_root"], root) + self.assertEqual(component["compose_root"], root) + self.assertEqual(component["compose_project"], "nodedc-device-plane") + self.assertEqual( + component["compose_files"], + (root / "docker-compose.device-plane.yml",), + ) + self.assertTrue(component["bootstrap_root"]) + self.assertTrue(component["compose_no_deps"]) + self.assertEqual( + component["services"], + ("device-control-core", "device-gateway"), + ) + self.assertNotIn("device-postgres", component["services"]) + self.assertIsNone(component.get("compose_env_file")) + + def test_files_select_only_the_affected_stateless_services(self): + self.assertEqual( + RUNNER.component_services( + "device-plane", + ("services/device-control-core/src/server.mjs",), + ), + ("device-control-core",), + ) + self.assertEqual( + RUNNER.component_services( + "device-plane", + ("services/device-gateway/src/server.mjs",), + ), + ("device-gateway",), + ) + self.assertEqual( + RUNNER.component_services( + "device-plane", + ("packages/arusnavi-b2-adapter",), + ), + ("device-control-core", "device-gateway"), + ) + services = RUNNER.component_services( + "device-plane", + ("docker-compose.device-plane.yml",), + ) + self.assertEqual( + services, + ("device-control-core", "device-gateway"), + ) + self.assertNotIn("device-postgres", services) + + def test_build_selection_is_exact_and_database_free(self): + builds = RUNNER.component_builds( + "device-plane", + ("services/device-gateway",), + ) + self.assertEqual(len(builds), 1) + self.assertEqual(builds[0][0], RUNNER.DEVICE_PLANE_ROOT) + self.assertIn( + "services/device-gateway/Dockerfile", + builds[0][1], + ) + self.assertIn(RUNNER.DEVICE_PLANE_GATEWAY_IMAGE, builds[0][1]) + + all_builds = RUNNER.component_builds( + "device-plane", + ("package-lock.json",), + ) + self.assertEqual(len(all_builds), 2) + self.assertNotIn("device-postgres", " ".join( + argument + for _root, arguments in all_builds + for argument in arguments + )) + + def test_payload_allowlist_rejects_runtime_secrets_and_broad_paths(self): + for allowed in ( + ".dockerignore", + "docker-compose.device-plane.yml", + "packages/device-protocol-contract/src/index.mjs", + "services/device-control-core/Dockerfile", + "services/device-gateway/src/runtime.mjs", + ): + self.assertTrue( + RUNNER.allowed_payload_path("device-plane", allowed), + ) + + for rejected in ( + ".env", + "secrets/postgres-password", + "runtime/postgres/data", + "services/unknown/server.mjs", + "docker-compose.yml", + "services/device-control-core/start.sh", + "services/device-control-core/test/app.test.mjs", + ): + with self.assertRaises(RUNNER.DeployError): + RUNNER.allowed_payload_path("device-plane", rejected) + + def test_healthchecks_are_service_scoped_and_fail_closed(self): + core_checks = RUNNER.component_healthchecks( + "device-plane", + ("services/device-control-core",), + ("device-control-core",), + ) + self.assertEqual(len(core_checks), 1) + self.assertEqual( + core_checks[0]["expected_json"]["commandTransport"], + "disabled", + ) + self.assertEqual( + core_checks[0]["expected_json"]["discoveryIngest"], + "disabled", + ) + + gateway_checks = RUNNER.component_healthchecks( + "device-plane", + ("services/device-gateway",), + ("device-gateway",), + ) + self.assertEqual(len(gateway_checks), 1) + self.assertEqual( + gateway_checks[0]["expected_json"]["publicIngress"], + "disabled", + ) + self.assertEqual( + gateway_checks[0]["expected_json"]["tcpListener"], + "disabled", + ) + + def test_runtime_secrets_are_runner_owned_and_not_manifest_selected(self): + with mock.patch.object( + RUNNER, + "ensure_platform_runtime_secret", + ) as ensure: + RUNNER.prepare_component_runtime( + "device-plane", + ("services/device-control-core",), + ) + 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, + ], + ) + + def test_compose_runtime_uses_no_deps_and_never_selects_database(self): + with mock.patch.object(RUNNER.subprocess, "run") as run: + RUNNER.run_compose( + "device-plane", + ("device-control-core", "device-gateway"), + ("docker-compose.device-plane.yml",), + ) + command = run.call_args_list[0].args[0] + self.assertIn("--no-deps", command) + self.assertNotIn("device-postgres", command) + self.assertEqual( + command[-2:], + ["device-control-core", "device-gateway"], + ) + + def test_initial_candidate_rollback_removes_only_stateless_services(self): + entries = ( + "docker-compose.device-plane.yml", + "services/device-control-core", + "services/device-gateway", + ) + with tempfile.TemporaryDirectory( + prefix="nodedc-device-plane-rollback-", + ) as directory: + root = Path(directory) / "live" + backup = Path(directory) / "backup" + root.mkdir() + backup.mkdir() + (backup / "existing-files.txt").write_text("", encoding="utf-8") + (backup / "missing-files.txt").write_text( + "\n".join(entries) + "\n", + encoding="utf-8", + ) + with ( + mock.patch.object( + RUNNER, + "stop_and_remove_compose_services", + ) as stop, + mock.patch.object( + RUNNER, + "restore_platform_overlay", + return_value=3, + ) as restore, + ): + result = RUNNER.rollback_device_plane_apply( + root, + backup, + entries, + "test-stamp", + True, + ("device-control-core", "device-gateway"), + ) + + stop.assert_called_once_with( + "device-plane", + ("device-control-core", "device-gateway"), + ) + restore.assert_called_once() + self.assertEqual(result, "source-restored-runtime-unchanged:3") + + +if __name__ == "__main__": + unittest.main(verbosity=2)