Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26dbcfd262 | ||
|
|
a9ea31f00a | ||
|
|
827bf0a58a | ||
|
|
40fbfcf351 | ||
|
|
185c54ced2 | ||
|
|
9c359111ea | ||
|
|
71fff82c99 | ||
|
|
fe1c8054be | ||
|
|
3ea2133bcd | ||
|
|
4b73a15765 | ||
|
|
bdb85cb4f7 | ||
|
|
66e0c62451 | ||
|
|
606872edcc | ||
|
|
6fd172ecc5 | ||
|
|
e21c188f85 | ||
|
|
b513ed973b | ||
|
|
20ef5894cc | ||
|
|
9fa74a1243 | ||
|
|
020d4dbad7 | ||
|
|
d8c0e9f3b1 | ||
|
|
c2d06bddc7 | ||
|
|
2eb6116880 | ||
|
|
6fa9164933 | ||
|
|
e85bb1448e | ||
|
|
3a13f0effc | ||
|
|
1e01161a83 | ||
|
|
d641744dca | ||
|
|
bb0724c52c | ||
|
|
094cf7143a | ||
|
|
f53759cb15 | ||
|
|
35c37de586 | ||
|
|
0f44cca27d | ||
|
|
724e745628 | ||
|
|
be964eccb7 | ||
|
|
6461e7fca8 | ||
|
|
1124c15216 | ||
|
|
50b9179fe4 | ||
|
|
393741f1bd | ||
|
|
227c7c26c1 | ||
|
|
c9d6068f36 | ||
|
|
42892cd43b | ||
|
|
5565486ac9 | ||
|
|
28371f67a5 | ||
|
|
6a8c1ce1ef | ||
|
|
9f12edd736 | ||
|
|
72b4846b32 | ||
|
|
1adbabefcf | ||
|
|
37bdbebb4e | ||
|
|
8defd55715 | ||
|
|
04ba3a9f99 | ||
|
|
4374a9b4e7 | ||
|
|
0679142561 | ||
|
|
29ba5de92e | ||
|
|
1d1e9a96b3 | ||
|
|
f5d7916338 | ||
|
|
43dc9b1f45 | ||
|
|
422ddb020f | ||
|
|
fceaca9546 | ||
|
|
72db23c0e9 | ||
|
|
9fdaed1c95 | ||
|
|
70bafdd028 | ||
|
|
336602c7ca | ||
|
|
bf0bc50abb |
@@ -1,12 +0,0 @@
|
||||
.git
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
docs
|
||||
node_modules
|
||||
**/test
|
||||
**/*.log
|
||||
**/*.prev-*
|
||||
**/*.next-*
|
||||
runtime
|
||||
secrets
|
||||
@@ -1,63 +0,0 @@
|
||||
# NDC Device Plane
|
||||
|
||||
`device-plane` is the provider-neutral runtime boundary for physical devices.
|
||||
It is intentionally separate from Foundry, Engine L2, External Data Plane and
|
||||
the preserved Gelios integration.
|
||||
|
||||
Recorded runtime status: the private Synology foundation and the isolated Mini
|
||||
IPvlan relay are running through their reviewed artifacts. Synology has no
|
||||
public device listener; the Mini relay is LAN-addressable only and router/NAT
|
||||
exposure remains a separate manual gate. The admission-gate source below is
|
||||
prepared and staged, but has not yet been applied.
|
||||
|
||||
- `packages/device-protocol-contract` owns safe discovery and presentation
|
||||
contracts.
|
||||
- `packages/arusnavi-b2-adapter` owns the first model-profile evidence and a
|
||||
fail-closed framing boundary.
|
||||
- `services/device-control-core` owns the initial PostgreSQL schema, health
|
||||
boundary and disabled-by-default quarantine ingest.
|
||||
- `services/device-gateway` owns a disabled-by-default, loopback-only TCP
|
||||
evidence listener that sends no bytes and extracts no identifier until the
|
||||
official framing contract is known.
|
||||
- `docker-compose.device-plane.yml` publishes only loopback health endpoints
|
||||
and keeps the raw TCP listener unpublished.
|
||||
- No device command can be built or sent.
|
||||
- No real IMEI, ICCID, password, packet or provider credential is stored in
|
||||
this source tree.
|
||||
|
||||
The planned runtime services are:
|
||||
|
||||
- `device-control-core`: contours, discoveries, devices, bindings, policy and
|
||||
audit;
|
||||
- `device-gateway`: raw TCP sessions, bounded codecs and presence;
|
||||
- `device-postgres`: private persistent state.
|
||||
|
||||
`services/device-edge-relay` is a separate, disabled-by-default runtime for the
|
||||
dedicated Debian ingress host. It is an L4 byte relay: it neither decodes
|
||||
ARUSNAVI B2, acknowledges a device nor holds a Core token, database credential
|
||||
or command capability. The implemented backhaul forwards only through a
|
||||
restricted outbound SSH path to the Synology Gateway loopback listener. The
|
||||
reviewed ingress target gives only this container a fixed IPvlan L2 address on
|
||||
the Mini's single Ethernet link, so the host Amnezia full tunnel stays intact
|
||||
and Docker publishes no host port. The fixed relay address is
|
||||
`192.168.71.253`, explicitly outside the documented Deco DHCP pool
|
||||
`192.168.68.50`–`192.168.71.250`. See `docs/IMPLEMENTATION_BASELINE.md` for
|
||||
the remaining activation gates. The staged admission update keeps the relay
|
||||
opaque but requires a public IPv4 source and bounds its source table and bytes
|
||||
per direction; it does not enable router/NAT exposure.
|
||||
|
||||
The 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.
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge.admission-gate.v1",
|
||||
"mode": "single-nic-ipvlan-b2-relay-only",
|
||||
"runtimeHost": "ndcmini12",
|
||||
"component": "device-edge",
|
||||
"selectedServices": [
|
||||
"device-edge-relay"
|
||||
],
|
||||
"preservedServices": [
|
||||
"device-edge-backhaul",
|
||||
"tailnet"
|
||||
],
|
||||
"composeProject": "nodedc-device-edge",
|
||||
"composeFiles": [
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml"
|
||||
],
|
||||
"parentInterface": "enp1s0f0",
|
||||
"lanSubnet": "192.168.68.0/22",
|
||||
"lanGateway": "192.168.68.1",
|
||||
"ingressIpv4": "192.168.71.253",
|
||||
"ingressIpv4Approval": "approved-outside-dhcp-pool",
|
||||
"ingressNetwork": "nodedc-device-edge-ingress",
|
||||
"deviceTcpListen": "192.168.71.253:9921",
|
||||
"hostPortPublication": "disabled",
|
||||
"healthPublication": "disabled",
|
||||
"privateUpstream": "device-edge-backhaul:19921",
|
||||
"sourceAdmission": "public-ipv4-only",
|
||||
"maxTrackedSourceAddresses": 2048,
|
||||
"maxBytesPerDirection": 262144,
|
||||
"protocolInspection": "gateway-owned",
|
||||
"identityTrust": "claimed-not-ownership-proof",
|
||||
"discoveryLifecycle": "quarantine",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"amneziaHostFullTunnel": "preserved",
|
||||
"routerNatFirewall": "separate-manual-gate",
|
||||
"rollback": "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge.backhaul.v1",
|
||||
"mode": "private-tailnet-ssh-local-forward",
|
||||
"runtimeHost": "ndcmini12",
|
||||
"selectedServices": [
|
||||
"device-edge-backhaul"
|
||||
],
|
||||
"preservedServices": [
|
||||
"device-edge-relay",
|
||||
"tailnet"
|
||||
],
|
||||
"tailnetSocksTarget": "nodedc-device-edge-tailnet-1:1055",
|
||||
"sshTarget": "100.109.216.21:2222",
|
||||
"sshUser": "device-backhaul",
|
||||
"localForward": "0.0.0.0:19921",
|
||||
"permittedRemoteTarget": "127.0.0.1:9921",
|
||||
"hostPortPublication": "disabled",
|
||||
"deviceIngress": "disabled",
|
||||
"protocolInspection": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"privateKey": "runtime-only-read-only",
|
||||
"knownHosts": "runner-prepared-exact-ed25519",
|
||||
"routerNatFirewall": "unchanged",
|
||||
"gelios": "untouched"
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge.ingress-ipvlan.v1",
|
||||
"mode": "single-nic-ipvlan-b2-relay-only",
|
||||
"runtimeHost": "ndcmini12",
|
||||
"component": "device-edge",
|
||||
"selectedServices": [
|
||||
"device-edge-relay"
|
||||
],
|
||||
"preservedServices": [
|
||||
"device-edge-backhaul",
|
||||
"tailnet"
|
||||
],
|
||||
"composeProject": "nodedc-device-edge",
|
||||
"composeFiles": [
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml"
|
||||
],
|
||||
"parentInterface": "enp1s0f0",
|
||||
"lanSubnet": "192.168.68.0/22",
|
||||
"lanGateway": "192.168.68.1",
|
||||
"ingressIpv4": "192.168.71.253",
|
||||
"ingressIpv4Approval": "approved-outside-dhcp-pool",
|
||||
"ingressNetwork": "nodedc-device-edge-ingress",
|
||||
"deviceTcpListen": "192.168.71.253:9921",
|
||||
"hostPortPublication": "disabled",
|
||||
"healthPublication": "disabled",
|
||||
"privateUpstream": "device-edge-backhaul:19921",
|
||||
"protocolInspection": "gateway-owned",
|
||||
"identityTrust": "claimed-not-ownership-proof",
|
||||
"discoveryLifecycle": "quarantine",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"amneziaHostFullTunnel": "preserved",
|
||||
"routerNatFirewall": "separate-manual-gate",
|
||||
"rollback": "restore-predecessor-relay-remove-unused-ingress-network"
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.b2-discovery-ingress.v1",
|
||||
"mode": "verified-b2-loopback-discovery-only",
|
||||
"predecessorPatchId": "device-plane-foundation-network-publication-20260725-003",
|
||||
"predecessorArtifactSha256": "6fdd5a12c310786db1753882fc1378184fe378d2cc533633a8c73c951521b7bf",
|
||||
"sourceAction": "publish-verified-b2-loopback-discovery-source",
|
||||
"runtimeAction": "build-and-recreate-stateless-services",
|
||||
"selectedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway"
|
||||
],
|
||||
"preservedServices": [
|
||||
"device-postgres"
|
||||
],
|
||||
"privateNetwork": "nodedc-device-plane-private",
|
||||
"controlNetwork": "nodedc-device-plane-control",
|
||||
"publishedPorts": [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121",
|
||||
"127.0.0.1:9921:9921/tcp"
|
||||
],
|
||||
"protocolProfile": "arusnavi.b2.internal.v1",
|
||||
"framingSpecification": "arusnavi.internal.protocol-sheet.gid-12.v1",
|
||||
"identityTrust": "claimed-not-ownership-proof",
|
||||
"discoveryLifecycle": "quarantine",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"databaseVolume": "nodedc-device-plane-postgres-data",
|
||||
"rollback": "restore-source-and-predecessor-stateless-runtime"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.b2-discovery-loopback-recovery.v1",
|
||||
"mode": "failed-b2-loopback-build-reconciliation",
|
||||
"failedPatchId": "device-plane-b2-discovery-loopback-20260801-003",
|
||||
"failedArtifactSha256": "7273c5bf67fe6bc1f1da66ad726009240d39ee3aee58201b96c23d6f707a3d84",
|
||||
"failedBackupId": "device-plane-device-plane-b2-discovery-loopback-20260801-003-20260802-154311",
|
||||
"sourceAction": "publish-reconciliation-marker-only",
|
||||
"runtimeAction": "read-only-acceptance",
|
||||
"preservedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres"
|
||||
],
|
||||
"expectedLoopbackPorts": [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121"
|
||||
],
|
||||
"closedPort": "127.0.0.1:9921/tcp",
|
||||
"databaseVolume": "nodedc-device-plane-postgres-data",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"rollback": "marker-only-runtime-unchanged"
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.backhaul-target-tailnet-serve.v1",
|
||||
"mode": "failed-backhaul-target-to-loopback-tailnet-serve",
|
||||
"failedPatchId": "device-plane-backhaul-target-20260803-001",
|
||||
"failedArtifactSha256": "ed0bda4110a756c32be68990e2e0f647409d5a77eec7e26c18502bafbdc1bb76",
|
||||
"failedBackupId": "device-plane-device-plane-backhaul-target-20260803-001-20260804-035519",
|
||||
"predecessorPatchId": "device-plane-b2-discovery-loopback-20260803-006",
|
||||
"predecessorArtifactSha256": "25f9e9e55e283e9b7bb5e128ff14a244f848b1c063acca9724a23206131c9adf",
|
||||
"sourceAction": "publish-loopback-backhaul-target-source",
|
||||
"runtimeAction": "build-create-target-and-register-private-tailnet-serve",
|
||||
"composeOverlay": "docker-compose.device-plane.backhaul-target.yml",
|
||||
"selectedServices": [
|
||||
"device-backhaul-target"
|
||||
],
|
||||
"preservedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres"
|
||||
],
|
||||
"loopbackListenAddress": "127.0.0.1",
|
||||
"listenPort": 2222,
|
||||
"tailnetAddress": "100.109.216.21",
|
||||
"tailnetExposure": "tailscale-serve-private",
|
||||
"tailscaleServeTarget": "tcp://127.0.0.1:2222",
|
||||
"permittedTarget": "127.0.0.1:9921",
|
||||
"networkMode": "host",
|
||||
"dockerPortPublication": "disabled",
|
||||
"routerNatFirewall": "unchanged",
|
||||
"edgePublicIngress": "disabled",
|
||||
"funnel": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"databaseVolume": "nodedc-device-plane-postgres-data",
|
||||
"runtimeTrust": "runner-managed",
|
||||
"rollback": "remove-tailnet-serve-target-and-restore-source"
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.foundation-network-publication.v1",
|
||||
"mode": "failed-foundation-network-publication-correction",
|
||||
"failedRecoveryPatchId": "device-plane-foundation-recovery-20260725-002",
|
||||
"failedRecoveryArtifactSha256": "9183cc385142584bfd12510bb0a3e6b833b2fd26607436f2486a564c628ea1bf",
|
||||
"failedRecoveryBackupId": "device-plane-device-plane-foundation-recovery-20260725-002-20260725-232447",
|
||||
"sourceAction": "publish-network-corrected-foundation-source",
|
||||
"runtimeAction": "recreate-stateless-services-no-build",
|
||||
"selectedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway"
|
||||
],
|
||||
"preservedServices": [
|
||||
"device-postgres"
|
||||
],
|
||||
"privateNetwork": "nodedc-device-plane-private",
|
||||
"controlNetwork": "nodedc-device-plane-control",
|
||||
"publishedLoopbackPorts": [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121"
|
||||
],
|
||||
"databaseVolume": "nodedc-device-plane-postgres-data",
|
||||
"rollback": "restore-partial-source-and-internal-only-stateless-runtime"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.foundation-recovery.v1",
|
||||
"mode": "failed-foundation-live-runtime-adoption",
|
||||
"failedPatchId": "device-plane-foundation-20260725-001",
|
||||
"failedArtifactSha256": "23d428de547854ad8b1a026671e2f850386ab0be98bde80f016f1e9db631ee24",
|
||||
"backupId": "device-plane-device-plane-foundation-20260725-001-20260725-223441",
|
||||
"sourceAction": "publish-exact-failed-artifact-source",
|
||||
"runtimeAction": "read-only-acceptance",
|
||||
"preservedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres"
|
||||
],
|
||||
"databaseVolume": "nodedc-device-plane-postgres-data",
|
||||
"rollback": "source-only-runtime-unchanged"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.postgres-bootstrap.v1",
|
||||
"service": "device-postgres",
|
||||
"volume": "nodedc-device-plane-postgres-data",
|
||||
"mode": "create-if-absent",
|
||||
"ordinaryApplicationSelection": "forbidden",
|
||||
"rollbackVolumePolicy": "preserve"
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
services:
|
||||
device-edge-backhaul:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/device-edge-backhaul/Dockerfile
|
||||
image: nodedc/device-edge-backhaul:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=8m,mode=1777
|
||||
volumes:
|
||||
- ../secrets/backhaul/id_ed25519:/run/keys/edge-to-synology:ro
|
||||
- ../trust/synology-backhaul-known_hosts:/run/trust/known_hosts:ro
|
||||
command:
|
||||
- -N
|
||||
- -T
|
||||
- -p
|
||||
- "2222"
|
||||
- -i
|
||||
- /run/keys/edge-to-synology
|
||||
- -L
|
||||
- 0.0.0.0:19921:127.0.0.1:9921
|
||||
- -o
|
||||
- BatchMode=yes
|
||||
- -o
|
||||
- PasswordAuthentication=no
|
||||
- -o
|
||||
- KbdInteractiveAuthentication=no
|
||||
- -o
|
||||
- PubkeyAuthentication=yes
|
||||
- -o
|
||||
- IdentitiesOnly=yes
|
||||
- -o
|
||||
- StrictHostKeyChecking=yes
|
||||
- -o
|
||||
- UserKnownHostsFile=/run/trust/known_hosts
|
||||
- -o
|
||||
- UpdateHostKeys=no
|
||||
- -o
|
||||
- ExitOnForwardFailure=yes
|
||||
- -o
|
||||
- ServerAliveInterval=15
|
||||
- -o
|
||||
- ServerAliveCountMax=3
|
||||
- -o
|
||||
- TCPKeepAlive=yes
|
||||
- -o
|
||||
- LogLevel=VERBOSE
|
||||
- -o
|
||||
- ProxyCommand=nc -X 5 -x nodedc-device-edge-tailnet-1:1055 %h %p
|
||||
- device-backhaul@100.109.216.21
|
||||
networks:
|
||||
- device-edge-private
|
||||
- device-edge-tailnet
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
pids_limit: 32
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- nc
|
||||
- -z
|
||||
- -w
|
||||
- "3"
|
||||
- 127.0.0.1
|
||||
- "19921"
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-edge-private:
|
||||
name: nodedc-device-edge-private
|
||||
external: true
|
||||
device-edge-tailnet:
|
||||
name: nodedc-device-edge-tailnet
|
||||
external: true
|
||||
@@ -1,35 +0,0 @@
|
||||
services:
|
||||
device-edge-relay:
|
||||
environment:
|
||||
DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"
|
||||
DEVICE_EDGE_RELAY_TCP_HOST: 0.0.0.0
|
||||
DEVICE_EDGE_RELAY_TCP_PORT: "9921"
|
||||
DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul
|
||||
DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"
|
||||
DEVICE_EDGE_RELAY_MAX_SESSIONS: "100"
|
||||
DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS: "10"
|
||||
DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
|
||||
DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only
|
||||
DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"
|
||||
DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"
|
||||
DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS: "10000"
|
||||
networks:
|
||||
device-edge-private:
|
||||
gw_priority: 0
|
||||
device-edge-ingress:
|
||||
ipv4_address: 192.168.71.253
|
||||
gw_priority: 100
|
||||
|
||||
networks:
|
||||
device-edge-private:
|
||||
name: nodedc-device-edge-private
|
||||
device-edge-ingress:
|
||||
name: nodedc-device-edge-ingress
|
||||
driver: ipvlan
|
||||
driver_opts:
|
||||
parent: enp1s0f0
|
||||
ipvlan_mode: l2
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 192.168.68.0/22
|
||||
gateway: 192.168.68.1
|
||||
@@ -1,44 +0,0 @@
|
||||
services:
|
||||
device-edge-relay:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/device-edge-relay/Dockerfile
|
||||
image: nodedc/device-edge-relay:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
DEVICE_EDGE_RELAY_HEALTH_HOST: 127.0.0.1
|
||||
DEVICE_EDGE_RELAY_HEALTH_PORT: "18221"
|
||||
DEVICE_EDGE_RELAY_INGRESS_ENABLED: "false"
|
||||
DEVICE_EDGE_RELAY_TCP_PORT: "9921"
|
||||
DEVICE_EDGE_RELAY_MAX_SESSIONS: "100"
|
||||
DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS: "10"
|
||||
DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
|
||||
DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"
|
||||
DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"
|
||||
DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS: "10000"
|
||||
networks:
|
||||
device-edge-private: {}
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18221/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-edge-private:
|
||||
name: nodedc-device-edge-private
|
||||
internal: true
|
||||
@@ -1,39 +0,0 @@
|
||||
services:
|
||||
device-backhaul-target:
|
||||
image: nodedc/device-backhaul-target:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /run:size=8m,mode=0755
|
||||
- /tmp:size=8m,mode=1777
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/backhaul-target/ssh_host_ed25519_key
|
||||
target: /run/nodedc-secrets/ssh_host_ed25519_key
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/backhaul-target/authorized_keys
|
||||
target: /run/nodedc-secrets/authorized_keys
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- DAC_OVERRIDE
|
||||
- SETGID
|
||||
- SETUID
|
||||
- SYS_CHROOT
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "-w", "3", "127.0.0.1", "2222"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
@@ -1,158 +0,0 @@
|
||||
services:
|
||||
device-postgres:
|
||||
image: postgres:16-alpine
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: device_plane
|
||||
POSTGRES_USER: device_plane
|
||||
POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
volumes:
|
||||
- type: volume
|
||||
source: device-plane-postgres-data
|
||||
target: /var/lib/postgresql/data
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
networks:
|
||||
- device-plane-private
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-control-core:
|
||||
image: nodedc/device-control-core:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "18120"
|
||||
DEVICE_DATABASE_HOST: device-postgres
|
||||
DEVICE_DATABASE_PORT: "5432"
|
||||
DEVICE_DATABASE_NAME: device_plane
|
||||
DEVICE_DATABASE_USER: device_plane
|
||||
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
DEVICE_DATABASE_POOL_SIZE: "10"
|
||||
DEVICE_DISCOVERY_INGEST_ENABLED: "true"
|
||||
DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token
|
||||
DEVICE_IDENTIFIER_PEPPER_FILE: /run/nodedc-secrets/identifier-pepper
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token
|
||||
target: /run/nodedc-secrets/gateway-core-token
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/identifier-pepper
|
||||
target: /run/nodedc-secrets/identifier-pepper
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18120:18120"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- device-plane-control
|
||||
depends_on:
|
||||
device-postgres:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-gateway:
|
||||
image: nodedc/device-gateway:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
|
||||
DEVICE_GATEWAY_HEALTH_PORT: "18121"
|
||||
DEVICE_GATEWAY_LISTEN_ENABLED: "true"
|
||||
DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false"
|
||||
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1
|
||||
DEVICE_GATEWAY_TCP_PORT: "9921"
|
||||
DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120
|
||||
DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token
|
||||
DEVICE_GATEWAY_CORE_TIMEOUT_MS: "5000"
|
||||
DEVICE_GATEWAY_MAX_BUFFERED_BYTES: "65536"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS: "100"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS: "10"
|
||||
DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
|
||||
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token
|
||||
target: /run/nodedc-secrets/gateway-core-token
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18121:18121"
|
||||
- "127.0.0.1:9921:9921"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- device-plane-control
|
||||
depends_on:
|
||||
device-control-core:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-plane-private:
|
||||
name: nodedc-device-plane-private
|
||||
internal: true
|
||||
device-plane-control:
|
||||
name: nodedc-device-plane-control
|
||||
driver: bridge
|
||||
internal: false
|
||||
driver_opts:
|
||||
com.docker.network.bridge.enable_ip_masquerade: "false"
|
||||
|
||||
volumes:
|
||||
device-plane-postgres-data:
|
||||
name: nodedc-device-plane-postgres-data
|
||||
@@ -1,106 +0,0 @@
|
||||
# Device Edge B2 public pilot runbook
|
||||
|
||||
Status: router/NAT is **not configured** by this document. It is a one-pilot,
|
||||
human-operated exposure gate for the already accepted Mini relay. It never
|
||||
changes Synology, Gelios, VPN, Device Plane command transport or device
|
||||
ownership.
|
||||
|
||||
## Exact traffic path
|
||||
|
||||
```text
|
||||
ARUSNAVI B2 pilot
|
||||
-> public IPv4 : TCP/9921
|
||||
-> provider router : TCP/9921 -> 192.168.1.151:9921
|
||||
-> Deco X55 : TCP/9921 -> 192.168.71.253:9921
|
||||
-> Mini IPvlan relay
|
||||
-> restricted private backhaul
|
||||
-> Synology Gateway 127.0.0.1:9921
|
||||
```
|
||||
|
||||
The observed Deco WAN is `192.168.1.151` with gateway `192.168.0.1`; it is an
|
||||
RFC1918 address. Therefore this is a double-NAT topology. A Deco rule alone
|
||||
cannot make the tracker reachable from the internet.
|
||||
|
||||
## Immutable safety boundary
|
||||
|
||||
- Forward **TCP only**, external and internal port `9921`.
|
||||
- Deco target is exactly `192.168.71.253`, never the Mini host
|
||||
`192.168.68.54` and never Synology.
|
||||
- Do not enable DMZ, UPnP, port ranges, UDP, 443 forwarding or any catch-all
|
||||
rule.
|
||||
- Do not remove, replace or edit the Gelios monitoring route.
|
||||
- The relay accepts connections only from a public IPv4 source, keeps
|
||||
quarantine-only discovery and has no command transport. A LAN client will
|
||||
intentionally be rejected; that is not a test failure.
|
||||
- The rule must be deleted again if the single pilot is abandoned or the
|
||||
expected quarantine evidence is not obtained.
|
||||
|
||||
## Gate 1 — DNS and public address
|
||||
|
||||
1. Determine the actual public IPv4 on the provider-facing edge. Do not use
|
||||
`192.168.0.1`, `192.168.1.151`, `192.168.68.1` or `192.168.71.253` as a
|
||||
DNS answer: all are private addresses.
|
||||
2. Point `device.nodedc.ru` to that public IPv4 only if the tracker UI accepts
|
||||
a hostname. Otherwise configure the public IPv4 directly.
|
||||
3. If the provider router has no public WAN IPv4, or an internet check still
|
||||
cannot reach it after both rules below, stop. This is provider CGNAT/bridge
|
||||
territory: request a public IPv4 or a bridge/forwarding option from the
|
||||
provider. Do not introduce a VPS as an unreviewed workaround.
|
||||
|
||||
## Gate 2 — provider router
|
||||
|
||||
On the upstream/provider router, create one port-forward rule:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | `NDC B2 pilot to Deco` |
|
||||
| Protocol | `TCP` |
|
||||
| External port | `9921` |
|
||||
| Target address | `192.168.1.151` |
|
||||
| Target port | `9921` |
|
||||
|
||||
Save only that rule. It targets the Deco WAN address, not a service host.
|
||||
|
||||
## Gate 3 — Deco X55
|
||||
|
||||
In the Deco mobile app: **More → Advanced → NAT Forwarding → Port Forwarding →
|
||||
Add**. Create exactly:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | `NDC B2 pilot` |
|
||||
| Protocol | `TCP` |
|
||||
| External port | `9921` |
|
||||
| Internal IP | `192.168.71.253` |
|
||||
| Internal port | `9921` |
|
||||
|
||||
If the app forces a client selection, select the IPvlan relay only if its
|
||||
address is shown as `192.168.71.253`. Do not select the Mini host or any NAS.
|
||||
If the UI will not accept the fixed IPvlan address, stop and record that fact;
|
||||
do not substitute a DHCP address.
|
||||
|
||||
## Gate 4 — ARUSNAVI B2 route
|
||||
|
||||
Use one known pilot B2 and one unused monitoring-server slot. Configure an
|
||||
additional route with the documented **INTERNAL** protocol and the public
|
||||
hostname/IP from Gate 1, TCP port `9921`. Preserve the existing Gelios route in
|
||||
its current slot. The ARUSNAVI account password stays in the operator surface;
|
||||
it is not entered into Foundry, Device Plane or the relay.
|
||||
|
||||
The device IMEI observed in `HEADER2` is a claimed identifier only. It becomes
|
||||
a quarantine discovery, not an owned device and never a command target.
|
||||
|
||||
## Acceptance and failure handling
|
||||
|
||||
The first valid HEADER/PACKAGE through the pilot route must produce a masked,
|
||||
quarantine-only discovery in Device Control Core and the existing Gelios map
|
||||
path must continue independently. Do not issue a device command.
|
||||
|
||||
On any unexpected behavior, remove the two NAT rules in reverse order:
|
||||
|
||||
1. remove the Deco `NDC B2 pilot` rule;
|
||||
2. remove the provider-router `NDC B2 pilot to Deco` rule.
|
||||
|
||||
This ends external reachability while leaving the Mini, VPN, backhaul,
|
||||
Synology and Gelios unchanged. Do not use a LAN port probe as acceptance: the
|
||||
relay correctly rejects private source addresses.
|
||||
@@ -1,277 +0,0 @@
|
||||
# Device Plane Implementation Baseline
|
||||
|
||||
Status: PostgreSQL, Control Core and Gateway foundation are running healthy on
|
||||
Synology. The accepted foundation has public ingress and discovery ingest
|
||||
disabled. The next additive transition enables only an authenticated,
|
||||
quarantine-only ARUSNAVI B2 discovery path on raw TCP 9921. Command transport
|
||||
remains disabled.
|
||||
|
||||
## Product boundary
|
||||
|
||||
The Device Manager user interface is a canonical Foundry Page Library
|
||||
template. Foundry owns page instances, layout, presentation and an opaque
|
||||
`device-plane-control` binding. It does not own device records, credentials,
|
||||
raw protocol or command delivery.
|
||||
|
||||
The independent NDC Device Plane owns physical-device state and direct
|
||||
connections:
|
||||
|
||||
```text
|
||||
Foundry Device Manager Page
|
||||
|
|
||||
| device-plane-control (typed server boundary)
|
||||
v
|
||||
Device Control Core <-> Device PostgreSQL
|
||||
|
|
||||
v
|
||||
Device Gateway <-> physical devices
|
||||
```
|
||||
|
||||
The isolated ingress placement replaces the direct physical-device arrow when
|
||||
the raw route must not terminate on the multi-service Synology:
|
||||
|
||||
```text
|
||||
ARUSNAVI B2 device
|
||||
|
|
||||
| raw TCP 9921 (future, separately approved)
|
||||
v
|
||||
Device Edge Relay on dedicated mini
|
||||
|
|
||||
| outbound restricted SSH local-forward; opaque byte stream only
|
||||
v
|
||||
Synology loopback 127.0.0.1:9921 -> Device Gateway -> Device Control Core
|
||||
```
|
||||
|
||||
The Edge Relay owns neither protocol acknowledgement nor device identity. It
|
||||
does not receive the Gateway/Core token, PostgreSQL credentials, Foundry
|
||||
bindings or any command capability. The Synology Gateway remains the sole B2
|
||||
codec and acknowledgement owner.
|
||||
|
||||
Engine L2 may consume safe decoded observations and build workflows/Data
|
||||
Products. It does not own TCP sessions, secrets or the command transport.
|
||||
|
||||
## Preserved production path
|
||||
|
||||
The existing Gelios -> Engine L2 -> External Data Plane -> Foundry Map path is
|
||||
outside this implementation slice. Its credentials, workflows, Data Products,
|
||||
bindings and map presentation must not be changed or restarted by a Device
|
||||
Plane artifact.
|
||||
|
||||
The first B2 pilot adds an NDC server route in parallel and keeps the existing
|
||||
Gelios route unchanged.
|
||||
|
||||
## Source and runtime placement
|
||||
|
||||
Source:
|
||||
|
||||
```text
|
||||
platform/device-plane/
|
||||
packages/device-protocol-contract/
|
||||
packages/arusnavi-b2-adapter/
|
||||
services/device-control-core/
|
||||
services/device-gateway/
|
||||
services/device-edge-relay/
|
||||
docker-compose.device-plane.yml
|
||||
docker-compose.device-edge.yml
|
||||
```
|
||||
|
||||
Planned Synology runtime:
|
||||
|
||||
```text
|
||||
/volume1/docker/nodedc-device-plane
|
||||
```
|
||||
|
||||
Planned Compose project and services:
|
||||
|
||||
```text
|
||||
nodedc-device-plane
|
||||
device-control-core
|
||||
device-gateway
|
||||
device-postgres
|
||||
```
|
||||
|
||||
`device-postgres` is a private persistent prerequisite. Application overlays
|
||||
must never force-recreate it or its volume.
|
||||
|
||||
The canonical runner selects only `device-control-core` and `device-gateway`
|
||||
with `--no-deps`. Its health acceptance is scoped to the selected services and
|
||||
requires the fail-closed fields to remain disabled. A failed first activation
|
||||
removes only candidate stateless services and never requests volume removal.
|
||||
Rollback now records an explicit pre-apply service inventory in the backup;
|
||||
the existence of the shared Compose file does not imply that Core or Gateway
|
||||
existed before apply.
|
||||
|
||||
The exact foundation recovery validates the failed archive, journal, backup,
|
||||
partial live source and observed healthy image/container generations. It then
|
||||
publishes the matching source and performs read-only runtime acceptance. It
|
||||
does not build, restart, recreate or remove any service.
|
||||
|
||||
## Network boundary
|
||||
|
||||
The accepted Synology foundation publishes no device port. Device Gateway's
|
||||
raw B2 listener is reachable only through `127.0.0.1:9921`; its health
|
||||
endpoints are loopback-only. The only planned external raw-TCP termination is
|
||||
the dedicated Mini Edge Relay described below.
|
||||
|
||||
`device.nodedc.ru` is a DNS name, not an HTTP/TCP mode. The same name may later
|
||||
serve an HTTPS Control API on 443 and the B2 raw TCP protocol on 9921.
|
||||
|
||||
DSM HTTP/HTTPS Reverse Proxy is not a raw TCP ingress and must not be configured
|
||||
as `443 -> 9921`.
|
||||
|
||||
The artifact never changes DSM firewall, DSM Router Configuration, DNS or a
|
||||
physical router.
|
||||
|
||||
### Dedicated mini Device Edge
|
||||
|
||||
The Debian mini is the isolated raw-TCP edge. Its accepted predecessor keeps the
|
||||
relay disabled and publishes health only on `127.0.0.1:18221`. The reviewed
|
||||
target removes even that host publication: health remains container-internal,
|
||||
the relay stays on the `internal: true` private bridge for backhaul, and a
|
||||
second IPvlan L2 attachment gives only the relay a LAN-routable address for
|
||||
`9921/TCP`. The relay has bounded global/per-address sessions and connection
|
||||
rate, a bounded source-rate table and a per-direction byte budget. It emits no
|
||||
bytes of its own and does not inspect device payloads.
|
||||
|
||||
The admission-gate transition is deliberately fail-closed at the relay: an
|
||||
ingress instance accepts only a syntactically public IPv4 source, limits its
|
||||
in-memory source table to 2,048 addresses and closes either direction after
|
||||
262,144 bytes. Private, loopback, link-local, carrier-grade NAT, multicast,
|
||||
reserved and documentation addresses are rejected before an upstream connection
|
||||
is made. This is a connection-admission and resource-boundary control, not a
|
||||
claim that Docker IPvlan traffic is filtered by a host firewall. A raw B2
|
||||
protocol has no TLS client identity and cellular devices do not offer a stable
|
||||
source-IP allowlist, so a router/NAT mapping remains prohibited until its
|
||||
separate exposure and abuse controls are reviewed.
|
||||
|
||||
IPvlan deliberately reuses the Mini's one physical parent `enp1s0f0`; a second
|
||||
Ethernet adapter is not required. The host keeps `192.168.68.54/22` and the
|
||||
Amnezia `0.0.0.0/1` plus `128.0.0.0/1` routes. The relay has its own fixed LAN
|
||||
IPv4 and default route through `192.168.68.1`, while its private connected route
|
||||
continues to reach `device-edge-backhaul:19921`. No Docker host `ports:` entry,
|
||||
host-network mode, privileged container or VPN teardown is allowed.
|
||||
|
||||
Enabling public ingress is a separate reviewed operation and requires all of
|
||||
the following evidence:
|
||||
|
||||
1. A distinct, no-shell Synology SSH account and key whose sole permitted open
|
||||
target is `127.0.0.1:9921`; host-key pinning and a persistent, monitored
|
||||
tunnel are required.
|
||||
2. A private backhaul sidecar/network; the raw listener may forward only to
|
||||
that tunnel. The Core token and all Core/Database secrets remain on
|
||||
Synology.
|
||||
3. Router evidence proving the fixed relay IPv4 is outside DHCP. The artifact
|
||||
cannot choose an address and never changes router, firewall or DHCP state.
|
||||
A manual router/NAT rule is a later independent approval, after the relay's
|
||||
admission gate and external-exposure runbook have been accepted.
|
||||
4. The host full-tunnel VPN remains active. Before production activation, the
|
||||
exact single-NIC IPvlan design must pass duplicate-address detection,
|
||||
gateway reachability, external return-path and private-backhaul checks.
|
||||
5. One pre-authorized B2 pilot route, quarantine-only Gateway/Core ingest and
|
||||
disabled command transport.
|
||||
|
||||
## Identity and onboarding
|
||||
|
||||
An IMEI is a claimed protocol identifier, not proof of tenant ownership.
|
||||
|
||||
- An unknown connection produces a quarantine-only discovery.
|
||||
- A discovery never receives commands.
|
||||
- Pilot claim requires an explicit platform-admin action.
|
||||
- Production assignment requires authoritative pre-enrollment or an audited
|
||||
inventory import.
|
||||
- First-claim-wins by IMEI is forbidden.
|
||||
|
||||
The ARUSNAVI Web account login/password is used only by the human operator to
|
||||
configure the additional device route. It is not a Device Plane credential.
|
||||
|
||||
## Protocol evidence
|
||||
|
||||
The official B2 material proves:
|
||||
|
||||
- four simultaneous monitoring server routes;
|
||||
- `INTERNAL`, `EXTERNAL`, `USER_AG` and EGTS variants;
|
||||
- INTERNAL server-side identification by modem IMEI;
|
||||
- server route fields for DNS/IP, TCP port, protocol and optional ID;
|
||||
- SMS/TCP command families and a six-digit device access password.
|
||||
|
||||
The official ARUSNAVI INTERNAL protocol sheet now provides the first read-path
|
||||
framing contract:
|
||||
|
||||
- HEADER2 for GPRS is `FF 23` followed by an eight-byte little-endian IMEI;
|
||||
- the server confirms HEADER2 with a bounded `SERVER_COM` carrying Unix time;
|
||||
- a PACKAGE begins with `5B`, carries a package number in `01..FB`, contains
|
||||
one or more length-framed PACKET records and ends with `5D`;
|
||||
- every PACKET checksum is verified before acknowledgement;
|
||||
- every valid PACKAGE is acknowledged by package number;
|
||||
- without acknowledgement the tracker repeats the transmission.
|
||||
|
||||
The pilot codec implements only that verified read/acknowledgement subset. It
|
||||
does not decode telemetry tags, export command builders or accept arbitrary
|
||||
server commands. An IMEI parsed from a valid HEADER2 remains a claimed
|
||||
identifier and never proves tenant ownership.
|
||||
|
||||
## Command boundary
|
||||
|
||||
Outbound command transport is disabled in this baseline. No command builder is
|
||||
exported.
|
||||
|
||||
Later lifecycle:
|
||||
|
||||
```text
|
||||
draft -> planned -> awaiting_confirmation -> queued -> dispatched
|
||||
-> acknowledged | failed | expired | unknown
|
||||
```
|
||||
|
||||
`send` is not success. An `unknown` result forbids automatic retry.
|
||||
|
||||
Erase, factory reset, firmware/custom firmware, physical outputs and arbitrary
|
||||
raw TCP remain forbidden until separate reviewed acceptance slices.
|
||||
|
||||
## Implemented local foundation
|
||||
|
||||
- Provider-neutral discovery, contour and opaque Foundry-binding contracts.
|
||||
- B2 model profile with four parallel routes and INTERNAL/IMEI evidence.
|
||||
- PostgreSQL migration for model profiles, contours, quarantine discoveries,
|
||||
claimed devices, Foundry bindings and append-only audit events.
|
||||
- Core health endpoint and an authenticated quarantine-ingest boundary that is
|
||||
disabled unless explicitly enabled with file-backed secrets.
|
||||
- Gateway discovery-only HEADER2/PACKAGE state machine with bounded buffers,
|
||||
handshake timeout, concurrent/per-source session limits and per-source
|
||||
connection rate limits.
|
||||
- Authenticated Gateway-to-Core discovery ingest. Core HMAC-hashes the full IMEI
|
||||
and persists only its digest, masked view and verified framing evidence.
|
||||
- Only HEADER2 and valid PACKAGE acknowledgements are emitted; no command
|
||||
builder or command transport is present.
|
||||
- Recursive rejection of secret-like fields, raw payloads and command-shaped
|
||||
input in presentation contracts.
|
||||
- Automated contract, adapter, migration, Core and Gateway tests.
|
||||
- Additive `component=device-plane` runner registry with exact roots, builds,
|
||||
services, allowlist/denylist, runner-owned secrets, health contracts and
|
||||
automatic source/runtime rollback.
|
||||
- Deterministic data-only artifact builder and positive/negative regression
|
||||
tests.
|
||||
- Compose foundation with a private internal network, preserved PostgreSQL
|
||||
volume, file-backed database password and loopback-only health publishing.
|
||||
- Exact one-time PostgreSQL bootstrap descriptor, deterministic builder and
|
||||
absence preflight: an existing database container or volume fails closed,
|
||||
and rollback never removes the volume.
|
||||
|
||||
## Next activation slice
|
||||
|
||||
1. The Deco DHCP range has been recorded as `192.168.68.50` through
|
||||
`192.168.71.250`; the fixed Relay IPv4 is `192.168.71.253`, outside that
|
||||
pool and independently DAD-tested. It is pinned in Compose, descriptor,
|
||||
builder and the separate Edge runner.
|
||||
2. Build the deterministic `component=device-edge` artifact, promote the
|
||||
root-owned Edge runner and review its `plan`. The Synology runner and inbox
|
||||
are not used for this host.
|
||||
3. Apply the admission-gate update only to `device-edge-relay`; prove exact
|
||||
IPvlan runtime, no host ports, `public-ipv4-only` admission, byte/source
|
||||
limits, internal health, private backhaul reachability, unchanged
|
||||
backhaul/tailnet identities and preserved Amnezia routes. Automatic rollback
|
||||
restores the reviewed IPvlan predecessor and leaves router state unchanged.
|
||||
4. Independently review and add the single router/NAT rule for TCP `9921` only,
|
||||
then verify that Synology still exposes no public device port.
|
||||
5. Add the NDC route to one approved B2 free server slot while preserving
|
||||
Gelios, then prove HEADER/discovery/PACKAGE acknowledgement. Claim and tenant
|
||||
assignment remain a later explicit platform-admin operation.
|
||||
@@ -1,30 +0,0 @@
|
||||
# Device Plane baseline test matrix
|
||||
|
||||
| Boundary | Required proof |
|
||||
| --- | --- |
|
||||
| Restricted identity | IMEI accepts exactly 15 decimal digits internally |
|
||||
| Browser projection | Safe discovery view contains only a masked identifier |
|
||||
| Identifier hashing | HMAC digest is deterministic and does not reveal input |
|
||||
| Secret boundary | Secret-like or raw-payload keys are rejected recursively |
|
||||
| Command boundary | Discovery contract rejects command-shaped input |
|
||||
| Framing bound | B2 evidence inspection rejects empty and oversized buffers |
|
||||
| Framing honesty | Unverified B2 bytes return `official_framing_required` |
|
||||
| No identifier guessing | Embedded digit sequences are never returned as IMEI |
|
||||
| Model profile | Four server routes and INTERNAL identification are recorded |
|
||||
| Gelios preservation | Gelios is a parallel route, not a dependency or failover |
|
||||
| Core database secret | Production Compose uses a file-backed password, not a plaintext environment value |
|
||||
| Core health | Database is ready while discovery ingest and command transport remain disabled |
|
||||
| Gateway health | Public ingress, TCP listener and command transport remain disabled |
|
||||
| Compose exposure | Only loopback health ports `18120/18121` are published; raw `9921` is not |
|
||||
| Application service scope | `files.txt` selects only affected Core/Gateway services with `--no-deps` |
|
||||
| Database preservation | Ordinary application artifacts never select `device-postgres` |
|
||||
| Database bootstrap | Exact descriptor selects PostgreSQL only when both container and volume are absent |
|
||||
| Bootstrap rollback | Candidate container may be removed; named volume is never removed |
|
||||
| Rollback predecessor | Backup records actual pre-apply services; Compose presence cannot invent Core/Gateway |
|
||||
| Failed-001 evidence | Recovery requires exact failed archive, journal and backup digests |
|
||||
| Partial source | Recovery accepts only DB-bootstrap source plus the observed healthy foundation runtime |
|
||||
| Recovery mutation | Source is published without build, restart, recreate or service removal |
|
||||
| Recovery rollback | Failed acceptance restores source only and leaves runtime unchanged |
|
||||
| Artifact policy | `.env`, secrets, runtime state, tests, logs and `node_modules` are excluded |
|
||||
| Artifact reproducibility | Repeated builds for the same patch id are byte-identical |
|
||||
| Runner compatibility | Existing canonical Platform registry tests remain green |
|
||||
Generated
-223
@@ -1,223 +0,0 @@
|
||||
{
|
||||
"name": "@nodedc/device-plane",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@nodedc/device-plane",
|
||||
"version": "0.1.0",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"services/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodedc/arusnavi-b2-adapter": {
|
||||
"resolved": "packages/arusnavi-b2-adapter",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nodedc/device-control-core": {
|
||||
"resolved": "services/device-control-core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nodedc/device-edge-relay": {
|
||||
"resolved": "services/device-edge-relay",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nodedc/device-gateway": {
|
||||
"resolved": "services/device-gateway",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nodedc/device-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-edge-relay": {
|
||||
"name": "@nodedc/device-edge-relay",
|
||||
"version": "0.1.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"services/device-gateway": {
|
||||
"name": "@nodedc/device-gateway",
|
||||
"version": "0.1.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "@nodedc/device-plane",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"services/*"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node --test packages/*/test/*.test.mjs services/*/test/*.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@nodedc/arusnavi-b2-adapter",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
export const ARUSNAVI_INTERNAL_SPECIFICATION_REF =
|
||||
"arusnavi.internal.protocol-sheet.gid-12.v1";
|
||||
|
||||
const HEADER2_LENGTH = 10;
|
||||
const HEADER_START = 0xff;
|
||||
const HEADER2_GPRS_VERSION = 0x23;
|
||||
const PACKAGE_START = 0x5b;
|
||||
const PACKAGE_END = 0x5d;
|
||||
const SERVER_COMMAND_START = 0x7b;
|
||||
const SERVER_COMMAND_END = 0x7d;
|
||||
const MIN_PACKAGE_NUMBER = 0x01;
|
||||
const MAX_PACKAGE_NUMBER = 0xfb;
|
||||
const PACKET_FIXED_LENGTH = 8;
|
||||
const MAX_PACKET_DATA_LENGTH = 32 * 1024;
|
||||
const MAX_PACKAGE_LENGTH = 64 * 1024;
|
||||
|
||||
export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({
|
||||
schemaVersion: "nodedc.device-model-profile.v1",
|
||||
profileRef: "arusnavi.b2.internal.v1",
|
||||
vendor: "ARUSNAVI",
|
||||
model: "B2",
|
||||
deviceType: "tracker",
|
||||
protocol: "INTERNAL",
|
||||
monitoringServerSlots: 4,
|
||||
serverIdentity: {
|
||||
kind: "imei",
|
||||
source: "modem",
|
||||
trust: "claimed-not-ownership-proof",
|
||||
},
|
||||
bootstrap: {
|
||||
operatorSurface: "ARUSNAVI_WEB_OR_LOCAL_CONFIGURATOR",
|
||||
platformCredentialRequired: false,
|
||||
preserveExistingRoutes: true,
|
||||
},
|
||||
framing: {
|
||||
status: "verified-read-only",
|
||||
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
headerVersion: "HEADER2_GPRS_0x23",
|
||||
headerBytes: HEADER2_LENGTH,
|
||||
maxBufferedBytes: MAX_PACKAGE_LENGTH,
|
||||
},
|
||||
acknowledgement: {
|
||||
header: "server-time-only",
|
||||
package: "package-number-only",
|
||||
},
|
||||
commandTransport: {
|
||||
status: "disabled",
|
||||
exportedCommandBuilders: 0,
|
||||
},
|
||||
routeCompatibility: {
|
||||
gelios: "parallel-preserved",
|
||||
automaticCommandFailover: false,
|
||||
},
|
||||
});
|
||||
|
||||
export function tryParseB2Header2(input) {
|
||||
assertBuffer(input, "b2_header_buffer_required");
|
||||
if (input.length < HEADER2_LENGTH) {
|
||||
return Object.freeze({
|
||||
status: "incomplete",
|
||||
minimumBytes: HEADER2_LENGTH,
|
||||
});
|
||||
}
|
||||
if (input[0] !== HEADER_START) {
|
||||
throw new TypeError("b2_header_start_invalid");
|
||||
}
|
||||
if (input[1] !== HEADER2_GPRS_VERSION) {
|
||||
throw new TypeError("b2_header_version_unsupported");
|
||||
}
|
||||
|
||||
const identifier = input.readBigUInt64LE(2).toString(10);
|
||||
if (!/^\d{15}$/.test(identifier)) {
|
||||
throw new TypeError("b2_header_imei_invalid");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
status: "complete",
|
||||
bytesConsumed: HEADER2_LENGTH,
|
||||
identifier: Object.freeze({
|
||||
kind: "imei",
|
||||
value: identifier,
|
||||
trust: "claimed-not-ownership-proof",
|
||||
}),
|
||||
evidence: Object.freeze({
|
||||
transport: "tcp",
|
||||
bytesObserved: HEADER2_LENGTH,
|
||||
framingStatus: "verified",
|
||||
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildB2HeaderAcknowledgement(unixSeconds) {
|
||||
const timestamp = normalizeUInt32(
|
||||
unixSeconds,
|
||||
"b2_header_ack_timestamp_invalid",
|
||||
);
|
||||
const commandData = Buffer.allocUnsafe(4);
|
||||
commandData.writeUInt32LE(timestamp);
|
||||
return Buffer.from([
|
||||
SERVER_COMMAND_START,
|
||||
commandData.length,
|
||||
0x00,
|
||||
checksum(commandData),
|
||||
...commandData,
|
||||
SERVER_COMMAND_END,
|
||||
]);
|
||||
}
|
||||
|
||||
export function tryParseB2Package(input) {
|
||||
assertBuffer(input, "b2_package_buffer_required");
|
||||
if (input.length === 0) {
|
||||
return Object.freeze({ status: "incomplete", minimumBytes: 1 });
|
||||
}
|
||||
if (input[0] !== PACKAGE_START) {
|
||||
throw new TypeError("b2_package_start_invalid");
|
||||
}
|
||||
if (input.length < 3) {
|
||||
return Object.freeze({ status: "incomplete", minimumBytes: 3 });
|
||||
}
|
||||
|
||||
const packageNumber = input[1];
|
||||
if (
|
||||
packageNumber < MIN_PACKAGE_NUMBER
|
||||
|| packageNumber > MAX_PACKAGE_NUMBER
|
||||
) {
|
||||
throw new TypeError("b2_package_number_invalid");
|
||||
}
|
||||
|
||||
let offset = 2;
|
||||
let packetCount = 0;
|
||||
while (true) {
|
||||
if (offset >= MAX_PACKAGE_LENGTH) {
|
||||
throw new TypeError("b2_package_length_exceeded");
|
||||
}
|
||||
if (offset >= input.length) {
|
||||
return Object.freeze({
|
||||
status: "incomplete",
|
||||
minimumBytes: offset + 1,
|
||||
});
|
||||
}
|
||||
if (input[offset] === PACKAGE_END) {
|
||||
if (packetCount === 0) {
|
||||
throw new TypeError("b2_package_empty");
|
||||
}
|
||||
return Object.freeze({
|
||||
status: "complete",
|
||||
bytesConsumed: offset + 1,
|
||||
packageNumber,
|
||||
packetCount,
|
||||
});
|
||||
}
|
||||
if (input.length - offset < 3) {
|
||||
return Object.freeze({
|
||||
status: "incomplete",
|
||||
minimumBytes: offset + 3,
|
||||
});
|
||||
}
|
||||
|
||||
const dataLength = input.readUInt16LE(offset + 1);
|
||||
if (dataLength > MAX_PACKET_DATA_LENGTH) {
|
||||
throw new TypeError("b2_packet_data_length_exceeded");
|
||||
}
|
||||
const packetLength = PACKET_FIXED_LENGTH + dataLength;
|
||||
const packetEnd = offset + packetLength;
|
||||
if (packetEnd + 1 > MAX_PACKAGE_LENGTH) {
|
||||
throw new TypeError("b2_package_length_exceeded");
|
||||
}
|
||||
if (input.length < packetEnd) {
|
||||
return Object.freeze({
|
||||
status: "incomplete",
|
||||
minimumBytes: packetEnd,
|
||||
});
|
||||
}
|
||||
|
||||
const expectedChecksum = checksum(
|
||||
input.subarray(offset + 3, packetEnd - 1),
|
||||
);
|
||||
if (input[packetEnd - 1] !== expectedChecksum) {
|
||||
throw new TypeError("b2_packet_checksum_invalid");
|
||||
}
|
||||
packetCount += 1;
|
||||
offset = packetEnd;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildB2PackageAcknowledgement(packageNumber) {
|
||||
const normalized = Number(packageNumber);
|
||||
if (
|
||||
!Number.isSafeInteger(normalized)
|
||||
|| normalized < MIN_PACKAGE_NUMBER
|
||||
|| normalized > MAX_PACKAGE_NUMBER
|
||||
) {
|
||||
throw new TypeError("b2_package_ack_number_invalid");
|
||||
}
|
||||
return Buffer.from([
|
||||
SERVER_COMMAND_START,
|
||||
0x00,
|
||||
normalized,
|
||||
SERVER_COMMAND_END,
|
||||
]);
|
||||
}
|
||||
|
||||
export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) {
|
||||
if (profile.monitoringServerSlots !== 4) {
|
||||
throw new TypeError("b2_server_slot_count_invalid");
|
||||
}
|
||||
if (profile.protocol !== "INTERNAL") {
|
||||
throw new TypeError("b2_protocol_invalid");
|
||||
}
|
||||
if (profile.serverIdentity.kind !== "imei") {
|
||||
throw new TypeError("b2_identity_kind_invalid");
|
||||
}
|
||||
if (profile.framing.status !== "verified-read-only") {
|
||||
throw new TypeError("b2_framing_must_be_verified");
|
||||
}
|
||||
if (
|
||||
profile.framing.specificationRef
|
||||
!== ARUSNAVI_INTERNAL_SPECIFICATION_REF
|
||||
) {
|
||||
throw new TypeError("b2_framing_specification_invalid");
|
||||
}
|
||||
if (profile.commandTransport.status !== "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 checksum(input) {
|
||||
let value = 0;
|
||||
for (const byte of input) value = (value + byte) & 0xff;
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeUInt32(value, errorCode) {
|
||||
const normalized = Number(value);
|
||||
if (
|
||||
!Number.isSafeInteger(normalized)
|
||||
|| normalized < 0
|
||||
|| normalized > 0xffffffff
|
||||
) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function assertBuffer(input, errorCode) {
|
||||
if (!Buffer.isBuffer(input)) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (!value || typeof value !== "object" || Object.isFrozen(value)) {
|
||||
return value;
|
||||
}
|
||||
Object.values(value).forEach(deepFreeze);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
ARUSNAVI_B2_MODEL_PROFILE,
|
||||
ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
assertB2ProfileInvariant,
|
||||
buildB2HeaderAcknowledgement,
|
||||
buildB2PackageAcknowledgement,
|
||||
tryParseB2Header2,
|
||||
tryParseB2Package,
|
||||
} from "../src/index.mjs";
|
||||
|
||||
const specificationHeader = Buffer.from(
|
||||
"FF23E9EF782DE7120300",
|
||||
"hex",
|
||||
);
|
||||
const specificationPackage = Buffer.from(
|
||||
"5B01010000FBDEC251EC5D",
|
||||
"hex",
|
||||
);
|
||||
|
||||
test("records the official B2 route, framing and identity evidence", () => {
|
||||
assert.equal(assertB2ProfileInvariant(), true);
|
||||
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.monitoringServerSlots, 4);
|
||||
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.protocol, "INTERNAL");
|
||||
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.serverIdentity.kind, "imei");
|
||||
assert.equal(
|
||||
ARUSNAVI_B2_MODEL_PROFILE.framing.specificationRef,
|
||||
ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
);
|
||||
assert.equal(
|
||||
ARUSNAVI_B2_MODEL_PROFILE.routeCompatibility.gelios,
|
||||
"parallel-preserved",
|
||||
);
|
||||
});
|
||||
|
||||
test("parses the official HEADER2 example as a claimed IMEI", () => {
|
||||
assert.equal(
|
||||
tryParseB2Header2(specificationHeader.subarray(0, 9)).status,
|
||||
"incomplete",
|
||||
);
|
||||
const parsed = tryParseB2Header2(specificationHeader);
|
||||
assert.equal(parsed.status, "complete");
|
||||
assert.equal(parsed.bytesConsumed, 10);
|
||||
assert.equal(parsed.identifier.kind, "imei");
|
||||
assert.equal(parsed.identifier.value, "865209039777769");
|
||||
assert.equal(parsed.identifier.trust, "claimed-not-ownership-proof");
|
||||
assert.deepEqual(parsed.evidence, {
|
||||
transport: "tcp",
|
||||
bytesObserved: 10,
|
||||
framingStatus: "verified",
|
||||
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
});
|
||||
});
|
||||
|
||||
test("builds the official HEADER2 acknowledgement example", () => {
|
||||
assert.equal(
|
||||
buildB2HeaderAcknowledgement(0x52db95de).toString("hex").toUpperCase(),
|
||||
"7B0400A0DE95DB527D",
|
||||
);
|
||||
});
|
||||
|
||||
test("parses and acknowledges the official package example", () => {
|
||||
assert.equal(
|
||||
tryParseB2Package(specificationPackage.subarray(0, -1)).status,
|
||||
"incomplete",
|
||||
);
|
||||
assert.deepEqual(tryParseB2Package(specificationPackage), {
|
||||
status: "complete",
|
||||
bytesConsumed: specificationPackage.length,
|
||||
packageNumber: 1,
|
||||
packetCount: 1,
|
||||
});
|
||||
assert.equal(
|
||||
buildB2PackageAcknowledgement(1).toString("hex").toUpperCase(),
|
||||
"7B00017D",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses packet lengths and checksum instead of scanning for 0x5D", () => {
|
||||
const packetData = Buffer.from([0x5d]);
|
||||
const unixTime = Buffer.from([0x01, 0x00, 0x00, 0x00]);
|
||||
const checksum = (0x01 + 0x5d) & 0xff;
|
||||
const packageBytes = Buffer.from([
|
||||
0x5b,
|
||||
0x02,
|
||||
0x01,
|
||||
packetData.length,
|
||||
0x00,
|
||||
...unixTime,
|
||||
...packetData,
|
||||
checksum,
|
||||
0x5d,
|
||||
]);
|
||||
assert.deepEqual(tryParseB2Package(packageBytes), {
|
||||
status: "complete",
|
||||
bytesConsumed: packageBytes.length,
|
||||
packageNumber: 2,
|
||||
packetCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("fails closed on unsupported headers and malformed packages", () => {
|
||||
assert.throws(
|
||||
() => tryParseB2Header2(Buffer.from("FE23E9EF782DE7120300", "hex")),
|
||||
/b2_header_start_invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => tryParseB2Header2(Buffer.from("FF24E9EF782DE7120300", "hex")),
|
||||
/b2_header_version_unsupported/,
|
||||
);
|
||||
const badChecksum = Buffer.from(specificationPackage);
|
||||
badChecksum[badChecksum.length - 2] ^= 0xff;
|
||||
assert.throws(
|
||||
() => tryParseB2Package(badChecksum),
|
||||
/b2_packet_checksum_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("exports 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,
|
||||
);
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@nodedc/device-protocol-contract",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
export const DEVICE_DISCOVERY_SIGNAL_SCHEMA =
|
||||
"nodedc.device.discovery-signal.v1";
|
||||
export const DEVICE_DISCOVERY_VIEW_SCHEMA =
|
||||
"nodedc.device.discovery-view.v1";
|
||||
export const DEVICE_PLANE_BINDING_SCHEMA =
|
||||
"nodedc.device-plane-control.binding.v1";
|
||||
|
||||
export const DEVICE_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`);
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
FROM alpine:3.22
|
||||
|
||||
RUN apk add --no-cache openssh-server \
|
||||
&& addgroup -g 11021 device-backhaul \
|
||||
&& adduser -D -H -u 11021 -G device-backhaul -s /sbin/nologin device-backhaul \
|
||||
&& passwd -u device-backhaul
|
||||
|
||||
COPY services/device-backhaul-target/sshd_config /etc/ssh/sshd_config
|
||||
|
||||
EXPOSE 2222/tcp
|
||||
|
||||
CMD ["sh", "-c", "mkdir -p /run/sshd && exec /usr/sbin/sshd -D -e -f /etc/ssh/sshd_config"]
|
||||
@@ -1,35 +0,0 @@
|
||||
Port 2222
|
||||
ListenAddress 127.0.0.1
|
||||
AddressFamily inet
|
||||
Protocol 2
|
||||
|
||||
HostKey /run/nodedc-secrets/ssh_host_ed25519_key
|
||||
AuthorizedKeysFile /run/nodedc-secrets/authorized_keys
|
||||
StrictModes no
|
||||
|
||||
PermitRootLogin no
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
AuthenticationMethods publickey
|
||||
AllowUsers device-backhaul
|
||||
|
||||
AllowTcpForwarding local
|
||||
PermitOpen 127.0.0.1:9921
|
||||
GatewayPorts no
|
||||
PermitTunnel no
|
||||
AllowAgentForwarding no
|
||||
X11Forwarding no
|
||||
PermitTTY no
|
||||
PermitUserEnvironment no
|
||||
ForceCommand /bin/false
|
||||
|
||||
ClientAliveInterval 30
|
||||
ClientAliveCountMax 3
|
||||
LoginGraceTime 20
|
||||
MaxAuthTries 3
|
||||
MaxSessions 4
|
||||
MaxStartups 4:30:8
|
||||
UseDNS no
|
||||
PrintMotd no
|
||||
LogLevel VERBOSE
|
||||
@@ -1,16 +0,0 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/device-protocol-contract ./packages/device-protocol-contract
|
||||
COPY packages/arusnavi-b2-adapter ./packages/arusnavi-b2-adapter
|
||||
COPY services/device-control-core ./services/device-control-core
|
||||
COPY services/device-gateway/package.json ./services/device-gateway/package.json
|
||||
COPY services/device-edge-relay/package.json ./services/device-edge-relay/package.json
|
||||
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
|
||||
USER node
|
||||
|
||||
CMD ["node", "services/device-control-core/src/server.mjs"]
|
||||
@@ -1,99 +0,0 @@
|
||||
begin;
|
||||
|
||||
create table if not exists device_model_profiles (
|
||||
profile_ref text primary key,
|
||||
schema_version text not null,
|
||||
vendor text not null,
|
||||
model text not null,
|
||||
device_type text not null,
|
||||
protocol text not null,
|
||||
profile jsonb not null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists device_contours (
|
||||
id uuid primary key,
|
||||
owner_scope text not null,
|
||||
name text not null,
|
||||
lifecycle_state text not null default 'active'
|
||||
check (lifecycle_state in ('active', 'suspended', 'retired')),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (owner_scope, name)
|
||||
);
|
||||
|
||||
create table if not exists device_discoveries (
|
||||
id uuid primary key,
|
||||
identifier_kind text not null,
|
||||
identifier_digest text not null,
|
||||
identifier_masked text not null,
|
||||
model_profile_ref text not null references device_model_profiles(profile_ref),
|
||||
protocol text not null,
|
||||
lifecycle_state text not null default 'quarantine'
|
||||
check (lifecycle_state in ('quarantine', 'claimed', 'rejected', 'expired')),
|
||||
first_observed_at timestamptz not null,
|
||||
last_observed_at timestamptz not null,
|
||||
evidence jsonb not null,
|
||||
claimed_device_id uuid,
|
||||
claimed_at timestamptz,
|
||||
claimed_by text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (identifier_kind, identifier_digest, model_profile_ref)
|
||||
);
|
||||
|
||||
create index if not exists device_discoveries_state_last_seen_idx
|
||||
on device_discoveries (lifecycle_state, last_observed_at desc);
|
||||
|
||||
create table if not exists device_instances (
|
||||
id uuid primary key,
|
||||
contour_id uuid not null references device_contours(id),
|
||||
model_profile_ref text not null references device_model_profiles(profile_ref),
|
||||
display_name text not null,
|
||||
identifier_kind text not null,
|
||||
identifier_digest text not null,
|
||||
identifier_masked text not null,
|
||||
credential_ref text,
|
||||
lifecycle_state text not null default 'claimed'
|
||||
check (lifecycle_state in ('claimed', 'online', 'offline', 'suspended', 'retired')),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (identifier_kind, identifier_digest, model_profile_ref)
|
||||
);
|
||||
|
||||
alter table device_discoveries
|
||||
drop constraint if exists device_discoveries_claimed_device_fk;
|
||||
|
||||
alter table device_discoveries
|
||||
add constraint device_discoveries_claimed_device_fk
|
||||
foreign key (claimed_device_id) references device_instances(id);
|
||||
|
||||
create table if not exists device_bindings (
|
||||
id uuid primary key,
|
||||
contour_id uuid not null references device_contours(id),
|
||||
target_kind text not null,
|
||||
target_ref text not null,
|
||||
capabilities text[] not null,
|
||||
lifecycle_state text not null default 'active'
|
||||
check (lifecycle_state in ('active', 'revoked')),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (contour_id, target_kind, target_ref)
|
||||
);
|
||||
|
||||
create table if not exists device_audit_events (
|
||||
id uuid primary key,
|
||||
event_type text not null,
|
||||
actor_ref text not null,
|
||||
contour_id uuid,
|
||||
device_id uuid,
|
||||
discovery_id uuid,
|
||||
payload jsonb not null,
|
||||
occurred_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists device_audit_events_device_time_idx
|
||||
on device_audit_events (device_id, occurred_at desc);
|
||||
|
||||
commit;
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "@nodedc/device-control-core",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.mjs",
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"pg": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
export async function resolveDeviceDatabaseUrl(
|
||||
environment = process.env,
|
||||
readSecret = readFile,
|
||||
) {
|
||||
const explicit = optionalValue(environment.DEVICE_DATABASE_URL);
|
||||
if (explicit) return explicit;
|
||||
|
||||
const host = restrictedValue(
|
||||
environment.DEVICE_DATABASE_HOST,
|
||||
/^[A-Za-z0-9.-]{1,253}$/,
|
||||
"device_database_host_invalid",
|
||||
);
|
||||
const port = parsePort(environment.DEVICE_DATABASE_PORT, 5432);
|
||||
const database = restrictedValue(
|
||||
environment.DEVICE_DATABASE_NAME,
|
||||
/^[A-Za-z_][A-Za-z0-9_-]{0,62}$/,
|
||||
"device_database_name_invalid",
|
||||
);
|
||||
const user = restrictedValue(
|
||||
environment.DEVICE_DATABASE_USER,
|
||||
/^[A-Za-z_][A-Za-z0-9_-]{0,62}$/,
|
||||
"device_database_user_invalid",
|
||||
);
|
||||
const passwordFile = requiredValue(
|
||||
environment.DEVICE_DATABASE_PASSWORD_FILE,
|
||||
"device_database_password_file_required",
|
||||
);
|
||||
const password = (await readSecret(passwordFile, "utf8")).trim();
|
||||
if (password.length < 32 || password.length > 512) {
|
||||
throw new Error("device_database_password_invalid");
|
||||
}
|
||||
|
||||
return [
|
||||
"postgresql://",
|
||||
encodeURIComponent(user),
|
||||
":",
|
||||
encodeURIComponent(password),
|
||||
"@",
|
||||
host,
|
||||
":",
|
||||
String(port),
|
||||
"/",
|
||||
encodeURIComponent(database),
|
||||
"?sslmode=disable",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function optionalValue(value) {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function requiredValue(value, errorCode) {
|
||||
const normalized = optionalValue(value);
|
||||
if (!normalized) throw new Error(errorCode);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function restrictedValue(value, pattern, errorCode) {
|
||||
const normalized = requiredValue(value, errorCode);
|
||||
if (!pattern.test(normalized)) throw new Error(errorCode);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parsePort(value, fallback) {
|
||||
const parsed = Number(value || fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
throw new Error("device_database_port_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import pg from "pg";
|
||||
|
||||
import { 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;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
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()));
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveDeviceDatabaseUrl } from "../src/database-config.mjs";
|
||||
|
||||
test("builds the database URL from a file-backed password", async () => {
|
||||
const password = "test-only-database-password-with-32-bytes";
|
||||
const url = await resolveDeviceDatabaseUrl(
|
||||
{
|
||||
DEVICE_DATABASE_HOST: "device-postgres",
|
||||
DEVICE_DATABASE_PORT: "5432",
|
||||
DEVICE_DATABASE_NAME: "device_plane",
|
||||
DEVICE_DATABASE_USER: "device_plane",
|
||||
DEVICE_DATABASE_PASSWORD_FILE: "/run/test/postgres-password",
|
||||
},
|
||||
async (path, encoding) => {
|
||||
assert.equal(path, "/run/test/postgres-password");
|
||||
assert.equal(encoding, "utf8");
|
||||
return `${password}\n`;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
url,
|
||||
`postgresql://device_plane:${encodeURIComponent(password)}@device-postgres:5432/device_plane?sslmode=disable`,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a short file-backed database password", async () => {
|
||||
await assert.rejects(
|
||||
resolveDeviceDatabaseUrl(
|
||||
{
|
||||
DEVICE_DATABASE_HOST: "device-postgres",
|
||||
DEVICE_DATABASE_NAME: "device_plane",
|
||||
DEVICE_DATABASE_USER: "device_plane",
|
||||
DEVICE_DATABASE_PASSWORD_FILE: "/run/test/postgres-password",
|
||||
},
|
||||
async () => "too-short",
|
||||
),
|
||||
/device_database_password_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps an explicit database URL as a compatibility-only boundary", async () => {
|
||||
const explicit = "postgresql://local:test@127.0.0.1:5432/device_plane";
|
||||
assert.equal(
|
||||
await resolveDeviceDatabaseUrl({ DEVICE_DATABASE_URL: explicit }),
|
||||
explicit,
|
||||
);
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const migrationUrl = new URL(
|
||||
"../migrations/001_device_plane_foundation.sql",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
test("foundation migration keeps restricted identifiers hashed and DB private", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
assert.match(sql, /identifier_digest text not null/);
|
||||
assert.match(sql, /identifier_masked text not null/);
|
||||
assert.doesNotMatch(sql, /imei\s+text/i);
|
||||
assert.doesNotMatch(sql, /password\s+text/i);
|
||||
assert.doesNotMatch(sql, /raw_packet/i);
|
||||
});
|
||||
|
||||
test("foundation migration has quarantine, contour, binding and audit tables", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
for (const table of [
|
||||
"device_model_profiles",
|
||||
"device_contours",
|
||||
"device_discoveries",
|
||||
"device_instances",
|
||||
"device_bindings",
|
||||
"device_audit_events",
|
||||
]) {
|
||||
assert.match(sql, new RegExp(`create table if not exists ${table}`));
|
||||
}
|
||||
assert.match(sql, /default 'quarantine'/);
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
FROM alpine:3.22
|
||||
|
||||
RUN apk add --no-cache openssh-client-default netcat-openbsd \
|
||||
&& addgroup -g 1000 edge-backhaul \
|
||||
&& adduser -D -H -u 1000 -G edge-backhaul -s /sbin/nologin edge-backhaul
|
||||
|
||||
USER 1000:1000
|
||||
|
||||
ENTRYPOINT ["/usr/bin/ssh"]
|
||||
@@ -1,34 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const devicePlane = resolve(here, "../../..");
|
||||
|
||||
test("edge backhaul is key-only, pinned and never host-published", async () => {
|
||||
const compose = await readFile(
|
||||
resolve(devicePlane, "docker-compose.device-edge.backhaul.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(devicePlane, "deployment/device-edge-backhaul-v1.json"),
|
||||
"utf8",
|
||||
));
|
||||
|
||||
assert.match(compose, /StrictHostKeyChecking=yes/);
|
||||
assert.match(compose, /UserKnownHostsFile=\/run\/trust\/known_hosts/);
|
||||
assert.match(compose, /PasswordAuthentication=no/);
|
||||
assert.match(compose, /KbdInteractiveAuthentication=no/);
|
||||
assert.match(compose, /ExitOnForwardFailure=yes/);
|
||||
assert.match(compose, /ProxyCommand=nc -X 5 -x nodedc-device-edge-tailnet-1:1055/);
|
||||
assert.match(compose, /\.\.\/secrets\/backhaul\/id_ed25519/);
|
||||
assert.doesNotMatch(compose, /\.\.\/keys\/edge-to-synology/);
|
||||
assert.match(compose, /0\.0\.0\.0:19921:127\.0\.0\.1:9921/);
|
||||
assert.doesNotMatch(compose, /^\s+ports:/m);
|
||||
assert.doesNotMatch(compose, /0\.0\.0\.0:9921/);
|
||||
assert.equal(descriptor.hostPortPublication, "disabled");
|
||||
assert.equal(descriptor.deviceIngress, "disabled");
|
||||
assert.equal(descriptor.commandTransport, "disabled");
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY services/device-edge-relay/src ./src
|
||||
|
||||
USER node
|
||||
|
||||
CMD ["node", "src/server.mjs"]
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "@nodedc/device-edge-relay",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.mjs",
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
import { createServer as createHttpServer } from "node:http";
|
||||
import { connect, createServer as createTcpServer } from "node:net";
|
||||
|
||||
export function createDeviceEdgeRelayRuntime(options = {}) {
|
||||
const config = normalizeConfig(options);
|
||||
const sessions = new Map();
|
||||
const sessionsByAddress = new Map();
|
||||
const connectionWindows = new Map();
|
||||
let totalAccepted = 0;
|
||||
let totalRejected = 0;
|
||||
let totalForwarded = 0;
|
||||
|
||||
const tcpServer = createTcpServer({ allowHalfOpen: true }, (socket) => {
|
||||
const remoteAddress = config.resolveRemoteAddress(socket.remoteAddress);
|
||||
if (
|
||||
!allowsSource(remoteAddress)
|
||||
|| sessions.size >= config.maxConcurrentSessions
|
||||
|| currentAddressSessions(remoteAddress) >= config.maxSessionsPerAddress
|
||||
|| !consumeConnectionPermit(remoteAddress)
|
||||
) {
|
||||
totalRejected += 1;
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const session = {
|
||||
remoteAddress,
|
||||
socket,
|
||||
upstream: null,
|
||||
closed: false,
|
||||
forwarded: false,
|
||||
inboundBytes: 0,
|
||||
outboundBytes: 0,
|
||||
};
|
||||
sessions.set(socket, session);
|
||||
incrementAddressSessions(remoteAddress);
|
||||
totalAccepted += 1;
|
||||
|
||||
socket.setNoDelay(true);
|
||||
socket.setTimeout(config.sessionTimeoutMs);
|
||||
socket.pause();
|
||||
socket.on("timeout", () => rejectSession(session));
|
||||
socket.on("close", () => closeSession(session));
|
||||
socket.on("error", () => rejectSession(session));
|
||||
socket.on("data", (chunk) => {
|
||||
session.inboundBytes += chunk.length;
|
||||
if (session.inboundBytes > config.maxBytesPerDirection) {
|
||||
rejectSession(session);
|
||||
}
|
||||
});
|
||||
|
||||
const upstream = connect({
|
||||
host: config.upstreamHost,
|
||||
port: config.upstreamPort,
|
||||
});
|
||||
session.upstream = upstream;
|
||||
upstream.setNoDelay(true);
|
||||
upstream.setTimeout(config.sessionTimeoutMs);
|
||||
upstream.on("connect", () => {
|
||||
if (session.closed) {
|
||||
upstream.destroy();
|
||||
return;
|
||||
}
|
||||
session.forwarded = true;
|
||||
totalForwarded += 1;
|
||||
socket.pipe(upstream);
|
||||
upstream.pipe(socket);
|
||||
socket.resume();
|
||||
});
|
||||
upstream.on("timeout", () => rejectSession(session));
|
||||
upstream.on("error", () => rejectSession(session));
|
||||
upstream.on("close", () => closeSession(session));
|
||||
upstream.on("data", (chunk) => {
|
||||
session.outboundBytes += chunk.length;
|
||||
if (session.outboundBytes > config.maxBytesPerDirection) {
|
||||
rejectSession(session);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const healthServer = createHttpServer((request, response) => {
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
if (request.method !== "GET" || request.url !== "/healthz") {
|
||||
response.statusCode = 404;
|
||||
response.end('{"ok":false,"error":"device_edge_relay_route_not_found"}\n');
|
||||
return;
|
||||
}
|
||||
response.statusCode = 200;
|
||||
response.end(`${JSON.stringify({
|
||||
ok: true,
|
||||
service: "nodedc-device-edge-relay",
|
||||
ingress: config.ingressEnabled ? "relay-only" : "disabled",
|
||||
protocolInspection: "disabled",
|
||||
commandTransport: "disabled",
|
||||
sourceAdmission: config.sourcePolicy,
|
||||
sessions: {
|
||||
active: sessions.size,
|
||||
accepted: totalAccepted,
|
||||
rejected: totalRejected,
|
||||
forwarded: totalForwarded,
|
||||
},
|
||||
})}\n`);
|
||||
});
|
||||
|
||||
return {
|
||||
async start() {
|
||||
await listen(healthServer, config.healthPort, config.healthHost);
|
||||
if (config.ingressEnabled) {
|
||||
await listen(tcpServer, config.tcpPort, config.tcpHost);
|
||||
}
|
||||
return {
|
||||
healthAddress: healthServer.address(),
|
||||
tcpAddress: config.ingressEnabled ? tcpServer.address() : null,
|
||||
};
|
||||
},
|
||||
async stop() {
|
||||
for (const session of sessions.values()) rejectSession(session);
|
||||
await Promise.all([
|
||||
closeServer(healthServer),
|
||||
config.ingressEnabled ? closeServer(tcpServer) : Promise.resolve(),
|
||||
]);
|
||||
},
|
||||
status() {
|
||||
return {
|
||||
activeSessions: sessions.size,
|
||||
totalAccepted,
|
||||
totalRejected,
|
||||
totalForwarded,
|
||||
ingress: config.ingressEnabled ? "relay-only" : "disabled",
|
||||
protocolInspection: "disabled",
|
||||
commandTransport: "disabled",
|
||||
sourceAdmission: config.sourcePolicy,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function currentAddressSessions(remoteAddress) {
|
||||
return sessionsByAddress.get(remoteAddress) || 0;
|
||||
}
|
||||
|
||||
function incrementAddressSessions(remoteAddress) {
|
||||
sessionsByAddress.set(
|
||||
remoteAddress,
|
||||
currentAddressSessions(remoteAddress) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
function decrementAddressSessions(remoteAddress) {
|
||||
const current = currentAddressSessions(remoteAddress);
|
||||
if (current <= 1) {
|
||||
sessionsByAddress.delete(remoteAddress);
|
||||
} else {
|
||||
sessionsByAddress.set(remoteAddress, current - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function consumeConnectionPermit(remoteAddress) {
|
||||
const nowMs = config.now().getTime();
|
||||
for (const [address, window] of connectionWindows) {
|
||||
if (nowMs - window.startedAt >= 60_000) {
|
||||
connectionWindows.delete(address);
|
||||
}
|
||||
}
|
||||
const current = connectionWindows.get(remoteAddress);
|
||||
if (!current || nowMs - current.startedAt >= 60_000) {
|
||||
if (connectionWindows.size >= config.maxTrackedSourceAddresses) {
|
||||
return false;
|
||||
}
|
||||
connectionWindows.set(remoteAddress, { startedAt: nowMs, count: 1 });
|
||||
return true;
|
||||
}
|
||||
if (current.count >= config.maxConnectionsPerMinutePerAddress) return false;
|
||||
current.count += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function allowsSource(remoteAddress) {
|
||||
if (config.sourcePolicy === "any") return true;
|
||||
return isPublicIpv4Address(remoteAddress);
|
||||
}
|
||||
|
||||
function rejectSession(session) {
|
||||
if (!session.closed) totalRejected += 1;
|
||||
session.socket.destroy();
|
||||
session.upstream?.destroy();
|
||||
closeSession(session);
|
||||
}
|
||||
|
||||
function closeSession(session) {
|
||||
if (session.closed) return;
|
||||
session.closed = true;
|
||||
sessions.delete(session.socket);
|
||||
decrementAddressSessions(session.remoteAddress);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfig(input) {
|
||||
const ingressEnabled = input.ingressEnabled === true;
|
||||
return {
|
||||
ingressEnabled,
|
||||
healthHost: normalizeHost(input.healthHost, "127.0.0.1"),
|
||||
healthPort: parseInteger(
|
||||
input.healthPort,
|
||||
18221,
|
||||
0,
|
||||
65535,
|
||||
"device_edge_relay_health_port_invalid",
|
||||
),
|
||||
tcpHost: normalizeTcpHost(input.tcpHost, ingressEnabled),
|
||||
tcpPort: parseInteger(
|
||||
input.tcpPort,
|
||||
9921,
|
||||
0,
|
||||
65535,
|
||||
"device_edge_relay_tcp_port_invalid",
|
||||
),
|
||||
upstreamHost: ingressEnabled
|
||||
? normalizeUpstreamHost(input.upstreamHost)
|
||||
: "disabled",
|
||||
upstreamPort: ingressEnabled
|
||||
? parseInteger(
|
||||
input.upstreamPort,
|
||||
undefined,
|
||||
1,
|
||||
65535,
|
||||
"device_edge_relay_upstream_port_invalid",
|
||||
)
|
||||
: 0,
|
||||
maxConcurrentSessions: parseInteger(
|
||||
input.maxConcurrentSessions,
|
||||
100,
|
||||
1,
|
||||
10000,
|
||||
"device_edge_relay_session_limit_invalid",
|
||||
),
|
||||
maxSessionsPerAddress: parseInteger(
|
||||
input.maxSessionsPerAddress,
|
||||
10,
|
||||
1,
|
||||
1000,
|
||||
"device_edge_relay_address_session_limit_invalid",
|
||||
),
|
||||
maxConnectionsPerMinutePerAddress: parseInteger(
|
||||
input.maxConnectionsPerMinutePerAddress,
|
||||
30,
|
||||
1,
|
||||
10000,
|
||||
"device_edge_relay_connection_rate_invalid",
|
||||
),
|
||||
maxTrackedSourceAddresses: parseInteger(
|
||||
input.maxTrackedSourceAddresses,
|
||||
2048,
|
||||
1,
|
||||
65_536,
|
||||
"device_edge_relay_source_table_limit_invalid",
|
||||
),
|
||||
maxBytesPerDirection: parseInteger(
|
||||
input.maxBytesPerDirection,
|
||||
262_144,
|
||||
1_024,
|
||||
16 * 1024 * 1024,
|
||||
"device_edge_relay_byte_limit_invalid",
|
||||
),
|
||||
sourcePolicy: normalizeSourcePolicy(input.sourcePolicy, ingressEnabled),
|
||||
resolveRemoteAddress: typeof input.resolveRemoteAddress === "function"
|
||||
? input.resolveRemoteAddress
|
||||
: normalizeRemoteAddress,
|
||||
sessionTimeoutMs: parseInteger(
|
||||
input.sessionTimeoutMs,
|
||||
10000,
|
||||
100,
|
||||
60000,
|
||||
"device_edge_relay_session_timeout_invalid",
|
||||
),
|
||||
now: typeof input.now === "function" ? input.now : () => new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHost(value, fallback) {
|
||||
const normalized = String(value || fallback).trim();
|
||||
if (!["127.0.0.1", "::1", "0.0.0.0", "::"].includes(normalized)) {
|
||||
throw new TypeError("device_edge_relay_health_host_invalid");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeTcpHost(value, ingressEnabled) {
|
||||
const fallback = ingressEnabled ? "0.0.0.0" : "127.0.0.1";
|
||||
const normalized = String(value || fallback).trim();
|
||||
const allowed = ingressEnabled ? ["0.0.0.0", "::"] : ["127.0.0.1", "::1"];
|
||||
if (!allowed.includes(normalized)) {
|
||||
throw new TypeError(
|
||||
ingressEnabled
|
||||
? "device_edge_relay_public_ingress_host_invalid"
|
||||
: "device_edge_relay_baseline_loopback_only",
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeUpstreamHost(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (
|
||||
normalized.length === 0
|
||||
|| normalized.length > 253
|
||||
|| /[/:\\s]/.test(normalized)
|
||||
) {
|
||||
throw new TypeError("device_edge_relay_upstream_host_invalid");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeRemoteAddress(value) {
|
||||
const normalized = String(value || "unknown").trim();
|
||||
return normalized.slice(0, 64) || "unknown";
|
||||
}
|
||||
|
||||
function normalizeSourcePolicy(value, ingressEnabled) {
|
||||
const fallback = ingressEnabled ? "public-ipv4-only" : "any";
|
||||
const normalized = String(value || fallback).trim().toLowerCase();
|
||||
if (!["any", "public-ipv4-only"].includes(normalized)) {
|
||||
throw new TypeError("device_edge_relay_source_policy_invalid");
|
||||
}
|
||||
if (ingressEnabled && normalized !== "public-ipv4-only") {
|
||||
throw new TypeError("device_edge_relay_ingress_source_policy_invalid");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isPublicIpv4Address(value) {
|
||||
const normalized = String(value || "").trim().replace(/^::ffff:/i, "");
|
||||
const parts = normalized.split(".");
|
||||
if (parts.length !== 4) return false;
|
||||
const octets = parts.map((part) => Number(part));
|
||||
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return false;
|
||||
}
|
||||
const [first, second, third] = octets;
|
||||
if (
|
||||
first === 0
|
||||
|| first === 10
|
||||
|| first === 127
|
||||
|| first >= 224
|
||||
|| (first === 100 && second >= 64 && second <= 127)
|
||||
|| (first === 169 && second === 254)
|
||||
|| (first === 172 && second >= 16 && second <= 31)
|
||||
|| (first === 192 && second === 0 && third === 0)
|
||||
|| (first === 192 && second === 0 && third === 2)
|
||||
|| (first === 192 && second === 88 && third === 99)
|
||||
|| (first === 192 && second === 168)
|
||||
|| (first === 198 && (second === 18 || second === 19))
|
||||
|| (first === 198 && second === 51 && third === 100)
|
||||
|| (first === 203 && second === 0 && third === 113)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseInteger(value, fallback, minimum, maximum, errorCode) {
|
||||
const parsed = Number(value ?? fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function listen(server, port, host) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { createDeviceEdgeRelayRuntime } from "./runtime.mjs";
|
||||
|
||||
const runtime = createDeviceEdgeRelayRuntime({
|
||||
ingressEnabled: parseBoolean(
|
||||
process.env.DEVICE_EDGE_RELAY_INGRESS_ENABLED,
|
||||
false,
|
||||
),
|
||||
healthHost: process.env.DEVICE_EDGE_RELAY_HEALTH_HOST || "127.0.0.1",
|
||||
healthPort: parsePort(process.env.DEVICE_EDGE_RELAY_HEALTH_PORT, 18221),
|
||||
tcpHost: process.env.DEVICE_EDGE_RELAY_TCP_HOST,
|
||||
tcpPort: parsePort(process.env.DEVICE_EDGE_RELAY_TCP_PORT, 9921),
|
||||
upstreamHost: process.env.DEVICE_EDGE_RELAY_UPSTREAM_HOST,
|
||||
upstreamPort: parsePort(
|
||||
process.env.DEVICE_EDGE_RELAY_UPSTREAM_PORT,
|
||||
undefined,
|
||||
),
|
||||
maxConcurrentSessions: parsePositiveInt(
|
||||
process.env.DEVICE_EDGE_RELAY_MAX_SESSIONS,
|
||||
100,
|
||||
),
|
||||
maxSessionsPerAddress: parsePositiveInt(
|
||||
process.env.DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS,
|
||||
10,
|
||||
),
|
||||
maxConnectionsPerMinutePerAddress: parsePositiveInt(
|
||||
process.env.DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS,
|
||||
30,
|
||||
),
|
||||
maxTrackedSourceAddresses: parsePositiveInt(
|
||||
process.env.DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES,
|
||||
2048,
|
||||
),
|
||||
maxBytesPerDirection: parsePositiveInt(
|
||||
process.env.DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION,
|
||||
262_144,
|
||||
),
|
||||
sourcePolicy: process.env.DEVICE_EDGE_RELAY_SOURCE_POLICY,
|
||||
sessionTimeoutMs: parsePositiveInt(
|
||||
process.env.DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS,
|
||||
10000,
|
||||
),
|
||||
});
|
||||
|
||||
const addresses = await runtime.start();
|
||||
console.log(JSON.stringify({
|
||||
event: "device_edge_relay_started",
|
||||
health: addresses.healthAddress,
|
||||
tcp: addresses.tcpAddress,
|
||||
ingress: runtime.status().ingress,
|
||||
protocolInspection: "disabled",
|
||||
commandTransport: "disabled",
|
||||
}));
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
async function shutdown() {
|
||||
await runtime.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function parsePort(value, fallback) {
|
||||
if (value === undefined && fallback === undefined) return undefined;
|
||||
const parsed = Number(value ?? fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
throw new Error("device_edge_relay_port_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value, fallback) {
|
||||
const parsed = Number(value || fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new Error("device_edge_relay_positive_integer_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseBoolean(value, fallback) {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
||||
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
||||
throw new Error("device_edge_relay_boolean_invalid");
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const devicePlaneRoot = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../..",
|
||||
);
|
||||
|
||||
test("single-NIC ingress source has no host publication and a fixed ipvlan", async () => {
|
||||
const baseline = await readFile(
|
||||
resolve(devicePlaneRoot, "docker-compose.device-edge.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const ingress = await readFile(
|
||||
resolve(devicePlaneRoot, "docker-compose.device-edge.ingress.yml"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(
|
||||
baseline,
|
||||
/DEVICE_EDGE_RELAY_HEALTH_HOST: 127\.0\.0\.1/,
|
||||
);
|
||||
assert.doesNotMatch(baseline, /^\s+ports:/m);
|
||||
assert.doesNotMatch(baseline, /device-edge-control/);
|
||||
|
||||
for (const required of [
|
||||
'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"',
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul",
|
||||
'DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"',
|
||||
"name: nodedc-device-edge-ingress",
|
||||
"driver: ipvlan",
|
||||
"parent: enp1s0f0",
|
||||
"ipvlan_mode: l2",
|
||||
"ipv4_address: 192.168.71.253",
|
||||
"subnet: 192.168.68.0/22",
|
||||
"gateway: 192.168.68.1",
|
||||
"gw_priority: 100",
|
||||
"DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only",
|
||||
'DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"',
|
||||
'DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"',
|
||||
]) {
|
||||
assert.ok(ingress.includes(required), `missing ingress boundary: ${required}`);
|
||||
}
|
||||
|
||||
for (const forbidden of [
|
||||
"ports:",
|
||||
"network_mode: host",
|
||||
"privileged: true",
|
||||
"DEVICE_EDGE_RELAY_COMMAND",
|
||||
"0.0.0.0:9921:9921",
|
||||
]) {
|
||||
assert.ok(
|
||||
!ingress.includes(forbidden),
|
||||
`forbidden ingress boundary: ${forbidden}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("ingress descriptor keeps address approval and router exposure gated", async () => {
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
devicePlaneRoot,
|
||||
"deployment/device-edge-ingress-ipvlan-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
|
||||
assert.equal(descriptor.component, "device-edge");
|
||||
assert.deepEqual(descriptor.selectedServices, ["device-edge-relay"]);
|
||||
assert.deepEqual(
|
||||
descriptor.preservedServices,
|
||||
["device-edge-backhaul", "tailnet"],
|
||||
);
|
||||
assert.equal(descriptor.ingressIpv4Approval, "approved-outside-dhcp-pool");
|
||||
assert.equal(descriptor.hostPortPublication, "disabled");
|
||||
assert.equal(descriptor.healthPublication, "disabled");
|
||||
assert.equal(descriptor.privateUpstream, "device-edge-backhaul:19921");
|
||||
assert.equal(descriptor.protocolInspection, "gateway-owned");
|
||||
assert.equal(descriptor.discoveryLifecycle, "quarantine");
|
||||
assert.equal(descriptor.commandTransport, "disabled");
|
||||
assert.equal(descriptor.gelios, "untouched");
|
||||
assert.equal(descriptor.amneziaHostFullTunnel, "preserved");
|
||||
assert.equal(descriptor.routerNatFirewall, "separate-manual-gate");
|
||||
});
|
||||
|
||||
test("admission-gate descriptor pins the fail-closed relay boundary", async () => {
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
devicePlaneRoot,
|
||||
"deployment/device-edge-admission-gate-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
|
||||
assert.equal(
|
||||
descriptor.schemaVersion,
|
||||
"nodedc.device-edge.admission-gate.v1",
|
||||
);
|
||||
assert.equal(descriptor.sourceAdmission, "public-ipv4-only");
|
||||
assert.equal(descriptor.maxTrackedSourceAddresses, 2048);
|
||||
assert.equal(descriptor.maxBytesPerDirection, 262144);
|
||||
assert.equal(descriptor.hostPortPublication, "disabled");
|
||||
assert.equal(descriptor.healthPublication, "disabled");
|
||||
assert.equal(descriptor.commandTransport, "disabled");
|
||||
assert.equal(descriptor.gelios, "untouched");
|
||||
assert.equal(descriptor.routerNatFirewall, "separate-manual-gate");
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer, connect } from "node:net";
|
||||
import test from "node:test";
|
||||
|
||||
import { createDeviceEdgeRelayRuntime } from "../src/runtime.mjs";
|
||||
|
||||
test("baseline starts only loopback health and no device TCP listener", async () => {
|
||||
const runtime = createDeviceEdgeRelayRuntime({ healthPort: 0 });
|
||||
const addresses = await runtime.start();
|
||||
try {
|
||||
assert.equal(addresses.tcpAddress, null);
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${addresses.healthAddress.port}/healthz`,
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
assert.equal(body.ingress, "disabled");
|
||||
assert.equal(body.protocolInspection, "disabled");
|
||||
assert.equal(body.commandTransport, "disabled");
|
||||
} finally {
|
||||
await runtime.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("relay is transparent and never emits its own protocol bytes", async () => {
|
||||
const upstream = await startEchoServer();
|
||||
const runtime = createDeviceEdgeRelayRuntime({
|
||||
healthPort: 0,
|
||||
ingressEnabled: true,
|
||||
tcpHost: "0.0.0.0",
|
||||
tcpPort: 0,
|
||||
upstreamHost: "127.0.0.1",
|
||||
upstreamPort: upstream.port,
|
||||
resolveRemoteAddress: () => "8.8.8.8",
|
||||
});
|
||||
const addresses = await runtime.start();
|
||||
try {
|
||||
const response = await sendAndCollect(
|
||||
addresses.tcpAddress.port,
|
||||
Buffer.from("ff230102030405060708", "hex"),
|
||||
);
|
||||
assert.equal(response.toString("hex"), "ff230102030405060708");
|
||||
assert.equal(runtime.status().totalForwarded, 1);
|
||||
assert.equal(runtime.status().commandTransport, "disabled");
|
||||
} finally {
|
||||
await runtime.stop();
|
||||
await closeServer(upstream.server);
|
||||
}
|
||||
});
|
||||
|
||||
test("enabled ingress rejects a non-public source before opening upstream", async () => {
|
||||
const upstream = await startEchoServer();
|
||||
const runtime = createDeviceEdgeRelayRuntime({
|
||||
healthPort: 0,
|
||||
ingressEnabled: true,
|
||||
tcpHost: "0.0.0.0",
|
||||
tcpPort: 0,
|
||||
upstreamHost: "127.0.0.1",
|
||||
upstreamPort: upstream.port,
|
||||
resolveRemoteAddress: () => "127.0.0.1",
|
||||
});
|
||||
const addresses = await runtime.start();
|
||||
try {
|
||||
const response = await sendAndCollect(
|
||||
addresses.tcpAddress.port,
|
||||
Buffer.from("denied"),
|
||||
);
|
||||
assert.equal(response.length, 0);
|
||||
assert.equal(runtime.status().totalAccepted, 0);
|
||||
assert.equal(runtime.status().totalForwarded, 0);
|
||||
assert.equal(runtime.status().sourceAdmission, "public-ipv4-only");
|
||||
} finally {
|
||||
await runtime.stop();
|
||||
await closeServer(upstream.server);
|
||||
}
|
||||
});
|
||||
|
||||
test("relay terminates a byte stream that exceeds its per-direction budget", async () => {
|
||||
const upstream = await startEchoServer();
|
||||
const runtime = createDeviceEdgeRelayRuntime({
|
||||
healthPort: 0,
|
||||
ingressEnabled: true,
|
||||
tcpHost: "0.0.0.0",
|
||||
tcpPort: 0,
|
||||
upstreamHost: "127.0.0.1",
|
||||
upstreamPort: upstream.port,
|
||||
resolveRemoteAddress: () => "8.8.8.8",
|
||||
maxBytesPerDirection: 1024,
|
||||
});
|
||||
const addresses = await runtime.start();
|
||||
try {
|
||||
const response = await sendAndCollect(
|
||||
addresses.tcpAddress.port,
|
||||
Buffer.alloc(1025, 0x5d),
|
||||
);
|
||||
assert.ok(response.length <= 1024);
|
||||
assert.equal(runtime.status().totalForwarded, 1);
|
||||
assert.ok(runtime.status().totalRejected >= 1);
|
||||
} finally {
|
||||
await runtime.stop();
|
||||
await closeServer(upstream.server);
|
||||
}
|
||||
});
|
||||
|
||||
test("production ingress cannot opt out of public IPv4 admission", () => {
|
||||
assert.throws(
|
||||
() => createDeviceEdgeRelayRuntime({
|
||||
ingressEnabled: true,
|
||||
tcpHost: "0.0.0.0",
|
||||
upstreamHost: "device-edge-backhaul",
|
||||
upstreamPort: 19921,
|
||||
sourcePolicy: "any",
|
||||
}),
|
||||
/device_edge_relay_ingress_source_policy_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("enabled relay requires a concrete private upstream", () => {
|
||||
assert.throws(
|
||||
() => createDeviceEdgeRelayRuntime({
|
||||
ingressEnabled: true,
|
||||
tcpHost: "0.0.0.0",
|
||||
upstreamPort: 19921,
|
||||
}),
|
||||
/device_edge_relay_upstream_host_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("baseline rejects a non-loopback device binding", () => {
|
||||
assert.throws(
|
||||
() => createDeviceEdgeRelayRuntime({ tcpHost: "0.0.0.0" }),
|
||||
/device_edge_relay_baseline_loopback_only/,
|
||||
);
|
||||
});
|
||||
|
||||
function startEchoServer() {
|
||||
const server = createServer((socket) => socket.pipe(socket));
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve({ server, port: server.address().port });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendAndCollect(port, payload) {
|
||||
return new Promise((resolve) => {
|
||||
const chunks = [];
|
||||
const socket = connect({ host: "127.0.0.1", port }, () => {
|
||||
socket.end(payload);
|
||||
});
|
||||
socket.on("data", (chunk) => chunks.push(chunk));
|
||||
socket.on("close", () => resolve(Buffer.concat(chunks)));
|
||||
socket.on("error", () => {});
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/device-protocol-contract ./packages/device-protocol-contract
|
||||
COPY packages/arusnavi-b2-adapter ./packages/arusnavi-b2-adapter
|
||||
COPY services/device-gateway ./services/device-gateway
|
||||
COPY services/device-control-core/package.json ./services/device-control-core/package.json
|
||||
COPY services/device-edge-relay/package.json ./services/device-edge-relay/package.json
|
||||
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
|
||||
USER node
|
||||
|
||||
CMD ["node", "services/device-gateway/src/server.mjs"]
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "@nodedc/device-gateway",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.mjs",
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
export function createCoreDiscoveryClient({
|
||||
coreUrl,
|
||||
gatewayToken,
|
||||
timeoutMs = 5000,
|
||||
fetchImpl = fetch,
|
||||
} = {}) {
|
||||
const endpoint = normalizeCoreEndpoint(coreUrl);
|
||||
if (typeof gatewayToken !== "string" || gatewayToken.length < 32) {
|
||||
throw new TypeError("device_gateway_core_token_invalid");
|
||||
}
|
||||
const normalizedTimeout = Number(timeoutMs);
|
||||
if (
|
||||
!Number.isSafeInteger(normalizedTimeout)
|
||||
|| normalizedTimeout < 100
|
||||
|| normalizedTimeout > 30_000
|
||||
) {
|
||||
throw new TypeError("device_gateway_core_timeout_invalid");
|
||||
}
|
||||
if (typeof fetchImpl !== "function") {
|
||||
throw new TypeError("device_gateway_core_fetch_invalid");
|
||||
}
|
||||
|
||||
return async function observeDiscovery(signal) {
|
||||
const response = await fetchImpl(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${gatewayToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(signal),
|
||||
signal: AbortSignal.timeout(normalizedTimeout),
|
||||
});
|
||||
const body = await readBoundedJson(response, 32 * 1024);
|
||||
if (!response.ok || body?.ok !== true) {
|
||||
throw new Error("device_gateway_core_ingest_failed");
|
||||
}
|
||||
if (
|
||||
!body.discovery
|
||||
|| body.discovery.lifecycleState !== "quarantine"
|
||||
|| body.discovery.commandTransport !== "disabled"
|
||||
) {
|
||||
throw new Error("device_gateway_core_ingest_contract_invalid");
|
||||
}
|
||||
return body.discovery;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCoreEndpoint(value) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(value || ""));
|
||||
} catch {
|
||||
throw new TypeError("device_gateway_core_url_invalid");
|
||||
}
|
||||
if (url.protocol !== "http:" || url.username || url.password) {
|
||||
throw new TypeError("device_gateway_core_url_invalid");
|
||||
}
|
||||
if (url.pathname !== "/" || url.search || url.hash) {
|
||||
throw new TypeError("device_gateway_core_url_invalid");
|
||||
}
|
||||
url.pathname = "/internal/v1/device-discoveries:observe";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function readBoundedJson(response, maxBytes) {
|
||||
const text = await response.text();
|
||||
if (Buffer.byteLength(text, "utf8") > maxBytes) {
|
||||
throw new Error("device_gateway_core_response_too_large");
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("device_gateway_core_response_invalid");
|
||||
}
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer as createHttpServer } from "node:http";
|
||||
import { createServer as createTcpServer } from "node:net";
|
||||
|
||||
import {
|
||||
ARUSNAVI_B2_MODEL_PROFILE,
|
||||
buildB2HeaderAcknowledgement,
|
||||
buildB2PackageAcknowledgement,
|
||||
tryParseB2Header2,
|
||||
tryParseB2Package,
|
||||
} from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
|
||||
import {
|
||||
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
|
||||
export function createDeviceGatewayRuntime(options = {}) {
|
||||
const config = normalizeConfig(options);
|
||||
const sessions = new Map();
|
||||
const sessionsByAddress = new Map();
|
||||
const connectionWindows = new Map();
|
||||
let totalAccepted = 0;
|
||||
let totalRejected = 0;
|
||||
let totalDiscoveries = 0;
|
||||
let totalPackagesAcknowledged = 0;
|
||||
|
||||
const tcpServer = createTcpServer((socket) => {
|
||||
const remoteAddress = normalizeRemoteAddress(socket.remoteAddress);
|
||||
if (
|
||||
sessions.size >= config.maxConcurrentSessions
|
||||
|| currentAddressSessions(remoteAddress) >= config.maxSessionsPerAddress
|
||||
|| !consumeConnectionPermit(remoteAddress)
|
||||
) {
|
||||
totalRejected += 1;
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionRef = `session:${randomUUID()}`;
|
||||
const session = {
|
||||
sessionRef,
|
||||
remoteAddress,
|
||||
buffer: Buffer.alloc(0),
|
||||
state: "awaiting-header",
|
||||
processing: false,
|
||||
rejected: false,
|
||||
closed: false,
|
||||
};
|
||||
sessions.set(socket, session);
|
||||
incrementAddressSessions(remoteAddress);
|
||||
totalAccepted += 1;
|
||||
socket.setNoDelay(true);
|
||||
socket.setTimeout(config.sessionTimeoutMs);
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
if (session.closed || session.rejected) return;
|
||||
socket.pause();
|
||||
session.buffer = Buffer.concat(
|
||||
[session.buffer, chunk],
|
||||
session.buffer.length + chunk.length,
|
||||
);
|
||||
if (session.buffer.length > config.maxBufferedBytes) {
|
||||
rejectSession(socket, session);
|
||||
return;
|
||||
}
|
||||
if (session.processing) return;
|
||||
session.processing = true;
|
||||
void processSession(socket, session)
|
||||
.catch(() => rejectSession(socket, session))
|
||||
.finally(() => {
|
||||
session.processing = false;
|
||||
if (!session.closed && !session.rejected) socket.resume();
|
||||
});
|
||||
});
|
||||
socket.on("timeout", () => rejectSession(socket, session));
|
||||
socket.on("close", () => closeSession(socket, session));
|
||||
socket.on("error", () => closeSession(socket, session));
|
||||
});
|
||||
|
||||
const healthServer = createHttpServer((request, response) => {
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
if (request.method !== "GET" || request.url !== "/healthz") {
|
||||
response.statusCode = 404;
|
||||
return response.end('{"ok":false,"error":"device_gateway_route_not_found"}\n');
|
||||
}
|
||||
response.statusCode = 200;
|
||||
return response.end(`${JSON.stringify({
|
||||
ok: true,
|
||||
service: "nodedc-device-gateway",
|
||||
protocolProfile: ARUSNAVI_B2_MODEL_PROFILE.profileRef,
|
||||
framing: ARUSNAVI_B2_MODEL_PROFILE.framing.status,
|
||||
tcpListener: config.listenEnabled ? "discovery-only" : "disabled",
|
||||
publicIngress: config.publicIngressEnabled
|
||||
? "discovery-only"
|
||||
: "disabled",
|
||||
commandTransport: "disabled",
|
||||
sessions: {
|
||||
active: sessions.size,
|
||||
accepted: totalAccepted,
|
||||
rejected: totalRejected,
|
||||
discoveries: totalDiscoveries,
|
||||
packagesAcknowledged: totalPackagesAcknowledged,
|
||||
},
|
||||
})}\n`);
|
||||
});
|
||||
|
||||
return {
|
||||
async start() {
|
||||
await listen(healthServer, config.healthPort, config.healthHost);
|
||||
if (config.listenEnabled) {
|
||||
await listen(tcpServer, config.tcpPort, config.tcpHost);
|
||||
}
|
||||
return {
|
||||
healthAddress: healthServer.address(),
|
||||
tcpAddress: config.listenEnabled ? tcpServer.address() : null,
|
||||
};
|
||||
},
|
||||
async stop() {
|
||||
for (const [socket, session] of sessions) {
|
||||
session.closed = true;
|
||||
socket.destroy();
|
||||
}
|
||||
await Promise.all([
|
||||
closeServer(healthServer),
|
||||
config.listenEnabled ? closeServer(tcpServer) : Promise.resolve(),
|
||||
]);
|
||||
},
|
||||
status() {
|
||||
return {
|
||||
activeSessions: sessions.size,
|
||||
totalAccepted,
|
||||
totalRejected,
|
||||
totalDiscoveries,
|
||||
totalPackagesAcknowledged,
|
||||
commandTransport: "disabled",
|
||||
publicIngress: config.publicIngressEnabled
|
||||
? "discovery-only"
|
||||
: "disabled",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
async function processSession(socket, session) {
|
||||
while (!session.closed && !session.rejected) {
|
||||
if (session.state === "awaiting-header") {
|
||||
const parsed = tryParseB2Header2(session.buffer);
|
||||
if (parsed.status === "incomplete") return;
|
||||
|
||||
const observedAt = config.now().toISOString();
|
||||
await config.onDiscovery?.({
|
||||
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
sessionRef: session.sessionRef,
|
||||
modelProfileRef: ARUSNAVI_B2_MODEL_PROFILE.profileRef,
|
||||
protocol: ARUSNAVI_B2_MODEL_PROFILE.protocol,
|
||||
observedAt,
|
||||
identifier: {
|
||||
kind: parsed.identifier.kind,
|
||||
value: parsed.identifier.value,
|
||||
},
|
||||
evidence: parsed.evidence,
|
||||
});
|
||||
session.buffer = session.buffer.subarray(parsed.bytesConsumed);
|
||||
session.state = "packages";
|
||||
totalDiscoveries += 1;
|
||||
socket.write(buildB2HeaderAcknowledgement(
|
||||
Math.floor(new Date(observedAt).getTime() / 1000),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = tryParseB2Package(session.buffer);
|
||||
if (parsed.status === "incomplete") return;
|
||||
session.buffer = session.buffer.subarray(parsed.bytesConsumed);
|
||||
totalPackagesAcknowledged += 1;
|
||||
socket.write(buildB2PackageAcknowledgement(parsed.packageNumber));
|
||||
}
|
||||
}
|
||||
|
||||
function currentAddressSessions(remoteAddress) {
|
||||
return sessionsByAddress.get(remoteAddress) || 0;
|
||||
}
|
||||
|
||||
function incrementAddressSessions(remoteAddress) {
|
||||
sessionsByAddress.set(
|
||||
remoteAddress,
|
||||
currentAddressSessions(remoteAddress) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
function decrementAddressSessions(remoteAddress) {
|
||||
const current = currentAddressSessions(remoteAddress);
|
||||
if (current <= 1) {
|
||||
sessionsByAddress.delete(remoteAddress);
|
||||
} else {
|
||||
sessionsByAddress.set(remoteAddress, current - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function consumeConnectionPermit(remoteAddress) {
|
||||
const nowMs = config.now().getTime();
|
||||
const current = connectionWindows.get(remoteAddress);
|
||||
if (!current || nowMs - current.startedAt >= 60_000) {
|
||||
connectionWindows.set(remoteAddress, {
|
||||
startedAt: nowMs,
|
||||
count: 1,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (current.count >= config.maxConnectionsPerMinutePerAddress) {
|
||||
return false;
|
||||
}
|
||||
current.count += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function rejectSession(socket, session) {
|
||||
if (!session.rejected) {
|
||||
session.rejected = true;
|
||||
totalRejected += 1;
|
||||
}
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
function closeSession(socket, session) {
|
||||
if (session.closed) return;
|
||||
session.closed = true;
|
||||
sessions.delete(socket);
|
||||
decrementAddressSessions(session.remoteAddress);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfig(input) {
|
||||
const listenEnabled = input.listenEnabled === true;
|
||||
const publicIngressEnabled = input.publicIngressEnabled === true;
|
||||
if (publicIngressEnabled && !listenEnabled) {
|
||||
throw new TypeError("device_gateway_public_ingress_listener_required");
|
||||
}
|
||||
if (
|
||||
publicIngressEnabled
|
||||
&& typeof input.onDiscovery !== "function"
|
||||
) {
|
||||
throw new TypeError("device_gateway_discovery_sink_required");
|
||||
}
|
||||
return {
|
||||
listenEnabled,
|
||||
publicIngressEnabled,
|
||||
healthHost: normalizeHealthHost(input.healthHost, "127.0.0.1"),
|
||||
healthPort: parseInteger(
|
||||
input.healthPort,
|
||||
18121,
|
||||
0,
|
||||
65535,
|
||||
"device_gateway_health_port_invalid",
|
||||
),
|
||||
tcpHost: normalizeTcpHost(
|
||||
input.tcpHost,
|
||||
publicIngressEnabled ? "0.0.0.0" : "127.0.0.1",
|
||||
publicIngressEnabled,
|
||||
),
|
||||
tcpPort: parseInteger(
|
||||
input.tcpPort,
|
||||
9921,
|
||||
0,
|
||||
65535,
|
||||
"device_gateway_tcp_port_invalid",
|
||||
),
|
||||
maxBufferedBytes: parseInteger(
|
||||
input.maxBufferedBytes,
|
||||
ARUSNAVI_B2_MODEL_PROFILE.framing.maxBufferedBytes,
|
||||
1024,
|
||||
ARUSNAVI_B2_MODEL_PROFILE.framing.maxBufferedBytes,
|
||||
"device_gateway_buffer_limit_invalid",
|
||||
),
|
||||
maxConcurrentSessions: parseInteger(
|
||||
input.maxConcurrentSessions,
|
||||
100,
|
||||
1,
|
||||
10000,
|
||||
"device_gateway_session_limit_invalid",
|
||||
),
|
||||
maxSessionsPerAddress: parseInteger(
|
||||
input.maxSessionsPerAddress,
|
||||
10,
|
||||
1,
|
||||
1000,
|
||||
"device_gateway_address_session_limit_invalid",
|
||||
),
|
||||
maxConnectionsPerMinutePerAddress: parseInteger(
|
||||
input.maxConnectionsPerMinutePerAddress,
|
||||
30,
|
||||
1,
|
||||
10000,
|
||||
"device_gateway_address_rate_limit_invalid",
|
||||
),
|
||||
sessionTimeoutMs: parseInteger(
|
||||
input.sessionTimeoutMs,
|
||||
10000,
|
||||
100,
|
||||
60000,
|
||||
"device_gateway_session_timeout_invalid",
|
||||
),
|
||||
onDiscovery: typeof input.onDiscovery === "function"
|
||||
? input.onDiscovery
|
||||
: undefined,
|
||||
now: typeof input.now === "function" ? input.now : () => new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHealthHost(value, fallback) {
|
||||
const normalized = String(value || fallback).trim();
|
||||
if (!["127.0.0.1", "::1", "0.0.0.0", "::"].includes(normalized)) {
|
||||
throw new TypeError("device_gateway_health_host_invalid");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeTcpHost(value, fallback, publicIngressEnabled) {
|
||||
const normalized = String(value || fallback).trim();
|
||||
const allowed = publicIngressEnabled
|
||||
? ["0.0.0.0", "::"]
|
||||
: ["127.0.0.1", "::1"];
|
||||
if (!allowed.includes(normalized)) {
|
||||
throw new TypeError(
|
||||
publicIngressEnabled
|
||||
? "device_gateway_public_ingress_host_invalid"
|
||||
: "device_gateway_baseline_loopback_only",
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeRemoteAddress(value) {
|
||||
const normalized = String(value || "unknown").trim();
|
||||
return normalized.slice(0, 64) || "unknown";
|
||||
}
|
||||
|
||||
function parseInteger(value, fallback, minimum, maximum, errorCode) {
|
||||
const parsed = Number(value ?? fallback);
|
||||
if (
|
||||
!Number.isSafeInteger(parsed)
|
||||
|| parsed < minimum
|
||||
|| parsed > maximum
|
||||
) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function listen(server, port, host) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
if (!server.listening) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { createCoreDiscoveryClient } from "./core-client.mjs";
|
||||
import { createDeviceGatewayRuntime } from "./runtime.mjs";
|
||||
|
||||
const config = await readConfig();
|
||||
const onDiscovery = config.listenEnabled
|
||||
? createCoreDiscoveryClient({
|
||||
coreUrl: config.coreUrl,
|
||||
gatewayToken: config.gatewayToken,
|
||||
timeoutMs: config.coreTimeoutMs,
|
||||
})
|
||||
: undefined;
|
||||
const runtime = createDeviceGatewayRuntime({
|
||||
listenEnabled: config.listenEnabled,
|
||||
publicIngressEnabled: config.publicIngressEnabled,
|
||||
healthHost: config.healthHost,
|
||||
healthPort: config.healthPort,
|
||||
tcpHost: config.tcpHost,
|
||||
tcpPort: config.tcpPort,
|
||||
maxBufferedBytes: config.maxBufferedBytes,
|
||||
maxConcurrentSessions: config.maxConcurrentSessions,
|
||||
maxSessionsPerAddress: config.maxSessionsPerAddress,
|
||||
maxConnectionsPerMinutePerAddress:
|
||||
config.maxConnectionsPerMinutePerAddress,
|
||||
sessionTimeoutMs: config.sessionTimeoutMs,
|
||||
onDiscovery,
|
||||
});
|
||||
|
||||
const addresses = await runtime.start();
|
||||
console.log(JSON.stringify({
|
||||
event: "device_gateway_started",
|
||||
health: addresses.healthAddress,
|
||||
tcp: addresses.tcpAddress,
|
||||
publicIngress: config.publicIngressEnabled
|
||||
? "discovery-only"
|
||||
: "disabled",
|
||||
commandTransport: "disabled",
|
||||
}));
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
async function shutdown() {
|
||||
await runtime.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function readConfig() {
|
||||
const listenEnabled = parseBoolean(
|
||||
process.env.DEVICE_GATEWAY_LISTEN_ENABLED,
|
||||
false,
|
||||
);
|
||||
const publicIngressEnabled = parseBoolean(
|
||||
process.env.DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED,
|
||||
false,
|
||||
);
|
||||
return {
|
||||
listenEnabled,
|
||||
publicIngressEnabled,
|
||||
healthHost: String(
|
||||
process.env.DEVICE_GATEWAY_HEALTH_HOST || "127.0.0.1",
|
||||
),
|
||||
healthPort: parsePort(process.env.DEVICE_GATEWAY_HEALTH_PORT, 18121),
|
||||
tcpHost: String(
|
||||
process.env.DEVICE_GATEWAY_TCP_HOST
|
||||
|| (publicIngressEnabled ? "0.0.0.0" : "127.0.0.1"),
|
||||
),
|
||||
tcpPort: parsePort(process.env.DEVICE_GATEWAY_TCP_PORT, 9921),
|
||||
maxBufferedBytes: parsePositiveInt(
|
||||
process.env.DEVICE_GATEWAY_MAX_BUFFERED_BYTES,
|
||||
65536,
|
||||
),
|
||||
maxConcurrentSessions: parsePositiveInt(
|
||||
process.env.DEVICE_GATEWAY_MAX_SESSIONS,
|
||||
100,
|
||||
),
|
||||
maxSessionsPerAddress: parsePositiveInt(
|
||||
process.env.DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS,
|
||||
10,
|
||||
),
|
||||
maxConnectionsPerMinutePerAddress: parsePositiveInt(
|
||||
process.env.DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS,
|
||||
30,
|
||||
),
|
||||
sessionTimeoutMs: parsePositiveInt(
|
||||
process.env.DEVICE_GATEWAY_SESSION_TIMEOUT_MS,
|
||||
10000,
|
||||
),
|
||||
coreUrl: listenEnabled
|
||||
? requiredValue(
|
||||
process.env.DEVICE_GATEWAY_CORE_URL,
|
||||
"device_gateway_core_url_required",
|
||||
)
|
||||
: "",
|
||||
gatewayToken: listenEnabled
|
||||
? await readRequiredSecretFile(
|
||||
process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE,
|
||||
"device_gateway_core_token_file_required",
|
||||
)
|
||||
: "",
|
||||
coreTimeoutMs: parsePositiveInt(
|
||||
process.env.DEVICE_GATEWAY_CORE_TIMEOUT_MS,
|
||||
5000,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function readRequiredSecretFile(path, errorCode) {
|
||||
const normalized = requiredValue(path, errorCode);
|
||||
const value = (await readFile(normalized, "utf8")).trim();
|
||||
if (value.length < 32) throw new Error(errorCode);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredValue(value, errorCode) {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw new Error(errorCode);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function parsePort(value, fallback) {
|
||||
const parsed = Number(value || fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
throw new Error("device_gateway_port_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value, fallback) {
|
||||
const parsed = Number(value || fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new Error("device_gateway_positive_integer_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseBoolean(value, fallback) {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
||||
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
||||
throw new Error("device_gateway_boolean_invalid");
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createCoreDiscoveryClient } from "../src/core-client.mjs";
|
||||
|
||||
const gatewayToken = "test-only-gateway-token-with-32-bytes";
|
||||
|
||||
test("posts a discovery through the authenticated internal Core boundary", async () => {
|
||||
let captured;
|
||||
const observe = createCoreDiscoveryClient({
|
||||
coreUrl: "http://device-control-core:18120",
|
||||
gatewayToken,
|
||||
fetchImpl: async (url, options) => {
|
||||
captured = { url, options };
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
discovery: {
|
||||
lifecycleState: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
},
|
||||
}), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
const signal = {
|
||||
schemaVersion: "nodedc.device.discovery-signal.v1",
|
||||
sessionRef: "session:test",
|
||||
};
|
||||
const discovery = await observe(signal);
|
||||
assert.equal(
|
||||
captured.url,
|
||||
"http://device-control-core:18120/internal/v1/device-discoveries:observe",
|
||||
);
|
||||
assert.equal(
|
||||
captured.options.headers.Authorization,
|
||||
`Bearer ${gatewayToken}`,
|
||||
);
|
||||
assert.deepEqual(JSON.parse(captured.options.body), signal);
|
||||
assert.equal(discovery.lifecycleState, "quarantine");
|
||||
});
|
||||
|
||||
test("fails closed when Core does not return a quarantine view", async () => {
|
||||
const observe = createCoreDiscoveryClient({
|
||||
coreUrl: "http://device-control-core:18120",
|
||||
gatewayToken,
|
||||
fetchImpl: async () => new Response(JSON.stringify({
|
||||
ok: true,
|
||||
discovery: {
|
||||
lifecycleState: "claimed",
|
||||
commandTransport: "disabled",
|
||||
},
|
||||
}), { status: 200 }),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => observe({ schemaVersion: "test" }),
|
||||
/device_gateway_core_ingest_contract_invalid/,
|
||||
);
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { connect } from "node:net";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createControlCoreApp,
|
||||
} from "../../device-control-core/src/app.mjs";
|
||||
import { createCoreDiscoveryClient } from "../src/core-client.mjs";
|
||||
import { createDeviceGatewayRuntime } from "../src/runtime.mjs";
|
||||
|
||||
const gatewayToken = "test-only-gateway-token-with-32-bytes";
|
||||
const identifierPepper = "test-only-identifier-pepper-with-32-bytes";
|
||||
const specificationHeader = Buffer.from(
|
||||
"FF23E9EF782DE7120300",
|
||||
"hex",
|
||||
);
|
||||
const specificationPackage = Buffer.from(
|
||||
"5B01010000FBDEC251EC5D",
|
||||
"hex",
|
||||
);
|
||||
|
||||
test("B2 HEADER2 becomes a persisted masked quarantine discovery before ACK", async () => {
|
||||
let stored;
|
||||
const core = createControlCoreApp({
|
||||
discoveryIngestEnabled: true,
|
||||
gatewayToken,
|
||||
identifierPepper,
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
upsertQuarantineDiscovery: async (value) => {
|
||||
stored = value;
|
||||
return {
|
||||
created: true,
|
||||
value: {
|
||||
...value.safeView,
|
||||
discoveryRef: "discovery:integration-001",
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
await listen(core);
|
||||
const coreAddress = core.address();
|
||||
const observe = createCoreDiscoveryClient({
|
||||
coreUrl: `http://127.0.0.1:${coreAddress.port}`,
|
||||
gatewayToken,
|
||||
});
|
||||
const gateway = createDeviceGatewayRuntime({
|
||||
healthPort: 0,
|
||||
tcpHost: "0.0.0.0",
|
||||
tcpPort: 0,
|
||||
listenEnabled: true,
|
||||
publicIngressEnabled: true,
|
||||
now: () => new Date(0x52db95de * 1000),
|
||||
onDiscovery: observe,
|
||||
});
|
||||
const addresses = await gateway.start();
|
||||
try {
|
||||
const response = await exchange(
|
||||
addresses.tcpAddress.port,
|
||||
Buffer.concat([specificationHeader, specificationPackage]),
|
||||
13,
|
||||
);
|
||||
assert.equal(
|
||||
response.toString("hex").toUpperCase(),
|
||||
"7B0400A0DE95DB527D7B00017D",
|
||||
);
|
||||
assert.match(stored.identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/);
|
||||
assert.equal(stored.safeView.lifecycleState, "quarantine");
|
||||
assert.equal(stored.safeView.identifier.masked, "***********7769");
|
||||
assert.equal(stored.safeView.commandTransport, "disabled");
|
||||
assert.equal(
|
||||
JSON.stringify(stored).includes("865209039777769"),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
await gateway.stop();
|
||||
await close(core);
|
||||
}
|
||||
});
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function close(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
function exchange(port, payload, expectedBytes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let byteLength = 0;
|
||||
const socket = connect({ host: "127.0.0.1", port }, () => {
|
||||
socket.write(payload);
|
||||
});
|
||||
socket.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
byteLength += chunk.length;
|
||||
if (byteLength >= expectedBytes) {
|
||||
socket.destroy();
|
||||
resolve(Buffer.concat(chunks, byteLength));
|
||||
}
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { connect } from "node:net";
|
||||
import test from "node:test";
|
||||
|
||||
import { createDeviceGatewayRuntime } from "../src/runtime.mjs";
|
||||
|
||||
const specificationHeader = Buffer.from(
|
||||
"FF23E9EF782DE7120300",
|
||||
"hex",
|
||||
);
|
||||
const specificationPackage = Buffer.from(
|
||||
"5B01010000FBDEC251EC5D",
|
||||
"hex",
|
||||
);
|
||||
|
||||
test("baseline health exposes no public ingress and no command transport", async () => {
|
||||
const runtime = createDeviceGatewayRuntime({
|
||||
healthPort: 0,
|
||||
listenEnabled: false,
|
||||
});
|
||||
const addresses = await runtime.start();
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${addresses.healthAddress.port}/healthz`,
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
assert.equal(body.publicIngress, "disabled");
|
||||
assert.equal(body.commandTransport, "disabled");
|
||||
assert.equal(body.tcpListener, "disabled");
|
||||
assert.equal(addresses.tcpAddress, null);
|
||||
} finally {
|
||||
await runtime.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("discovery-only ingress persists HEADER2 before acknowledging packages", async () => {
|
||||
const captured = [];
|
||||
const runtime = createDeviceGatewayRuntime({
|
||||
healthPort: 0,
|
||||
tcpHost: "0.0.0.0",
|
||||
tcpPort: 0,
|
||||
listenEnabled: true,
|
||||
publicIngressEnabled: true,
|
||||
now: () => new Date(0x52db95de * 1000),
|
||||
onDiscovery: async (value) => captured.push(value),
|
||||
});
|
||||
const addresses = await runtime.start();
|
||||
const client = await connectAndCollect(addresses.tcpAddress.port);
|
||||
try {
|
||||
client.socket.write(specificationHeader.subarray(0, 4));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(client.bytes().length, 0);
|
||||
|
||||
client.socket.write(specificationHeader.subarray(4));
|
||||
await client.waitForBytes(9);
|
||||
assert.equal(
|
||||
client.bytes().subarray(0, 9).toString("hex").toUpperCase(),
|
||||
"7B0400A0DE95DB527D",
|
||||
);
|
||||
assert.equal(captured.length, 1);
|
||||
assert.equal(captured[0].identifier.value, "865209039777769");
|
||||
assert.equal(captured[0].evidence.framingStatus, "verified");
|
||||
assert.equal(captured[0].commandTransport, undefined);
|
||||
|
||||
client.socket.write(specificationPackage);
|
||||
await client.waitForBytes(13);
|
||||
assert.equal(
|
||||
client.bytes().subarray(9).toString("hex").toUpperCase(),
|
||||
"7B00017D",
|
||||
);
|
||||
assert.equal(runtime.status().totalDiscoveries, 1);
|
||||
assert.equal(runtime.status().totalPackagesAcknowledged, 1);
|
||||
assert.equal(runtime.status().commandTransport, "disabled");
|
||||
assert.equal(runtime.status().publicIngress, "discovery-only");
|
||||
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${addresses.healthAddress.port}/healthz`,
|
||||
);
|
||||
const body = await response.json();
|
||||
assert.equal(body.framing, "verified-read-only");
|
||||
assert.equal(body.tcpListener, "discovery-only");
|
||||
assert.equal(body.publicIngress, "discovery-only");
|
||||
assert.equal(body.commandTransport, "disabled");
|
||||
} finally {
|
||||
client.socket.destroy();
|
||||
await runtime.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not acknowledge malformed or unverified initial bytes", async () => {
|
||||
const captured = [];
|
||||
const runtime = createDeviceGatewayRuntime({
|
||||
healthPort: 0,
|
||||
tcpPort: 0,
|
||||
listenEnabled: true,
|
||||
onDiscovery: async (value) => captured.push(value),
|
||||
});
|
||||
const addresses = await runtime.start();
|
||||
try {
|
||||
const received = await sendAndCollect(
|
||||
addresses.tcpAddress.port,
|
||||
Buffer.from("not-a-b2-header", "utf8"),
|
||||
);
|
||||
assert.equal(received.length, 0);
|
||||
assert.equal(captured.length, 0);
|
||||
assert.equal(runtime.status().totalRejected, 1);
|
||||
} finally {
|
||||
await runtime.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("public ingress requires an authenticated discovery sink", () => {
|
||||
assert.throws(
|
||||
() => createDeviceGatewayRuntime({
|
||||
listenEnabled: true,
|
||||
publicIngressEnabled: true,
|
||||
tcpHost: "0.0.0.0",
|
||||
}),
|
||||
/device_gateway_discovery_sink_required/,
|
||||
);
|
||||
});
|
||||
|
||||
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 disabled", async () => {
|
||||
const runtime = createDeviceGatewayRuntime({
|
||||
healthHost: "0.0.0.0",
|
||||
healthPort: 0,
|
||||
listenEnabled: false,
|
||||
});
|
||||
const addresses = await runtime.start();
|
||||
try {
|
||||
assert.equal(addresses.healthAddress.address, "0.0.0.0");
|
||||
assert.equal(addresses.tcpAddress, null);
|
||||
assert.equal(runtime.status().publicIngress, "disabled");
|
||||
} finally {
|
||||
await runtime.stop();
|
||||
}
|
||||
});
|
||||
|
||||
function connectAndCollect(port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let byteLength = 0;
|
||||
const waiters = [];
|
||||
const socket = connect({ host: "127.0.0.1", port }, () => {
|
||||
resolve({
|
||||
socket,
|
||||
bytes: () => Buffer.concat(chunks, byteLength),
|
||||
waitForBytes: (minimum) => {
|
||||
if (byteLength >= minimum) return Promise.resolve();
|
||||
return new Promise((waitResolve, waitReject) => {
|
||||
waiters.push({ minimum, waitResolve, waitReject });
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
socket.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
byteLength += chunk.length;
|
||||
for (let index = waiters.length - 1; index >= 0; index -= 1) {
|
||||
if (byteLength >= waiters[index].minimum) {
|
||||
waiters[index].waitResolve();
|
||||
waiters.splice(index, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on("error", (error) => {
|
||||
for (const waiter of waiters.splice(0)) waiter.waitReject(error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendAndCollect(port, payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
const socket = connect({ host: "127.0.0.1", port }, () => {
|
||||
socket.end(payload);
|
||||
});
|
||||
socket.on("data", (chunk) => chunks.push(chunk));
|
||||
socket.on("close", () => resolve(Buffer.concat(chunks)));
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = resolve(platformRoot, "device-plane");
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-edge-admission-gate-20260804-002",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-edge-ingress-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml",
|
||||
"services/device-edge-relay/Dockerfile",
|
||||
"services/device-edge-relay/src",
|
||||
"deployment/device-edge-admission-gate-v1.json",
|
||||
];
|
||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||
const descriptor = await assertBoundary();
|
||||
if (descriptor.ingressIpv4Approval !== "approved-outside-dhcp-pool") {
|
||||
throw new Error("device_edge_ingress_ipv4_approval_pending");
|
||||
}
|
||||
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-edge-ingress-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-device-edge-${patchId}.tgz`);
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
await copySafe(
|
||||
resolve(sourceRoot, sourceRelative),
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-edge\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{ encoding: "utf8", maxBuffer: 128 * 1024 * 1024 },
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-edge",
|
||||
transition: "reviewed-ipvlan-b2-relay-admission-gate",
|
||||
entries: files,
|
||||
services: ["device-edge-relay"],
|
||||
preservedRuntime: ["device-edge-backhaul", "tailnet", "Gelios"],
|
||||
ingress: {
|
||||
parent: descriptor.parentInterface,
|
||||
subnet: descriptor.lanSubnet,
|
||||
gateway: descriptor.lanGateway,
|
||||
ipv4: descriptor.ingressIpv4,
|
||||
ipv4Approval: descriptor.ingressIpv4Approval,
|
||||
tcp: 9921,
|
||||
hostPortPublication: "disabled",
|
||||
sourceAdmission: descriptor.sourceAdmission,
|
||||
maxTrackedSourceAddresses: descriptor.maxTrackedSourceAddresses,
|
||||
maxBytesPerDirection: descriptor.maxBytesPerDirection,
|
||||
lifecycle: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
},
|
||||
rollback: descriptor.rollback,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertBoundary() {
|
||||
const baseline = await readFile(
|
||||
resolve(sourceRoot, "docker-compose.device-edge.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const ingress = await readFile(
|
||||
resolve(sourceRoot, "docker-compose.device-edge.ingress.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-edge-admission-gate-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
|
||||
for (const fragment of [
|
||||
"DEVICE_EDGE_RELAY_HEALTH_HOST: 127.0.0.1",
|
||||
'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "false"',
|
||||
"read_only: true",
|
||||
'user: "1000:1000"',
|
||||
"no-new-privileges:true",
|
||||
"cap_drop:",
|
||||
"- ALL",
|
||||
]) {
|
||||
if (!baseline.includes(fragment)) {
|
||||
throw new Error(`device_edge_baseline_boundary_missing:${fragment}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of ["ports:", "device-edge-control"]){
|
||||
if (baseline.includes(forbidden)) {
|
||||
throw new Error(`device_edge_baseline_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const fragment of [
|
||||
'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"',
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul",
|
||||
'DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"',
|
||||
"DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only",
|
||||
'DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"',
|
||||
'DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"',
|
||||
"name: nodedc-device-edge-ingress",
|
||||
"driver: ipvlan",
|
||||
"parent: enp1s0f0",
|
||||
"ipvlan_mode: l2",
|
||||
"ipv4_address: 192.168.71.253",
|
||||
"gw_priority: 100",
|
||||
"subnet: 192.168.68.0/22",
|
||||
"gateway: 192.168.68.1",
|
||||
]) {
|
||||
if (!ingress.includes(fragment)) {
|
||||
throw new Error(`device_edge_ingress_boundary_missing:${fragment}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"ports:",
|
||||
"network_mode: host",
|
||||
"privileged: true",
|
||||
"DEVICE_EDGE_RELAY_COMMAND",
|
||||
"0.0.0.0:9921:9921",
|
||||
]) {
|
||||
if (ingress.includes(forbidden)) {
|
||||
throw new Error(`device_edge_ingress_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-edge.admission-gate.v1",
|
||||
mode: "single-nic-ipvlan-b2-relay-only",
|
||||
runtimeHost: "ndcmini12",
|
||||
component: "device-edge",
|
||||
selectedServices: ["device-edge-relay"],
|
||||
preservedServices: ["device-edge-backhaul", "tailnet"],
|
||||
composeProject: "nodedc-device-edge",
|
||||
composeFiles: [
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml",
|
||||
],
|
||||
parentInterface: "enp1s0f0",
|
||||
lanSubnet: "192.168.68.0/22",
|
||||
lanGateway: "192.168.68.1",
|
||||
ingressIpv4: "192.168.71.253",
|
||||
ingressIpv4Approval: "approved-outside-dhcp-pool",
|
||||
ingressNetwork: "nodedc-device-edge-ingress",
|
||||
deviceTcpListen: "192.168.71.253:9921",
|
||||
hostPortPublication: "disabled",
|
||||
healthPublication: "disabled",
|
||||
privateUpstream: "device-edge-backhaul:19921",
|
||||
sourceAdmission: "public-ipv4-only",
|
||||
maxTrackedSourceAddresses: 2048,
|
||||
maxBytesPerDirection: 262144,
|
||||
protocolInspection: "gateway-owned",
|
||||
identityTrust: "claimed-not-ownership-proof",
|
||||
discoveryLifecycle: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
amneziaHostFullTunnel: "preserved",
|
||||
routerNatFirewall: "separate-manual-gate",
|
||||
rollback: "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_edge_ingress_descriptor_mismatch");
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(`source_symlink_rejected:${relative(sourceRoot, source)}`);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) {
|
||||
throw new Error(`source_type_rejected:${source}`);
|
||||
}
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
|
||||
);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = resolve(platformRoot, "device-plane");
|
||||
const failedFoundationCompose = resolve(
|
||||
scriptDir,
|
||||
"fixtures/device-plane-foundation-internal-only-v1.yml",
|
||||
);
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [patchId = "device-plane-foundation-20260725-001", ...extra] =
|
||||
process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error("usage: build-device-plane-artifact.mjs [patch-id]");
|
||||
}
|
||||
|
||||
const files = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
];
|
||||
const ignoredBasenames = new Set([
|
||||
".DS_Store",
|
||||
".git",
|
||||
"node_modules",
|
||||
]);
|
||||
const ignoredDirectoryNames = new Set(["test"]);
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-plane-artifact-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
|
||||
|
||||
await assertSourceBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
const source = sourceRelative === "docker-compose.device-plane.yml"
|
||||
? failedFoundationCompose
|
||||
: resolve(sourceRoot, sourceRelative);
|
||||
await copySafe(
|
||||
source,
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
entries: files,
|
||||
services: ["device-control-core", "device-gateway"],
|
||||
preserved: [
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Gelios",
|
||||
],
|
||||
excluded: [
|
||||
".env*",
|
||||
"node_modules",
|
||||
"**/test",
|
||||
"docs",
|
||||
"runtime",
|
||||
"secrets",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertSourceBoundary() {
|
||||
const composeSource = failedFoundationCompose;
|
||||
const compose = await readFile(
|
||||
composeSource,
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "false"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "false"',
|
||||
'"127.0.0.1:18120:18120"',
|
||||
'"127.0.0.1:18121:18121"',
|
||||
"source: /volume1/docker/nodedc-device-plane/secrets/postgres-password",
|
||||
"create_host_path: false",
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
"pull_policy: never",
|
||||
]) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(`device_plane_compose_boundary_missing:${fragment}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"9921:9921",
|
||||
"0.0.0.0:9921",
|
||||
"DEVICE_DISCOVERY_INGEST_ENABLED: \"true\"",
|
||||
"DEVICE_GATEWAY_LISTEN_ENABLED: \"true\"",
|
||||
"POSTGRES_PASSWORD:",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(`device_plane_compose_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, source)}`,
|
||||
);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) {
|
||||
throw new Error(`source_type_rejected:${source}`);
|
||||
}
|
||||
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
|| (entry.isDirectory() && ignoredDirectoryNames.has(entry.name))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
|
||||
);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = resolve(platformRoot, "device-plane");
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-b2-discovery-loopback-20260726-002",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-b2-discovery-ingress-artifact.mjs "
|
||||
+ "[patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
"services/device-edge-relay/package.json",
|
||||
"deployment/device-plane-b2-discovery-ingress-v1.json",
|
||||
];
|
||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||
const ignoredDirectoryNames = new Set(["test"]);
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-b2-discovery-ingress-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
await copySafe(
|
||||
resolve(sourceRoot, sourceRelative),
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(stage, "files.txt"),
|
||||
`${files.join("\n")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
transition: "verified-b2-loopback-discovery-only",
|
||||
entries: files,
|
||||
services: ["device-control-core", "device-gateway"],
|
||||
preservedRuntime: [
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Gelios",
|
||||
],
|
||||
ingress: {
|
||||
transport: "tcp",
|
||||
published: "127.0.0.1:9921:9921",
|
||||
mode: "loopback-discovery-only",
|
||||
framing: "verified-read-only",
|
||||
lifecycle: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
},
|
||||
rollback: "restore-source-and-predecessor-stateless-runtime",
|
||||
excluded: [
|
||||
".env*",
|
||||
"node_modules",
|
||||
"**/test",
|
||||
"docs",
|
||||
"runtime",
|
||||
"secrets",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertBoundary() {
|
||||
const compose = await readFile(
|
||||
resolve(sourceRoot, "docker-compose.device-plane.yml"),
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "true"',
|
||||
'DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false"',
|
||||
"DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token",
|
||||
'"127.0.0.1:18120:18120"',
|
||||
'"127.0.0.1:18121:18121"',
|
||||
'"127.0.0.1:9921:9921"',
|
||||
"name: nodedc-device-plane-private",
|
||||
"internal: true",
|
||||
"name: nodedc-device-plane-control",
|
||||
"internal: false",
|
||||
'com.docker.network.bridge.enable_ip_masquerade: "false"',
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
"pull_policy: never",
|
||||
]) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(
|
||||
`device_plane_b2_ingress_boundary_missing:${fragment}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"POSTGRES_PASSWORD:",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN:",
|
||||
"DEVICE_IDENTIFIER_PEPPER:",
|
||||
"DEVICE_GATEWAY_COMMAND",
|
||||
"9921:9921/udp",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(
|
||||
`device_plane_b2_ingress_boundary_violation:${forbidden}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-plane-b2-discovery-ingress-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.b2-discovery-ingress.v1",
|
||||
mode: "verified-b2-loopback-discovery-only",
|
||||
predecessorPatchId:
|
||||
"device-plane-foundation-network-publication-20260725-003",
|
||||
predecessorArtifactSha256:
|
||||
"6fdd5a12c310786db1753882fc1378184fe378d2cc533633a8c73c951521b7bf",
|
||||
sourceAction: "publish-verified-b2-loopback-discovery-source",
|
||||
runtimeAction: "build-and-recreate-stateless-services",
|
||||
selectedServices: ["device-control-core", "device-gateway"],
|
||||
preservedServices: ["device-postgres"],
|
||||
privateNetwork: "nodedc-device-plane-private",
|
||||
controlNetwork: "nodedc-device-plane-control",
|
||||
publishedPorts: [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121",
|
||||
"127.0.0.1:9921:9921/tcp",
|
||||
],
|
||||
protocolProfile: "arusnavi.b2.internal.v1",
|
||||
framingSpecification:
|
||||
"arusnavi.internal.protocol-sheet.gid-12.v1",
|
||||
identityTrust: "claimed-not-ownership-proof",
|
||||
discoveryLifecycle: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
rollback: "restore-source-and-predecessor-stateless-runtime",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_b2_ingress_descriptor_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, source)}`,
|
||||
);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, {
|
||||
force: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) {
|
||||
throw new Error(`source_type_rejected:${source}`);
|
||||
}
|
||||
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
|| (
|
||||
entry.isDirectory()
|
||||
&& ignoredDirectoryNames.has(entry.name)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
|
||||
);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = resolve(platformRoot, "device-plane");
|
||||
const descriptorRelative =
|
||||
"deployment/device-plane-b2-discovery-loopback-recovery-v1.json";
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-b2-discovery-loopback-recovery-20260802-004",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: "
|
||||
+ "build-device-plane-b2-discovery-loopback-recovery-artifact.mjs "
|
||||
+ "[patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [descriptorRelative];
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-b2-loopback-recovery-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertRecoveryDescriptor();
|
||||
|
||||
try {
|
||||
const source = resolve(sourceRoot, descriptorRelative);
|
||||
const sourceStat = await lstat(source);
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||
throw new Error("device_plane_b2_recovery_descriptor_unsafe");
|
||||
}
|
||||
await mkdir(dirname(join(payload, descriptorRelative)), {
|
||||
recursive: true,
|
||||
});
|
||||
await cp(source, join(payload, descriptorRelative), {
|
||||
force: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(stage, "files.txt"),
|
||||
`${files.join("\n")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
transition: "failed-b2-loopback-build-reconciliation",
|
||||
entries: files,
|
||||
build: [],
|
||||
services: [],
|
||||
preservedRuntime: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Gelios",
|
||||
],
|
||||
sourceAction: "publish-reconciliation-marker-only",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
ingress: "disabled:127.0.0.1:9921/tcp:closed",
|
||||
rollback: "marker-only-runtime-unchanged",
|
||||
excluded: [
|
||||
"application-source",
|
||||
"compose",
|
||||
"Dockerfile",
|
||||
"secrets",
|
||||
"runtime",
|
||||
"database",
|
||||
"Gelios",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertRecoveryDescriptor() {
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(sourceRoot, descriptorRelative),
|
||||
"utf8",
|
||||
));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.b2-discovery-loopback-recovery.v1",
|
||||
mode: "failed-b2-loopback-build-reconciliation",
|
||||
failedPatchId: "device-plane-b2-discovery-loopback-20260801-003",
|
||||
failedArtifactSha256:
|
||||
"7273c5bf67fe6bc1f1da66ad726009240d39ee3aee58201b96c23d6f707a3d84",
|
||||
failedBackupId:
|
||||
"device-plane-device-plane-b2-discovery-loopback-20260801-003-20260802-154311",
|
||||
sourceAction: "publish-reconciliation-marker-only",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
preservedServices: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
],
|
||||
expectedLoopbackPorts: [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121",
|
||||
],
|
||||
closedPort: "127.0.0.1:9921/tcp",
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
rollback: "marker-only-runtime-unchanged",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_b2_recovery_descriptor_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = resolve(platformRoot, "device-plane");
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-backhaul-target-tailnet-serve-20260804-002",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-backhaul-target-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
"docker-compose.device-plane.backhaul-target.yml",
|
||||
"services/device-backhaul-target",
|
||||
"deployment/device-plane-backhaul-target-tailnet-serve-v1.json",
|
||||
];
|
||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-backhaul-target-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
await copySafe(
|
||||
resolve(sourceRoot, sourceRelative),
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{ encoding: "utf8", maxBuffer: 128 * 1024 * 1024 },
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
transition: "failed-backhaul-target-to-loopback-tailnet-serve",
|
||||
entries: files,
|
||||
services: ["device-backhaul-target"],
|
||||
preservedRuntime: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Gelios",
|
||||
],
|
||||
ingress: {
|
||||
loopbackListen: "127.0.0.1:2222/tcp",
|
||||
tailnetListen: "100.109.216.21:2222/tcp",
|
||||
transport: "tailscale-serve-private-ssh",
|
||||
serveTarget: "tcp://127.0.0.1:2222",
|
||||
permittedTarget: "127.0.0.1:9921",
|
||||
dockerPortPublication: "disabled",
|
||||
routerNatFirewall: "unchanged",
|
||||
edgePublicIngress: "disabled",
|
||||
funnel: "disabled",
|
||||
commandTransport: "disabled",
|
||||
},
|
||||
runtimeTrust: "runner-managed-not-in-artifact",
|
||||
rollback: "remove-tailnet-serve-target-and-restore-source",
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertBoundary() {
|
||||
const compose = await readFile(
|
||||
resolve(sourceRoot, "docker-compose.device-plane.backhaul-target.yml"),
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
"device-backhaul-target:",
|
||||
"image: nodedc/device-backhaul-target:local",
|
||||
"network_mode: host",
|
||||
'"127.0.0.1", "2222"',
|
||||
"/secrets/backhaul-target/ssh_host_ed25519_key",
|
||||
"/secrets/backhaul-target/authorized_keys",
|
||||
"no-new-privileges:true",
|
||||
]) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(`device_plane_backhaul_boundary_missing:${fragment}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"PasswordAuthentication yes",
|
||||
"0.0.0.0:2222",
|
||||
"9921:9921/udp",
|
||||
"DEVICE_GATEWAY_COMMAND",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(`device_plane_backhaul_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sshd = await readFile(
|
||||
resolve(sourceRoot, "services/device-backhaul-target/sshd_config"),
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
"ListenAddress 127.0.0.1",
|
||||
"PasswordAuthentication no",
|
||||
"KbdInteractiveAuthentication no",
|
||||
"AllowTcpForwarding local",
|
||||
"PermitOpen 127.0.0.1:9921",
|
||||
"GatewayPorts no",
|
||||
"PermitTunnel no",
|
||||
"AllowAgentForwarding no",
|
||||
"PermitTTY no",
|
||||
"ForceCommand /bin/false",
|
||||
]) {
|
||||
if (!sshd.includes(fragment)) {
|
||||
throw new Error(`device_plane_backhaul_sshd_boundary_missing:${fragment}`);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-plane-backhaul-target-tailnet-serve-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.backhaul-target-tailnet-serve.v1",
|
||||
mode: "failed-backhaul-target-to-loopback-tailnet-serve",
|
||||
failedPatchId: "device-plane-backhaul-target-20260803-001",
|
||||
failedArtifactSha256:
|
||||
"ed0bda4110a756c32be68990e2e0f647409d5a77eec7e26c18502bafbdc1bb76",
|
||||
failedBackupId:
|
||||
"device-plane-device-plane-backhaul-target-20260803-001-20260804-035519",
|
||||
predecessorPatchId:
|
||||
"device-plane-b2-discovery-loopback-20260803-006",
|
||||
predecessorArtifactSha256:
|
||||
"25f9e9e55e283e9b7bb5e128ff14a244f848b1c063acca9724a23206131c9adf",
|
||||
sourceAction: "publish-loopback-backhaul-target-source",
|
||||
runtimeAction: "build-create-target-and-register-private-tailnet-serve",
|
||||
composeOverlay: "docker-compose.device-plane.backhaul-target.yml",
|
||||
selectedServices: ["device-backhaul-target"],
|
||||
preservedServices: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
],
|
||||
loopbackListenAddress: "127.0.0.1",
|
||||
listenPort: 2222,
|
||||
tailnetAddress: "100.109.216.21",
|
||||
tailnetExposure: "tailscale-serve-private",
|
||||
tailscaleServeTarget: "tcp://127.0.0.1:2222",
|
||||
permittedTarget: "127.0.0.1:9921",
|
||||
networkMode: "host",
|
||||
dockerPortPublication: "disabled",
|
||||
routerNatFirewall: "unchanged",
|
||||
edgePublicIngress: "disabled",
|
||||
funnel: "disabled",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
runtimeTrust: "runner-managed",
|
||||
rollback: "remove-tailnet-serve-target-and-restore-source",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_backhaul_descriptor_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(`source_symlink_rejected:${relative(sourceRoot, source)}`);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) {
|
||||
throw new Error(`source_type_rejected:${source}`);
|
||||
}
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
|| entry.name.endsWith("~")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await copySafe(join(source, entry.name), join(destination, entry.name));
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = resolve(platformRoot, "device-plane");
|
||||
const networkPublicationCompose = resolve(
|
||||
scriptDir,
|
||||
"fixtures/device-plane-foundation-network-publication-v1.yml",
|
||||
);
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-foundation-network-publication-20260725-003",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-foundation-network-publication-artifact.mjs "
|
||||
+ "[patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
"deployment/device-plane-foundation-network-publication-v1.json",
|
||||
];
|
||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||
const ignoredDirectoryNames = new Set(["test"]);
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-network-publication-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
const source = sourceRelative === "docker-compose.device-plane.yml"
|
||||
? networkPublicationCompose
|
||||
: resolve(sourceRoot, sourceRelative);
|
||||
await copySafe(
|
||||
source,
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(stage, "files.txt"),
|
||||
`${files.join("\n")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
transition: "failed-foundation-network-publication-correction",
|
||||
entries: files,
|
||||
build: [],
|
||||
services: ["device-control-core", "device-gateway"],
|
||||
preservedRuntime: [
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Gelios",
|
||||
],
|
||||
networkChange: {
|
||||
private: "preserved:internal",
|
||||
control: "create:non-internal:no-masquerade",
|
||||
published: [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121",
|
||||
],
|
||||
disabled: ["9921", "public-ingress", "command-transport"],
|
||||
},
|
||||
rollback:
|
||||
"restore-partial-source-and-internal-only-stateless-runtime",
|
||||
excluded: [
|
||||
".env*",
|
||||
"node_modules",
|
||||
"**/test",
|
||||
"docs",
|
||||
"runtime",
|
||||
"secrets",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertBoundary() {
|
||||
const compose = await readFile(
|
||||
networkPublicationCompose,
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "false"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "false"',
|
||||
'"127.0.0.1:18120:18120"',
|
||||
'"127.0.0.1:18121:18121"',
|
||||
"name: nodedc-device-plane-private",
|
||||
"internal: true",
|
||||
"name: nodedc-device-plane-control",
|
||||
"internal: false",
|
||||
'com.docker.network.bridge.enable_ip_masquerade: "false"',
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
"pull_policy: never",
|
||||
]) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(
|
||||
`device_plane_network_publication_boundary_missing:${fragment}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"9921:9921",
|
||||
"0.0.0.0:9921",
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "true"',
|
||||
"POSTGRES_PASSWORD:",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(
|
||||
`device_plane_network_publication_boundary_violation:${forbidden}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-plane-foundation-network-publication-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
const expected = {
|
||||
schemaVersion:
|
||||
"nodedc.device-plane.foundation-network-publication.v1",
|
||||
mode: "failed-foundation-network-publication-correction",
|
||||
failedRecoveryPatchId:
|
||||
"device-plane-foundation-recovery-20260725-002",
|
||||
failedRecoveryArtifactSha256:
|
||||
"9183cc385142584bfd12510bb0a3e6b833b2fd26607436f2486a564c628ea1bf",
|
||||
failedRecoveryBackupId:
|
||||
"device-plane-device-plane-foundation-recovery-20260725-002-20260725-232447",
|
||||
sourceAction: "publish-network-corrected-foundation-source",
|
||||
runtimeAction: "recreate-stateless-services-no-build",
|
||||
selectedServices: ["device-control-core", "device-gateway"],
|
||||
preservedServices: ["device-postgres"],
|
||||
privateNetwork: "nodedc-device-plane-private",
|
||||
controlNetwork: "nodedc-device-plane-control",
|
||||
publishedLoopbackPorts: [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121",
|
||||
],
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
rollback:
|
||||
"restore-partial-source-and-internal-only-stateless-runtime",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
"device_plane_network_publication_descriptor_mismatch",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, source)}`,
|
||||
);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, {
|
||||
force: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) {
|
||||
throw new Error(`source_type_rejected:${source}`);
|
||||
}
|
||||
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
|| (
|
||||
entry.isDirectory()
|
||||
&& ignoredDirectoryNames.has(entry.name)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
|
||||
);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = resolve(platformRoot, "device-plane");
|
||||
const predecessorCompose = resolve(
|
||||
scriptDir,
|
||||
"fixtures/device-plane-foundation-internal-only-v1.yml",
|
||||
);
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-foundation-recovery-20260725-002",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-foundation-recovery-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
"deployment/device-plane-foundation-recovery-v1.json",
|
||||
];
|
||||
const ignoredBasenames = new Set([
|
||||
".DS_Store",
|
||||
".git",
|
||||
"node_modules",
|
||||
]);
|
||||
const ignoredDirectoryNames = new Set(["test"]);
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-foundation-recovery-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertRecoveryBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
const source = sourceRelative === "docker-compose.device-plane.yml"
|
||||
? predecessorCompose
|
||||
: resolve(sourceRoot, sourceRelative);
|
||||
await copySafe(
|
||||
source,
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(stage, "files.txt"),
|
||||
`${files.join("\n")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
transition: "failed-foundation-live-runtime-adoption",
|
||||
entries: files,
|
||||
build: [],
|
||||
services: [],
|
||||
preservedRuntime: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
],
|
||||
sourceAction: "publish-exact-failed-artifact-source",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
excluded: [
|
||||
".env*",
|
||||
"node_modules",
|
||||
"**/test",
|
||||
"docs",
|
||||
"runtime",
|
||||
"secrets",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertRecoveryBoundary() {
|
||||
const compose = await readFile(
|
||||
predecessorCompose,
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "false"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "false"',
|
||||
'"127.0.0.1:18120:18120"',
|
||||
'"127.0.0.1:18121:18121"',
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
]) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(
|
||||
`device_plane_recovery_compose_boundary_missing:${fragment}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"9921:9921",
|
||||
"0.0.0.0:9921",
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "true"',
|
||||
"POSTGRES_PASSWORD:",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(
|
||||
`device_plane_recovery_compose_boundary_violation:${forbidden}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-plane-foundation-recovery-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.foundation-recovery.v1",
|
||||
mode: "failed-foundation-live-runtime-adoption",
|
||||
failedPatchId: "device-plane-foundation-20260725-001",
|
||||
failedArtifactSha256:
|
||||
"23d428de547854ad8b1a026671e2f850386ab0be98bde80f016f1e9db631ee24",
|
||||
backupId:
|
||||
"device-plane-device-plane-foundation-20260725-001-20260725-223441",
|
||||
sourceAction: "publish-exact-failed-artifact-source",
|
||||
runtimeAction: "read-only-acceptance",
|
||||
preservedServices: [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
],
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
rollback: "source-only-runtime-unchanged",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_recovery_descriptor_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, source)}`,
|
||||
);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, {
|
||||
force: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) {
|
||||
throw new Error(`source_type_rejected:${source}`);
|
||||
}
|
||||
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
|| (
|
||||
entry.isDirectory()
|
||||
&& ignoredDirectoryNames.has(entry.name)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
|
||||
);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = resolve(platformRoot, "device-plane");
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [patchId = "device-plane-postgres-bootstrap-20260725-001", ...extra] =
|
||||
process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-postgres-bootstrap-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
"docker-compose.device-plane.yml",
|
||||
"deployment/device-postgres-bootstrap-v1.json",
|
||||
];
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-postgres-bootstrap-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertSourceBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const relativePath of files) {
|
||||
const source = resolve(sourceRoot, relativePath);
|
||||
const sourceStat = await lstat(source);
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||
throw new Error(`bootstrap_source_file_required:${relativePath}`);
|
||||
}
|
||||
const destination = join(payload, relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, {
|
||||
force: true,
|
||||
verbatimSymlinks: false,
|
||||
});
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const sha256 = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256,
|
||||
component: "device-plane",
|
||||
entries: files,
|
||||
services: ["device-postgres"],
|
||||
mode: "create-if-absent",
|
||||
rollbackVolumePolicy: "preserve",
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertSourceBoundary() {
|
||||
const descriptor = JSON.parse(
|
||||
await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-postgres-bootstrap-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.postgres-bootstrap.v1",
|
||||
service: "device-postgres",
|
||||
volume: "nodedc-device-plane-postgres-data",
|
||||
mode: "create-if-absent",
|
||||
ordinaryApplicationSelection: "forbidden",
|
||||
rollbackVolumePolicy: "preserve",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_postgres_bootstrap_descriptor_mismatch");
|
||||
}
|
||||
|
||||
const compose = await readFile(
|
||||
resolve(sourceRoot, "docker-compose.device-plane.yml"),
|
||||
"utf8",
|
||||
);
|
||||
for (const required of [
|
||||
"device-postgres:",
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
"POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password",
|
||||
"create_host_path: false",
|
||||
]) {
|
||||
if (!compose.includes(required)) {
|
||||
throw new Error(`device_plane_postgres_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
const postgresStart = compose.indexOf(" device-postgres:");
|
||||
const postgresEnd = compose.indexOf("\n device-control-core:");
|
||||
if (
|
||||
postgresStart < 0
|
||||
|| postgresEnd <= postgresStart
|
||||
|| compose.slice(postgresStart, postgresEnd).includes("\n ports:")
|
||||
) {
|
||||
throw new Error("device_plane_postgres_host_port_forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/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 launcherRoot = resolve(process.env.NODEDC_LAUNCHER_REPO || resolve(scriptDir, "../../../../data/nodedc_launcher"));
|
||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||
const [patchId = "launcher-device-core-session-20260810-001", ...extra] = process.argv.slice(2);
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-launcher-device-core-artifact.mjs [patch-id]");
|
||||
|
||||
const entries = [
|
||||
"server/control-plane-store.mjs",
|
||||
"server/dev-server.mjs",
|
||||
"server/device-core-session-access.mjs",
|
||||
"server/internal-request-auth.mjs",
|
||||
"src/shared/api/adminApi.ts",
|
||||
];
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-launcher-device-core-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-launcher-${patchId}.tgz`);
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const entry of entries) {
|
||||
const source = resolve(launcherRoot, entry);
|
||||
const sourceStat = await lstat(source);
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) throw new Error(`launcher_source_rejected:${entry}`);
|
||||
const destination = join(payload, entry);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { force: true });
|
||||
}
|
||||
const server = await readFile(join(payload, "server/dev-server.mjs"), "utf8");
|
||||
for (const required of ["resolveDeviceCoreSessionAccess", "NODEDC_DEVICE_CORE_INTERNAL_TOKEN_FILE", "deviceCoreInternalAccessConfigured"]) {
|
||||
if (!server.includes(required)) throw new Error(`launcher_device_core_contract_missing:${required}`);
|
||||
}
|
||||
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=launcher\ntype=app-overlay\n`, "utf8");
|
||||
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], { encoding: "utf8" });
|
||||
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
const sha256 = createHash("sha256").update(await readFile(target)).digest("hex");
|
||||
console.log(JSON.stringify({ ok: true, patchId, component: "launcher", artifact: target, sha256, entries, services: ["launcher"] }, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/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 launcherRoot = resolve(process.env.NODEDC_LAUNCHER_REPO || resolve(scriptDir, "../../../../data/nodedc_launcher"));
|
||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||
const [patchId = "launcher-hub-service-trust-ui-20260810-001", ...extra] = process.argv.slice(2);
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error("usage: build-launcher-hub-service-trust-ui-artifact.mjs [patch-id]");
|
||||
}
|
||||
|
||||
const entries = [
|
||||
"src/app/LauncherApp.tsx",
|
||||
"src/styles/globals.css",
|
||||
"src/widgets/admin-overlay/AdminOverlay.tsx",
|
||||
];
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-launcher-hub-service-trust-ui-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-launcher-${patchId}.tgz`);
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const entry of entries) {
|
||||
const source = resolve(launcherRoot, entry);
|
||||
const sourceStat = await lstat(source);
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) throw new Error(`launcher_source_rejected:${entry}`);
|
||||
const destination = join(payload, entry);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { force: true });
|
||||
}
|
||||
|
||||
const app = await readFile(join(payload, "src/app/LauncherApp.tsx"), "utf8");
|
||||
const overlay = await readFile(join(payload, "src/widgets/admin-overlay/AdminOverlay.tsx"), "utf8");
|
||||
const styles = await readFile(join(payload, "src/styles/globals.css"), "utf8");
|
||||
for (const required of ["upsertAdminGrant", 'targetType: "client"', "pendingClientGrantAssignments"]) {
|
||||
if (!app.includes(required)) throw new Error(`launcher_hub_grant_contract_missing:${required}`);
|
||||
}
|
||||
for (const required of ["authentikGroupName", "authHandoffPath", "onSetClientServiceGrant"]) {
|
||||
if (!overlay.includes(required)) throw new Error(`launcher_hub_service_trust_contract_missing:${required}`);
|
||||
}
|
||||
if (!styles.includes(".client-service-grants")) throw new Error("launcher_hub_service_trust_style_missing");
|
||||
|
||||
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=launcher\ntype=app-overlay\n`, "utf8");
|
||||
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], { encoding: "utf8" });
|
||||
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
const sha256 = createHash("sha256").update(await readFile(target)).digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
component: "launcher",
|
||||
artifact: target,
|
||||
sha256,
|
||||
entries,
|
||||
services: ["launcher"],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE");
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [patchId = "module-foundry-map-grid-lod-20260805-001", ...extra] =
|
||||
const [patchId = "module-foundry-map-grid-lod-20260805-002", ...extra] =
|
||||
process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^module-foundry-map-grid-lod-\d{8}-\d{3}$/.test(patchId)) {
|
||||
@@ -28,9 +28,10 @@ const files = Object.freeze([
|
||||
"apps/catalog/src/MapFixturePreview.tsx",
|
||||
"apps/catalog/src/mapGridPolicy.d.mts",
|
||||
"apps/catalog/src/mapGridPolicy.mjs",
|
||||
"package.json",
|
||||
"apps/catalog/src/styles.css",
|
||||
"runtime-seed/page-layouts/map.json",
|
||||
"scripts/map-grid-lod.test.mjs",
|
||||
"scripts/map-object-layers.test.mjs",
|
||||
"server/catalog-server.mjs",
|
||||
"server/foundry-mcp.mjs",
|
||||
]);
|
||||
@@ -69,7 +70,7 @@ try {
|
||||
checksum,
|
||||
sha256,
|
||||
services: ["nodedc-module-foundry"],
|
||||
transition: "five-band-grid-lod-with-seamless-layer-swap",
|
||||
transition: "five-lod-mmap-parity-fast-grid-and-focus-recovery",
|
||||
files,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
@@ -80,31 +81,40 @@ async function assertGridLodBoundary() {
|
||||
const policy = await readFile(join(foundryRoot, "apps/catalog/src/mapGridPolicy.mjs"), "utf8");
|
||||
const renderer = await readFile(join(foundryRoot, "apps/catalog/src/CesiumMapRenderer.tsx"), "utf8");
|
||||
const inspector = await readFile(join(foundryRoot, "apps/catalog/src/MapFixturePreview.tsx"), "utf8");
|
||||
const styles = await readFile(join(foundryRoot, "apps/catalog/src/styles.css"), "utf8");
|
||||
const server = await readFile(join(foundryRoot, "server/catalog-server.mjs"), "utf8");
|
||||
const tests = await readFile(join(foundryRoot, "scripts/map-grid-lod.test.mjs"), "utf8");
|
||||
const focusTests = await readFile(join(foundryRoot, "scripts/map-object-layers.test.mjs"), "utf8");
|
||||
|
||||
for (const marker of ["gridLod1MaxHeightKm", "gridLod5StepKm", "GRID_LOD_HYSTERESIS_RATIO"]) {
|
||||
for (const marker of ["gridLodProfile", "gridLodProfiles", "GRID_LOD_HYSTERESIS_RATIO"]) {
|
||||
if (!policy.includes(marker)) throw new Error(`grid_policy_marker_missing:${marker}`);
|
||||
}
|
||||
for (const marker of ["class GridLayerController", "corridor:", "gridLineDiameterMeters"]) {
|
||||
for (const marker of ["class GridLayerController", "gridLegacyMode", "lineDiameterMeters", "viewer.flyTo(entity"]) {
|
||||
if (!renderer.includes(marker)) throw new Error(`grid_renderer_marker_missing:${marker}`);
|
||||
}
|
||||
if (/function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/.test(renderer)) {
|
||||
throw new Error("grid_renderer_destructive_layer_swap_detected");
|
||||
}
|
||||
for (const marker of ["LOD 5: режим", "3D-линии: диаметр", "Гратикула: толщина"]) {
|
||||
for (const marker of ["LOD ${index + 1}", "Конус видимости 3D", "Размер major-тайла ENU", "Радиус ENU-поля", "Диаметр 3D-линий", "Кружки: диаметр", "Кресты: длина"]) {
|
||||
if (!inspector.includes(marker)) throw new Error(`grid_inspector_marker_missing:${marker}`);
|
||||
}
|
||||
for (const marker of ["gridLod5Mode", "gridLineDiameterMeters", "gridRebuildOnMoveEnd"]) {
|
||||
for (const marker of ["catalog-map-grid-lod-tabs", "flex: 1 1 0"]) {
|
||||
if (!styles.includes(marker)) throw new Error(`grid_styles_marker_missing:${marker}`);
|
||||
}
|
||||
for (const marker of ["gridLodProfiles", "validateGridLodProfiles", "gridRebuildOnMoveEnd"]) {
|
||||
if (!server.includes(marker)) throw new Error(`grid_server_contract_missing:${marker}`);
|
||||
}
|
||||
for (const marker of [
|
||||
"five grid LODs preserve the close 3D and distant graticule contract",
|
||||
"LOD hysteresis holds the previous band around a threshold",
|
||||
"renderer swaps double-buffered data sources and never clears the live grid first",
|
||||
"canonical defaults preserve the exact effective MMAP/MOSCOWMAP five-LOD donor profile",
|
||||
"every LOD owns independent metric and angular spacing and visual fields",
|
||||
"LOD hysteresis holds the previous band for eight percent on either side of a threshold",
|
||||
"renderer uses fixed ENU sectors, angular graticules and a non-blank double-buffer swap",
|
||||
]) {
|
||||
if (!tests.includes(marker)) throw new Error(`grid_regression_missing:${marker}`);
|
||||
}
|
||||
for (const marker of ["void viewer\\.flyTo\\(entity", "HeadingPitchRange\\(0, -0\\.9, 8_000\\)"]) {
|
||||
if (!focusTests.includes(marker)) throw new Error(`map_focus_regression_missing:${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertFresh(path) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFile, 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 workspaceRoot = resolve(scriptDir, "../../..");
|
||||
const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE");
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [patchId = "module-foundry-map-focus-recovery-20260805-001", ...extra] =
|
||||
process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^module-foundry-map-focus-recovery-\d{8}-\d{3}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-module-foundry-map-focus-artifact.mjs "
|
||||
+ "[module-foundry-map-focus-recovery-YYYYMMDD-NNN]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = Object.freeze([
|
||||
"apps/catalog/src/CesiumMapRenderer.tsx",
|
||||
"scripts/map-object-layers.test.mjs",
|
||||
]);
|
||||
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
|
||||
const checksum = `${artifact}.sha256`;
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-foundry-map-focus-"));
|
||||
|
||||
await assertFresh(artifact);
|
||||
await assertMapFocusBoundary();
|
||||
|
||||
try {
|
||||
for (const relativePath of files) {
|
||||
const source = join(foundryRoot, relativePath);
|
||||
const info = await lstat(source);
|
||||
if (!info.isFile() || info.isSymbolicLink()) {
|
||||
throw new Error(`source_file_rejected:${relativePath}`);
|
||||
}
|
||||
const destination = join(stage, "payload", relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await copyFile(source, destination);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=module-foundry\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
|
||||
const sha256 = digest(await readFile(artifact));
|
||||
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact,
|
||||
checksum,
|
||||
sha256,
|
||||
services: ["nodedc-module-foundry"],
|
||||
transition: "restore-entity-aware-map-focus-flight",
|
||||
files,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertMapFocusBoundary() {
|
||||
const renderer = await readFile(join(foundryRoot, files[0]), "utf8");
|
||||
const regression = await readFile(join(foundryRoot, files[1]), "utf8");
|
||||
|
||||
for (const marker of [
|
||||
"void viewer.flyTo(entity, {",
|
||||
"duration: 0.45",
|
||||
"offset: new HeadingPitchRange(0, -0.9, 8_000)",
|
||||
]) {
|
||||
if (!renderer.includes(marker)) throw new Error(`map_focus_renderer_marker_missing:${marker}`);
|
||||
}
|
||||
for (const marker of [
|
||||
"void viewer\\.flyTo\\(entity, \\{",
|
||||
"duration: 0\\.45",
|
||||
"offset: new HeadingPitchRange\\(0, -0\\.9, 8_000\\)",
|
||||
]) {
|
||||
if (!regression.includes(marker)) {
|
||||
throw new Error(`map_focus_regression_marker_missing:${marker}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertFresh(path) {
|
||||
try {
|
||||
await lstat(path);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return;
|
||||
throw error;
|
||||
}
|
||||
throw new Error("artifact_already_exists");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
function digest(value) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||
const [patchId = "platform-device-core-hub-trust-20260810-001", ...extra] = process.argv.slice(2);
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-platform-device-core-hub-trust-artifact.mjs [patch-id]");
|
||||
|
||||
const entries = [
|
||||
"platform/docker-compose.platform-http.yml",
|
||||
"platform/deployment/device-core-hub-trust-v1.json",
|
||||
];
|
||||
const sources = new Map([
|
||||
[entries[0], resolve(platformRoot, "infra/synology/docker-compose.platform-http.yml")],
|
||||
[entries[1], resolve(platformRoot, "infra/deployment/device-core-hub-trust-v1.json")],
|
||||
]);
|
||||
|
||||
await buildArtifact({ patchId, component: "platform", entries, sources });
|
||||
|
||||
async function buildArtifact({ patchId, component, entries, sources }) {
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-platform-device-core-trust-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-${component}-${patchId}.tgz`);
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const entry of entries) {
|
||||
const destination = join(payload, entry);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(sources.get(entry), destination, { force: true });
|
||||
}
|
||||
const compose = await readFile(join(payload, entries[0]), "utf8");
|
||||
for (const required of [
|
||||
"NODEDC_DEVICE_CORE_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token",
|
||||
"source: /volume1/docker/nodedc-platform/secrets/device-core-internal-token",
|
||||
"create_host_path: false",
|
||||
]) if (!compose.includes(required)) throw new Error(`hub_trust_compose_contract_missing:${required}`);
|
||||
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=${component}\ntype=app-overlay\n`, "utf8");
|
||||
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
canonicalTar(target, stage);
|
||||
const sha256 = createHash("sha256").update(await readFile(target)).digest("hex");
|
||||
console.log(JSON.stringify({ ok: true, patchId, component, artifact: target, sha256, entries, services: ["launcher"] }, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTar(target, stage) {
|
||||
const result = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], { encoding: "utf8" });
|
||||
if (result.status !== 0) throw new Error(`tar_failed:${result.stderr || result.stdout}`);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||
const [patchId = "platform-device-manager-public-route-20260810-001", ...extra] = process.argv.slice(2);
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-platform-device-manager-route-artifact.mjs [patch-id]");
|
||||
|
||||
const entries = [
|
||||
"platform/Caddyfile.http",
|
||||
"platform/deployment/device-manager-public-route-v1.json",
|
||||
];
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-manager-route-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`);
|
||||
try {
|
||||
await mkdir(join(payload, "platform/deployment"), { recursive: true });
|
||||
await cp(resolve(platformRoot, "infra/synology/Caddyfile.http"), join(payload, entries[0]), { force: true });
|
||||
await cp(resolve(platformRoot, "infra/deployment/device-manager-public-route-v1.json"), join(payload, entries[1]), { force: true });
|
||||
const caddy = await readFile(join(payload, entries[0]), "utf8");
|
||||
for (const required of ["http://device.nodedc.ru", "reverse_proxy device-manager:18122", "X-Forwarded-Proto https"]) {
|
||||
if (!caddy.includes(required)) throw new Error(`device_manager_route_contract_missing:${required}`);
|
||||
}
|
||||
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
|
||||
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], { encoding: "utf8" });
|
||||
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
const sha256 = createHash("sha256").update(await readFile(target)).digest("hex");
|
||||
console.log(JSON.stringify({ ok: true, patchId, component: "platform", artifact: target, sha256, entries, services: ["reverse-proxy"] }, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix()); info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
services:
|
||||
device-postgres:
|
||||
image: postgres:16-alpine
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: device_plane
|
||||
POSTGRES_USER: device_plane
|
||||
POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
volumes:
|
||||
- type: volume
|
||||
source: device-plane-postgres-data
|
||||
target: /var/lib/postgresql/data
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
networks:
|
||||
- device-plane-private
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-control-core:
|
||||
image: nodedc/device-control-core:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "18120"
|
||||
DEVICE_DATABASE_HOST: device-postgres
|
||||
DEVICE_DATABASE_PORT: "5432"
|
||||
DEVICE_DATABASE_NAME: device_plane
|
||||
DEVICE_DATABASE_USER: device_plane
|
||||
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
DEVICE_DATABASE_POOL_SIZE: "10"
|
||||
DEVICE_DISCOVERY_INGEST_ENABLED: "false"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18120:18120"
|
||||
networks:
|
||||
- device-plane-private
|
||||
depends_on:
|
||||
device-postgres:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-gateway:
|
||||
image: nodedc/device-gateway:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
|
||||
DEVICE_GATEWAY_HEALTH_PORT: "18121"
|
||||
DEVICE_GATEWAY_LISTEN_ENABLED: "false"
|
||||
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1
|
||||
DEVICE_GATEWAY_TCP_PORT: "9921"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS: "100"
|
||||
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000"
|
||||
ports:
|
||||
- "127.0.0.1:18121:18121"
|
||||
networks:
|
||||
- device-plane-private
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-plane-private:
|
||||
name: nodedc-device-plane-private
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
device-plane-postgres-data:
|
||||
name: nodedc-device-plane-postgres-data
|
||||
@@ -1,126 +0,0 @@
|
||||
services:
|
||||
device-postgres:
|
||||
image: postgres:16-alpine
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: device_plane
|
||||
POSTGRES_USER: device_plane
|
||||
POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
volumes:
|
||||
- type: volume
|
||||
source: device-plane-postgres-data
|
||||
target: /var/lib/postgresql/data
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
networks:
|
||||
- device-plane-private
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-control-core:
|
||||
image: nodedc/device-control-core:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "18120"
|
||||
DEVICE_DATABASE_HOST: device-postgres
|
||||
DEVICE_DATABASE_PORT: "5432"
|
||||
DEVICE_DATABASE_NAME: device_plane
|
||||
DEVICE_DATABASE_USER: device_plane
|
||||
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
DEVICE_DATABASE_POOL_SIZE: "10"
|
||||
DEVICE_DISCOVERY_INGEST_ENABLED: "false"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18120:18120"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- device-plane-control
|
||||
depends_on:
|
||||
device-postgres:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-gateway:
|
||||
image: nodedc/device-gateway:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
|
||||
DEVICE_GATEWAY_HEALTH_PORT: "18121"
|
||||
DEVICE_GATEWAY_LISTEN_ENABLED: "false"
|
||||
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1
|
||||
DEVICE_GATEWAY_TCP_PORT: "9921"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS: "100"
|
||||
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000"
|
||||
ports:
|
||||
- "127.0.0.1:18121:18121"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- device-plane-control
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-plane-private:
|
||||
name: nodedc-device-plane-private
|
||||
internal: true
|
||||
device-plane-control:
|
||||
name: nodedc-device-plane-control
|
||||
driver: bridge
|
||||
internal: false
|
||||
driver_opts:
|
||||
com.docker.network.bridge.enable_ip_masquerade: "false"
|
||||
|
||||
volumes:
|
||||
device-plane-postgres-data:
|
||||
name: nodedc-device-plane-postgres-data
|
||||
+5191
-46
File diff suppressed because it is too large
Load Diff
@@ -1,868 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Canonical data-only deploy runner for the dedicated NODE.DC Device Edge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
RUNNER_PATH = Path("/usr/local/sbin/nodedc-edge-deploy")
|
||||
LIVE_ROOT = Path("/home/ndcsudo/nodedc-device-edge/source")
|
||||
INBOX_ROOT = Path("/home/ndcsudo/nodedc-device-edge/deploy/inbox")
|
||||
STATE_ROOT = Path("/var/lib/nodedc-edge-deploy")
|
||||
APPLIED_ROOT = STATE_ROOT / "applied"
|
||||
FAILED_ROOT = STATE_ROOT / "failed"
|
||||
BACKUP_ROOT = STATE_ROOT / "backups"
|
||||
APPLIED_JOURNAL = STATE_ROOT / "state/applied.jsonl"
|
||||
FAILED_JOURNAL = STATE_ROOT / "state/failed.jsonl"
|
||||
DEPLOY_LOCK = STATE_ROOT / "state/deploy.lock"
|
||||
|
||||
DOCKER = "/usr/bin/docker"
|
||||
COMPONENT = "device-edge"
|
||||
ARTIFACT_TYPE = "app-overlay"
|
||||
PATCH_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
|
||||
MAX_ARTIFACT_BYTES = 16 * 1024 * 1024
|
||||
|
||||
COMPOSE_PROJECT = "nodedc-device-edge"
|
||||
BASE_COMPOSE = LIVE_ROOT / "docker-compose.device-edge.yml"
|
||||
INGRESS_COMPOSE = LIVE_ROOT / "docker-compose.device-edge.ingress.yml"
|
||||
RELAY_SERVICE = "device-edge-relay"
|
||||
RELAY_CONTAINER = "nodedc-device-edge-device-edge-relay-1"
|
||||
BACKHAUL_CONTAINER = "nodedc-device-edge-device-edge-backhaul-1"
|
||||
TAILNET_CONTAINER = "nodedc-device-edge-tailnet-1"
|
||||
RELAY_IMAGE = "nodedc/device-edge-relay:local"
|
||||
|
||||
INGRESS_PARENT = "enp1s0f0"
|
||||
INGRESS_SUBNET = "192.168.68.0/22"
|
||||
INGRESS_GATEWAY = "192.168.68.1"
|
||||
INGRESS_IPV4 = "192.168.71.253"
|
||||
INGRESS_PORT = 9921
|
||||
INGRESS_NETWORK = "nodedc-device-edge-ingress"
|
||||
INGRESS_IPV4_APPROVED = True
|
||||
INGRESS_IPV4_APPROVAL = "approved-outside-dhcp-pool"
|
||||
|
||||
ENTRIES = (
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml",
|
||||
"services/device-edge-relay/Dockerfile",
|
||||
"services/device-edge-relay/src",
|
||||
"deployment/device-edge-admission-gate-v1.json",
|
||||
)
|
||||
|
||||
PAYLOAD_FILE_SHA256 = {
|
||||
"docker-compose.device-edge.yml":
|
||||
"666945ffd9512355e610ecd36a9df96936477315150555def93e0243e8ff1e22",
|
||||
"docker-compose.device-edge.ingress.yml":
|
||||
"11bedfd7fdea749ca1bdb3b35b9c136c86b330f51a9001f0b38c4618f6f96108",
|
||||
"services/device-edge-relay/Dockerfile":
|
||||
"f2f15b7618ac2ab3a1d4dd40041d06d1e9a695edcfc168d8835f9560b4897e70",
|
||||
"services/device-edge-relay/src/runtime.mjs":
|
||||
"21e83678980aa61127bf9f3d77982dd485c4aaae208c43818db7bb1cc150b83a",
|
||||
"services/device-edge-relay/src/server.mjs":
|
||||
"1b99ec944f1d3fbadded045b159f08624e2829620cec39a97f6b4b8cdcd2be22",
|
||||
"deployment/device-edge-admission-gate-v1.json":
|
||||
"e6c1f21ff297b451c42b6746bc2063484874435dfa9f1614410a7cbe84f0ce6f",
|
||||
}
|
||||
|
||||
PREDECESSOR_FILE_SHA256 = {
|
||||
"docker-compose.device-edge.yml":
|
||||
"7f13c11d6d4d541964053c0a8cf791e401947d34c42e0f7c26f9f9df26fa00b5",
|
||||
"docker-compose.device-edge.ingress.yml":
|
||||
"a4afd04755530fc3b9be64d1a65f0f7282a9539bcc1985bfd880aa904e1c4d8f",
|
||||
"services/device-edge-relay/Dockerfile":
|
||||
"f2f15b7618ac2ab3a1d4dd40041d06d1e9a695edcfc168d8835f9560b4897e70",
|
||||
"services/device-edge-relay/src/runtime.mjs":
|
||||
"ae8bf8b55603bab266b6fa6e9bc65c9f310a9d94a54db04e2130704e38622ffc",
|
||||
"services/device-edge-relay/src/server.mjs":
|
||||
"e4b051b74f934bd37322440e6a013fb6774a76607da08f9cc1e844fc109c83c1",
|
||||
"deployment/device-edge-ingress-ipvlan-v1.json":
|
||||
"b9ce402db0c059a76f07a8d4a34297aff2250fd1c0d1aed9970b3a88f4e75d7f",
|
||||
}
|
||||
|
||||
PREDECESSOR_ABSENT = {
|
||||
"deployment/device-edge-admission-gate-v1.json",
|
||||
}
|
||||
|
||||
|
||||
class DeployError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def die(message: str) -> None:
|
||||
raise DeployError(message)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def run(command, *, check=True, capture=True, cwd=None, timeout=180):
|
||||
result = subprocess.run(
|
||||
[str(value) for value in command],
|
||||
check=False,
|
||||
capture_output=capture,
|
||||
text=True,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
timeout=timeout,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout or "command failed").strip()
|
||||
die(f"command failed: {command[0]}: {detail}")
|
||||
return result
|
||||
|
||||
|
||||
def docker_json(*args):
|
||||
result = run([DOCKER, *args])
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as error:
|
||||
die(f"Docker JSON response invalid: {error}")
|
||||
|
||||
|
||||
def expected_descriptor():
|
||||
return {
|
||||
"schemaVersion": "nodedc.device-edge.admission-gate.v1",
|
||||
"mode": "single-nic-ipvlan-b2-relay-only",
|
||||
"runtimeHost": "ndcmini12",
|
||||
"component": COMPONENT,
|
||||
"selectedServices": [RELAY_SERVICE],
|
||||
"preservedServices": ["device-edge-backhaul", "tailnet"],
|
||||
"composeProject": COMPOSE_PROJECT,
|
||||
"composeFiles": [
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml",
|
||||
],
|
||||
"parentInterface": INGRESS_PARENT,
|
||||
"lanSubnet": INGRESS_SUBNET,
|
||||
"lanGateway": INGRESS_GATEWAY,
|
||||
"ingressIpv4": INGRESS_IPV4,
|
||||
"ingressIpv4Approval": INGRESS_IPV4_APPROVAL,
|
||||
"ingressNetwork": INGRESS_NETWORK,
|
||||
"deviceTcpListen": f"{INGRESS_IPV4}:{INGRESS_PORT}",
|
||||
"hostPortPublication": "disabled",
|
||||
"healthPublication": "disabled",
|
||||
"privateUpstream": "device-edge-backhaul:19921",
|
||||
"sourceAdmission": "public-ipv4-only",
|
||||
"maxTrackedSourceAddresses": 2048,
|
||||
"maxBytesPerDirection": 262144,
|
||||
"protocolInspection": "gateway-owned",
|
||||
"identityTrust": "claimed-not-ownership-proof",
|
||||
"discoveryLifecycle": "quarantine",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"amneziaHostFullTunnel": "preserved",
|
||||
"routerNatFirewall": "separate-manual-gate",
|
||||
"rollback": "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation",
|
||||
}
|
||||
|
||||
|
||||
def assert_root():
|
||||
if os.geteuid() != 0:
|
||||
die("nodedc-edge-deploy must run as root")
|
||||
|
||||
|
||||
def assert_regular_nonsymlink(path: Path, label: str):
|
||||
if not path.exists() or path.is_symlink() or not path.is_file():
|
||||
die(f"{label} must be a regular non-symlink file")
|
||||
|
||||
|
||||
def parse_manifest(raw: str):
|
||||
values = {}
|
||||
for line in raw.splitlines():
|
||||
if not line or "=" not in line:
|
||||
die("artifact manifest is malformed")
|
||||
key, value = line.split("=", 1)
|
||||
if key in values or key not in {"id", "component", "type"}:
|
||||
die("artifact manifest key set is invalid")
|
||||
values[key] = value
|
||||
if set(values) != {"id", "component", "type"}:
|
||||
die("artifact manifest key set is incomplete")
|
||||
if not PATCH_ID_RE.fullmatch(values["id"]):
|
||||
die("artifact patch id is invalid")
|
||||
if values["component"] != COMPONENT or values["type"] != ARTIFACT_TYPE:
|
||||
die("artifact component/type mismatch")
|
||||
return values
|
||||
|
||||
|
||||
def safe_tar_member(member: tarfile.TarInfo):
|
||||
path = PurePosixPath(member.name)
|
||||
if path.is_absolute() or ".." in path.parts or not path.parts:
|
||||
die("artifact contains an unsafe path")
|
||||
if not (member.isfile() or member.isdir()):
|
||||
die("artifact contains a non-file/non-directory member")
|
||||
lowered = {part.lower() for part in path.parts}
|
||||
if any(
|
||||
part.startswith(".env")
|
||||
or part in {
|
||||
".git",
|
||||
"node_modules",
|
||||
"secrets",
|
||||
"keys",
|
||||
"trust",
|
||||
"runtime",
|
||||
"logs",
|
||||
"uploads",
|
||||
}
|
||||
for part in lowered
|
||||
):
|
||||
die("artifact contains a forbidden boundary")
|
||||
if any(part.startswith("._") for part in path.parts):
|
||||
die("artifact contains AppleDouble metadata")
|
||||
|
||||
|
||||
def load_artifact(artifact: Path, extraction_root: Path):
|
||||
artifact = artifact.resolve(strict=True)
|
||||
if artifact.parent != INBOX_ROOT.resolve(strict=True):
|
||||
die("artifact must be an explicit file in the Device Edge inbox")
|
||||
assert_regular_nonsymlink(artifact, "artifact")
|
||||
if artifact.suffix != ".tgz" or artifact.stat().st_size > MAX_ARTIFACT_BYTES:
|
||||
die("artifact extension/size rejected")
|
||||
|
||||
seen = set()
|
||||
with tarfile.open(artifact, "r:gz") as archive:
|
||||
for member in archive.getmembers():
|
||||
safe_tar_member(member)
|
||||
if member.name in seen:
|
||||
die("artifact contains duplicate members")
|
||||
seen.add(member.name)
|
||||
required = {"manifest.env", "files.txt", "payload"}
|
||||
if not required.issubset(seen):
|
||||
die("artifact top-level contract is incomplete")
|
||||
if any(name.split("/", 1)[0] not in required for name in seen):
|
||||
die("artifact contains an unexpected top-level member")
|
||||
archive.extractall(extraction_root, filter="data")
|
||||
|
||||
manifest = parse_manifest(
|
||||
(extraction_root / "manifest.env").read_text(encoding="utf-8")
|
||||
)
|
||||
entries = tuple(
|
||||
line for line in
|
||||
(extraction_root / "files.txt").read_text(encoding="utf-8").splitlines()
|
||||
if line
|
||||
)
|
||||
if entries != ENTRIES or len(entries) != len(set(entries)):
|
||||
die("Device Edge artifact file selection mismatch")
|
||||
payload = extraction_root / "payload"
|
||||
validate_payload(payload)
|
||||
return manifest, entries, payload, sha256_file(artifact), artifact
|
||||
|
||||
|
||||
def validate_payload(payload: Path):
|
||||
actual_files = {
|
||||
path.relative_to(payload).as_posix(): sha256_file(path)
|
||||
for path in payload.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
if actual_files != PAYLOAD_FILE_SHA256:
|
||||
die("Device Edge artifact payload digest set mismatch")
|
||||
descriptor = json.loads(
|
||||
(payload / "deployment/device-edge-admission-gate-v1.json")
|
||||
.read_text(encoding="utf-8")
|
||||
)
|
||||
if descriptor != expected_descriptor():
|
||||
die("Device Edge ingress descriptor mismatch")
|
||||
|
||||
|
||||
def journal_records(path: Path):
|
||||
if not path.exists():
|
||||
return []
|
||||
records = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
records.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
die(f"journal is malformed: {path}")
|
||||
return records
|
||||
|
||||
|
||||
def assert_new_identity(patch_id: str, artifact_sha256: str):
|
||||
records = journal_records(APPLIED_JOURNAL) + journal_records(FAILED_JOURNAL)
|
||||
if any(record.get("patch") == patch_id for record in records):
|
||||
die("Device Edge patch id is terminally recorded")
|
||||
if any(record.get("sha256") == artifact_sha256 for record in records):
|
||||
die("Device Edge artifact digest is terminally recorded")
|
||||
|
||||
|
||||
def current_source_state():
|
||||
state = {}
|
||||
for relative, expected in PREDECESSOR_FILE_SHA256.items():
|
||||
path = LIVE_ROOT / relative
|
||||
assert_regular_nonsymlink(path, f"predecessor {relative}")
|
||||
state[relative] = sha256_file(path)
|
||||
if state[relative] != expected:
|
||||
die(f"Device Edge predecessor drift: {relative}")
|
||||
for relative in PREDECESSOR_ABSENT:
|
||||
if (LIVE_ROOT / relative).exists():
|
||||
die(f"Device Edge predecessor unexpected path: {relative}")
|
||||
return state
|
||||
|
||||
|
||||
def inspect_container(name: str):
|
||||
response = docker_json("inspect", name)
|
||||
if len(response) != 1:
|
||||
die(f"container inspect cardinality mismatch: {name}")
|
||||
return response[0]
|
||||
|
||||
|
||||
def container_health(container):
|
||||
health = container.get("State", {}).get("Health")
|
||||
return health.get("Status") if health else None
|
||||
|
||||
|
||||
def preserved_runtime_snapshot():
|
||||
snapshot = {}
|
||||
for name in (BACKHAUL_CONTAINER, TAILNET_CONTAINER):
|
||||
container = inspect_container(name)
|
||||
if container.get("State", {}).get("Status") != "running":
|
||||
die(f"preserved Device Edge service is not running: {name}")
|
||||
if name == BACKHAUL_CONTAINER and container_health(container) != "healthy":
|
||||
die("Device Edge backhaul is not healthy")
|
||||
snapshot[name] = {
|
||||
"Id": container.get("Id"),
|
||||
"Image": container.get("Image"),
|
||||
"StartedAt": container.get("State", {}).get("StartedAt"),
|
||||
"RestartCount": container.get("RestartCount"),
|
||||
"PortBindings": container.get("HostConfig", {}).get("PortBindings"),
|
||||
}
|
||||
return snapshot
|
||||
|
||||
|
||||
def assert_preserved_runtime(snapshot):
|
||||
current_snapshot = preserved_runtime_snapshot()
|
||||
for name, expected in snapshot.items():
|
||||
current = current_snapshot[name]
|
||||
if current != expected:
|
||||
die(f"preserved Device Edge runtime changed: {name}")
|
||||
|
||||
|
||||
def validate_predecessor_runtime():
|
||||
relay = inspect_container(RELAY_CONTAINER)
|
||||
if relay.get("State", {}).get("Status") != "running":
|
||||
die("Device Edge IPvlan predecessor relay is not running")
|
||||
if container_health(relay) != "healthy":
|
||||
die("Device Edge IPvlan predecessor relay is not healthy")
|
||||
environment = set(relay.get("Config", {}).get("Env") or [])
|
||||
required = {
|
||||
"DEVICE_EDGE_RELAY_HEALTH_HOST=127.0.0.1",
|
||||
"DEVICE_EDGE_RELAY_INGRESS_ENABLED=true",
|
||||
"DEVICE_EDGE_RELAY_TCP_HOST=0.0.0.0",
|
||||
"DEVICE_EDGE_RELAY_TCP_PORT=9921",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_HOST=device-edge-backhaul",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921",
|
||||
}
|
||||
if not required.issubset(environment):
|
||||
die("Device Edge IPvlan predecessor environment mismatch")
|
||||
bindings = relay.get("HostConfig", {}).get("PortBindings") or {}
|
||||
if bindings not in ({}, None):
|
||||
die("Device Edge IPvlan predecessor host publication mismatch")
|
||||
validate_network_runtime(relay)
|
||||
|
||||
|
||||
def validate_host_network_boundary():
|
||||
if socket.gethostname() != "ndcmini12":
|
||||
die("Device Edge runtime host mismatch")
|
||||
route = run(["/usr/sbin/ip", "-4", "route", "show"]).stdout
|
||||
for line in (
|
||||
"0.0.0.0/1 dev amn0 metric 1",
|
||||
"128.0.0.0/1 dev amn0 metric 1",
|
||||
"default via 192.168.68.1 dev enp1s0f0",
|
||||
"192.168.68.0/22 dev enp1s0f0",
|
||||
):
|
||||
if line not in route:
|
||||
die(f"Device Edge host route boundary mismatch: {line}")
|
||||
if run(["/usr/bin/systemctl", "is-active", "AmneziaVPN.service"]).stdout.strip() != "active":
|
||||
die("AmneziaVPN must remain active for this transition")
|
||||
interface = run([
|
||||
"/usr/sbin/ip", "-4", "-brief", "address", "show", "dev", INGRESS_PARENT,
|
||||
]).stdout
|
||||
if "192.168.68.54/22" not in interface or "UP" not in interface:
|
||||
die("Device Edge physical interface boundary mismatch")
|
||||
|
||||
|
||||
def arp_duplicate_detected(target_ip: str, interface: str, attempts=3):
|
||||
protocol = 0x0806
|
||||
raw = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(protocol))
|
||||
try:
|
||||
raw.bind((interface, 0))
|
||||
source_mac = raw.getsockname()[4]
|
||||
target = socket.inet_aton(target_ip)
|
||||
ethernet = b"\xff" * 6 + source_mac + struct.pack("!H", protocol)
|
||||
arp = struct.pack(
|
||||
"!HHBBH6s4s6s4s",
|
||||
1,
|
||||
0x0800,
|
||||
6,
|
||||
4,
|
||||
1,
|
||||
source_mac,
|
||||
b"\x00" * 4,
|
||||
b"\x00" * 6,
|
||||
target,
|
||||
)
|
||||
raw.setblocking(False)
|
||||
for _ in range(attempts):
|
||||
raw.send(ethernet + arp)
|
||||
deadline = time.monotonic() + 0.7
|
||||
while time.monotonic() < deadline:
|
||||
ready, _, _ = select.select([raw], [], [], deadline - time.monotonic())
|
||||
if not ready:
|
||||
break
|
||||
packet = raw.recv(2048)
|
||||
if len(packet) < 42 or packet[12:14] != b"\x08\x06":
|
||||
continue
|
||||
if packet[28:32] == target and packet[22:28] != source_mac:
|
||||
return True
|
||||
return False
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
def preflight(manifest, artifact_sha256):
|
||||
if not INGRESS_IPV4_APPROVED:
|
||||
die("Device Edge ingress IPv4 approval is not granted")
|
||||
if INGRESS_IPV4_APPROVAL != "approved-outside-dhcp-pool":
|
||||
die("Device Edge ingress IPv4 approval contract mismatch")
|
||||
assert_new_identity(manifest["id"], artifact_sha256)
|
||||
current_source_state()
|
||||
validate_predecessor_runtime()
|
||||
preserved = preserved_runtime_snapshot()
|
||||
validate_host_network_boundary()
|
||||
return preserved
|
||||
|
||||
|
||||
def compose_command(*args, baseline=False):
|
||||
command = [
|
||||
DOCKER,
|
||||
"compose",
|
||||
"--project-name",
|
||||
COMPOSE_PROJECT,
|
||||
"--file",
|
||||
str(BASE_COMPOSE),
|
||||
]
|
||||
if not baseline:
|
||||
command.extend(["--file", str(INGRESS_COMPOSE)])
|
||||
command.extend(args)
|
||||
return command
|
||||
|
||||
|
||||
def ensure_state_directories():
|
||||
for path in (
|
||||
APPLIED_ROOT,
|
||||
FAILED_ROOT,
|
||||
BACKUP_ROOT,
|
||||
APPLIED_JOURNAL.parent,
|
||||
):
|
||||
path.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
os.chmod(path, 0o750)
|
||||
|
||||
|
||||
def acquire_lock():
|
||||
ensure_state_directories()
|
||||
try:
|
||||
descriptor = os.open(
|
||||
DEPLOY_LOCK,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
0o600,
|
||||
)
|
||||
except FileExistsError:
|
||||
die("Device Edge deploy lock is present")
|
||||
os.write(descriptor, f"pid={os.getpid()}\n".encode())
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def release_lock():
|
||||
try:
|
||||
DEPLOY_LOCK.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def create_backup(patch_id: str):
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
backup_id = f"{patch_id}-{timestamp}"
|
||||
backup = BACKUP_ROOT / backup_id
|
||||
backup.mkdir(parents=False, mode=0o750)
|
||||
present = []
|
||||
absent = []
|
||||
for relative in ENTRIES:
|
||||
source = LIVE_ROOT / relative
|
||||
target = backup / "payload" / relative
|
||||
if not source.exists():
|
||||
absent.append(relative)
|
||||
continue
|
||||
present.append(relative)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, target, symlinks=False)
|
||||
else:
|
||||
shutil.copy2(source, target, follow_symlinks=False)
|
||||
(backup / "backup.json").write_text(json.dumps({
|
||||
"schemaVersion": "nodedc.device-edge.backup.v1",
|
||||
"patch": patch_id,
|
||||
"present": present,
|
||||
"absent": absent,
|
||||
}, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||
return backup_id, backup
|
||||
|
||||
|
||||
def publish_payload(payload: Path):
|
||||
for relative in ENTRIES:
|
||||
source = payload / relative
|
||||
target = LIVE_ROOT / relative
|
||||
if target.exists():
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, target, symlinks=False)
|
||||
else:
|
||||
shutil.copy2(source, target, follow_symlinks=False)
|
||||
|
||||
|
||||
def restore_backup(backup: Path):
|
||||
descriptor = json.loads((backup / "backup.json").read_text(encoding="utf-8"))
|
||||
for relative in ENTRIES:
|
||||
target = LIVE_ROOT / relative
|
||||
if target.exists():
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
for relative in descriptor["present"]:
|
||||
source = backup / "payload" / relative
|
||||
target = LIVE_ROOT / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, target, symlinks=False)
|
||||
else:
|
||||
shutil.copy2(source, target, follow_symlinks=False)
|
||||
|
||||
|
||||
def build_relay():
|
||||
run([
|
||||
DOCKER,
|
||||
"build",
|
||||
"--no-cache",
|
||||
"--network=host",
|
||||
"--file",
|
||||
"services/device-edge-relay/Dockerfile",
|
||||
"--tag",
|
||||
RELAY_IMAGE,
|
||||
".",
|
||||
], cwd=LIVE_ROOT, timeout=900, capture=False)
|
||||
|
||||
|
||||
def wait_healthy(name: str, timeout_seconds=150):
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
container = inspect_container(name)
|
||||
except DeployError:
|
||||
time.sleep(2)
|
||||
continue
|
||||
if (
|
||||
container.get("State", {}).get("Status") == "running"
|
||||
and container_health(container) == "healthy"
|
||||
):
|
||||
return container
|
||||
if container.get("State", {}).get("Status") in {"exited", "dead"}:
|
||||
die(f"container stopped before health acceptance: {name}")
|
||||
time.sleep(2)
|
||||
die(f"container health timeout: {name}")
|
||||
|
||||
|
||||
def validate_network_runtime(relay):
|
||||
networks = relay.get("NetworkSettings", {}).get("Networks") or {}
|
||||
if set(networks) != {"nodedc-device-edge-private", INGRESS_NETWORK}:
|
||||
die("Device Edge relay network set mismatch")
|
||||
if networks[INGRESS_NETWORK].get("IPAddress") != INGRESS_IPV4:
|
||||
die("Device Edge relay IPvlan address mismatch")
|
||||
response = docker_json("network", "inspect", INGRESS_NETWORK)
|
||||
if len(response) != 1:
|
||||
die("Device Edge ingress network cardinality mismatch")
|
||||
network = response[0]
|
||||
if network.get("Driver") != "ipvlan" or network.get("Internal") is True:
|
||||
die("Device Edge ingress network driver mismatch")
|
||||
options = network.get("Options") or {}
|
||||
if options.get("parent") != INGRESS_PARENT or options.get("ipvlan_mode") != "l2":
|
||||
die("Device Edge ingress network option mismatch")
|
||||
configs = network.get("IPAM", {}).get("Config") or []
|
||||
if len(configs) != 1:
|
||||
die("Device Edge ingress IPAM cardinality mismatch")
|
||||
if configs[0].get("Subnet") != INGRESS_SUBNET or configs[0].get("Gateway") != INGRESS_GATEWAY:
|
||||
die("Device Edge ingress IPAM mismatch")
|
||||
|
||||
|
||||
def validate_relay_runtime(preserved):
|
||||
relay = wait_healthy(RELAY_CONTAINER)
|
||||
if relay.get("Config", {}).get("User") != "1000:1000":
|
||||
die("Device Edge relay user mismatch")
|
||||
host = relay.get("HostConfig", {})
|
||||
if host.get("ReadonlyRootfs") is not True or host.get("Privileged") is not False:
|
||||
die("Device Edge relay filesystem/privilege mismatch")
|
||||
if set(host.get("CapDrop") or []) != {"ALL"}:
|
||||
die("Device Edge relay capability mismatch")
|
||||
if host.get("PortBindings") not in ({}, None):
|
||||
die("Device Edge relay host port publication detected")
|
||||
environment = set(relay.get("Config", {}).get("Env") or [])
|
||||
required = {
|
||||
"DEVICE_EDGE_RELAY_HEALTH_HOST=127.0.0.1",
|
||||
"DEVICE_EDGE_RELAY_INGRESS_ENABLED=true",
|
||||
"DEVICE_EDGE_RELAY_TCP_HOST=0.0.0.0",
|
||||
"DEVICE_EDGE_RELAY_TCP_PORT=9921",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_HOST=device-edge-backhaul",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921",
|
||||
"DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only",
|
||||
"DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES=2048",
|
||||
"DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION=262144",
|
||||
}
|
||||
if not required.issubset(environment):
|
||||
die("Device Edge relay environment mismatch")
|
||||
validate_network_runtime(relay)
|
||||
health_result = run([
|
||||
DOCKER,
|
||||
"exec",
|
||||
RELAY_CONTAINER,
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:18221/healthz').then(async r=>{if(!r.ok)process.exit(2);console.log(await r.text())}).catch(()=>process.exit(3))",
|
||||
])
|
||||
try:
|
||||
health = json.loads(health_result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
die("Device Edge relay health JSON invalid")
|
||||
expected_health = {
|
||||
"ok": True,
|
||||
"service": "nodedc-device-edge-relay",
|
||||
"ingress": "relay-only",
|
||||
"protocolInspection": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"sourceAdmission": "public-ipv4-only",
|
||||
}
|
||||
for key, expected in expected_health.items():
|
||||
if health.get(key) != expected:
|
||||
die(f"Device Edge relay health contract mismatch: {key}")
|
||||
run([
|
||||
DOCKER,
|
||||
"exec",
|
||||
RELAY_CONTAINER,
|
||||
"node",
|
||||
"-e",
|
||||
"const n=require('node:net');const s=n.connect({host:'device-edge-backhaul',port:19921});s.setTimeout(5000);s.once('connect',()=>{s.destroy();process.exit(0)});s.once('timeout',()=>process.exit(2));s.once('error',()=>process.exit(3))",
|
||||
])
|
||||
validate_host_network_boundary()
|
||||
assert_preserved_runtime(preserved)
|
||||
|
||||
|
||||
def write_journal(path: Path, record):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def archive_artifact(artifact: Path, destination_root: Path):
|
||||
destination = destination_root / artifact.name
|
||||
if destination.exists():
|
||||
die("Device Edge artifact archive collision")
|
||||
os.replace(artifact, destination)
|
||||
return destination
|
||||
|
||||
|
||||
def rollback(backup: Path, preserved):
|
||||
restore_backup(backup)
|
||||
run(compose_command(
|
||||
"up",
|
||||
"--detach",
|
||||
"--no-deps",
|
||||
"--force-recreate",
|
||||
"--pull",
|
||||
"never",
|
||||
RELAY_SERVICE,
|
||||
), cwd=LIVE_ROOT, timeout=300, capture=False)
|
||||
wait_healthy(RELAY_CONTAINER)
|
||||
current_source_state()
|
||||
validate_predecessor_runtime()
|
||||
assert_preserved_runtime(preserved)
|
||||
|
||||
|
||||
def plan_artifact(artifact_argument: str):
|
||||
assert_root()
|
||||
artifact = Path(artifact_argument)
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-edge-plan-") as directory:
|
||||
manifest, entries, _payload, digest, resolved = load_artifact(
|
||||
artifact,
|
||||
Path(directory),
|
||||
)
|
||||
preflight(manifest, digest)
|
||||
print("== plan ==")
|
||||
print(f"artifact={resolved.name}")
|
||||
print(f"sha256={digest}")
|
||||
print(f"id={manifest['id']}")
|
||||
print(f"component={COMPONENT}")
|
||||
print(f"type={ARTIFACT_TYPE}")
|
||||
print(f"payload_root={LIVE_ROOT}")
|
||||
print(f"compose_root={LIVE_ROOT}")
|
||||
print(f"compose_project={COMPOSE_PROJECT}")
|
||||
print("compose_files=docker-compose.device-edge.yml docker-compose.device-edge.ingress.yml")
|
||||
print("build=/usr/bin/docker build --no-cache --network=host -f services/device-edge-relay/Dockerfile -t nodedc/device-edge-relay:local .")
|
||||
print("services=device-edge-relay")
|
||||
print("preserved_services=device-edge-backhaul tailnet")
|
||||
print(f"device_edge_ingress=ipvlan:l2:{INGRESS_PARENT}:{INGRESS_IPV4}:{INGRESS_PORT}/tcp")
|
||||
print(f"device_edge_lan={INGRESS_SUBNET}:gateway:{INGRESS_GATEWAY}")
|
||||
print(f"device_edge_ingress_ipv4_approval={INGRESS_IPV4_APPROVAL}")
|
||||
print("device_edge_host_port_publication=disabled")
|
||||
print("device_edge_health_publication=disabled")
|
||||
print("device_edge_private_upstream=device-edge-backhaul:19921")
|
||||
print("device_edge_source_admission=public-ipv4-only")
|
||||
print("device_edge_source_table_limit=2048")
|
||||
print("device_edge_byte_limit_per_direction=262144")
|
||||
print("device_edge_command_transport=disabled")
|
||||
print("device_edge_discovery_lifecycle=quarantine")
|
||||
print("device_edge_gelios=untouched")
|
||||
print("device_edge_amnezia=preserved:active:host-full-tunnel")
|
||||
print("device_edge_router_nat_firewall=unchanged")
|
||||
print("device_edge_rollback=restore-reviewed-ipvlan-predecessor-no-router-mutation")
|
||||
print("state=new")
|
||||
print("== files ==")
|
||||
for entry in entries:
|
||||
print(f" {entry}")
|
||||
|
||||
|
||||
def apply_artifact(artifact_argument: str):
|
||||
assert_root()
|
||||
artifact = Path(artifact_argument)
|
||||
acquire_lock()
|
||||
manifest = None
|
||||
digest = None
|
||||
resolved = None
|
||||
backup_id = None
|
||||
backup = None
|
||||
preserved = None
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-edge-apply-") as directory:
|
||||
manifest, _entries, payload, digest, resolved = load_artifact(
|
||||
artifact,
|
||||
Path(directory),
|
||||
)
|
||||
preserved = preflight(manifest, digest)
|
||||
backup_id, backup = create_backup(manifest["id"])
|
||||
publish_payload(payload)
|
||||
build_relay()
|
||||
run(compose_command(
|
||||
"up",
|
||||
"--detach",
|
||||
"--no-deps",
|
||||
"--force-recreate",
|
||||
"--pull",
|
||||
"never",
|
||||
RELAY_SERVICE,
|
||||
), cwd=LIVE_ROOT, timeout=300, capture=False)
|
||||
validate_relay_runtime(preserved)
|
||||
archived = archive_artifact(resolved, APPLIED_ROOT)
|
||||
write_journal(APPLIED_JOURNAL, {
|
||||
"status": "ok",
|
||||
"patch": manifest["id"],
|
||||
"component": COMPONENT,
|
||||
"sha256": digest,
|
||||
"artifact": archived.name,
|
||||
"backup": backup_id,
|
||||
"appliedAt": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
print(
|
||||
f"deploy-ok patch={manifest['id']} component={COMPONENT} "
|
||||
f"backup={backup_id}"
|
||||
)
|
||||
except Exception as error:
|
||||
rollback_status = "not-started"
|
||||
if backup is not None and preserved is not None:
|
||||
try:
|
||||
rollback(backup, preserved)
|
||||
rollback_status = "ok"
|
||||
except Exception as rollback_error:
|
||||
rollback_status = f"failed:{type(rollback_error).__name__}"
|
||||
if resolved is not None and resolved.exists():
|
||||
failed_name = (
|
||||
FAILED_ROOT
|
||||
/ f"{resolved.name}.{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
|
||||
)
|
||||
os.replace(resolved, failed_name)
|
||||
if manifest is not None and digest is not None:
|
||||
write_journal(FAILED_JOURNAL, {
|
||||
"status": "failed",
|
||||
"patch": manifest["id"],
|
||||
"component": COMPONENT,
|
||||
"sha256": digest,
|
||||
"backup": backup_id,
|
||||
"rollback": rollback_status,
|
||||
"error": type(error).__name__,
|
||||
"failedAt": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
if rollback_status.startswith("failed"):
|
||||
die(f"apply failed and rollback failed: {error}")
|
||||
die(f"apply failed; automatic rollback={rollback_status}: {error}")
|
||||
finally:
|
||||
release_lock()
|
||||
|
||||
|
||||
def verify_install():
|
||||
assert_root()
|
||||
path = RUNNER_PATH if RUNNER_PATH.exists() else Path(__file__).resolve()
|
||||
assert_regular_nonsymlink(path, "runner")
|
||||
docker_version = run([DOCKER, "version", "--format", "{{.Server.Version}}"]).stdout.strip()
|
||||
compose_version = run([DOCKER, "compose", "version", "--short"]).stdout.strip()
|
||||
print(f"path={path}")
|
||||
print(f"sha256={sha256_file(path)}")
|
||||
print(f"python={sys.version.split()[0]}")
|
||||
print(f"docker={docker_version}")
|
||||
print(f"compose={compose_version}")
|
||||
print(f"device_edge_ingress_ipv4={INGRESS_IPV4}")
|
||||
print(f"device_edge_ingress_ipv4_approval={INGRESS_IPV4_APPROVAL}")
|
||||
print("device_edge_source_admission=public-ipv4-only")
|
||||
print("verify-install-ok")
|
||||
|
||||
|
||||
def main(arguments):
|
||||
if len(arguments) == 1 and arguments[0] == "verify-install":
|
||||
verify_install()
|
||||
return 0
|
||||
if len(arguments) == 2 and arguments[0] == "plan":
|
||||
plan_artifact(arguments[1])
|
||||
return 0
|
||||
if len(arguments) == 2 and arguments[0] == "apply":
|
||||
apply_artifact(arguments[1])
|
||||
return 0
|
||||
print(
|
||||
"usage: nodedc-edge-deploy verify-install | plan <artifact.tgz> | apply <artifact.tgz>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
except DeployError as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -1,323 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = SCRIPT_DIR / "build-device-edge-ingress-artifact.mjs"
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-edge-deploy"
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_edge_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DeviceEdgeIngressArtifactTest(unittest.TestCase):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
return subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
|
||||
def test_builder_is_deterministic_narrow_and_secret_free(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-artifact-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
patch_id = "device-edge-ingress-ipvlan-unit-001"
|
||||
first_result = self.build(artifact_dir, patch_id)
|
||||
self.assertEqual(first_result.returncode, 0, first_result.stderr)
|
||||
first = json.loads(first_result.stdout)
|
||||
first_bytes = Path(first["artifact"]).read_bytes()
|
||||
second_result = self.build(artifact_dir, patch_id)
|
||||
self.assertEqual(second_result.returncode, 0, second_result.stderr)
|
||||
second = json.loads(second_result.stdout)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(first["sha256"], second["sha256"])
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(first["component"], "device-edge")
|
||||
self.assertEqual(first["entries"], list(RUNNER.ENTRIES))
|
||||
self.assertEqual(first["services"], ["device-edge-relay"])
|
||||
self.assertEqual(
|
||||
first["ingress"]["ipv4Approval"],
|
||||
"approved-outside-dhcp-pool",
|
||||
)
|
||||
|
||||
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
names = {member.name for member in members}
|
||||
manifest = archive.extractfile("manifest.env").read().decode()
|
||||
files = archive.extractfile("files.txt").read().decode().splitlines()
|
||||
payload_bytes = b"\n".join(
|
||||
archive.extractfile(member).read()
|
||||
for member in members
|
||||
if member.isfile()
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
manifest,
|
||||
f"id={patch_id}\ncomponent=device-edge\ntype=app-overlay\n",
|
||||
)
|
||||
self.assertEqual(files, list(RUNNER.ENTRIES))
|
||||
self.assertIn(
|
||||
"payload/docker-compose.device-edge.ingress.yml",
|
||||
names,
|
||||
)
|
||||
self.assertNotIn(b"PRIVATE KEY", payload_bytes)
|
||||
self.assertFalse(any(
|
||||
"/test/" in name
|
||||
or "/secrets/" in name
|
||||
or "/keys/" in name
|
||||
or "/trust/" in name
|
||||
or "/node_modules/" in name
|
||||
or Path(name).name.startswith(".env")
|
||||
for name in names
|
||||
))
|
||||
|
||||
def test_production_builder_accepts_the_explicitly_approved_address(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-address-gate-",
|
||||
) as directory:
|
||||
result = self.build(
|
||||
Path(directory),
|
||||
"device-edge-admission-gate-20260804-002",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
built = json.loads(result.stdout)
|
||||
self.assertEqual(
|
||||
built["ingress"]["ipv4Approval"],
|
||||
"approved-outside-dhcp-pool",
|
||||
)
|
||||
self.assertTrue(Path(built["artifact"]).is_file())
|
||||
|
||||
def test_runner_loads_exact_artifact_and_enters_runtime_preflight(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-runner-load-",
|
||||
) as directory:
|
||||
workspace = Path(directory)
|
||||
inbox = workspace / "inbox"
|
||||
inbox.mkdir()
|
||||
result = self.build(
|
||||
inbox,
|
||||
"device-edge-admission-gate-20260804-003",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||
extracted = workspace / "extracted"
|
||||
extracted.mkdir()
|
||||
|
||||
old_inbox = RUNNER.INBOX_ROOT
|
||||
RUNNER.INBOX_ROOT = inbox
|
||||
try:
|
||||
manifest, entries, payload, digest, resolved = (
|
||||
RUNNER.load_artifact(artifact, extracted)
|
||||
)
|
||||
finally:
|
||||
RUNNER.INBOX_ROOT = old_inbox
|
||||
|
||||
self.assertEqual(manifest["component"], "device-edge")
|
||||
self.assertEqual(entries, RUNNER.ENTRIES)
|
||||
self.assertEqual(resolved, artifact.resolve())
|
||||
self.assertEqual(digest, hashlib.sha256(artifact.read_bytes()).hexdigest())
|
||||
self.assertEqual(
|
||||
json.loads(
|
||||
(payload / "deployment/device-edge-admission-gate-v1.json")
|
||||
.read_text(encoding="utf-8")
|
||||
),
|
||||
RUNNER.expected_descriptor(),
|
||||
)
|
||||
preserved = {
|
||||
RUNNER.BACKHAUL_CONTAINER: {"Id": "backhaul"},
|
||||
RUNNER.TAILNET_CONTAINER: {"Id": "tailnet"},
|
||||
}
|
||||
with patch.object(RUNNER, "assert_new_identity"), patch.object(
|
||||
RUNNER,
|
||||
"current_source_state",
|
||||
), patch.object(RUNNER, "validate_predecessor_runtime"), patch.object(
|
||||
RUNNER,
|
||||
"preserved_runtime_snapshot",
|
||||
return_value=preserved,
|
||||
), patch.object(RUNNER, "validate_host_network_boundary"), patch.object(
|
||||
RUNNER,
|
||||
"arp_duplicate_detected",
|
||||
return_value=False,
|
||||
), patch.object(
|
||||
RUNNER,
|
||||
"run",
|
||||
return_value=subprocess.CompletedProcess([], 1, "", ""),
|
||||
):
|
||||
self.assertEqual(RUNNER.preflight(manifest, digest), preserved)
|
||||
|
||||
def test_backup_restore_preserves_exact_predecessor_partition(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-edge-backup-",
|
||||
) as directory:
|
||||
workspace = Path(directory)
|
||||
live = workspace / "live"
|
||||
backups = workspace / "backups"
|
||||
live.mkdir()
|
||||
backups.mkdir()
|
||||
for relative in RUNNER.ENTRIES:
|
||||
if relative in RUNNER.PREDECESSOR_ABSENT:
|
||||
continue
|
||||
target = live / relative
|
||||
if relative.endswith("/src"):
|
||||
target.mkdir(parents=True)
|
||||
(target / "server.mjs").write_text("old\n", encoding="utf-8")
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(f"old:{relative}\n", encoding="utf-8")
|
||||
|
||||
old_live = RUNNER.LIVE_ROOT
|
||||
old_backups = RUNNER.BACKUP_ROOT
|
||||
RUNNER.LIVE_ROOT = live
|
||||
RUNNER.BACKUP_ROOT = backups
|
||||
try:
|
||||
_backup_id, backup = RUNNER.create_backup("unit-backup")
|
||||
for relative in RUNNER.ENTRIES:
|
||||
target = live / relative
|
||||
if target.exists():
|
||||
if target.is_dir():
|
||||
import shutil
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("candidate\n", encoding="utf-8")
|
||||
RUNNER.restore_backup(backup)
|
||||
finally:
|
||||
RUNNER.LIVE_ROOT = old_live
|
||||
RUNNER.BACKUP_ROOT = old_backups
|
||||
|
||||
for relative in RUNNER.PREDECESSOR_ABSENT:
|
||||
self.assertFalse((live / relative).exists())
|
||||
self.assertEqual(
|
||||
(live / "docker-compose.device-edge.yml").read_text(),
|
||||
"old:docker-compose.device-edge.yml\n",
|
||||
)
|
||||
self.assertEqual(
|
||||
(live / "services/device-edge-relay/src/server.mjs").read_text(),
|
||||
"old\n",
|
||||
)
|
||||
|
||||
def test_runner_selection_and_compose_commands_are_exact(self):
|
||||
self.assertTrue(RUNNER.INGRESS_IPV4_APPROVED)
|
||||
self.assertEqual(
|
||||
RUNNER.INGRESS_IPV4_APPROVAL,
|
||||
"approved-outside-dhcp-pool",
|
||||
)
|
||||
self.assertEqual(RUNNER.RELAY_SERVICE, "device-edge-relay")
|
||||
self.assertEqual(
|
||||
RUNNER.expected_descriptor()["preservedServices"],
|
||||
["device-edge-backhaul", "tailnet"],
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.compose_command(
|
||||
"up",
|
||||
"--detach",
|
||||
"--no-deps",
|
||||
"--force-recreate",
|
||||
"--pull",
|
||||
"never",
|
||||
RUNNER.RELAY_SERVICE,
|
||||
),
|
||||
[
|
||||
RUNNER.DOCKER,
|
||||
"compose",
|
||||
"--project-name",
|
||||
RUNNER.COMPOSE_PROJECT,
|
||||
"--file",
|
||||
str(RUNNER.BASE_COMPOSE),
|
||||
"--file",
|
||||
str(RUNNER.INGRESS_COMPOSE),
|
||||
"up",
|
||||
"--detach",
|
||||
"--no-deps",
|
||||
"--force-recreate",
|
||||
"--pull",
|
||||
"never",
|
||||
RUNNER.RELAY_SERVICE,
|
||||
],
|
||||
)
|
||||
self.assertNotIn("down", RUNNER_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
def test_preserved_runtime_is_compared_from_one_atomic_snapshot(self):
|
||||
expected = {
|
||||
RUNNER.BACKHAUL_CONTAINER: {"Id": "backhaul"},
|
||||
RUNNER.TAILNET_CONTAINER: {"Id": "tailnet"},
|
||||
}
|
||||
with patch.object(
|
||||
RUNNER,
|
||||
"preserved_runtime_snapshot",
|
||||
return_value=expected,
|
||||
) as snapshot:
|
||||
RUNNER.assert_preserved_runtime(expected)
|
||||
snapshot.assert_called_once_with()
|
||||
|
||||
def test_network_acceptance_rejects_any_non_ipvlan_substitution(self):
|
||||
relay = {
|
||||
"NetworkSettings": {
|
||||
"Networks": {
|
||||
"nodedc-device-edge-private": {"IPAddress": "172.18.0.4"},
|
||||
RUNNER.INGRESS_NETWORK: {"IPAddress": RUNNER.INGRESS_IPV4},
|
||||
},
|
||||
},
|
||||
}
|
||||
accepted_network = [{
|
||||
"Driver": "ipvlan",
|
||||
"Internal": False,
|
||||
"Options": {
|
||||
"parent": RUNNER.INGRESS_PARENT,
|
||||
"ipvlan_mode": "l2",
|
||||
},
|
||||
"IPAM": {
|
||||
"Config": [{
|
||||
"Subnet": RUNNER.INGRESS_SUBNET,
|
||||
"Gateway": RUNNER.INGRESS_GATEWAY,
|
||||
}],
|
||||
},
|
||||
}]
|
||||
with patch.object(RUNNER, "docker_json", return_value=accepted_network):
|
||||
RUNNER.validate_network_runtime(relay)
|
||||
|
||||
rejected_network = json.loads(json.dumps(accepted_network))
|
||||
rejected_network[0]["Driver"] = "bridge"
|
||||
with patch.object(RUNNER, "docker_json", return_value=rejected_network):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"ingress network driver mismatch",
|
||||
):
|
||||
RUNNER.validate_network_runtime(relay)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,156 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = SCRIPT_DIR / "build-device-plane-artifact.mjs"
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||
EXPECTED_ENTRIES = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
]
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_plane_artifact_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DevicePlaneArtifactTest(unittest.TestCase):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_artifact_is_narrow_safe_and_deterministic(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-artifact-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
first = self.build(
|
||||
artifact_dir,
|
||||
"device-plane-foundation-unit-001",
|
||||
)
|
||||
artifact = Path(first["artifact"])
|
||||
first_bytes = artifact.read_bytes()
|
||||
second = self.build(
|
||||
artifact_dir,
|
||||
"device-plane-foundation-unit-001",
|
||||
)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first["component"], "device-plane")
|
||||
self.assertEqual(first["entries"], EXPECTED_ENTRIES)
|
||||
self.assertEqual(
|
||||
first["services"],
|
||||
["device-control-core", "device-gateway"],
|
||||
)
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(first["sha256"], second["sha256"])
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
|
||||
with tarfile.open(artifact, "r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
names = {member.name for member in members}
|
||||
files = (
|
||||
archive.extractfile("files.txt")
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
.splitlines()
|
||||
)
|
||||
manifest = (
|
||||
archive.extractfile("manifest.env")
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
)
|
||||
compose = (
|
||||
archive.extractfile(
|
||||
"payload/docker-compose.device-plane.yml",
|
||||
)
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
)
|
||||
regular_payloads = [
|
||||
archive.extractfile(member).read()
|
||||
for member in members
|
||||
if member.isfile()
|
||||
]
|
||||
|
||||
self.assertEqual(files, EXPECTED_ENTRIES)
|
||||
self.assertEqual(
|
||||
manifest,
|
||||
"id=device-plane-foundation-unit-001\n"
|
||||
"component=device-plane\n"
|
||||
"type=app-overlay\n",
|
||||
)
|
||||
self.assertIn(
|
||||
"payload/services/device-control-core/Dockerfile",
|
||||
names,
|
||||
)
|
||||
self.assertIn(
|
||||
"payload/services/device-gateway/Dockerfile",
|
||||
names,
|
||||
)
|
||||
self.assertFalse(any(
|
||||
"/test/" in name
|
||||
or "/node_modules/" in name
|
||||
or Path(name).name.startswith(".env")
|
||||
or name.startswith("payload/docs/")
|
||||
or name.startswith("payload/runtime/")
|
||||
or name.startswith("payload/secrets/")
|
||||
for name in names
|
||||
))
|
||||
self.assertNotIn("9921:9921", compose)
|
||||
self.assertIn('DEVICE_GATEWAY_LISTEN_ENABLED: "false"', compose)
|
||||
self.assertIn('DEVICE_DISCOVERY_INGEST_ENABLED: "false"', compose)
|
||||
self.assertNotIn(b"-----BEGIN PRIVATE KEY-----", b"\n".join(
|
||||
regular_payloads,
|
||||
))
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-runner-load-",
|
||||
) as work_directory:
|
||||
manifest_loaded, entries_loaded, payload_loaded = (
|
||||
RUNNER.load_artifact(artifact, Path(work_directory))
|
||||
)
|
||||
self.assertEqual(manifest_loaded["component"], "device-plane")
|
||||
self.assertEqual(entries_loaded, EXPECTED_ENTRIES)
|
||||
self.assertEqual(payload_loaded.name, "payload")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,477 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = (
|
||||
SCRIPT_DIR / "build-device-plane-b2-discovery-ingress-artifact.mjs"
|
||||
)
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||
COMPOSE = (
|
||||
SCRIPT_DIR.parent.parent
|
||||
/ "device-plane/docker-compose.device-plane.yml"
|
||||
)
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_plane_b2_ingress_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DevicePlaneB2DiscoveryIngressArtifactTest(unittest.TestCase):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_artifact_is_exact_deterministic_and_database_free(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-b2-ingress-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
patch_id = "device-plane-b2-discovery-ingress-unit-001"
|
||||
first = self.build(artifact_dir, patch_id)
|
||||
first_bytes = Path(first["artifact"]).read_bytes()
|
||||
second = self.build(artifact_dir, patch_id)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(
|
||||
first["transition"],
|
||||
"verified-b2-loopback-discovery-only",
|
||||
)
|
||||
self.assertEqual(
|
||||
first["services"],
|
||||
["device-control-core", "device-gateway"],
|
||||
)
|
||||
self.assertNotIn("device-postgres", first["services"])
|
||||
self.assertEqual(
|
||||
first["entries"],
|
||||
list(RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES),
|
||||
)
|
||||
|
||||
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||
files = (
|
||||
archive.extractfile("files.txt")
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
.splitlines()
|
||||
)
|
||||
compose = archive.extractfile(
|
||||
"payload/docker-compose.device-plane.yml"
|
||||
).read()
|
||||
descriptor = json.loads(
|
||||
archive.extractfile(
|
||||
"payload/deployment/"
|
||||
"device-plane-b2-discovery-ingress-v1.json"
|
||||
)
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
)
|
||||
edge_manifest = json.loads(
|
||||
archive.extractfile(
|
||||
"payload/services/device-edge-relay/package.json"
|
||||
)
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
)
|
||||
dockerignore = archive.extractfile(
|
||||
"payload/.dockerignore"
|
||||
).read().decode("utf-8")
|
||||
core_dockerfile = archive.extractfile(
|
||||
"payload/services/device-control-core/Dockerfile"
|
||||
).read().decode("utf-8")
|
||||
gateway_dockerfile = archive.extractfile(
|
||||
"payload/services/device-gateway/Dockerfile"
|
||||
).read().decode("utf-8")
|
||||
|
||||
self.assertEqual(files, first["entries"])
|
||||
self.assertEqual(
|
||||
hashlib.sha256(compose).hexdigest(),
|
||||
RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_COMPOSE_SHA256,
|
||||
)
|
||||
self.assertEqual(
|
||||
descriptor,
|
||||
RUNNER.expected_device_plane_b2_discovery_ingress_descriptor(),
|
||||
)
|
||||
self.assertEqual(
|
||||
edge_manifest["name"],
|
||||
"@nodedc/device-edge-relay",
|
||||
)
|
||||
self.assertIn("**/*.prev-*", dockerignore.splitlines())
|
||||
self.assertIn("**/*.next-*", dockerignore.splitlines())
|
||||
for dockerfile in (core_dockerfile, gateway_dockerfile):
|
||||
self.assertNotIn("COPY packages ./packages", dockerfile)
|
||||
self.assertIn(
|
||||
"COPY packages/device-protocol-contract "
|
||||
"./packages/device-protocol-contract",
|
||||
dockerfile,
|
||||
)
|
||||
self.assertIn(
|
||||
"COPY packages/arusnavi-b2-adapter "
|
||||
"./packages/arusnavi-b2-adapter",
|
||||
dockerfile,
|
||||
)
|
||||
|
||||
def test_compose_opens_only_discovery_tcp_and_preserves_database(self):
|
||||
compose = COMPOSE.read_text(encoding="utf-8")
|
||||
postgres, stateless = compose.split(" device-control-core:", 1)
|
||||
self.assertNotIn("9921", postgres)
|
||||
self.assertNotIn("device-plane-control", postgres)
|
||||
self.assertIn(
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
|
||||
stateless,
|
||||
)
|
||||
self.assertIn(
|
||||
'DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false"',
|
||||
stateless,
|
||||
)
|
||||
self.assertIn('"127.0.0.1:9921:9921"', stateless)
|
||||
self.assertNotIn('"0.0.0.0:9921:9921"', stateless)
|
||||
self.assertNotIn("DEVICE_GATEWAY_COMMAND", compose)
|
||||
self.assertNotIn("POSTGRES_PASSWORD:", compose)
|
||||
|
||||
def test_runner_builds_only_stateless_services_and_accepts_new_health(self):
|
||||
entries = RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES
|
||||
self.assertEqual(
|
||||
RUNNER.component_services("device-plane", entries),
|
||||
("device-control-core", "device-gateway"),
|
||||
)
|
||||
builds = RUNNER.component_builds("device-plane", entries)
|
||||
self.assertEqual(len(builds), 2)
|
||||
checks = RUNNER.component_healthchecks(
|
||||
"device-plane",
|
||||
entries,
|
||||
("device-control-core", "device-gateway"),
|
||||
)
|
||||
self.assertEqual(
|
||||
checks[0]["expected_json"]["discoveryIngest"],
|
||||
"enabled",
|
||||
)
|
||||
self.assertEqual(
|
||||
checks[1]["expected_json"]["publicIngress"],
|
||||
"disabled",
|
||||
)
|
||||
self.assertEqual(
|
||||
checks[1]["expected_json"]["commandTransport"],
|
||||
"disabled",
|
||||
)
|
||||
|
||||
def test_ingress_preflight_requires_terminal_recovery_marker(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-b2-recovery-barrier-",
|
||||
) as directory:
|
||||
workspace = Path(directory)
|
||||
built = self.build(
|
||||
workspace,
|
||||
"device-plane-b2-discovery-ingress-unit-recovery-barrier",
|
||||
)
|
||||
extracted = workspace / "extracted"
|
||||
extracted.mkdir()
|
||||
_manifest, _entries, payload = RUNNER.load_artifact(
|
||||
Path(built["artifact"]),
|
||||
extracted,
|
||||
)
|
||||
|
||||
def has_patch(patch_id):
|
||||
return (
|
||||
patch_id
|
||||
== RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_PATCH_ID
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"state_has_patch_id",
|
||||
side_effect=has_patch,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"state_has_sha",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"rollback recovery patch is not applied",
|
||||
):
|
||||
RUNNER.validate_device_plane_b2_discovery_ingress_evidence(
|
||||
payload
|
||||
)
|
||||
|
||||
def test_runtime_acceptance_preserves_postgres_and_replaces_stateless(self):
|
||||
before = {
|
||||
"schemaVersion": "nodedc.device-plane.runtime-inventory.v1",
|
||||
"composeProject": "nodedc-device-plane",
|
||||
"services": [
|
||||
runtime_item("device-control-core", "1", "a"),
|
||||
runtime_item("device-gateway", "2", "b"),
|
||||
runtime_item("device-postgres", "3", "c"),
|
||||
],
|
||||
}
|
||||
containers = {
|
||||
"core": stateless_container(
|
||||
service="device-control-core",
|
||||
container_id="4" * 64,
|
||||
image_id="sha256:" + "d" * 64,
|
||||
image=RUNNER.DEVICE_PLANE_CONTROL_CORE_IMAGE,
|
||||
ports={
|
||||
"18120/tcp": [{
|
||||
"HostIp": "127.0.0.1",
|
||||
"HostPort": "18120",
|
||||
}],
|
||||
},
|
||||
environment={
|
||||
"DEVICE_DISCOVERY_INGEST_ENABLED": "true",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN_FILE":
|
||||
"/run/nodedc-secrets/gateway-core-token",
|
||||
"DEVICE_IDENTIFIER_PEPPER_FILE":
|
||||
"/run/nodedc-secrets/identifier-pepper",
|
||||
},
|
||||
mounts=[
|
||||
secret_mount(
|
||||
RUNNER.DEVICE_PLANE_POSTGRES_PASSWORD_FILE,
|
||||
"/run/nodedc-secrets/postgres-password",
|
||||
),
|
||||
secret_mount(
|
||||
RUNNER.DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE,
|
||||
"/run/nodedc-secrets/gateway-core-token",
|
||||
),
|
||||
secret_mount(
|
||||
RUNNER.DEVICE_PLANE_IDENTIFIER_PEPPER_FILE,
|
||||
"/run/nodedc-secrets/identifier-pepper",
|
||||
),
|
||||
],
|
||||
),
|
||||
"gateway": stateless_container(
|
||||
service="device-gateway",
|
||||
container_id="5" * 64,
|
||||
image_id="sha256:" + "e" * 64,
|
||||
image=RUNNER.DEVICE_PLANE_GATEWAY_IMAGE,
|
||||
ports={
|
||||
"18121/tcp": [{
|
||||
"HostIp": "127.0.0.1",
|
||||
"HostPort": "18121",
|
||||
}],
|
||||
"9921/tcp": [{
|
||||
"HostIp": "127.0.0.1",
|
||||
"HostPort": "9921",
|
||||
}],
|
||||
},
|
||||
environment={
|
||||
"DEVICE_GATEWAY_LISTEN_ENABLED": "true",
|
||||
"DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED": "false",
|
||||
"DEVICE_GATEWAY_TCP_HOST": "127.0.0.1",
|
||||
"DEVICE_GATEWAY_TCP_PORT": "9921",
|
||||
"DEVICE_GATEWAY_CORE_URL":
|
||||
"http://device-control-core:18120",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN_FILE":
|
||||
"/run/nodedc-secrets/gateway-core-token",
|
||||
"DEVICE_GATEWAY_CORE_TIMEOUT_MS": "5000",
|
||||
"DEVICE_GATEWAY_MAX_BUFFERED_BYTES": "65536",
|
||||
"DEVICE_GATEWAY_MAX_SESSIONS": "100",
|
||||
"DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS": "10",
|
||||
"DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS":
|
||||
"30",
|
||||
"DEVICE_GATEWAY_SESSION_TIMEOUT_MS": "10000",
|
||||
},
|
||||
mounts=[
|
||||
secret_mount(
|
||||
RUNNER.DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE,
|
||||
"/run/nodedc-secrets/gateway-core-token",
|
||||
),
|
||||
],
|
||||
),
|
||||
"postgres": postgres_container(),
|
||||
}
|
||||
service_ids = {
|
||||
"device-control-core": ("core",),
|
||||
"device-gateway": ("gateway",),
|
||||
"device-postgres": ("postgres",),
|
||||
}
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_runtime_secret_metadata",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_plane_service_container_ids",
|
||||
side_effect=lambda service: service_ids[service],
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"inspect_device_plane_container",
|
||||
side_effect=lambda container_id: containers[container_id],
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_network_contract",
|
||||
) as network,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"assert_loopback_tcp_port_open",
|
||||
) as port_open,
|
||||
):
|
||||
accepted = (
|
||||
RUNNER.validate_device_plane_b2_discovery_ingress_runtime(
|
||||
before
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
accepted["device-postgres"]["containerId"],
|
||||
"3" * 64,
|
||||
)
|
||||
self.assertEqual(network.call_count, 2)
|
||||
port_open.assert_called_once_with(9921)
|
||||
|
||||
|
||||
def runtime_item(service, container_digit, image_digit):
|
||||
return {
|
||||
"service": service,
|
||||
"containerId": container_digit * 64,
|
||||
"imageId": "sha256:" + image_digit * 64,
|
||||
"status": "running",
|
||||
"running": True,
|
||||
"health": "healthy",
|
||||
"restartCount": 0,
|
||||
}
|
||||
|
||||
|
||||
def secret_mount(source, destination):
|
||||
return {
|
||||
"Type": "bind",
|
||||
"Source": str(source),
|
||||
"Destination": destination,
|
||||
"RW": False,
|
||||
}
|
||||
|
||||
|
||||
def stateless_container(
|
||||
*,
|
||||
service,
|
||||
container_id,
|
||||
image_id,
|
||||
image,
|
||||
ports,
|
||||
environment,
|
||||
mounts,
|
||||
):
|
||||
return {
|
||||
"Id": container_id,
|
||||
"Image": image_id,
|
||||
"RestartCount": 0,
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": True,
|
||||
"Restarting": False,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"Health": {"Status": "healthy"},
|
||||
},
|
||||
"Config": {
|
||||
"Image": image,
|
||||
"User": "1000:1000",
|
||||
"Labels": {
|
||||
"com.docker.compose.project": "nodedc-device-plane",
|
||||
"com.docker.compose.service": service,
|
||||
},
|
||||
"Env": [f"{key}={value}" for key, value in environment.items()],
|
||||
},
|
||||
"HostConfig": {
|
||||
"PortBindings": ports,
|
||||
"RestartPolicy": {"Name": "unless-stopped"},
|
||||
"ReadonlyRootfs": True,
|
||||
"CapDrop": ["ALL"],
|
||||
"SecurityOpt": ["no-new-privileges:true"],
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Ports": ports,
|
||||
"Networks": {
|
||||
RUNNER.DEVICE_PLANE_PRIVATE_NETWORK: {},
|
||||
RUNNER.DEVICE_PLANE_CONTROL_NETWORK: {},
|
||||
},
|
||||
},
|
||||
"Mounts": mounts,
|
||||
}
|
||||
|
||||
|
||||
def postgres_container():
|
||||
return {
|
||||
"Id": "3" * 64,
|
||||
"Image": "sha256:" + "c" * 64,
|
||||
"RestartCount": 0,
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": True,
|
||||
"Restarting": False,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"Health": {"Status": "healthy"},
|
||||
},
|
||||
"Config": {
|
||||
"Image": "postgres:16-alpine",
|
||||
"User": "",
|
||||
"Labels": {
|
||||
"com.docker.compose.project": "nodedc-device-plane",
|
||||
"com.docker.compose.service": "device-postgres",
|
||||
},
|
||||
"Env": [],
|
||||
},
|
||||
"HostConfig": {
|
||||
"PortBindings": {},
|
||||
"RestartPolicy": {"Name": "unless-stopped"},
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Ports": {},
|
||||
"Networks": {
|
||||
RUNNER.DEVICE_PLANE_PRIVATE_NETWORK: {},
|
||||
},
|
||||
},
|
||||
"Mounts": [
|
||||
secret_mount(
|
||||
RUNNER.DEVICE_PLANE_POSTGRES_PASSWORD_FILE,
|
||||
"/run/nodedc-secrets/postgres-password",
|
||||
),
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": RUNNER.DEVICE_PLANE_POSTGRES_VOLUME,
|
||||
"Destination": "/var/lib/postgresql/data",
|
||||
"RW": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,323 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = (
|
||||
SCRIPT_DIR
|
||||
/ "build-device-plane-b2-discovery-loopback-recovery-artifact.mjs"
|
||||
)
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_plane_b2_recovery_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DevicePlaneB2DiscoveryLoopbackRecoveryArtifactTest(
|
||||
unittest.TestCase,
|
||||
):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_artifact_is_marker_only_exact_and_deterministic(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-b2-recovery-artifact-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
patch_id = RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_PATCH_ID
|
||||
first = self.build(artifact_dir, patch_id)
|
||||
first_bytes = Path(first["artifact"]).read_bytes()
|
||||
second = self.build(artifact_dir, patch_id)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(first["build"], [])
|
||||
self.assertEqual(first["services"], [])
|
||||
self.assertEqual(
|
||||
first["entries"],
|
||||
list(
|
||||
RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_ENTRIES
|
||||
),
|
||||
)
|
||||
|
||||
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||
names = {
|
||||
member.name
|
||||
for member in archive.getmembers()
|
||||
if member.isfile()
|
||||
}
|
||||
descriptor = json.loads(
|
||||
archive.extractfile(
|
||||
"payload/"
|
||||
+ RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_REL
|
||||
)
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
)
|
||||
self.assertEqual(
|
||||
names,
|
||||
{
|
||||
"manifest.env",
|
||||
"files.txt",
|
||||
"payload/"
|
||||
+ RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_REL,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
descriptor,
|
||||
RUNNER.expected_device_plane_b2_discovery_rollback_recovery_descriptor(),
|
||||
)
|
||||
entries = (
|
||||
RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_ENTRIES
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_services("device-plane", entries),
|
||||
(),
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_builds("device-plane", entries),
|
||||
(),
|
||||
)
|
||||
|
||||
def test_recovery_preflight_requires_exact_failed_evidence(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-b2-recovery-preflight-",
|
||||
) as directory:
|
||||
workspace = Path(directory)
|
||||
artifacts = workspace / "artifacts"
|
||||
failed_root = workspace / "failed"
|
||||
backups_root = workspace / "backups"
|
||||
state_root = workspace / "state"
|
||||
temp_root = workspace / "tmp"
|
||||
live_root = workspace / "live"
|
||||
for path in (
|
||||
artifacts,
|
||||
failed_root,
|
||||
backups_root,
|
||||
state_root,
|
||||
temp_root,
|
||||
live_root,
|
||||
):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recovery = self.build(
|
||||
artifacts,
|
||||
RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_PATCH_ID,
|
||||
)
|
||||
extracted = workspace / "extracted"
|
||||
extracted.mkdir()
|
||||
_manifest, entries, payload = RUNNER.load_artifact(
|
||||
Path(recovery["artifact"]),
|
||||
extracted,
|
||||
)
|
||||
self.assertEqual(
|
||||
tuple(entries),
|
||||
RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_ENTRIES,
|
||||
)
|
||||
|
||||
failed_artifact = (
|
||||
failed_root / RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_ARTIFACT
|
||||
)
|
||||
failed_artifact.write_bytes(b"failed-b2-artifact-fixture\n")
|
||||
failed_sha = RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_ARTIFACT_SHA256
|
||||
|
||||
backup = (
|
||||
backups_root
|
||||
/ RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_BACKUP_ID
|
||||
)
|
||||
backup.mkdir()
|
||||
backup_names = (
|
||||
"manifest.env",
|
||||
"files.txt",
|
||||
"existing-files.txt",
|
||||
"missing-files.txt",
|
||||
"runtime-before.json",
|
||||
"source-before.tgz",
|
||||
)
|
||||
for name in backup_names:
|
||||
(backup / name).write_text(
|
||||
f"fixture:{name}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
backup_hashes = {
|
||||
name: hashlib.sha256((backup / name).read_bytes()).hexdigest()
|
||||
for name in backup_names
|
||||
}
|
||||
|
||||
(state_root / "failed.jsonl").write_text(
|
||||
json.dumps({
|
||||
"artifact": RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_ARTIFACT,
|
||||
"backup_id": (
|
||||
RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_BACKUP_ID
|
||||
),
|
||||
"component": "device-plane",
|
||||
"id": RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_PATCH_ID,
|
||||
"message": (
|
||||
"Command '['/usr/local/bin/docker', 'build', "
|
||||
"'--no-cache', '--network=host', '-f', "
|
||||
"'services/device-control-core/Dockerfile', '-t', "
|
||||
"'nodedc/device-control-core:local', '.']' returned "
|
||||
"non-zero exit status 1."
|
||||
),
|
||||
"rollback_status": "failed:CalledProcessError",
|
||||
"sha256": failed_sha,
|
||||
"started_apply": True,
|
||||
"status": "failed",
|
||||
})
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runtime = {"accepted": True}
|
||||
failed_manifest = {
|
||||
"id": RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_PATCH_ID,
|
||||
"component": "device-plane",
|
||||
"type": "app-overlay",
|
||||
}
|
||||
with (
|
||||
mock.patch.object(RUNNER, "BACKUPS_DIR", backups_root),
|
||||
mock.patch.object(RUNNER, "FAILED_DIR", failed_root),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"FAILED_STATE_FILE",
|
||||
state_root / "failed.jsonl",
|
||||
),
|
||||
mock.patch.object(RUNNER, "TMP_DIR", temp_root),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"component_root",
|
||||
return_value=live_root,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_BACKUP_SHA256",
|
||||
backup_hashes,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"sha256_file",
|
||||
side_effect=lambda path: (
|
||||
failed_sha
|
||||
if Path(path) == failed_artifact
|
||||
else hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"load_artifact",
|
||||
return_value=(
|
||||
failed_manifest,
|
||||
list(RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_ENTRIES),
|
||||
workspace / "unused-payload",
|
||||
),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_foundation_network_publication_installed_source",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_foundation_runtime",
|
||||
return_value=runtime,
|
||||
),
|
||||
mock.patch.object(RUNNER, "assert_loopback_tcp_port_closed"),
|
||||
):
|
||||
evidence = (
|
||||
RUNNER.validate_device_plane_b2_discovery_rollback_recovery_evidence(
|
||||
payload
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
evidence["mode"],
|
||||
"failed-b2-loopback-build-reconciliation",
|
||||
)
|
||||
self.assertEqual(evidence["runtime"], runtime)
|
||||
|
||||
def test_build_failure_does_not_mark_runtime_started(self):
|
||||
marker = mock.Mock()
|
||||
entries = RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES
|
||||
services = ("device-control-core", "device-gateway")
|
||||
failure = subprocess.CalledProcessError(1, ["docker", "build"])
|
||||
with (
|
||||
mock.patch.object(RUNNER, "run_build", side_effect=failure),
|
||||
mock.patch.object(RUNNER, "prepare_component_runtime") as prepare,
|
||||
mock.patch.object(RUNNER, "run_compose") as compose,
|
||||
):
|
||||
with self.assertRaises(subprocess.CalledProcessError):
|
||||
RUNNER.run_device_plane_runtime_for_apply(
|
||||
entries,
|
||||
services,
|
||||
marker,
|
||||
)
|
||||
marker.assert_not_called()
|
||||
prepare.assert_not_called()
|
||||
compose.assert_not_called()
|
||||
|
||||
def test_compose_failure_is_marked_after_build_and_prepare(self):
|
||||
events = []
|
||||
|
||||
def mark():
|
||||
events.append("mark")
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"run_build",
|
||||
side_effect=lambda *_args: events.append("build"),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"prepare_component_runtime",
|
||||
side_effect=lambda *_args: events.append("prepare"),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"run_compose",
|
||||
side_effect=RuntimeError("compose failed"),
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "compose failed"):
|
||||
RUNNER.run_device_plane_runtime_for_apply(
|
||||
RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES,
|
||||
("device-control-core", "device-gateway"),
|
||||
mark,
|
||||
)
|
||||
self.assertEqual(events, ["build", "prepare", "mark"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,649 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = SCRIPT_DIR / "build-device-plane-backhaul-target-artifact.mjs"
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||
COMPOSE = (
|
||||
SCRIPT_DIR.parent.parent
|
||||
/ "device-plane/docker-compose.device-plane.backhaul-target.yml"
|
||||
)
|
||||
PREDECESSOR_COMPOSE = (
|
||||
SCRIPT_DIR.parent.parent / "device-plane/docker-compose.device-plane.yml"
|
||||
)
|
||||
SSHD_CONFIG = (
|
||||
SCRIPT_DIR.parent.parent
|
||||
/ "device-plane/services/device-backhaul-target/sshd_config"
|
||||
)
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_plane_backhaul_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
def valid_public_key(comment="nodedc-device-edge-backhaul"):
|
||||
blob = b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20" + bytes(range(32))
|
||||
return f"ssh-ed25519 {base64.b64encode(blob).decode()} {comment}\n"
|
||||
|
||||
|
||||
def healthy_inventory():
|
||||
return {
|
||||
"schemaVersion": "nodedc.device-plane.runtime-inventory.v1",
|
||||
"composeProject": "nodedc-device-plane",
|
||||
"services": [
|
||||
{
|
||||
"service": service,
|
||||
"containerId": character * 64,
|
||||
"imageId": f"sha256:{character * 64}",
|
||||
"status": "running",
|
||||
"running": True,
|
||||
"health": "healthy",
|
||||
"restartCount": 0,
|
||||
}
|
||||
for service, character in (
|
||||
("device-control-core", "a"),
|
||||
("device-gateway", "b"),
|
||||
("device-postgres", "c"),
|
||||
)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class DevicePlaneBackhaulTargetArtifactTest(unittest.TestCase):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_artifact_is_exact_deterministic_and_contains_no_keys(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-backhaul-artifact-",
|
||||
) as directory:
|
||||
target = Path(directory)
|
||||
first = self.build(target, "device-plane-backhaul-target-unit-001")
|
||||
first_bytes = Path(first["artifact"]).read_bytes()
|
||||
second = self.build(target, "device-plane-backhaul-target-unit-001")
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(first["sha256"], hashlib.sha256(first_bytes).hexdigest())
|
||||
self.assertEqual(
|
||||
first["entries"],
|
||||
list(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES),
|
||||
)
|
||||
self.assertEqual(first["services"], ["device-backhaul-target"])
|
||||
|
||||
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||
names = archive.getnames()
|
||||
files = archive.extractfile("files.txt").read().decode().splitlines()
|
||||
descriptor = json.loads(
|
||||
archive.extractfile(
|
||||
"payload/deployment/"
|
||||
"device-plane-backhaul-target-tailnet-serve-v1.json"
|
||||
).read()
|
||||
)
|
||||
self.assertEqual(files, first["entries"])
|
||||
self.assertEqual(
|
||||
descriptor,
|
||||
RUNNER.expected_device_plane_backhaul_target_descriptor(),
|
||||
)
|
||||
self.assertFalse(any(
|
||||
name.endswith((".key", ".pem", "authorized_keys"))
|
||||
for name in names
|
||||
))
|
||||
|
||||
def test_registry_selects_only_target_and_preserves_red_boundaries(self):
|
||||
entries = RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES
|
||||
self.assertEqual(
|
||||
RUNNER.component_services("device-plane", entries),
|
||||
("device-backhaul-target",),
|
||||
)
|
||||
builds = RUNNER.component_builds("device-plane", entries)
|
||||
self.assertEqual(len(builds), 1)
|
||||
self.assertIn("services/device-backhaul-target/Dockerfile", builds[0][1])
|
||||
self.assertIn(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_IMAGE, builds[0][1])
|
||||
for path in (
|
||||
RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL,
|
||||
"services/device-backhaul-target/Dockerfile",
|
||||
"services/device-backhaul-target/sshd_config",
|
||||
RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_REL,
|
||||
):
|
||||
self.assertTrue(RUNNER.allowed_payload_path("device-plane", path))
|
||||
|
||||
compose = COMPOSE.read_text(encoding="utf-8")
|
||||
sshd = SSHD_CONFIG.read_text(encoding="utf-8")
|
||||
self.assertIn("network_mode: host", compose)
|
||||
self.assertNotIn("0.0.0.0:2222", compose)
|
||||
self.assertIn('"127.0.0.1", "2222"', compose)
|
||||
self.assertIn("ListenAddress 127.0.0.1", sshd)
|
||||
self.assertIn("AllowTcpForwarding local", sshd)
|
||||
self.assertIn("PermitOpen 127.0.0.1:9921", sshd)
|
||||
self.assertIn("ForceCommand /bin/false", sshd)
|
||||
self.assertIn("PasswordAuthentication no", sshd)
|
||||
|
||||
def test_preflight_requires_exact_applied_006_and_enrollment_key(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-backhaul-preflight-",
|
||||
) as directory:
|
||||
work = Path(directory)
|
||||
built = self.build(work, "device-plane-backhaul-target-unit-002")
|
||||
extracted = work / "extracted"
|
||||
extracted.mkdir()
|
||||
_manifest, _entries, payload = RUNNER.load_artifact(
|
||||
Path(built["artifact"]),
|
||||
extracted,
|
||||
)
|
||||
live = work / "live"
|
||||
live.mkdir()
|
||||
(live / "docker-compose.device-plane.yml").write_bytes(
|
||||
PREDECESSOR_COMPOSE.read_bytes()
|
||||
)
|
||||
descriptor = live / RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL
|
||||
descriptor.parent.mkdir(parents=True)
|
||||
descriptor.write_text(
|
||||
json.dumps(RUNNER.expected_device_plane_b2_discovery_ingress_descriptor()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
enrollment = work / "device-edge-backhaul.pub"
|
||||
enrollment.write_text(valid_public_key(), encoding="ascii")
|
||||
|
||||
def has_patch(value):
|
||||
return value == RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_PREDECESSOR_PATCH_ID
|
||||
|
||||
def has_sha(value):
|
||||
return value == RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_PREDECESSOR_ARTIFACT_SHA256
|
||||
|
||||
with (
|
||||
mock.patch.object(RUNNER, "component_root", return_value=live),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_BACKHAUL_ENROLLMENT_PUBLIC_KEY_FILE",
|
||||
enrollment,
|
||||
),
|
||||
mock.patch.object(RUNNER, "state_has_patch_id", side_effect=has_patch),
|
||||
mock.patch.object(RUNNER, "state_has_sha", side_effect=has_sha),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_plane_service_container_ids",
|
||||
return_value=[],
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_plane_runtime_inventory",
|
||||
return_value=healthy_inventory(),
|
||||
),
|
||||
mock.patch.object(RUNNER, "assert_loopback_tcp_port_open"),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_backhaul_failed_evidence",
|
||||
return_value={
|
||||
"backup": work / "failed-backup",
|
||||
"failedArtifact": work / "failed-artifact.tgz",
|
||||
},
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_tailscale_cli",
|
||||
return_value={
|
||||
"binary": str(RUNNER.DEVICE_PLANE_TAILSCALE),
|
||||
"uid": 1024,
|
||||
"gid": 1024,
|
||||
"binarySha256": "d" * 64,
|
||||
},
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_tailscale_runtime",
|
||||
return_value={
|
||||
"self": {
|
||||
"Online": True,
|
||||
"TailscaleIPs": ["100.109.216.21"],
|
||||
},
|
||||
"serve": {},
|
||||
},
|
||||
),
|
||||
):
|
||||
accepted = RUNNER.validate_device_plane_backhaul_target_evidence(payload)
|
||||
self.assertEqual(
|
||||
accepted["mode"],
|
||||
"failed-backhaul-target-to-loopback-tailnet-serve",
|
||||
)
|
||||
self.assertRegex(accepted["enrollmentPublicKeySha256"], r"^[a-f0-9]{64}$")
|
||||
self.assertEqual(accepted["tailscaleServeBefore"], {})
|
||||
self.assertEqual(accepted["tailscaleCli"]["uid"], 1024)
|
||||
|
||||
def test_registered_health_gate_checks_preserved_and_target_services(self):
|
||||
with mock.patch.object(RUNNER, "healthcheck_compose_service") as health:
|
||||
RUNNER.run_healthchecks(
|
||||
"device-plane",
|
||||
RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES,
|
||||
(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,),
|
||||
)
|
||||
self.assertEqual(
|
||||
[call.args[1] for call in health.call_args_list],
|
||||
[
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
"device-backhaul-target",
|
||||
],
|
||||
)
|
||||
|
||||
def test_candidate_rollback_removes_only_target_and_preserves_runtime(self):
|
||||
runtime = healthy_inventory()
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"read_backup_path_list",
|
||||
side_effect=[[], list(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES)],
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_backup_partition",
|
||||
return_value=(set(), set(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES)),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"read_strict_json",
|
||||
side_effect=[runtime, {}],
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"disable_device_plane_tailscale_serve",
|
||||
) as disable_serve,
|
||||
mock.patch.object(RUNNER, "stop_and_remove_compose_services") as stop,
|
||||
mock.patch.object(RUNNER, "restore_platform_overlay", return_value=3),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_plane_service_container_ids",
|
||||
return_value=[],
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_plane_runtime_inventory",
|
||||
return_value=runtime,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_tailscale_runtime",
|
||||
return_value={"serve": {}},
|
||||
),
|
||||
mock.patch.object(RUNNER, "assert_loopback_tcp_port_open") as port,
|
||||
):
|
||||
result = RUNNER.rollback_device_plane_apply(
|
||||
Path("/unused/live"),
|
||||
Path("/unused/backup"),
|
||||
RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES,
|
||||
"20260803-000000",
|
||||
True,
|
||||
(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,),
|
||||
)
|
||||
self.assertEqual(
|
||||
result,
|
||||
"tailscale-serve-restored-source-restored-target-removed-"
|
||||
"preserved-runtime-unchanged:3",
|
||||
)
|
||||
stop.assert_called_once_with(
|
||||
"device-plane",
|
||||
(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,),
|
||||
)
|
||||
disable_serve.assert_called_once_with({})
|
||||
port.assert_called_once_with(9921)
|
||||
|
||||
def test_tailscale_serve_port_parser_rejects_funnel_and_nested_collision(self):
|
||||
clean = {"TCP": {"443": {"HTTPS": True}}}
|
||||
self.assertEqual(
|
||||
RUNNER.device_plane_tailscale_handlers_for_port(clean, 2222),
|
||||
[],
|
||||
)
|
||||
active = {
|
||||
"TCP": {"2222": {"TCPForward": "127.0.0.1:2222"}},
|
||||
"Foreground": {
|
||||
"session": {
|
||||
"TCP": {"443": {"HTTPS": True}},
|
||||
},
|
||||
},
|
||||
}
|
||||
self.assertEqual(
|
||||
RUNNER.device_plane_tailscale_handlers_for_port(active, 2222),
|
||||
[((), {"TCPForward": "127.0.0.1:2222"})],
|
||||
)
|
||||
self.assertFalse(
|
||||
RUNNER.device_plane_tailscale_funnel_uses_port(active, 2222)
|
||||
)
|
||||
active["AllowFunnel"] = {"edge.example.ts.net:2222": True}
|
||||
self.assertTrue(
|
||||
RUNNER.device_plane_tailscale_funnel_uses_port(active, 2222)
|
||||
)
|
||||
|
||||
def test_runtime_activation_enables_private_tailscale_serve_after_health(self):
|
||||
calls = []
|
||||
with (
|
||||
mock.patch.object(RUNNER, "run_build", side_effect=lambda *a: calls.append("build")),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"prepare_component_runtime",
|
||||
side_effect=lambda *a: calls.append("prepare"),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"run_compose",
|
||||
side_effect=lambda *a: calls.append("compose"),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service",
|
||||
side_effect=lambda *a: calls.append("health"),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"enable_device_plane_tailscale_serve",
|
||||
side_effect=lambda *a: calls.append("serve"),
|
||||
) as enable,
|
||||
):
|
||||
RUNNER.run_device_plane_runtime_for_apply(
|
||||
RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES,
|
||||
(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,),
|
||||
lambda: calls.append("started"),
|
||||
backhaul_serve_before={"TCP": {"443": {"HTTPS": True}}},
|
||||
)
|
||||
self.assertEqual(
|
||||
calls,
|
||||
["build", "prepare", "started", "compose", "health", "serve"],
|
||||
)
|
||||
enable.assert_called_once_with({"TCP": {"443": {"HTTPS": True}}})
|
||||
|
||||
def test_tailscale_serve_enable_and_disable_preserve_unrelated_routes(self):
|
||||
before = {"TCP": {"443": {"HTTPS": True}}}
|
||||
active = {
|
||||
"TCP": {
|
||||
"443": {"HTTPS": True},
|
||||
"2222": {"TCPForward": "127.0.0.1:2222"},
|
||||
},
|
||||
}
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_tailscale_runtime",
|
||||
side_effect=[{"serve": before}, {"serve": active}],
|
||||
),
|
||||
mock.patch.object(RUNNER, "run_device_plane_tailscale") as run,
|
||||
):
|
||||
result = RUNNER.enable_device_plane_tailscale_serve(before)
|
||||
self.assertEqual(result, active)
|
||||
self.assertEqual(
|
||||
run.call_args.args[0],
|
||||
[
|
||||
"serve",
|
||||
"--bg",
|
||||
"--yes",
|
||||
"--tcp=2222",
|
||||
"tcp://127.0.0.1:2222",
|
||||
],
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"read_device_plane_tailscale_json",
|
||||
return_value=active,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_tailscale_runtime",
|
||||
return_value={"serve": before},
|
||||
),
|
||||
mock.patch.object(RUNNER, "run_device_plane_tailscale") as run,
|
||||
):
|
||||
changed = RUNNER.disable_device_plane_tailscale_serve(before)
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(
|
||||
run.call_args.args[0],
|
||||
[
|
||||
"serve",
|
||||
"--tcp=2222",
|
||||
"off",
|
||||
],
|
||||
)
|
||||
|
||||
def test_tailscale_cli_runs_as_official_package_account(self):
|
||||
context = {
|
||||
"binary": "/var/packages/Tailscale/target/bin/tailscale",
|
||||
"uid": 1051,
|
||||
"gid": 1051,
|
||||
"binarySha256": "e" * 64,
|
||||
}
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_tailscale_cli",
|
||||
return_value=context,
|
||||
),
|
||||
mock.patch.object(RUNNER.subprocess, "run") as run,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_plane_tailscale_drop_privileges",
|
||||
return_value="drop-to-package-account",
|
||||
) as drop,
|
||||
):
|
||||
RUNNER.run_device_plane_tailscale(
|
||||
["status", "--json"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
run.call_args.args[0],
|
||||
[context["binary"], "status", "--json"],
|
||||
)
|
||||
self.assertEqual(
|
||||
run.call_args.kwargs["preexec_fn"],
|
||||
"drop-to-package-account",
|
||||
)
|
||||
drop.assert_called_once_with(1051, 1051)
|
||||
|
||||
def test_tailscale_cli_accepts_package_owned_binary_without_root_execution(self):
|
||||
privilege_path = mock.MagicMock()
|
||||
privilege_path.__str__.return_value = (
|
||||
"/var/packages/Tailscale/conf/privilege"
|
||||
)
|
||||
privilege_path.lstat.return_value = SimpleNamespace(
|
||||
st_mode=RUNNER.stat.S_IFREG | 0o644,
|
||||
st_uid=0,
|
||||
)
|
||||
binary_path = mock.MagicMock()
|
||||
binary_path.__str__.return_value = (
|
||||
"/var/packages/Tailscale/target/bin/tailscale"
|
||||
)
|
||||
binary_path.lstat.return_value = SimpleNamespace(
|
||||
st_mode=RUNNER.stat.S_IFREG | 0o755,
|
||||
st_uid=1051,
|
||||
st_gid=1051,
|
||||
st_size=32 * 1024 * 1024,
|
||||
)
|
||||
account = SimpleNamespace(pw_uid=1051, pw_gid=1051)
|
||||
group = SimpleNamespace(gr_gid=1051)
|
||||
help_result = SimpleNamespace(
|
||||
stdout="--tcp --bg --yes",
|
||||
stderr="",
|
||||
)
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_TAILSCALE_PRIVILEGE",
|
||||
privilege_path,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_TAILSCALE",
|
||||
binary_path,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"read_strict_json",
|
||||
return_value={
|
||||
"defaults": {"run-as": "package"},
|
||||
"username": "tailscale",
|
||||
"groupname": "tailscale",
|
||||
},
|
||||
),
|
||||
mock.patch.object(RUNNER.pwd, "getpwnam", return_value=account),
|
||||
mock.patch.object(RUNNER.grp, "getgrnam", return_value=group),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"sha256_file",
|
||||
return_value="f" * 64,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER.subprocess,
|
||||
"run",
|
||||
return_value=help_result,
|
||||
) as run,
|
||||
):
|
||||
context = RUNNER.validate_device_plane_tailscale_cli()
|
||||
self.assertEqual(context["uid"], 1051)
|
||||
self.assertEqual(context["gid"], 1051)
|
||||
self.assertEqual(context["binarySha256"], "f" * 64)
|
||||
self.assertTrue(callable(run.call_args.kwargs["preexec_fn"]))
|
||||
|
||||
def test_failed_001_evidence_is_exact_and_terminal(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-backhaul-failed-evidence-",
|
||||
) as directory:
|
||||
root = Path(directory)
|
||||
backups = root / "backups"
|
||||
failed = root / "failed"
|
||||
state = root / "state"
|
||||
tmp = root / "tmp"
|
||||
for path in (backups, failed, state, tmp):
|
||||
path.mkdir()
|
||||
|
||||
backup = backups / RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_BACKUP_ID
|
||||
backup.mkdir()
|
||||
backup_hashes = {}
|
||||
for name in (
|
||||
"manifest.env",
|
||||
"files.txt",
|
||||
"source-before.tgz",
|
||||
"existing-files.txt",
|
||||
"missing-files.txt",
|
||||
"runtime-before.json",
|
||||
):
|
||||
payload = f"fixture:{name}\n".encode()
|
||||
(backup / name).write_bytes(payload)
|
||||
backup_hashes[name] = hashlib.sha256(payload).hexdigest()
|
||||
|
||||
stage = root / "failed-stage"
|
||||
payload = stage / "payload"
|
||||
service = payload / "services/device-backhaul-target"
|
||||
deployment = payload / "deployment"
|
||||
service.mkdir(parents=True)
|
||||
deployment.mkdir(parents=True)
|
||||
(stage / "manifest.env").write_text(
|
||||
"id=device-plane-backhaul-target-20260803-001\n"
|
||||
"component=device-plane\n"
|
||||
"type=app-overlay\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(stage / "files.txt").write_text(
|
||||
"\n".join(RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_TARGET_ENTRIES)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(payload / RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL).write_text(
|
||||
"services: {}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(service / "Dockerfile").write_text(
|
||||
"FROM scratch\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(service / "sshd_config").write_text(
|
||||
"PasswordAuthentication no\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(payload / RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_TARGET_REL).write_text(
|
||||
json.dumps(
|
||||
RUNNER.expected_failed_device_plane_backhaul_target_descriptor()
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
failed_artifact = failed / RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_ARTIFACT
|
||||
with tarfile.open(failed_artifact, "w:gz") as archive:
|
||||
for name in ("manifest.env", "files.txt", "payload"):
|
||||
archive.add(stage / name, arcname=name)
|
||||
failed_sha = hashlib.sha256(failed_artifact.read_bytes()).hexdigest()
|
||||
|
||||
failed_state = state / "failed.jsonl"
|
||||
failed_state.write_text(
|
||||
json.dumps({
|
||||
"artifact": RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_ARTIFACT,
|
||||
"backup_id": RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_BACKUP_ID,
|
||||
"component": "device-plane",
|
||||
"id": RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_PATCH_ID,
|
||||
"message": RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_MESSAGE,
|
||||
"rollback_status": (
|
||||
"ok:device-plane-overlay:source-restored-target-removed-"
|
||||
"preserved-runtime-unchanged:3"
|
||||
),
|
||||
"sha256": failed_sha,
|
||||
"started_apply": True,
|
||||
"status": "failed",
|
||||
})
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(RUNNER, "BACKUPS_DIR", backups),
|
||||
mock.patch.object(RUNNER, "FAILED_DIR", failed),
|
||||
mock.patch.object(RUNNER, "FAILED_STATE_FILE", failed_state),
|
||||
mock.patch.object(RUNNER, "TMP_DIR", tmp),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_BACKHAUL_FAILED_BACKUP_SHA256",
|
||||
backup_hashes,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_BACKHAUL_FAILED_ARTIFACT_SHA256",
|
||||
failed_sha,
|
||||
),
|
||||
):
|
||||
evidence = RUNNER.validate_device_plane_backhaul_failed_evidence()
|
||||
self.assertEqual(evidence["backup"], backup)
|
||||
self.assertEqual(evidence["failedArtifact"], failed_artifact)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,302 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = (
|
||||
SCRIPT_DIR
|
||||
/ "build-device-plane-foundation-network-publication-artifact.mjs"
|
||||
)
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||
COMPOSE = (
|
||||
SCRIPT_DIR
|
||||
/ "fixtures/device-plane-foundation-network-publication-v1.yml"
|
||||
)
|
||||
PREDECESSOR_COMPOSE = (
|
||||
SCRIPT_DIR
|
||||
/ "fixtures/device-plane-foundation-internal-only-v1.yml"
|
||||
)
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_plane_network_publication_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DevicePlaneFoundationNetworkPublicationArtifactTest(
|
||||
unittest.TestCase
|
||||
):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_artifact_is_exact_deterministic_and_database_free(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-network-publication-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
patch_id = (
|
||||
"device-plane-foundation-network-publication-unit-003"
|
||||
)
|
||||
first = self.build(artifact_dir, patch_id)
|
||||
first_bytes = Path(first["artifact"]).read_bytes()
|
||||
second = self.build(artifact_dir, patch_id)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(
|
||||
first["transition"],
|
||||
"failed-foundation-network-publication-correction",
|
||||
)
|
||||
self.assertEqual(first["build"], [])
|
||||
self.assertEqual(
|
||||
first["services"],
|
||||
["device-control-core", "device-gateway"],
|
||||
)
|
||||
self.assertNotIn("device-postgres", first["services"])
|
||||
self.assertEqual(
|
||||
first["entries"],
|
||||
list(
|
||||
RUNNER
|
||||
.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES
|
||||
),
|
||||
)
|
||||
|
||||
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||
files = (
|
||||
archive.extractfile("files.txt")
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
.splitlines()
|
||||
)
|
||||
compose = (
|
||||
archive.extractfile(
|
||||
"payload/docker-compose.device-plane.yml"
|
||||
)
|
||||
.read()
|
||||
)
|
||||
descriptor = json.loads(
|
||||
archive.extractfile(
|
||||
"payload/deployment/"
|
||||
"device-plane-foundation-network-publication-v1.json"
|
||||
)
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
)
|
||||
|
||||
self.assertEqual(files, first["entries"])
|
||||
self.assertEqual(
|
||||
hashlib.sha256(compose).hexdigest(),
|
||||
RUNNER
|
||||
.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_COMPOSE_SHA256,
|
||||
)
|
||||
self.assertEqual(
|
||||
descriptor,
|
||||
RUNNER
|
||||
.expected_device_plane_foundation_network_publication_descriptor(),
|
||||
)
|
||||
|
||||
def test_compose_preserves_database_and_adds_control_network_only_to_stateless_services(
|
||||
self,
|
||||
):
|
||||
compose = COMPOSE.read_text(encoding="utf-8")
|
||||
predecessor = PREDECESSOR_COMPOSE.read_text(encoding="utf-8")
|
||||
self.assertEqual(
|
||||
hashlib.sha256(PREDECESSOR_COMPOSE.read_bytes()).hexdigest(),
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_PREDECESSOR_COMPOSE_SHA256,
|
||||
)
|
||||
self.assertEqual(
|
||||
hashlib.sha256(COMPOSE.read_bytes()).hexdigest(),
|
||||
RUNNER
|
||||
.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_COMPOSE_SHA256,
|
||||
)
|
||||
self.assertIn("name: nodedc-device-plane-control", compose)
|
||||
self.assertIn(
|
||||
'com.docker.network.bridge.enable_ip_masquerade: "false"',
|
||||
compose,
|
||||
)
|
||||
self.assertNotIn("nodedc-device-plane-control", predecessor)
|
||||
postgres_section, stateless = compose.split(
|
||||
" device-control-core:",
|
||||
1,
|
||||
)
|
||||
self.assertNotIn("device-plane-control", postgres_section)
|
||||
self.assertEqual(stateless.count(" - device-plane-control"), 2)
|
||||
self.assertNotIn("9921:9921", compose)
|
||||
|
||||
def test_runner_selects_exact_services_no_build_and_no_deps(self):
|
||||
entries = (
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES
|
||||
)
|
||||
services = RUNNER.component_services("device-plane", entries)
|
||||
self.assertEqual(
|
||||
services,
|
||||
("device-control-core", "device-gateway"),
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_builds("device-plane", entries),
|
||||
(),
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(RUNNER, "run_build") as run_build,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"prepare_component_runtime",
|
||||
) as prepare,
|
||||
mock.patch.object(RUNNER, "run_compose") as run_compose,
|
||||
):
|
||||
RUNNER.run_component_runtime(
|
||||
"device-plane",
|
||||
entries,
|
||||
services,
|
||||
)
|
||||
run_build.assert_not_called()
|
||||
prepare.assert_called_once_with("device-plane", entries)
|
||||
run_compose.assert_called_once_with(
|
||||
"device-plane",
|
||||
services,
|
||||
entries,
|
||||
)
|
||||
|
||||
with mock.patch.object(RUNNER.subprocess, "run") as run:
|
||||
RUNNER.run_compose("device-plane", services, entries)
|
||||
command = run.call_args_list[0].args[0]
|
||||
self.assertIn("--force-recreate", command)
|
||||
self.assertIn("--no-deps", command)
|
||||
self.assertEqual(
|
||||
command[-2:],
|
||||
["device-control-core", "device-gateway"],
|
||||
)
|
||||
|
||||
def test_terminal_failed_recovery_is_rejected(self):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"recovery 002 is terminal failed",
|
||||
):
|
||||
RUNNER.reject_terminal_device_plane_foundation_artifact(
|
||||
{
|
||||
"id":
|
||||
RUNNER
|
||||
.DEVICE_PLANE_FOUNDATION_RECOVERY_FAILED_PATCH_ID,
|
||||
},
|
||||
"0" * 64,
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"recovery 002 is terminal failed",
|
||||
):
|
||||
RUNNER.reject_terminal_device_plane_foundation_artifact(
|
||||
{"id": "unrelated"},
|
||||
RUNNER
|
||||
.DEVICE_PLANE_FOUNDATION_RECOVERY_FAILED_ARTIFACT_SHA256,
|
||||
)
|
||||
|
||||
def test_rollback_is_stateless_and_removes_only_control_network(self):
|
||||
entries = (
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES
|
||||
)
|
||||
services = ("device-control-core", "device-gateway")
|
||||
runtime = {
|
||||
"device-control-core": {"containerId": "1" * 64},
|
||||
"device-gateway": {"containerId": "2" * 64},
|
||||
"device-postgres": {
|
||||
"containerId":
|
||||
RUNNER
|
||||
.DEVICE_PLANE_FOUNDATION_PREDECESSOR_CONTAINER_IDS[
|
||||
"device-postgres"
|
||||
],
|
||||
},
|
||||
}
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"stop_and_remove_compose_services",
|
||||
) as stop,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"restore_platform_overlay",
|
||||
return_value=9,
|
||||
) as restore,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"remove_device_plane_control_network_if_unused",
|
||||
return_value="removed",
|
||||
) as remove_network,
|
||||
mock.patch.object(RUNNER, "run_compose") as compose,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service",
|
||||
) as health,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_foundation_runtime",
|
||||
return_value=runtime,
|
||||
) as validate_runtime,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"assert_loopback_tcp_port_closed",
|
||||
) as closed,
|
||||
):
|
||||
result = (
|
||||
RUNNER
|
||||
.rollback_device_plane_network_publication_apply(
|
||||
Path("/live"),
|
||||
Path("/backup"),
|
||||
entries,
|
||||
"stamp",
|
||||
True,
|
||||
services,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(result, "source+internal-runtime-restored:9")
|
||||
stop.assert_called_once_with("device-plane", services)
|
||||
restore.assert_called_once()
|
||||
remove_network.assert_called_once_with()
|
||||
compose.assert_called_once_with(
|
||||
"device-plane",
|
||||
services,
|
||||
("docker-compose.device-plane.yml",),
|
||||
)
|
||||
self.assertEqual(health.call_count, 3)
|
||||
validate_runtime.assert_called_once_with()
|
||||
self.assertEqual(
|
||||
[call.args[0] for call in closed.call_args_list],
|
||||
[18120, 18121, 9921],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,386 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
RECOVERY_BUILDER = (
|
||||
SCRIPT_DIR / "build-device-plane-foundation-recovery-artifact.mjs"
|
||||
)
|
||||
FOUNDATION_BUILDER = SCRIPT_DIR / "build-device-plane-artifact.mjs"
|
||||
PREDECESSOR_COMPOSE = (
|
||||
SCRIPT_DIR
|
||||
/ "fixtures/device-plane-foundation-internal-only-v1.yml"
|
||||
)
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||
RECOVERY_ENTRIES = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
"deployment/device-plane-foundation-recovery-v1.json",
|
||||
]
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_plane_recovery_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DevicePlaneFoundationRecoveryArtifactTest(unittest.TestCase):
|
||||
def build(self, builder, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
result = subprocess.run(
|
||||
["node", str(builder), patch_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_recovery_artifact_is_source_only_exact_and_deterministic(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-recovery-artifact-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
first = self.build(
|
||||
RECOVERY_BUILDER,
|
||||
artifact_dir,
|
||||
"device-plane-foundation-recovery-unit-002",
|
||||
)
|
||||
artifact = Path(first["artifact"])
|
||||
first_bytes = artifact.read_bytes()
|
||||
second = self.build(
|
||||
RECOVERY_BUILDER,
|
||||
artifact_dir,
|
||||
"device-plane-foundation-recovery-unit-002",
|
||||
)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first["component"], "device-plane")
|
||||
self.assertEqual(first["entries"], RECOVERY_ENTRIES)
|
||||
self.assertEqual(first["build"], [])
|
||||
self.assertEqual(first["services"], [])
|
||||
self.assertEqual(
|
||||
first["transition"],
|
||||
"failed-foundation-live-runtime-adoption",
|
||||
)
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
|
||||
with tarfile.open(artifact, "r:gz") as archive:
|
||||
names = {member.name for member in archive.getmembers()}
|
||||
files = (
|
||||
archive.extractfile("files.txt")
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
.splitlines()
|
||||
)
|
||||
descriptor = json.loads(
|
||||
archive.extractfile(
|
||||
"payload/deployment/"
|
||||
"device-plane-foundation-recovery-v1.json"
|
||||
)
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
)
|
||||
|
||||
self.assertEqual(files, RECOVERY_ENTRIES)
|
||||
self.assertIn(
|
||||
"payload/services/device-control-core/src/server.mjs",
|
||||
names,
|
||||
)
|
||||
self.assertIn(
|
||||
"payload/services/device-gateway/src/server.mjs",
|
||||
names,
|
||||
)
|
||||
self.assertFalse(any(
|
||||
"/test/" in name
|
||||
or "/node_modules/" in name
|
||||
or Path(name).name.startswith(".env")
|
||||
or name.startswith("payload/runtime/")
|
||||
or name.startswith("payload/secrets/")
|
||||
for name in names
|
||||
))
|
||||
self.assertEqual(
|
||||
descriptor,
|
||||
RUNNER.expected_device_plane_foundation_recovery_descriptor(),
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_services(
|
||||
"device-plane",
|
||||
tuple(RECOVERY_ENTRIES),
|
||||
),
|
||||
(),
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_builds(
|
||||
"device-plane",
|
||||
tuple(RECOVERY_ENTRIES),
|
||||
),
|
||||
(),
|
||||
)
|
||||
|
||||
def test_preflight_requires_exact_failed_evidence_partial_source_and_runtime(
|
||||
self,
|
||||
):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-recovery-preflight-",
|
||||
) as directory:
|
||||
workspace = Path(directory)
|
||||
artifacts = workspace / "artifacts"
|
||||
failed_root = workspace / "failed"
|
||||
backups_root = workspace / "backups"
|
||||
state_root = workspace / "state"
|
||||
temp_root = workspace / "tmp"
|
||||
live_root = workspace / "live"
|
||||
for path in (
|
||||
artifacts,
|
||||
failed_root,
|
||||
backups_root,
|
||||
state_root,
|
||||
temp_root,
|
||||
live_root / "deployment",
|
||||
):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
failed_build = self.build(
|
||||
FOUNDATION_BUILDER,
|
||||
artifacts,
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID,
|
||||
)
|
||||
self.assertEqual(
|
||||
failed_build["sha256"],
|
||||
hashlib.sha256(
|
||||
Path(failed_build["artifact"]).read_bytes()
|
||||
).hexdigest(),
|
||||
)
|
||||
failed_artifact = (
|
||||
failed_root / RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT
|
||||
)
|
||||
shutil.copy2(failed_build["artifact"], failed_artifact)
|
||||
|
||||
recovery_build = self.build(
|
||||
RECOVERY_BUILDER,
|
||||
artifacts,
|
||||
"device-plane-foundation-recovery-unit-002",
|
||||
)
|
||||
recovery_extract = workspace / "recovery-extract"
|
||||
recovery_extract.mkdir()
|
||||
_manifest, entries, payload = RUNNER.load_artifact(
|
||||
Path(recovery_build["artifact"]),
|
||||
recovery_extract,
|
||||
)
|
||||
self.assertEqual(entries, RECOVERY_ENTRIES)
|
||||
|
||||
backup = (
|
||||
backups_root
|
||||
/ RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_ID
|
||||
)
|
||||
backup.mkdir()
|
||||
for name in (
|
||||
"manifest.env",
|
||||
"files.txt",
|
||||
"existing-files.txt",
|
||||
"missing-files.txt",
|
||||
"source-before.tgz",
|
||||
):
|
||||
(backup / name).write_text(
|
||||
f"fixture:{name}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
backup_hashes = {
|
||||
name: hashlib.sha256((backup / name).read_bytes()).hexdigest()
|
||||
for name in (
|
||||
"manifest.env",
|
||||
"files.txt",
|
||||
"existing-files.txt",
|
||||
"missing-files.txt",
|
||||
"source-before.tgz",
|
||||
)
|
||||
}
|
||||
|
||||
(state_root / "failed.jsonl").write_text(
|
||||
json.dumps({
|
||||
"artifact":
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT,
|
||||
"backup_id":
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_ID,
|
||||
"component": "device-plane",
|
||||
"id": RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID,
|
||||
"message":
|
||||
"healthcheck failed for "
|
||||
"http://127.0.0.1:18120/healthz: "
|
||||
"<urlopen error [Errno 111] Connection refused>",
|
||||
"rollback_status": "failed:DeployError",
|
||||
"sha256":
|
||||
failed_build["sha256"],
|
||||
"started_apply": True,
|
||||
"status": "failed",
|
||||
})
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
source_root = SCRIPT_DIR.parent.parent / "device-plane"
|
||||
shutil.copy2(
|
||||
PREDECESSOR_COMPOSE,
|
||||
live_root / "docker-compose.device-plane.yml",
|
||||
)
|
||||
shutil.copy2(
|
||||
source_root
|
||||
/ "deployment/device-postgres-bootstrap-v1.json",
|
||||
live_root
|
||||
/ "deployment/device-postgres-bootstrap-v1.json",
|
||||
)
|
||||
runtime = {
|
||||
service: {
|
||||
"containerId": service,
|
||||
"imageId":
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_IMAGE_IDS[
|
||||
service
|
||||
],
|
||||
"health": "healthy",
|
||||
"restartCount": 0,
|
||||
}
|
||||
for service in (
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
)
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch.object(RUNNER, "BACKUPS_DIR", backups_root),
|
||||
mock.patch.object(RUNNER, "FAILED_DIR", failed_root),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"FAILED_STATE_FILE",
|
||||
state_root / "failed.jsonl",
|
||||
),
|
||||
mock.patch.object(RUNNER, "TMP_DIR", temp_root),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"component_root",
|
||||
return_value=live_root,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256",
|
||||
failed_build["sha256"],
|
||||
),
|
||||
mock.patch.dict(
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_SHA256,
|
||||
backup_hashes,
|
||||
clear=True,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_foundation_runtime",
|
||||
return_value=runtime,
|
||||
),
|
||||
):
|
||||
result = (
|
||||
RUNNER
|
||||
.validate_device_plane_foundation_recovery_evidence(
|
||||
payload
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
result["mode"],
|
||||
"failed-foundation-live-runtime-adoption",
|
||||
)
|
||||
self.assertEqual(result["runtime"], runtime)
|
||||
|
||||
def test_recovery_health_acceptance_never_mutates_runtime(self):
|
||||
entries = tuple(RECOVERY_ENTRIES)
|
||||
checks = ("core-health", "gateway-health")
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service",
|
||||
) as compose_health,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"component_healthchecks",
|
||||
return_value=checks,
|
||||
),
|
||||
mock.patch.object(RUNNER, "healthcheck_url") as url_health,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_foundation_installed_source",
|
||||
) as source_acceptance,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_foundation_runtime",
|
||||
) as runtime_acceptance,
|
||||
mock.patch.object(RUNNER, "run_compose") as compose_mutation,
|
||||
mock.patch.object(RUNNER, "run_build") as build_mutation,
|
||||
):
|
||||
RUNNER.run_healthchecks("device-plane", entries, ())
|
||||
|
||||
self.assertEqual(
|
||||
[call.args for call in compose_health.call_args_list],
|
||||
[
|
||||
("device-plane", "device-control-core"),
|
||||
("device-plane", "device-gateway"),
|
||||
("device-plane", "device-postgres"),
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
[call.args[0] for call in url_health.call_args_list],
|
||||
list(checks),
|
||||
)
|
||||
source_acceptance.assert_called_once_with()
|
||||
runtime_acceptance.assert_called_once_with()
|
||||
compose_mutation.assert_not_called()
|
||||
build_mutation.assert_not_called()
|
||||
|
||||
def test_recovery_runtime_phase_has_no_runtime_mutation(self):
|
||||
entries = tuple(RECOVERY_ENTRIES)
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"prepare_component_runtime",
|
||||
) as prepare,
|
||||
mock.patch.object(RUNNER, "run_compose") as compose_mutation,
|
||||
mock.patch.object(RUNNER, "run_build") as build_mutation,
|
||||
):
|
||||
RUNNER.run_component_runtime("device-plane", entries, ())
|
||||
|
||||
prepare.assert_not_called()
|
||||
compose_mutation.assert_not_called()
|
||||
build_mutation.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,210 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||
BUILDER = (
|
||||
SCRIPT_DIR
|
||||
/ "build-device-plane-postgres-bootstrap-artifact.mjs"
|
||||
)
|
||||
EXPECTED_ENTRIES = [
|
||||
"docker-compose.device-plane.yml",
|
||||
"deployment/device-postgres-bootstrap-v1.json",
|
||||
]
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_plane_postgres_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DevicePlanePostgresBootstrapTest(unittest.TestCase):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_bootstrap_artifact_is_exact_deterministic_and_runner_accepted(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-postgres-artifact-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
first = self.build(
|
||||
artifact_dir,
|
||||
"device-plane-postgres-bootstrap-unit-001",
|
||||
)
|
||||
artifact = Path(first["artifact"])
|
||||
first_bytes = artifact.read_bytes()
|
||||
second = self.build(
|
||||
artifact_dir,
|
||||
"device-plane-postgres-bootstrap-unit-001",
|
||||
)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first["entries"], EXPECTED_ENTRIES)
|
||||
self.assertEqual(first["services"], ["device-postgres"])
|
||||
self.assertEqual(first["mode"], "create-if-absent")
|
||||
self.assertEqual(first["rollbackVolumePolicy"], "preserve")
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
|
||||
with tarfile.open(artifact, "r:gz") as archive:
|
||||
names = {member.name for member in archive.getmembers()}
|
||||
self.assertEqual(
|
||||
archive.extractfile("files.txt")
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
.splitlines(),
|
||||
EXPECTED_ENTRIES,
|
||||
)
|
||||
self.assertEqual(
|
||||
names,
|
||||
{
|
||||
"manifest.env",
|
||||
"files.txt",
|
||||
"payload",
|
||||
"payload/docker-compose.device-plane.yml",
|
||||
"payload/deployment",
|
||||
"payload/deployment/device-postgres-bootstrap-v1.json",
|
||||
},
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-postgres-load-",
|
||||
) as work_directory:
|
||||
manifest, entries, _payload = RUNNER.load_artifact(
|
||||
artifact,
|
||||
Path(work_directory),
|
||||
)
|
||||
self.assertEqual(manifest["component"], "device-plane")
|
||||
self.assertEqual(entries, EXPECTED_ENTRIES)
|
||||
self.assertTrue(
|
||||
RUNNER.is_device_plane_postgres_bootstrap_slice(
|
||||
manifest["component"],
|
||||
entries,
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_services("device-plane", entries),
|
||||
("device-postgres",),
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.component_builds("device-plane", entries),
|
||||
(),
|
||||
)
|
||||
|
||||
def test_preflight_accepts_only_absent_container_and_volume(self):
|
||||
absent_container = mock.Mock(returncode=0, stdout="")
|
||||
absent_volume = mock.Mock(returncode=1)
|
||||
with mock.patch.object(
|
||||
RUNNER.subprocess,
|
||||
"run",
|
||||
side_effect=[absent_container, absent_volume],
|
||||
):
|
||||
self.assertEqual(
|
||||
RUNNER.preflight_device_plane_postgres_bootstrap(),
|
||||
"absent",
|
||||
)
|
||||
|
||||
existing_container = mock.Mock(
|
||||
returncode=0,
|
||||
stdout="abc123def456\n",
|
||||
)
|
||||
with mock.patch.object(
|
||||
RUNNER.subprocess,
|
||||
"run",
|
||||
return_value=existing_container,
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"container already exists",
|
||||
):
|
||||
RUNNER.preflight_device_plane_postgres_bootstrap()
|
||||
|
||||
with mock.patch.object(
|
||||
RUNNER.subprocess,
|
||||
"run",
|
||||
side_effect=[
|
||||
absent_container,
|
||||
mock.Mock(returncode=0),
|
||||
],
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"volume already exists",
|
||||
):
|
||||
RUNNER.preflight_device_plane_postgres_bootstrap()
|
||||
|
||||
def test_plan_selection_is_unambiguous_for_bootstrap_and_application(self):
|
||||
self.assertEqual(
|
||||
RUNNER.device_plane_postgres_plan_selection(None),
|
||||
"preserved-prerequisite:not-selected",
|
||||
)
|
||||
self.assertEqual(
|
||||
RUNNER.device_plane_postgres_plan_selection("absent"),
|
||||
"bootstrap-selected:create-if-absent",
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"plan preflight state is invalid",
|
||||
):
|
||||
RUNNER.device_plane_postgres_plan_selection("unknown")
|
||||
|
||||
def test_bootstrap_health_acceptance_is_database_service_only(self):
|
||||
with mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service",
|
||||
) as health:
|
||||
RUNNER.run_healthchecks(
|
||||
"device-plane",
|
||||
EXPECTED_ENTRIES,
|
||||
("device-postgres",),
|
||||
)
|
||||
health.assert_called_once_with(
|
||||
"device-plane",
|
||||
"device-postgres",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"service set mismatch",
|
||||
):
|
||||
RUNNER.run_healthchecks(
|
||||
"device-plane",
|
||||
EXPECTED_ENTRIES,
|
||||
("device-control-core",),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -27,6 +28,75 @@ RUNNER = load_runner()
|
||||
|
||||
|
||||
class DevicePlaneRegistryTest(unittest.TestCase):
|
||||
def test_manager_predecessor_health_is_phase_scoped(self):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"preflight phase is invalid",
|
||||
):
|
||||
RUNNER.validate_device_plane_manager_activation_predecessor(
|
||||
Path("/not-used"),
|
||||
preflight_phase="unknown",
|
||||
)
|
||||
|
||||
plan_source = inspect.getsource(RUNNER.plan_artifact)
|
||||
self.assertIn('preflight_phase="plan"', plan_source)
|
||||
apply_source = inspect.getsource(RUNNER.apply_artifact)
|
||||
self.assertIn('preflight_phase="apply"', apply_source)
|
||||
|
||||
def test_manager_v3_health_gate_checks_only_preserved_services(self):
|
||||
descriptor = {
|
||||
"schemaVersion": (
|
||||
"nodedc.device-plane.device-manager-release.v3"
|
||||
),
|
||||
}
|
||||
with mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service",
|
||||
) as healthcheck:
|
||||
services = (
|
||||
RUNNER.validate_device_plane_manager_preserved_runtime_health(
|
||||
descriptor
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
services,
|
||||
("device-control-core", "device-postgres"),
|
||||
)
|
||||
self.assertEqual(
|
||||
healthcheck.call_args_list,
|
||||
[
|
||||
mock.call("device-plane", "device-control-core"),
|
||||
mock.call("device-plane", "device-postgres"),
|
||||
],
|
||||
)
|
||||
self.assertNotIn(
|
||||
mock.call("device-plane", "device-manager"),
|
||||
healthcheck.call_args_list,
|
||||
)
|
||||
|
||||
def test_legacy_manager_health_gate_excludes_selected_services(self):
|
||||
descriptor = {
|
||||
"schemaVersion": (
|
||||
"nodedc.device-plane.device-manager-release.v2"
|
||||
),
|
||||
}
|
||||
with mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service",
|
||||
) as healthcheck:
|
||||
services = (
|
||||
RUNNER.validate_device_plane_manager_preserved_runtime_health(
|
||||
descriptor
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(services, ("device-postgres",))
|
||||
healthcheck.assert_called_once_with(
|
||||
"device-plane",
|
||||
"device-postgres",
|
||||
)
|
||||
|
||||
def test_registry_has_exact_roots_project_and_stateless_services(self):
|
||||
component = RUNNER.COMPONENTS["device-plane"]
|
||||
root = Path("/volume1/docker/nodedc-device-plane")
|
||||
@@ -107,6 +177,7 @@ class DevicePlaneRegistryTest(unittest.TestCase):
|
||||
".dockerignore",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract/src/index.mjs",
|
||||
"packages/device-edge-channel-contract/src/index.mjs",
|
||||
"services/device-control-core/Dockerfile",
|
||||
"services/device-gateway/src/runtime.mjs",
|
||||
):
|
||||
@@ -119,6 +190,9 @@ class DevicePlaneRegistryTest(unittest.TestCase):
|
||||
"secrets/postgres-password",
|
||||
"runtime/postgres/data",
|
||||
"services/unknown/server.mjs",
|
||||
"packages/device-adapter-runtime/package.json",
|
||||
"packages/device-adapter-catalog/package.json",
|
||||
"services/device-gateway-core/src/runtime.mjs",
|
||||
"docker-compose.yml",
|
||||
"services/device-control-core/start.sh",
|
||||
"services/device-control-core/test/app.test.mjs",
|
||||
|
||||
@@ -22,9 +22,10 @@ EXPECTED_FILES = (
|
||||
"apps/catalog/src/MapFixturePreview.tsx",
|
||||
"apps/catalog/src/mapGridPolicy.d.mts",
|
||||
"apps/catalog/src/mapGridPolicy.mjs",
|
||||
"package.json",
|
||||
"apps/catalog/src/styles.css",
|
||||
"runtime-seed/page-layouts/map.json",
|
||||
"scripts/map-grid-lod.test.mjs",
|
||||
"scripts/map-object-layers.test.mjs",
|
||||
"server/catalog-server.mjs",
|
||||
"server/foundry-mcp.mjs",
|
||||
)
|
||||
@@ -81,9 +82,16 @@ class ModuleFoundryGridLodArtifactTest(unittest.TestCase):
|
||||
inspector = (payload / EXPECTED_FILES[1]).read_text(encoding="utf-8")
|
||||
policy = (payload / EXPECTED_FILES[3]).read_text(encoding="utf-8")
|
||||
self.assertIn("class GridLayerController", renderer)
|
||||
self.assertIn("gridLineDiameterMeters", renderer)
|
||||
self.assertIn("LOD 5: режим", inspector)
|
||||
self.assertIn("viewer.flyTo(entity", renderer)
|
||||
self.assertIn("lineDiameterMeters", renderer)
|
||||
self.assertIn("LOD ${index + 1}", inspector)
|
||||
self.assertIn("Конус видимости 3D", inspector)
|
||||
self.assertIn("Размер major-тайла ENU", inspector)
|
||||
self.assertIn("Радиус ENU-поля", inspector)
|
||||
self.assertIn("Диаметр 3D-линий", inspector)
|
||||
self.assertIn("Кресты: длина", inspector)
|
||||
self.assertIn("GRID_LOD_HYSTERESIS_RATIO", policy)
|
||||
self.assertIn("gridLodProfiles", policy)
|
||||
|
||||
with tarfile.open(first_artifact, "r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/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
|
||||
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
|
||||
BUILDER = SCRIPT_DIR / "build-module-foundry-map-focus-artifact.mjs"
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||
PATCH_ID = "module-foundry-map-focus-recovery-20260805-999"
|
||||
EXPECTED_FILES = (
|
||||
"apps/catalog/src/CesiumMapRenderer.tsx",
|
||||
"scripts/map-object-layers.test.mjs",
|
||||
)
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_module_foundry_map_focus_artifact",
|
||||
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 ModuleFoundryMapFocusArtifactTest(unittest.TestCase):
|
||||
def build(self, artifact_dir):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
completed = subprocess.run(
|
||||
["node", str(BUILDER), PATCH_ID],
|
||||
cwd=PLATFORM_ROOT,
|
||||
env=environment,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
def test_builder_is_deterministic_exact_scope_and_runner_compatible(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-foundry-map-focus-") as directory:
|
||||
root = Path(directory)
|
||||
first = self.build(root / "first")
|
||||
second = self.build(root / "second")
|
||||
first_artifact = Path(first["artifact"])
|
||||
second_artifact = Path(second["artifact"])
|
||||
|
||||
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
|
||||
self.assertEqual(first["sha256"], hashlib.sha256(first_artifact.read_bytes()).hexdigest())
|
||||
self.assertEqual(tuple(first["files"]), EXPECTED_FILES)
|
||||
|
||||
extracted = root / "loaded"
|
||||
extracted.mkdir()
|
||||
manifest, entries, payload = RUNNER.load_artifact(first_artifact, extracted)
|
||||
self.assertEqual(manifest["component"], "module-foundry")
|
||||
self.assertEqual(tuple(entries), EXPECTED_FILES)
|
||||
self.assertEqual(RUNNER.component_services("module-foundry", entries), ("nodedc-module-foundry",))
|
||||
|
||||
renderer = (payload / EXPECTED_FILES[0]).read_text(encoding="utf-8")
|
||||
regression = (payload / EXPECTED_FILES[1]).read_text(encoding="utf-8")
|
||||
self.assertIn("void viewer.flyTo(entity, {", renderer)
|
||||
self.assertIn("offset: new HeadingPitchRange(0, -0.9, 8_000)", renderer)
|
||||
self.assertIn("void viewer\\.flyTo\\(entity, \\{", regression)
|
||||
|
||||
with tarfile.open(first_artifact, "r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
names = [member.name for member in members]
|
||||
regular_payloads = [
|
||||
archive.extractfile(member).read()
|
||||
for member in members
|
||||
if member.isfile()
|
||||
]
|
||||
self.assertFalse(any(Path(name).name.startswith("._") for name in names))
|
||||
self.assertFalse(any("/.git/" in name or "/node_modules/" in name or "/runtime-data/" in name for name in names))
|
||||
self.assertNotIn(b"-----BEGIN PRIVATE KEY-----", b"\n".join(regular_payloads))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.platform.device-core-hub-trust.v1",
|
||||
"action": "activate",
|
||||
"serviceSlug": "device-core",
|
||||
"launcherCredential": "runner-managed-file",
|
||||
"credentialScope": [
|
||||
"handoff.consume",
|
||||
"session.validate"
|
||||
],
|
||||
"publicRoute": "unchanged"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.platform.device-manager-public-route.v1",
|
||||
"action": "activate",
|
||||
"hostname": "device.nodedc.ru",
|
||||
"upstream": "device-manager:18122",
|
||||
"transport": "reverse-proxy",
|
||||
"rawTcpIngress": "forbidden"
|
||||
}
|
||||
@@ -87,6 +87,16 @@ http://hub.nodedc.ru {
|
||||
}
|
||||
}
|
||||
|
||||
http://device.nodedc.ru {
|
||||
reverse_proxy device-manager:18122 {
|
||||
header_up Host device.nodedc.ru
|
||||
header_up X-Forwarded-Host device.nodedc.ru
|
||||
header_up X-Forwarded-Proto https
|
||||
header_up X-Forwarded-Port 443
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
http://ops.nodedc.ru {
|
||||
reverse_proxy {$SYNOLOGY_TASK_MANAGER_UPSTREAM} {
|
||||
header_up Host ops.nodedc.ru
|
||||
|
||||
@@ -46,11 +46,18 @@ services:
|
||||
AUTHENTIK_BASE_URL: http://nodedc-platform-authentik-server:9000
|
||||
NODEDC_NOTIFICATION_CORE_URL: http://notification-core:5185
|
||||
NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082
|
||||
NODEDC_DEVICE_CORE_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token
|
||||
expose:
|
||||
- "5173"
|
||||
volumes:
|
||||
- ../launcher/server-storage:/app/server/storage
|
||||
- ../launcher/uploads:/app/server/storage/uploads
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-platform/secrets/device-core-internal-token
|
||||
target: /run/nodedc-secrets/device-core-internal-token
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
extra_hosts:
|
||||
- "id.nodedc.ru:host-gateway"
|
||||
- "hub.nodedc.ru:host-gateway"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export const NDC_CREDENTIAL_REFERENCE_OWNER = "ndc_l2_credentials";
|
||||
|
||||
const CREDENTIAL_REFERENCE_PATTERN =
|
||||
/^ndc-credref:[A-Za-z0-9][A-Za-z0-9._:-]{7,240}$/;
|
||||
|
||||
export function normalizeNdcCredentialReference(input) {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new TypeError("ndc_credential_reference_invalid");
|
||||
}
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!new Set(["owner", "reference"]).has(key)) {
|
||||
throw new TypeError(`ndc_credential_reference_field_unexpected:${key}`);
|
||||
}
|
||||
}
|
||||
if (input.owner !== NDC_CREDENTIAL_REFERENCE_OWNER) {
|
||||
throw new TypeError("ndc_credential_reference_owner_invalid");
|
||||
}
|
||||
if (!isNdcCredentialReferenceValue(input.reference)) {
|
||||
throw new TypeError("ndc_credential_reference_value_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
owner: NDC_CREDENTIAL_REFERENCE_OWNER,
|
||||
reference: input.reference,
|
||||
});
|
||||
}
|
||||
|
||||
export function isNdcCredentialReferenceValue(value) {
|
||||
return typeof value === "string" && CREDENTIAL_REFERENCE_PATTERN.test(value);
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.mjs";
|
||||
import { isNdcCredentialReferenceValue } from "./credential-reference.mjs";
|
||||
|
||||
export { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.mjs";
|
||||
export {
|
||||
NDC_CREDENTIAL_REFERENCE_OWNER,
|
||||
isNdcCredentialReferenceValue,
|
||||
normalizeNdcCredentialReference,
|
||||
} from "./credential-reference.mjs";
|
||||
export { validateIntakeBatch } from "./intake-batch.mjs";
|
||||
export {
|
||||
DATA_PRODUCT_GEOMETRY_TYPES,
|
||||
@@ -199,6 +205,12 @@ export function validateConnectionProfile(value) {
|
||||
if (value?.credentialRef?.owner && value.credentialRef.owner !== "ndc_l2_credentials") {
|
||||
errors.push("credentialRef.owner_must_be_ndc_l2_credentials");
|
||||
}
|
||||
if (
|
||||
typeof value?.credentialRef?.reference === "string"
|
||||
&& !isNdcCredentialReferenceValue(value.credentialRef.reference)
|
||||
) {
|
||||
errors.push("credentialRef.reference_must_be_canonical_ndc_ref");
|
||||
}
|
||||
if (containsSecretLikeMaterial(value)) errors.push("profile_must_not_contain_secret_material");
|
||||
if (value?.scope !== undefined) {
|
||||
if (!isPlainObject(value.scope)) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { isNdcCredentialReferenceValue } from "./credential-reference.mjs";
|
||||
import { validateProviderPackage } from "./provider-package.mjs";
|
||||
import {
|
||||
compileTelemetryFieldProjection,
|
||||
@@ -20,7 +22,6 @@ export const L2_EXECUTION_PLAN_SUPPORTED_COMPILER_VERSIONS = Object.freeze([
|
||||
|
||||
const HASH = /^(?:sha256:)?[a-f0-9]{64}$/;
|
||||
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
|
||||
const CREDENTIAL_REF = /^ndc-credref:[A-Za-z0-9._:-]{8,255}$/;
|
||||
const COMPILE_OPTION_KEYS = new Set(["telemetryFieldRegistry"]);
|
||||
const RECEIPT_OPTION_KEYS = new Set(["graphRevision", "graphDigest", "materializedStepIds"]);
|
||||
const STEP_RUNTIME_KINDS = Object.freeze({
|
||||
@@ -478,7 +479,7 @@ function validateConnectionInstance(providerPackage, value) {
|
||||
if (typeof value.connectionId !== "string" || !IDENTIFIER.test(value.connectionId)) {
|
||||
throw new Error("l2_execution_plan_connection_id_invalid");
|
||||
}
|
||||
if (!CREDENTIAL_REF.test(String(value.credentialRefs?.provider?.reference || ""))
|
||||
if (!isNdcCredentialReferenceValue(value.credentialRefs?.provider?.reference)
|
||||
|| value.credentialRefs?.provider?.owner !== "ndc_l2_credentials") {
|
||||
throw new Error("l2_execution_plan_provider_credential_ref_invalid");
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export const L2_CONNECTION_INSTANCE_SCHEMA_VERSION = "nodedc.l2-connection-insta
|
||||
export const SEMANTIC_MAPPING_SCHEMA_VERSION = "nodedc.semantic-mapping/v1";
|
||||
|
||||
import { SECRET_LIKE_VALUE as SECRET_VALUE } from "./sensitive-field-policy.mjs";
|
||||
import { isNdcCredentialReferenceValue } from "./credential-reference.mjs";
|
||||
import { isBoundedTelemetryReadings } from "./telemetry-readings.mjs";
|
||||
|
||||
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
|
||||
@@ -1319,7 +1320,7 @@ function requiredString(value, path, errors) {
|
||||
}
|
||||
|
||||
function requiredOpaqueReference(value, path, errors) {
|
||||
if (typeof value !== "string" || !/^ndc-credref:[A-Za-z0-9][A-Za-z0-9._:-]{7,240}$/.test(value)) errors.push(`${path}_invalid`);
|
||||
if (!isNdcCredentialReferenceValue(value)) errors.push(`${path}_invalid`);
|
||||
}
|
||||
|
||||
function validateProviderBaseUrl(value, path, errors) {
|
||||
|
||||
@@ -2,7 +2,10 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
EXTERNAL_PROVIDER_CONTRACT_VERSION,
|
||||
FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
|
||||
NDC_CREDENTIAL_REFERENCE_OWNER,
|
||||
assertValid,
|
||||
isNdcCredentialReferenceValue,
|
||||
normalizeNdcCredentialReference,
|
||||
validateCollectionProfile,
|
||||
validateConnectionProfile,
|
||||
validateDataProduct,
|
||||
@@ -19,6 +22,29 @@ assert.equal(validateConnectionProfile({
|
||||
...geliosPositionsCurrentExample.connection,
|
||||
credentialRef: { ...geliosPositionsCurrentExample.connection.credentialRef, owner: "engine" },
|
||||
}).errors.includes("credentialRef.owner_must_be_ndc_l2_credentials"), true);
|
||||
assert.deepEqual(normalizeNdcCredentialReference({
|
||||
owner: NDC_CREDENTIAL_REFERENCE_OWNER,
|
||||
reference: "ndc-credref:provider-example-0001",
|
||||
}), {
|
||||
owner: "ndc_l2_credentials",
|
||||
reference: "ndc-credref:provider-example-0001",
|
||||
});
|
||||
assert.equal(isNdcCredentialReferenceValue("ndc-credref:provider-example-0001"), true);
|
||||
assert.equal(isNdcCredentialReferenceValue("secret://provider-example"), false);
|
||||
assert.equal(validateConnectionProfile({
|
||||
...geliosPositionsCurrentExample.connection,
|
||||
credentialRef: {
|
||||
owner: "ndc_l2_credentials",
|
||||
reference: "provider-example-0001",
|
||||
},
|
||||
}).errors.includes("credentialRef.reference_must_be_canonical_ndc_ref"), true);
|
||||
assert.throws(
|
||||
() => normalizeNdcCredentialReference({
|
||||
owner: "device_core",
|
||||
reference: "ndc-credref:provider-example-0001",
|
||||
}),
|
||||
/ndc_credential_reference_owner_invalid/,
|
||||
);
|
||||
assert.equal(validateCollectionProfile(geliosPositionsCurrentExample.collectionProfile).ok, true);
|
||||
assert.equal(validateDataProduct(geliosPositionsCurrentExample.dataProduct).ok, true);
|
||||
assert.equal(validateFoundryBinding(geliosPositionsCurrentExample.foundryBinding).ok, true);
|
||||
|
||||
Reference in New Issue
Block a user