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`
|
- `bim-viewer`
|
||||||
- `n8n-private-extension`
|
- `n8n-private-extension`
|
||||||
- `module-foundry`
|
- `module-foundry`
|
||||||
|
- `device-plane`
|
||||||
- `proxy-contur`
|
- `proxy-contur`
|
||||||
- `dc-amd-proxy`
|
- `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 plan /volume1/docker/nodedc-deploy/inbox/<artifact>.tgz
|
||||||
sudo /usr/local/sbin/nodedc-deploy apply /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"
|
MAP_EGRESS_PROXY_SECRET_FILE = MAP_GATEWAY_SECRET_DIR / "map-egress-proxy-token"
|
||||||
PROXY_CONTUR_ENV_FILE = Path("/volume1/docker/proxy-contur/.env")
|
PROXY_CONTUR_ENV_FILE = Path("/volume1/docker/proxy-contur/.env")
|
||||||
DC_AMD_PROXY_RUNTIME_DIR = Path("/volume1/docker/dc-amd-proxy/runtime")
|
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_DIR = MAP_GATEWAY_SECRET_DIR / "external-data-plane-provisioner"
|
||||||
EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE = EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR / "token"
|
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"
|
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",
|
"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": {
|
"proxy-contur": {
|
||||||
"payload_root": Path("/volume1/docker/proxy-contur"),
|
"payload_root": Path("/volume1/docker/proxy-contur"),
|
||||||
"compose_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",
|
"infra/docker-compose.module-foundry.yml",
|
||||||
):
|
):
|
||||||
pass
|
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 (
|
elif component == "proxy-contur" and rel in (
|
||||||
"Dockerfile",
|
"Dockerfile",
|
||||||
"docker-compose.yml",
|
"docker-compose.yml",
|
||||||
|
|
@ -2426,6 +2460,25 @@ def denied_payload_path(component, rel):
|
||||||
return "module-foundry env file"
|
return "module-foundry env file"
|
||||||
if rel.startswith(("Dockerfile.bak", "infra/docker-compose.module-foundry.yml.bak")):
|
if rel.startswith(("Dockerfile.bak", "infra/docker-compose.module-foundry.yml.bak")):
|
||||||
return "module-foundry backup file"
|
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":
|
elif component == "n8n-private-extension":
|
||||||
# An extension release is inert data at this boundary. Activation is
|
# An extension release is inert data at this boundary. Activation is
|
||||||
# Engine-owned and must never be smuggled into the artifact as a script,
|
# 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
|
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":
|
if component == "n8n-private-extension":
|
||||||
parts = PurePosixPath(rel).parts
|
parts = PurePosixPath(rel).parts
|
||||||
if (
|
if (
|
||||||
|
|
@ -7681,6 +7755,11 @@ def load_artifact(artifact, work_dir):
|
||||||
die(f"files.txt entry missing in payload: {rel}")
|
die(f"files.txt entry missing in payload: {rel}")
|
||||||
|
|
||||||
validate_payload_tree(manifest["component"], payload_dir, entries)
|
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":
|
if manifest["component"] == "n8n-private-extension":
|
||||||
validate_n8n_private_extension_release(payload_dir, entries)
|
validate_n8n_private_extension_release(payload_dir, entries)
|
||||||
if manifest["component"] == "engine":
|
if manifest["component"] == "engine":
|
||||||
|
|
@ -7972,6 +8051,79 @@ def component_root(component):
|
||||||
return COMPONENTS[component]["payload_root"]
|
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):
|
def component_compose_root(component):
|
||||||
return COMPONENTS[component].get("compose_root", component_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):
|
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):
|
if is_engine_l2_closed_loop_slice(component, entries):
|
||||||
# The failed 030 apply published both the backend source and the built
|
# The failed 030 apply published both the backend source and the built
|
||||||
# UI before Compose rejected the descriptor/source mismatch. The exact
|
# 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.
|
# authorization normalization inside the already-active backend.
|
||||||
return ("nodedc-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:
|
if component == "dc-cms" and entries is not None:
|
||||||
selected = []
|
selected = []
|
||||||
|
|
||||||
|
|
@ -9783,6 +9981,42 @@ def component_build_args(component, entries=None):
|
||||||
|
|
||||||
|
|
||||||
def component_builds(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:
|
if component == "platform" and entries is not None:
|
||||||
touches_compose = any(rel == "platform/docker-compose.platform-http.yml" for rel in entries)
|
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)
|
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_registered_execution_profiles_preflight = None
|
||||||
mcp_gelios_units_items_preflight = None
|
mcp_gelios_units_items_preflight = None
|
||||||
provider_catalog_preflight = None
|
provider_catalog_preflight = None
|
||||||
|
device_plane_postgres_preflight = None
|
||||||
with tempfile.TemporaryDirectory(prefix="plan-", dir=TMP_DIR) as tmp:
|
with tempfile.TemporaryDirectory(prefix="plan-", dir=TMP_DIR) as tmp:
|
||||||
manifest, entries, payload_dir = load_artifact(artifact, Path(tmp))
|
manifest, entries, payload_dir = load_artifact(artifact, Path(tmp))
|
||||||
reject_terminal_engine_l2_failed_artifact(manifest, sha)
|
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):
|
if is_engine_provider_security_catalog_slice(manifest["component"], entries):
|
||||||
provider_catalog_preflight = preflight_engine_provider_security_catalog_predecessor()
|
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"]
|
component = manifest["component"]
|
||||||
root = component_root(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_grants=runner-managed:{FOUNDRY_BINDING_GRANTS_DIR}")
|
||||||
print(f"runtime_private_key=runner-managed:{FOUNDRY_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE}")
|
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}")
|
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:
|
if touches_external_data_plane:
|
||||||
print(f"runtime_secret=runner-managed:{EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE}")
|
print(f"runtime_secret=runner-managed:{EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE}")
|
||||||
print(f"runtime_grants=runner-managed:{EXTERNAL_DATA_PLANE_READER_GRANTS_DIR}")
|
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}"
|
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):
|
def rollback_engine_apply(root, backup_dir, entries, current_stamp, runtime_started, applied_services):
|
||||||
existing = read_backup_path_list(backup_dir / "existing-files.txt")
|
existing = read_backup_path_list(backup_dir / "existing-files.txt")
|
||||||
missing = read_backup_path_list(backup_dir / "missing-files.txt")
|
missing = read_backup_path_list(backup_dir / "missing-files.txt")
|
||||||
|
|
@ -14130,6 +14453,24 @@ def prepare_component_runtime(component, entries=None):
|
||||||
)
|
)
|
||||||
return
|
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":
|
if component == "proxy-contur":
|
||||||
sync_map_egress_proxy_secret()
|
sync_map_egress_proxy_secret()
|
||||||
return
|
return
|
||||||
|
|
@ -14309,6 +14650,8 @@ def module_foundry_healthcheck():
|
||||||
|
|
||||||
|
|
||||||
def component_healthchecks(component, entries=None, services=None):
|
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):
|
if is_engine_n8n_transition(component, entries):
|
||||||
# The transition does not restart the Engine UI/backend generation.
|
# The transition does not restart the Engine UI/backend generation.
|
||||||
# Its own acceptance below verifies n8n readiness, image, mount, logs,
|
# Its own acceptance below verifies n8n readiness, image, mount, logs,
|
||||||
|
|
@ -14323,6 +14666,36 @@ def component_healthchecks(component, entries=None, services=None):
|
||||||
return ()
|
return ()
|
||||||
if component == "module-foundry":
|
if component == "module-foundry":
|
||||||
return (module_foundry_healthcheck(),)
|
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 (
|
if (
|
||||||
(
|
(
|
||||||
is_engine_data_product_publish_grant_slice(component, entries)
|
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):
|
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):
|
if component == "platform" and entries is not None and is_platform_provider_catalog_only(entries):
|
||||||
return
|
return
|
||||||
if is_engine_l2_closed_loop_slice(component, entries):
|
if is_engine_l2_closed_loop_slice(component, entries):
|
||||||
|
|
@ -15504,6 +15882,11 @@ def apply_artifact(artifact):
|
||||||
die(f"artifact sha already applied: {sha}")
|
die(f"artifact sha already applied: {sha}")
|
||||||
if state_has_patch_id(patch_id):
|
if state_has_patch_id(patch_id):
|
||||||
die(f"patch id already applied: {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 not root.is_dir():
|
||||||
if bootstrap_root:
|
if bootstrap_root:
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -16064,6 +16447,31 @@ def apply_artifact(artifact):
|
||||||
except Exception as rollback_exc:
|
except Exception as rollback_exc:
|
||||||
rollback_status = f"failed:{type(rollback_exc).__name__}"
|
rollback_status = f"failed:{type(rollback_exc).__name__}"
|
||||||
print("platform-automatic-rollback=failed", file=sys.stderr)
|
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
|
failed_path = None
|
||||||
if artifact.exists() and rollback_status != "deferred:reconciliation-required":
|
if artifact.exists() and rollback_status != "deferred:reconciliation-required":
|
||||||
try:
|
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