feat(device-plane): add fail-closed deploy foundation

This commit is contained in:
Codex 2026-07-25 21:29:05 +03:00
parent e9e03143cd
commit e217723784
36 changed files with 3729 additions and 0 deletions

View File

@ -0,0 +1,10 @@
.git
.DS_Store
.env
.env.*
docs
node_modules
**/test
**/*.log
runtime
secrets

45
device-plane/README.md Normal file
View File

@ -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.

View File

@ -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"
}

View File

@ -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

View File

@ -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.

View File

@ -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 |

212
device-plane/package-lock.json generated Normal file
View File

@ -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"
}
}
}
}

16
device-plane/package.json Normal file
View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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);
}

View File

@ -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,
);
});

View File

@ -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"
}
}

View File

@ -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`);
}
}

View File

@ -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);
});

View File

@ -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"]

View File

@ -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;

View File

@ -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"
}
}

View File

@ -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";
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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");
}

View File

@ -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()));
}),
};
}

View File

@ -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,
);
});

View File

@ -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'/);
});

View File

@ -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"]

View File

@ -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"
}
}

View File

@ -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()));
});
}

View File

@ -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");
}

View File

@ -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);
});
}

View File

@ -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
```

View File

@ -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);
}
}

View File

@ -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");
}

View File

@ -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:

View File

@ -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)

View File

@ -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)

View File

@ -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)