feat(device-plane): add fail-closed deploy foundation
This commit is contained in:
parent
e9e03143cd
commit
e217723784
|
|
@ -0,0 +1,10 @@
|
|||
.git
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
docs
|
||||
node_modules
|
||||
**/test
|
||||
**/*.log
|
||||
runtime
|
||||
secrets
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
@ -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'/);
|
||||
});
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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/<artifact>.tgz
|
||||
sudo /usr/local/sbin/nodedc-deploy apply /volume1/docker/nodedc-deploy/inbox/<artifact>.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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue