Compare commits
106
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49813d7839 | ||
|
|
07224c6f0d | ||
|
|
6352a26020 | ||
|
|
e868fd4bcf | ||
|
|
cd5bf2a6d9 | ||
|
|
4f75f57177 | ||
|
|
b48b6dbfc9 | ||
|
|
0688516003 | ||
|
|
96227461dd | ||
|
|
1eb6c462e3 | ||
|
|
3bb5e6dc27 | ||
|
|
b1a5a26b9a | ||
|
|
558940f691 | ||
|
|
9d8638b8d2 | ||
|
|
a3308c9b3d | ||
|
|
2b1795509b | ||
|
|
3c538ad98c | ||
|
|
aca47c6143 | ||
|
|
1102e25e6e | ||
|
|
e217723784 | ||
|
|
e9e03143cd | ||
|
|
951b884c4b | ||
|
|
fe274872e4 | ||
|
|
f0a4fb43c5 | ||
|
|
a9fe5f44e3 | ||
|
|
c19b0789c7 | ||
|
|
34769af350 | ||
|
|
659f98ad8f | ||
|
|
abebb9fac6 | ||
|
|
8cc917508e | ||
|
|
eab47bc1fa | ||
|
|
f02a1d4125 | ||
|
|
73364b4acb | ||
|
|
59f9fc2b26 | ||
|
|
25b12d77e4 | ||
|
|
4cf3a83ef1 | ||
|
|
a5a5644dbf | ||
|
|
6f9da1b677 | ||
|
|
f97a9d924d | ||
|
|
e4383b6162 | ||
|
|
50cba59bde | ||
|
|
ad3760e767 | ||
|
|
2a30e1e991 | ||
|
|
ec45d09509 | ||
|
|
9db0de540d | ||
|
|
6c764d0d28 | ||
|
|
93eb13219a | ||
|
|
23919e384e | ||
|
|
4cdc78d545 | ||
|
|
e843bbda66 | ||
|
|
2def90fa33 | ||
|
|
92070f21e8 | ||
|
|
fdda153595 | ||
|
|
4402c9ed25 | ||
|
|
a9b8d71968 | ||
|
|
45884cd4dc | ||
|
|
358b259aac | ||
|
|
bc23c550db | ||
|
|
77bf8036d2 | ||
|
|
9bebd2f504 | ||
|
|
92d292ce87 | ||
|
|
343812b90d | ||
|
|
3446f3edc2 | ||
|
|
8a7465cf0e | ||
|
|
def9a24e0d | ||
|
|
847a08da93 | ||
|
|
56b766b23b | ||
|
|
d85912b9d7 | ||
|
|
f67c49bc4d | ||
|
|
95446bdc24 | ||
|
|
0611a88971 | ||
|
|
3c5d8f6cef | ||
|
|
02816c4352 | ||
|
|
31e078d6e5 | ||
|
|
2dd6e33a54 | ||
|
|
a0a4d36fa2 | ||
|
|
3415674e76 | ||
|
|
567f1550ab | ||
|
|
fedaf24098 | ||
|
|
7b55d887bc | ||
|
|
59e9c92415 | ||
|
|
569b8762e6 | ||
|
|
e527812826 | ||
|
|
d196d4b0c7 | ||
|
|
7ef0ca8eab | ||
|
|
a31b679ab9 | ||
|
|
7460258228 | ||
|
|
099796a61d | ||
|
|
a7c6c34c82 | ||
|
|
1aceabee44 | ||
|
|
5953b1bd0e | ||
|
|
f35cd34167 | ||
|
|
e11f9bd7e7 | ||
|
|
7003efa75a | ||
|
|
79928d822e | ||
|
|
999fe4d906 | ||
|
|
92feff97e6 | ||
|
|
f8c19d712a | ||
|
|
1aaf53c378 | ||
|
|
25b4ce0f6d | ||
|
|
cfaed054d1 | ||
|
|
6258de83e3 | ||
|
|
3526351b1b | ||
|
|
2d5fef3948 | ||
|
|
83cca4224b | ||
|
|
390907cc9c |
@@ -16,6 +16,9 @@ build/
|
||||
.next/
|
||||
coverage/
|
||||
|
||||
# canonical deploy packages are transferred through the NAS inbox, not Git
|
||||
infra/deploy-artifacts/
|
||||
|
||||
# logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
@@ -34,6 +34,7 @@ Git repo:
|
||||
- `services/notification-core/README.md`
|
||||
- `services/ai-workspace-assistant/README.md`
|
||||
- `services/ai-workspace-hub/README.md`
|
||||
- `services/ontology-core/README.md`
|
||||
- `tasks/CODEX_PLATFORM_AUTH_TASK.md`
|
||||
|
||||
## Базовое правило
|
||||
@@ -43,3 +44,9 @@ Plane не переносится внутрь Launcher. Launcher не стан
|
||||
Notification Core живёт в `services/notification-core` как отдельный платформенный сервис с собственным Postgres. Он не использует Authentik DB: Authentik отвечает за identity/session, Notification Core отвечает за events/deliveries/read-state.
|
||||
|
||||
AI Workspace Assistant живёт в `services/ai-workspace-assistant` как общий платформенный слой для пользовательских Codex executors, selected executor, shared conversations, surfaces и tool packs. AI Workspace Hub остаётся отдельным тонким транспортом для remote Codex workers и не хранит смысловое состояние ассистента.
|
||||
|
||||
Ontology Core живёт в `services/ontology-core` как docs-first семантический слой для canonical entities, relations, aliases, guardrails, evidence, первого resolver MVP между OPS и ENGINE и policy MVP для NDC Core Assistant access. Он не владеет доменными БД HUB/OPS/ENGINE и не заменяет Launcher/HUB roles или OPS Gateway enforcement.
|
||||
|
||||
Map Gateway живёт в `services/map-gateway` как общий platform boundary для provider credentials, безопасного asset endpoint exchange, tile/3D Tiles proxy и persistent offline TileCache. Runtime cache не является source artifact и хранится в named volume/object storage, а не в Git.
|
||||
|
||||
Внешние бизнес-поставщики подключаются по `packages/external-provider-contract`: adapter конкретного API принадлежит изолированному NDC L2 workflow, а Platform даёт один provider-neutral External Data Plane для raw retention, canonical facts, current/history projections и scoped read products. Connection instance несёт tenant scope, credential reference и collection profile, но не secret value. Provider/domain mapping, entity filtering и renderer logic не попадают в Platform Data Plane. Provider-issued credentials сохраняются в native NDC L2 Credentials; внутренний scoped publish grant генерируется и сохраняется generic control plane внутри native credential boundary, EDP получает только digest, а MCP видит только opaque reference/status. Legacy plaintext provisioning и managed digest-only ensure включаются независимо и по умолчанию закрыты. Подробный канон — `docs/ADR_L2_OWNED_EXTERNAL_CONNECTORS.md`.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
docs
|
||||
node_modules
|
||||
**/test
|
||||
**/*.log
|
||||
**/*.prev-*
|
||||
**/*.next-*
|
||||
runtime
|
||||
secrets
|
||||
@@ -0,0 +1,63 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.postgres-bootstrap.v1",
|
||||
"service": "device-postgres",
|
||||
"volume": "nodedc-device-plane-postgres-data",
|
||||
"mode": "create-if-absent",
|
||||
"ordinaryApplicationSelection": "forbidden",
|
||||
"rollbackVolumePolicy": "preserve"
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
@@ -0,0 +1,44 @@
|
||||
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
|
||||
@@ -0,0 +1,39 @@
|
||||
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
|
||||
@@ -0,0 +1,158 @@
|
||||
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
|
||||
@@ -0,0 +1,106 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,277 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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
@@ -0,0 +1,223 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@nodedc/device-plane",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"services/*"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node --test packages/*/test/*.test.mjs services/*/test/*.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@nodedc/arusnavi-b2-adapter",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@nodedc/device-protocol-contract",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
export const DEVICE_DISCOVERY_SIGNAL_SCHEMA =
|
||||
"nodedc.device.discovery-signal.v1";
|
||||
export const DEVICE_DISCOVERY_VIEW_SCHEMA =
|
||||
"nodedc.device.discovery-view.v1";
|
||||
export const DEVICE_PLANE_BINDING_SCHEMA =
|
||||
"nodedc.device-plane-control.binding.v1";
|
||||
|
||||
export const DEVICE_LIFECYCLE_STATES = Object.freeze([
|
||||
"quarantine",
|
||||
"claimed",
|
||||
"online",
|
||||
"offline",
|
||||
"retired",
|
||||
]);
|
||||
|
||||
export const DEVICE_BINDING_CAPABILITIES = Object.freeze([
|
||||
"observe",
|
||||
"inspect",
|
||||
"configure",
|
||||
"command",
|
||||
]);
|
||||
|
||||
const OPAQUE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const IMEI_RE = /^\d{15}$/;
|
||||
const DIGEST_RE = /^hmac-sha256:[a-f0-9]{64}$/;
|
||||
const forbiddenKeyFragments = Object.freeze([
|
||||
"password",
|
||||
"secret",
|
||||
"credential",
|
||||
"rawpayload",
|
||||
"rawpacket",
|
||||
"command",
|
||||
"authorization",
|
||||
"token",
|
||||
]);
|
||||
const safeStatusKeys = new Set([
|
||||
"commandtransport",
|
||||
]);
|
||||
|
||||
export function normalizeDiscoverySignal(input) {
|
||||
assertPlainObject(input, "discovery_signal");
|
||||
rejectForbiddenKeys(input);
|
||||
|
||||
if (input.schemaVersion !== DEVICE_DISCOVERY_SIGNAL_SCHEMA) {
|
||||
throw new TypeError("discovery_signal_schema_invalid");
|
||||
}
|
||||
|
||||
const sessionRef = normalizeOpaqueRef(input.sessionRef, "session_ref");
|
||||
const modelProfileRef = normalizeOpaqueRef(
|
||||
input.modelProfileRef,
|
||||
"model_profile_ref",
|
||||
);
|
||||
const protocol = normalizeUpperToken(input.protocol, "protocol");
|
||||
const observedAt = normalizeTimestamp(input.observedAt, "observed_at");
|
||||
const identifier = normalizeRestrictedIdentifier(input.identifier);
|
||||
const evidence = normalizeDiscoveryEvidence(input.evidence);
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
sessionRef,
|
||||
modelProfileRef,
|
||||
protocol,
|
||||
observedAt,
|
||||
identifier,
|
||||
evidence,
|
||||
lifecycleState: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
export function toSafeDiscoveryView(signal, options = {}) {
|
||||
const normalized = normalizeDiscoverySignal(signal);
|
||||
const discoveryRef = options.discoveryRef
|
||||
? normalizeOpaqueRef(options.discoveryRef, "discovery_ref")
|
||||
: undefined;
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: DEVICE_DISCOVERY_VIEW_SCHEMA,
|
||||
...(discoveryRef ? { discoveryRef } : {}),
|
||||
modelProfileRef: normalized.modelProfileRef,
|
||||
protocol: normalized.protocol,
|
||||
observedAt: normalized.observedAt,
|
||||
lifecycleState: normalized.lifecycleState,
|
||||
identifier: Object.freeze({
|
||||
kind: normalized.identifier.kind,
|
||||
masked: maskRestrictedIdentifier(normalized.identifier),
|
||||
}),
|
||||
evidence: normalized.evidence,
|
||||
commandTransport: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
export function hashRestrictedIdentifier(identifier, pepper) {
|
||||
const normalized = normalizeRestrictedIdentifier(identifier);
|
||||
if (typeof pepper !== "string" || pepper.length < 32) {
|
||||
throw new TypeError("identifier_pepper_invalid");
|
||||
}
|
||||
|
||||
const digest = createHmac("sha256", pepper)
|
||||
.update(`${normalized.kind}\0${normalized.value}`, "utf8")
|
||||
.digest("hex");
|
||||
return `hmac-sha256:${digest}`;
|
||||
}
|
||||
|
||||
export function assertIdentifierDigest(value) {
|
||||
if (typeof value !== "string" || !DIGEST_RE.test(value)) {
|
||||
throw new TypeError("identifier_digest_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeDevicePlaneBinding(input) {
|
||||
assertPlainObject(input, "device_plane_binding");
|
||||
rejectForbiddenKeys(input);
|
||||
if (input.schemaVersion !== DEVICE_PLANE_BINDING_SCHEMA) {
|
||||
throw new TypeError("device_plane_binding_schema_invalid");
|
||||
}
|
||||
|
||||
const allowed = new Set(DEVICE_BINDING_CAPABILITIES);
|
||||
if (!Array.isArray(input.capabilities) || input.capabilities.length === 0) {
|
||||
throw new TypeError("device_plane_binding_capabilities_invalid");
|
||||
}
|
||||
const capabilities = [...new Set(input.capabilities.map((value) => {
|
||||
if (typeof value !== "string" || !allowed.has(value)) {
|
||||
throw new TypeError("device_plane_binding_capability_invalid");
|
||||
}
|
||||
return value;
|
||||
}))].sort();
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: DEVICE_PLANE_BINDING_SCHEMA,
|
||||
bindingRef: normalizeOpaqueRef(input.bindingRef, "binding_ref"),
|
||||
contourRef: normalizeOpaqueRef(input.contourRef, "contour_ref"),
|
||||
capabilities: Object.freeze(capabilities),
|
||||
});
|
||||
}
|
||||
|
||||
export function assertSafeProjection(value) {
|
||||
assertPlainObject(value, "safe_projection");
|
||||
rejectForbiddenKeys(value);
|
||||
const serialized = JSON.stringify(value);
|
||||
if (/\b\d{15}\b/.test(serialized)) {
|
||||
throw new TypeError("safe_projection_contains_unmasked_imei");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeRestrictedIdentifier(input) {
|
||||
assertPlainObject(input, "restricted_identifier");
|
||||
if (input.kind !== "imei") {
|
||||
throw new TypeError("restricted_identifier_kind_unsupported");
|
||||
}
|
||||
if (typeof input.value !== "string" || !IMEI_RE.test(input.value)) {
|
||||
throw new TypeError("restricted_identifier_imei_invalid");
|
||||
}
|
||||
return Object.freeze({ kind: "imei", value: input.value });
|
||||
}
|
||||
|
||||
function maskRestrictedIdentifier(identifier) {
|
||||
if (identifier.kind === "imei") {
|
||||
return `***********${identifier.value.slice(-4)}`;
|
||||
}
|
||||
throw new TypeError("restricted_identifier_kind_unsupported");
|
||||
}
|
||||
|
||||
function normalizeDiscoveryEvidence(input) {
|
||||
assertPlainObject(input, "discovery_evidence");
|
||||
rejectForbiddenKeys(input);
|
||||
if (input.transport !== "tcp") {
|
||||
throw new TypeError("discovery_evidence_transport_invalid");
|
||||
}
|
||||
const bytesObserved = Number(input.bytesObserved);
|
||||
if (!Number.isSafeInteger(bytesObserved) || bytesObserved < 1 || bytesObserved > 4096) {
|
||||
throw new TypeError("discovery_evidence_bytes_invalid");
|
||||
}
|
||||
if (input.framingStatus !== "verified") {
|
||||
throw new TypeError("discovery_evidence_framing_unverified");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
transport: "tcp",
|
||||
bytesObserved,
|
||||
framingStatus: "verified",
|
||||
specificationRef: normalizeOpaqueRef(
|
||||
input.specificationRef,
|
||||
"framing_specification_ref",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function rejectForbiddenKeys(value, path = "$") {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => rejectForbiddenKeys(item, `${path}[${index}]`));
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") return;
|
||||
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
if (
|
||||
!safeStatusKeys.has(normalizedKey)
|
||||
&& forbiddenKeyFragments.some((fragment) => normalizedKey.includes(fragment))
|
||||
) {
|
||||
throw new TypeError(`forbidden_device_field:${path}.${key}`);
|
||||
}
|
||||
rejectForbiddenKeys(child, `${path}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOpaqueRef(value, label) {
|
||||
if (typeof value !== "string" || !OPAQUE_REF_RE.test(value)) {
|
||||
throw new TypeError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeUpperToken(value, label) {
|
||||
if (typeof value !== "string" || !/^[A-Z][A-Z0-9_]{0,31}$/.test(value)) {
|
||||
throw new TypeError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value, label) {
|
||||
if (typeof value !== "string") throw new TypeError(`${label}_invalid`);
|
||||
const date = new Date(value);
|
||||
if (!Number.isFinite(date.getTime()) || date.toISOString() !== value) {
|
||||
throw new TypeError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertPlainObject(value, label) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${label}_invalid`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
DEVICE_PLANE_BINDING_SCHEMA,
|
||||
assertIdentifierDigest,
|
||||
assertSafeProjection,
|
||||
hashRestrictedIdentifier,
|
||||
normalizeDevicePlaneBinding,
|
||||
normalizeDiscoverySignal,
|
||||
toSafeDiscoveryView,
|
||||
} from "../src/index.mjs";
|
||||
|
||||
const fakeImei = "000000000000001";
|
||||
const fakeSignal = {
|
||||
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
sessionRef: "session:test-001",
|
||||
modelProfileRef: "arusnavi.b2.internal.v1",
|
||||
protocol: "INTERNAL",
|
||||
observedAt: "2026-07-25T00:00:00.000Z",
|
||||
identifier: {
|
||||
kind: "imei",
|
||||
value: fakeImei,
|
||||
},
|
||||
evidence: {
|
||||
transport: "tcp",
|
||||
bytesObserved: 128,
|
||||
framingStatus: "verified",
|
||||
specificationRef: "arusnavi.internal.framing.test-v1",
|
||||
},
|
||||
};
|
||||
|
||||
test("normalizes a verified discovery into quarantine with commands disabled", () => {
|
||||
const signal = normalizeDiscoverySignal(fakeSignal);
|
||||
assert.equal(signal.lifecycleState, "quarantine");
|
||||
assert.equal(signal.commandTransport, "disabled");
|
||||
assert.equal(signal.identifier.value, fakeImei);
|
||||
});
|
||||
|
||||
test("safe discovery projection masks the restricted identifier", () => {
|
||||
const view = toSafeDiscoveryView(fakeSignal, {
|
||||
discoveryRef: "discovery:test-001",
|
||||
});
|
||||
const serialized = JSON.stringify(view);
|
||||
assert.equal(view.identifier.masked, "***********0001");
|
||||
assert.equal(serialized.includes(fakeImei), false);
|
||||
assertSafeProjection(view);
|
||||
});
|
||||
|
||||
test("identifier hashing requires a strong process-only pepper", () => {
|
||||
const identifier = { kind: "imei", value: fakeImei };
|
||||
assert.throws(
|
||||
() => hashRestrictedIdentifier(identifier, "short"),
|
||||
/identifier_pepper_invalid/,
|
||||
);
|
||||
const digest = hashRestrictedIdentifier(
|
||||
identifier,
|
||||
"test-only-pepper-with-at-least-32-bytes",
|
||||
);
|
||||
assertIdentifierDigest(digest);
|
||||
assert.equal(digest.includes(fakeImei), false);
|
||||
assert.equal(
|
||||
digest,
|
||||
hashRestrictedIdentifier(
|
||||
identifier,
|
||||
"test-only-pepper-with-at-least-32-bytes",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects unverified framing and command-shaped discovery input", () => {
|
||||
assert.throws(
|
||||
() => normalizeDiscoverySignal({
|
||||
...fakeSignal,
|
||||
evidence: { ...fakeSignal.evidence, framingStatus: "unverified" },
|
||||
}),
|
||||
/discovery_evidence_framing_unverified/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDiscoverySignal({
|
||||
...fakeSignal,
|
||||
command: { kind: "restart" },
|
||||
}),
|
||||
/forbidden_device_field/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects secret-like fields recursively", () => {
|
||||
assert.throws(
|
||||
() => normalizeDiscoverySignal({
|
||||
...fakeSignal,
|
||||
metadata: { devicePassword: "not-a-real-password" },
|
||||
}),
|
||||
/forbidden_device_field/,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes an opaque Foundry control binding without device data", () => {
|
||||
const binding = normalizeDevicePlaneBinding({
|
||||
schemaVersion: DEVICE_PLANE_BINDING_SCHEMA,
|
||||
bindingRef: "binding:test-001",
|
||||
contourRef: "contour:robot2b-test",
|
||||
capabilities: ["inspect", "observe", "observe"],
|
||||
});
|
||||
assert.deepEqual(binding.capabilities, ["inspect", "observe"]);
|
||||
assertSafeProjection(binding);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
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"]
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
@@ -0,0 +1,16 @@
|
||||
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"]
|
||||
@@ -0,0 +1,99 @@
|
||||
begin;
|
||||
|
||||
create table if not exists device_model_profiles (
|
||||
profile_ref text primary key,
|
||||
schema_version text not null,
|
||||
vendor text not null,
|
||||
model text not null,
|
||||
device_type text not null,
|
||||
protocol text not null,
|
||||
profile jsonb not null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists device_contours (
|
||||
id uuid primary key,
|
||||
owner_scope text not null,
|
||||
name text not null,
|
||||
lifecycle_state text not null default 'active'
|
||||
check (lifecycle_state in ('active', 'suspended', 'retired')),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (owner_scope, name)
|
||||
);
|
||||
|
||||
create table if not exists device_discoveries (
|
||||
id uuid primary key,
|
||||
identifier_kind text not null,
|
||||
identifier_digest text not null,
|
||||
identifier_masked text not null,
|
||||
model_profile_ref text not null references device_model_profiles(profile_ref),
|
||||
protocol text not null,
|
||||
lifecycle_state text not null default 'quarantine'
|
||||
check (lifecycle_state in ('quarantine', 'claimed', 'rejected', 'expired')),
|
||||
first_observed_at timestamptz not null,
|
||||
last_observed_at timestamptz not null,
|
||||
evidence jsonb not null,
|
||||
claimed_device_id uuid,
|
||||
claimed_at timestamptz,
|
||||
claimed_by text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (identifier_kind, identifier_digest, model_profile_ref)
|
||||
);
|
||||
|
||||
create index if not exists device_discoveries_state_last_seen_idx
|
||||
on device_discoveries (lifecycle_state, last_observed_at desc);
|
||||
|
||||
create table if not exists device_instances (
|
||||
id uuid primary key,
|
||||
contour_id uuid not null references device_contours(id),
|
||||
model_profile_ref text not null references device_model_profiles(profile_ref),
|
||||
display_name text not null,
|
||||
identifier_kind text not null,
|
||||
identifier_digest text not null,
|
||||
identifier_masked text not null,
|
||||
credential_ref text,
|
||||
lifecycle_state text not null default 'claimed'
|
||||
check (lifecycle_state in ('claimed', 'online', 'offline', 'suspended', 'retired')),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (identifier_kind, identifier_digest, model_profile_ref)
|
||||
);
|
||||
|
||||
alter table device_discoveries
|
||||
drop constraint if exists device_discoveries_claimed_device_fk;
|
||||
|
||||
alter table device_discoveries
|
||||
add constraint device_discoveries_claimed_device_fk
|
||||
foreign key (claimed_device_id) references device_instances(id);
|
||||
|
||||
create table if not exists device_bindings (
|
||||
id uuid primary key,
|
||||
contour_id uuid not null references device_contours(id),
|
||||
target_kind text not null,
|
||||
target_ref text not null,
|
||||
capabilities text[] not null,
|
||||
lifecycle_state text not null default 'active'
|
||||
check (lifecycle_state in ('active', 'revoked')),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (contour_id, target_kind, target_ref)
|
||||
);
|
||||
|
||||
create table if not exists device_audit_events (
|
||||
id uuid primary key,
|
||||
event_type text not null,
|
||||
actor_ref text not null,
|
||||
contour_id uuid,
|
||||
device_id uuid,
|
||||
discovery_id uuid,
|
||||
payload jsonb not null,
|
||||
occurred_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists device_audit_events_device_time_idx
|
||||
on device_audit_events (device_id, occurred_at desc);
|
||||
|
||||
commit;
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@nodedc/device-control-core",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.mjs",
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"pg": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import {
|
||||
assertSafeProjection,
|
||||
hashRestrictedIdentifier,
|
||||
normalizeDiscoverySignal,
|
||||
toSafeDiscoveryView,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
|
||||
export function createControlCoreApp({
|
||||
repository,
|
||||
gatewayToken = "",
|
||||
identifierPepper = "",
|
||||
discoveryIngestEnabled = false,
|
||||
} = {}) {
|
||||
if (!repository || typeof repository.health !== "function") {
|
||||
throw new TypeError("device_repository_required");
|
||||
}
|
||||
if (discoveryIngestEnabled) {
|
||||
if (typeof repository.upsertQuarantineDiscovery !== "function") {
|
||||
throw new TypeError("device_discovery_repository_required");
|
||||
}
|
||||
if (typeof gatewayToken !== "string" || gatewayToken.length < 32) {
|
||||
throw new TypeError("device_gateway_token_invalid");
|
||||
}
|
||||
if (typeof identifierPepper !== "string" || identifierPepper.length < 32) {
|
||||
throw new TypeError("device_identifier_pepper_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
|
||||
try {
|
||||
const requestUrl = new URL(
|
||||
request.url || "/",
|
||||
`http://${request.headers.host || "127.0.0.1"}`,
|
||||
);
|
||||
|
||||
if (request.method === "GET" && requestUrl.pathname === "/healthz") {
|
||||
const database = await repository.health();
|
||||
return writeJson(response, 200, {
|
||||
ok: true,
|
||||
service: "nodedc-device-control-core",
|
||||
database,
|
||||
discoveryIngest: discoveryIngestEnabled ? "enabled" : "disabled",
|
||||
commandTransport: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === "POST"
|
||||
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
|
||||
) {
|
||||
if (!discoveryIngestEnabled) {
|
||||
return writeJson(response, 404, {
|
||||
ok: false,
|
||||
error: "device_discovery_ingest_disabled",
|
||||
});
|
||||
}
|
||||
if (!matchesBearer(request.headers.authorization, gatewayToken)) {
|
||||
return writeJson(response, 401, {
|
||||
ok: false,
|
||||
error: "device_gateway_auth_required",
|
||||
});
|
||||
}
|
||||
|
||||
const input = await readJsonBody(request, 32 * 1024);
|
||||
const signal = normalizeDiscoverySignal(input);
|
||||
const identifierDigest = hashRestrictedIdentifier(
|
||||
signal.identifier,
|
||||
identifierPepper,
|
||||
);
|
||||
const safeView = assertSafeProjection(toSafeDiscoveryView(signal));
|
||||
const discovery = await repository.upsertQuarantineDiscovery({
|
||||
identifierDigest,
|
||||
safeView,
|
||||
});
|
||||
return writeJson(response, discovery.created ? 201 : 200, {
|
||||
ok: true,
|
||||
created: discovery.created,
|
||||
discovery: assertSafeProjection(discovery.value),
|
||||
});
|
||||
}
|
||||
|
||||
return writeJson(response, 404, {
|
||||
ok: false,
|
||||
error: "device_control_core_route_not_found",
|
||||
});
|
||||
} catch (error) {
|
||||
const status = Number(error?.statusCode || 400);
|
||||
return writeJson(
|
||||
response,
|
||||
Number.isInteger(status) && status >= 400 && status < 600
|
||||
? status
|
||||
: 500,
|
||||
{
|
||||
ok: false,
|
||||
error: safeErrorCode(error),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function matchesBearer(header, expected) {
|
||||
if (typeof header !== "string" || !header.startsWith("Bearer ")) return false;
|
||||
const actual = Buffer.from(header.slice("Bearer ".length), "utf8");
|
||||
const required = Buffer.from(expected, "utf8");
|
||||
return (
|
||||
actual.length === required.length
|
||||
&& required.length > 0
|
||||
&& timingSafeEqual(actual, required)
|
||||
);
|
||||
}
|
||||
|
||||
async function readJsonBody(request, maxBytes) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) {
|
||||
const error = new Error("device_request_body_too_large");
|
||||
error.statusCode = 413;
|
||||
throw error;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (size === 0) throw new TypeError("device_request_body_required");
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
} catch {
|
||||
throw new TypeError("device_request_json_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(response, status, body) {
|
||||
response.statusCode = status;
|
||||
return response.end(`${JSON.stringify(body)}\n`);
|
||||
}
|
||||
|
||||
function safeErrorCode(error) {
|
||||
const value = error instanceof Error ? error.message : "device_control_error";
|
||||
return /^[a-z0-9_:-]{1,128}$/.test(value)
|
||||
? value
|
||||
: "device_control_error";
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
export async function resolveDeviceDatabaseUrl(
|
||||
environment = process.env,
|
||||
readSecret = readFile,
|
||||
) {
|
||||
const explicit = optionalValue(environment.DEVICE_DATABASE_URL);
|
||||
if (explicit) return explicit;
|
||||
|
||||
const host = restrictedValue(
|
||||
environment.DEVICE_DATABASE_HOST,
|
||||
/^[A-Za-z0-9.-]{1,253}$/,
|
||||
"device_database_host_invalid",
|
||||
);
|
||||
const port = parsePort(environment.DEVICE_DATABASE_PORT, 5432);
|
||||
const database = restrictedValue(
|
||||
environment.DEVICE_DATABASE_NAME,
|
||||
/^[A-Za-z_][A-Za-z0-9_-]{0,62}$/,
|
||||
"device_database_name_invalid",
|
||||
);
|
||||
const user = restrictedValue(
|
||||
environment.DEVICE_DATABASE_USER,
|
||||
/^[A-Za-z_][A-Za-z0-9_-]{0,62}$/,
|
||||
"device_database_user_invalid",
|
||||
);
|
||||
const passwordFile = requiredValue(
|
||||
environment.DEVICE_DATABASE_PASSWORD_FILE,
|
||||
"device_database_password_file_required",
|
||||
);
|
||||
const password = (await readSecret(passwordFile, "utf8")).trim();
|
||||
if (password.length < 32 || password.length > 512) {
|
||||
throw new Error("device_database_password_invalid");
|
||||
}
|
||||
|
||||
return [
|
||||
"postgresql://",
|
||||
encodeURIComponent(user),
|
||||
":",
|
||||
encodeURIComponent(password),
|
||||
"@",
|
||||
host,
|
||||
":",
|
||||
String(port),
|
||||
"/",
|
||||
encodeURIComponent(database),
|
||||
"?sslmode=disable",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function optionalValue(value) {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function requiredValue(value, errorCode) {
|
||||
const normalized = optionalValue(value);
|
||||
if (!normalized) throw new Error(errorCode);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function restrictedValue(value, pattern, errorCode) {
|
||||
const normalized = requiredValue(value, errorCode);
|
||||
if (!pattern.test(normalized)) throw new Error(errorCode);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parsePort(value, fallback) {
|
||||
const parsed = Number(value || fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
throw new Error("device_database_port_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import pg from "pg";
|
||||
|
||||
import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
|
||||
|
||||
const { Pool } = pg;
|
||||
const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export class PostgresDeviceRepository {
|
||||
constructor({ databaseUrl, poolSize = 10 } = {}) {
|
||||
if (typeof databaseUrl !== "string" || databaseUrl.trim() === "") {
|
||||
throw new TypeError("device_database_url_required");
|
||||
}
|
||||
this.pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
max: normalizePoolSize(poolSize),
|
||||
});
|
||||
}
|
||||
|
||||
async migrate() {
|
||||
const sql = await readFile(
|
||||
resolve(serviceRoot, "migrations/001_device_plane_foundation.sql"),
|
||||
"utf8",
|
||||
);
|
||||
await this.pool.query(sql);
|
||||
await this.pool.query(
|
||||
`insert into device_model_profiles (
|
||||
profile_ref,
|
||||
schema_version,
|
||||
vendor,
|
||||
model,
|
||||
device_type,
|
||||
protocol,
|
||||
profile
|
||||
) values ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
on conflict (profile_ref) do update set
|
||||
schema_version = excluded.schema_version,
|
||||
profile = excluded.profile,
|
||||
updated_at = now()`,
|
||||
[
|
||||
ARUSNAVI_B2_MODEL_PROFILE.profileRef,
|
||||
ARUSNAVI_B2_MODEL_PROFILE.schemaVersion,
|
||||
ARUSNAVI_B2_MODEL_PROFILE.vendor,
|
||||
ARUSNAVI_B2_MODEL_PROFILE.model,
|
||||
ARUSNAVI_B2_MODEL_PROFILE.deviceType,
|
||||
ARUSNAVI_B2_MODEL_PROFILE.protocol,
|
||||
JSON.stringify(ARUSNAVI_B2_MODEL_PROFILE),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async health() {
|
||||
await this.pool.query("select 1");
|
||||
return "ready";
|
||||
}
|
||||
|
||||
async upsertQuarantineDiscovery({ identifierDigest, safeView }) {
|
||||
const result = await this.pool.query(
|
||||
`insert into device_discoveries (
|
||||
id,
|
||||
identifier_kind,
|
||||
identifier_digest,
|
||||
identifier_masked,
|
||||
model_profile_ref,
|
||||
protocol,
|
||||
lifecycle_state,
|
||||
first_observed_at,
|
||||
last_observed_at,
|
||||
evidence
|
||||
) values ($1, $2, $3, $4, $5, $6, 'quarantine', $7, $7, $8::jsonb)
|
||||
on conflict (identifier_kind, identifier_digest, model_profile_ref)
|
||||
do update set
|
||||
last_observed_at = greatest(
|
||||
device_discoveries.last_observed_at,
|
||||
excluded.last_observed_at
|
||||
),
|
||||
evidence = excluded.evidence,
|
||||
updated_at = now()
|
||||
returning id, lifecycle_state, model_profile_ref, protocol,
|
||||
identifier_kind, identifier_masked, first_observed_at,
|
||||
last_observed_at, (xmax = 0) as created`,
|
||||
[
|
||||
randomUUID(),
|
||||
safeView.identifier.kind,
|
||||
identifierDigest,
|
||||
safeView.identifier.masked,
|
||||
safeView.modelProfileRef,
|
||||
safeView.protocol,
|
||||
safeView.observedAt,
|
||||
JSON.stringify(safeView.evidence),
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
created: row.created === true,
|
||||
value: {
|
||||
schemaVersion: "nodedc.device.discovery-view.v1",
|
||||
discoveryRef: `discovery:${row.id}`,
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
protocol: row.protocol,
|
||||
observedAt: new Date(row.last_observed_at).toISOString(),
|
||||
lifecycleState: row.lifecycle_state,
|
||||
identifier: {
|
||||
kind: row.identifier_kind,
|
||||
masked: row.identifier_masked,
|
||||
},
|
||||
evidence: safeView.evidence,
|
||||
commandTransport: "disabled",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePoolSize(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 50) {
|
||||
throw new TypeError("device_database_pool_size_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { createControlCoreApp } from "./app.mjs";
|
||||
import { resolveDeviceDatabaseUrl } from "./database-config.mjs";
|
||||
import { PostgresDeviceRepository } from "./postgres-repository.mjs";
|
||||
|
||||
const config = await readConfig();
|
||||
const repository = new PostgresDeviceRepository({
|
||||
databaseUrl: config.databaseUrl,
|
||||
poolSize: config.databasePoolSize,
|
||||
});
|
||||
|
||||
await repository.migrate();
|
||||
|
||||
const server = createControlCoreApp({
|
||||
repository,
|
||||
gatewayToken: config.gatewayToken,
|
||||
identifierPepper: config.identifierPepper,
|
||||
discoveryIngestEnabled: config.discoveryIngestEnabled,
|
||||
});
|
||||
|
||||
server.listen(config.port, config.host, () => {
|
||||
console.log(JSON.stringify({
|
||||
event: "device_control_core_started",
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
discoveryIngest: config.discoveryIngestEnabled,
|
||||
commandTransport: "disabled",
|
||||
}));
|
||||
});
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
async function shutdown() {
|
||||
server.close(async () => {
|
||||
await repository.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
async function readConfig() {
|
||||
const discoveryIngestEnabled = parseBoolean(
|
||||
process.env.DEVICE_DISCOVERY_INGEST_ENABLED,
|
||||
false,
|
||||
);
|
||||
return {
|
||||
host: String(process.env.HOST || "127.0.0.1").trim(),
|
||||
port: parsePort(process.env.PORT, 18120),
|
||||
databaseUrl: await resolveDeviceDatabaseUrl(process.env),
|
||||
databasePoolSize: parsePositiveInt(
|
||||
process.env.DEVICE_DATABASE_POOL_SIZE,
|
||||
10,
|
||||
),
|
||||
discoveryIngestEnabled,
|
||||
gatewayToken: discoveryIngestEnabled
|
||||
? await readRequiredSecretFile(
|
||||
process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE,
|
||||
"device_gateway_core_token_file_required",
|
||||
)
|
||||
: "",
|
||||
identifierPepper: discoveryIngestEnabled
|
||||
? await readRequiredSecretFile(
|
||||
process.env.DEVICE_IDENTIFIER_PEPPER_FILE,
|
||||
"device_identifier_pepper_file_required",
|
||||
)
|
||||
: "",
|
||||
};
|
||||
}
|
||||
|
||||
async function readRequiredSecretFile(path, errorCode) {
|
||||
const normalized = requiredValue(path, errorCode);
|
||||
const value = (await readFile(normalized, "utf8")).trim();
|
||||
if (value.length < 32) throw new Error(errorCode);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredValue(value, errorCode) {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw new Error(errorCode);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function parsePort(value, fallback) {
|
||||
const parsed = Number(value || fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
throw new Error("device_control_port_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value, fallback) {
|
||||
const parsed = Number(value || fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new Error("device_positive_integer_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseBoolean(value, fallback) {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
||||
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
||||
throw new Error("device_boolean_invalid");
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
import { createControlCoreApp } from "../src/app.mjs";
|
||||
|
||||
const gatewayToken = "test-only-gateway-token-with-32-bytes";
|
||||
const identifierPepper = "test-only-identifier-pepper-with-32-bytes";
|
||||
const fakeImei = "000000000000001";
|
||||
|
||||
test("health reports database readiness and disabled command transport", async () => {
|
||||
const runtime = await startTestServer({
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${runtime.baseUrl}/healthz`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), {
|
||||
ok: true,
|
||||
service: "nodedc-device-control-core",
|
||||
database: "ready",
|
||||
discoveryIngest: "disabled",
|
||||
commandTransport: "disabled",
|
||||
});
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("discovery ingest is closed by default", async () => {
|
||||
const runtime = await startTestServer({
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/device-discoveries:observe`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
},
|
||||
);
|
||||
assert.equal(response.status, 404);
|
||||
assert.equal(
|
||||
(await response.json()).error,
|
||||
"device_discovery_ingest_disabled",
|
||||
);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("authenticated ingest stores only digest and returns a masked view", async () => {
|
||||
let stored;
|
||||
const runtime = await startTestServer({
|
||||
discoveryIngestEnabled: true,
|
||||
gatewayToken,
|
||||
identifierPepper,
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
upsertQuarantineDiscovery: async (value) => {
|
||||
stored = value;
|
||||
return {
|
||||
created: true,
|
||||
value: {
|
||||
...value.safeView,
|
||||
discoveryRef: "discovery:test-001",
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const unauthorized = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/device-discoveries:observe`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(fakeSignal()),
|
||||
},
|
||||
);
|
||||
assert.equal(unauthorized.status, 401);
|
||||
|
||||
const response = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/device-discoveries:observe`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${gatewayToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(fakeSignal()),
|
||||
},
|
||||
);
|
||||
assert.equal(response.status, 201);
|
||||
const body = await response.json();
|
||||
const serialized = JSON.stringify(body);
|
||||
assert.equal(serialized.includes(fakeImei), false);
|
||||
assert.equal(body.discovery.identifier.masked, "***********0001");
|
||||
assert.match(stored.identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/);
|
||||
assert.equal(JSON.stringify(stored).includes(fakeImei), false);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
function fakeSignal() {
|
||||
return {
|
||||
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
sessionRef: "session:test-001",
|
||||
modelProfileRef: "arusnavi.b2.internal.v1",
|
||||
protocol: "INTERNAL",
|
||||
observedAt: "2026-07-25T00:00:00.000Z",
|
||||
identifier: { kind: "imei", value: fakeImei },
|
||||
evidence: {
|
||||
transport: "tcp",
|
||||
bytesObserved: 128,
|
||||
framingStatus: "verified",
|
||||
specificationRef: "arusnavi.internal.framing.test-v1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function startTestServer(options) {
|
||||
const server = createControlCoreApp(options);
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
close: () => new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveDeviceDatabaseUrl } from "../src/database-config.mjs";
|
||||
|
||||
test("builds the database URL from a file-backed password", async () => {
|
||||
const password = "test-only-database-password-with-32-bytes";
|
||||
const url = await resolveDeviceDatabaseUrl(
|
||||
{
|
||||
DEVICE_DATABASE_HOST: "device-postgres",
|
||||
DEVICE_DATABASE_PORT: "5432",
|
||||
DEVICE_DATABASE_NAME: "device_plane",
|
||||
DEVICE_DATABASE_USER: "device_plane",
|
||||
DEVICE_DATABASE_PASSWORD_FILE: "/run/test/postgres-password",
|
||||
},
|
||||
async (path, encoding) => {
|
||||
assert.equal(path, "/run/test/postgres-password");
|
||||
assert.equal(encoding, "utf8");
|
||||
return `${password}\n`;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
url,
|
||||
`postgresql://device_plane:${encodeURIComponent(password)}@device-postgres:5432/device_plane?sslmode=disable`,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a short file-backed database password", async () => {
|
||||
await assert.rejects(
|
||||
resolveDeviceDatabaseUrl(
|
||||
{
|
||||
DEVICE_DATABASE_HOST: "device-postgres",
|
||||
DEVICE_DATABASE_NAME: "device_plane",
|
||||
DEVICE_DATABASE_USER: "device_plane",
|
||||
DEVICE_DATABASE_PASSWORD_FILE: "/run/test/postgres-password",
|
||||
},
|
||||
async () => "too-short",
|
||||
),
|
||||
/device_database_password_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps an explicit database URL as a compatibility-only boundary", async () => {
|
||||
const explicit = "postgresql://local:test@127.0.0.1:5432/device_plane";
|
||||
assert.equal(
|
||||
await resolveDeviceDatabaseUrl({ DEVICE_DATABASE_URL: explicit }),
|
||||
explicit,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const migrationUrl = new URL(
|
||||
"../migrations/001_device_plane_foundation.sql",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
test("foundation migration keeps restricted identifiers hashed and DB private", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
assert.match(sql, /identifier_digest text not null/);
|
||||
assert.match(sql, /identifier_masked text not null/);
|
||||
assert.doesNotMatch(sql, /imei\s+text/i);
|
||||
assert.doesNotMatch(sql, /password\s+text/i);
|
||||
assert.doesNotMatch(sql, /raw_packet/i);
|
||||
});
|
||||
|
||||
test("foundation migration has quarantine, contour, binding and audit tables", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
for (const table of [
|
||||
"device_model_profiles",
|
||||
"device_contours",
|
||||
"device_discoveries",
|
||||
"device_instances",
|
||||
"device_bindings",
|
||||
"device_audit_events",
|
||||
]) {
|
||||
assert.match(sql, new RegExp(`create table if not exists ${table}`));
|
||||
}
|
||||
assert.match(sql, /default 'quarantine'/);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
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"]
|
||||
@@ -0,0 +1,34 @@
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY services/device-edge-relay/src ./src
|
||||
|
||||
USER node
|
||||
|
||||
CMD ["node", "src/server.mjs"]
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
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()));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
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()));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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"]
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@nodedc/device-gateway",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.mjs",
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
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()));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
# ADR: канон External Provider Data Plane
|
||||
|
||||
Статус: **superseded**.
|
||||
Дата: 2026-07-13.
|
||||
Владелец решения: NODE.DC Platform.
|
||||
|
||||
> Заменено 2026-07-14 документом
|
||||
> [`ADR_L2_OWNED_EXTERNAL_CONNECTORS.md`](ADR_L2_OWNED_EXTERNAL_CONNECTORS.md).
|
||||
> Этот черновик неверно помещал provider adapter, normalisation и collection
|
||||
> policy в `services/<provider>-gateway`. Новое правило: adapter принадлежит
|
||||
> изолированному L2 workflow; Platform Data Plane остаётся provider-neutral.
|
||||
|
||||
## Контекст
|
||||
|
||||
Клиент может подключить к NODE.DC любой внешний продукт: телеметрию, ERP,
|
||||
роботов, энергетику, BIM-систему или иной источник данных. Gelios Pro для
|
||||
Gelios — первый конкретный поставщик для проверки канона, а не исключительная
|
||||
архитектурная ветка. Нельзя превращать Engine workflow, Ontology Core или общую БД Platform
|
||||
в место, куда попадают токены, raw payloads и частная логика каждого API.
|
||||
|
||||
Нужна повторяемая форма, в которой новый provider добавляется как отдельный
|
||||
adapter, но получает общие правила scope, секретов, collection, хранения,
|
||||
read-model, аудита и безопасной публикации в NDC.
|
||||
|
||||
## Решение
|
||||
|
||||
1. В `platform/packages/external-provider-contract` живёт общий versioned
|
||||
контракт интеграции. Это не runtime и не база данных. Он задаёт форму
|
||||
`provider`, `connection`, `capability catalog`, `credential reference`,
|
||||
`access scope`, `field policy`, `collection profile`, `retention policy`,
|
||||
`read model` и красный command-domain.
|
||||
2. Каждый provider получает самостоятельный app-owned adapter в
|
||||
`platform/services/<provider>-gateway`. Первый экземпляр —
|
||||
`platform/services/gelios-gateway`. Adapter владеет intake contract, rate
|
||||
budget, normalisation, collection policy, storage и своим internal
|
||||
read/realtime API. Сам provider secret остаётся в Engine Credentials и
|
||||
доступен только назначенному защищённому execution workflow.
|
||||
3. Один provider service может обслуживать много клиентов. Каждая строка,
|
||||
cursor, audit-event и read-model обязана иметь `tenant_id` и
|
||||
`connection_id`; видимость provider account сама по себе не является
|
||||
продуктовым scope. Для клиента с отдельными требованиями изоляции допустим
|
||||
отдельный deployment/database profile без изменения контракта.
|
||||
4. База принадлежит adapter-сервису, а не Ontology Core, Engine, Tasker или
|
||||
общему Platform Postgres. Для пространственно-временного Gelios-кейса
|
||||
базой служит PostgreSQL 16 + TimescaleDB + PostGIS (`gelios-postgres`).
|
||||
Другой provider может выбрать иной storage engine только через явный ADR,
|
||||
сохранив внешний контракт.
|
||||
5. Ontology Core хранит только provider-neutral и provider-specific смыслы,
|
||||
связи, правила и контракты. Он не хранит credentials, runtime telemetry,
|
||||
customer raw payloads или renderer objects. Enforcement остаётся в
|
||||
gateway/adapters. Engine Credentials хранит provider secret в границе
|
||||
специально назначенного execution workflow; значение не сериализуется в
|
||||
граф, ontology, логи, read-model или UI.
|
||||
6. Engine L2 Collector использует credential reference и получает разрешённые
|
||||
данные поставщика. Он передаёт в adapter только аутентифицированный нормализованный
|
||||
intake payload. Остальные L2 workflow получают исключительно scoped
|
||||
internal API/event contract и не читают gateway DB напрямую.
|
||||
|
||||
## Каноническая форма нового подключения
|
||||
|
||||
```text
|
||||
Client / tenant
|
||||
-> Engine Credential + protected Collector workflow
|
||||
-> provider connection instance
|
||||
-> provider adapter (safe intake policy + normalizer)
|
||||
-> provider-owned storage and projections
|
||||
-> internal read/realtime contract
|
||||
-> L2 workflow / approved interface binding
|
||||
-> renderer adapter
|
||||
```
|
||||
|
||||
Каждый новый provider добавляет только свой adapter package, capability
|
||||
catalog, schema mappings, scrubbed fixtures и domain ontology package. Он не
|
||||
добавляет отдельную схему доступа к Engine/Studio и не создаёт прямой путь из
|
||||
browser в provider API.
|
||||
|
||||
## Полнота данных без неконтролируемого объёма
|
||||
|
||||
«Предусмотреть все данные» означает каталогизировать каждую provider
|
||||
capability и поле, а не опрашивать весь account на максимальной частоте.
|
||||
Collection profile явно решает, какие safe-read capabilities, поля, scope и
|
||||
частота включены в конкретной connection instance.
|
||||
|
||||
| Слой | Что хранится | Режим |
|
||||
| --- | --- | --- |
|
||||
| Capability catalog | documented endpoint/read-field/command capability и его риск | versioned source + ontology |
|
||||
| Inventory/configuration | units, devices, groups, sensors, custom definitions | медленный reconcile |
|
||||
| Current projection | последняя разрешённая позиция, состояние и display fields | idempotent upsert |
|
||||
| Event history | нормализованные события и approved measurements | append-only, partitioned |
|
||||
| Raw envelope | полный safe-read ответ с provenance и hash | restricted cold layer, retention-bound |
|
||||
| Aggregates/features | rollups и признаки для аналитики/предиктива | derived, replaceable |
|
||||
|
||||
Raw envelope не выдаётся UI и не становится таблицей «всё в JSON навсегда».
|
||||
Вначале он может быть compressed/partitioned storage с метаданными в БД; при
|
||||
реальном объёме переносится в object storage, а PostgreSQL хранит immutable
|
||||
index, hash, policy и ссылку. Retention, raw depth и downsampling утверждаются
|
||||
после замера сообщений/сек, размера payload, требуемой истории, RPO/RTO и
|
||||
стоимости. Так инженер может запросить ранее не показанное поле из
|
||||
каталога/архива, не раздувая горячую read-модель.
|
||||
|
||||
## Realtime и интерфейс
|
||||
|
||||
Частота provider collection, обновления `current projection` и выдачи в UI —
|
||||
три разные настройки. Например, map consumer может получать выбранную
|
||||
read-модель раз в 3 секунды, но это не даёт ему права опрашивать Gelios раз в
|
||||
3 секунды или создавать отдельный polling loop на каждого зрителя.
|
||||
|
||||
Gateway сначала обновляет одну current projection и публикует change event.
|
||||
L2/Map binding затем может sampling/throttle этот поток по утверждённой
|
||||
настройке интерфейса. Источник истины для live state — gateway storage, не
|
||||
долгоживущий workflow и не Cesium session.
|
||||
|
||||
## Commands: моделируются, но не подключаются
|
||||
|
||||
Command templates, параметры, delivery states и audit входят в capability
|
||||
catalog и ontology полностью. Read adapter не содержит send route и не
|
||||
использует command capability. В будущем command execution создаётся только
|
||||
как отдельный `provider-command-gateway`/red-domain deployment с явным
|
||||
человеческим подтверждением, role/scope check, idempotency, audit и отдельным
|
||||
security review. До этого команда не может быть отправлена из collector, L2,
|
||||
Map или AI Workspace.
|
||||
|
||||
## Обязательные артефакты каждого adapter
|
||||
|
||||
- `provider manifest`: provider id, adapter version, auth modes, rate limits;
|
||||
- capability and field catalog: read/write classification, source evidence,
|
||||
pagination and error semantics;
|
||||
- connection profile: tenant, secret reference, approved scope, field policy,
|
||||
collection and retention profile;
|
||||
- normalised contract and migrations; scrubbed fixtures and contract tests;
|
||||
- health/metrics/audit without secrets or raw personal data;
|
||||
- ontology package with stable subjects, relations and guardrails;
|
||||
- internal read/realtime API contract; no browser/provider bypass.
|
||||
|
||||
## Не решено этим ADR
|
||||
|
||||
- конкретный deployment topology и HA/PITR target для каждого volume;
|
||||
- выбор object storage после real-volume measurement;
|
||||
- L2 stream execution contract и Module Studio binding implementation;
|
||||
- параметры first Gelios collection profile и owner-approved connection scope.
|
||||
|
||||
Gelios-specific применение этого решения описано в
|
||||
`docs/ADR_GELIOS_DATA_PLANE.md`.
|
||||
@@ -0,0 +1,107 @@
|
||||
# ADR: Gelios adapter в External Provider Data Plane
|
||||
|
||||
Статус: **superseded**.
|
||||
Дата: 2026-07-13.
|
||||
Владелец решения: NODE.DC Platform.
|
||||
|
||||
> Заменено 2026-07-14 документом
|
||||
> [`ADR_L2_OWNED_EXTERNAL_CONNECTORS.md`](ADR_L2_OWNED_EXTERNAL_CONNECTORS.md).
|
||||
> Gelios остаётся domain/ontology примером, но не получает отдельный
|
||||
> provider-owned gateway: fetch, mapping и collection policy живут в его L2
|
||||
> connector instance; Platform предоставляет нейтральный Data Plane.
|
||||
|
||||
## Контекст
|
||||
|
||||
Gelios Pro поставляет непрерывную телеметрию, конфигурацию устройств и пространственные данные. В текущем доступе подтверждены 107 видимых units, из них 105 с `lastMsg`; окончательный connection scope должен быть зафиксирован allowlist-ом владельца. Это не данные Ontology Core и не данные Tasker. Они требуют отдельного контура для текущего состояния, истории, геозапросов, аналитики и будущих прогнозов.
|
||||
|
||||
Gelios является первым provider adapter, который подчиняется общему
|
||||
`docs/ADR_EXTERNAL_PROVIDER_DATA_PLANE.md`: connection instance хранит
|
||||
connection scope/policy, Engine Credentials владеет provider secret, gateway
|
||||
владеет storage/read-моделью, а ontology описывает значения, но не runtime
|
||||
data.
|
||||
|
||||
Физические trike-команды существуют в домене, но **не входят в ingestion или тестирование**. Для них позднее потребуется отдельный, ручной и аудируемый контур.
|
||||
|
||||
## Решение
|
||||
|
||||
1. Создать отдельный сервис `platform/services/gelios-gateway` как storage/read gateway:
|
||||
- не хранит и не получает provider token;
|
||||
- принимает только аутентифицированный safe-read intake от защищённого Engine L2 Collector workflow;
|
||||
- применяет allowlist и field policy до записи;
|
||||
- нормализует ответ в сущности пакета `gelios`;
|
||||
- публикует read-модель для Engine L2 workflow и Map View.
|
||||
2. В Engine появляется отдельный Gelios Credential и NDC Agent L2 collection profile:
|
||||
- credential скрывает access/refresh pair и его lifecycle;
|
||||
- credential привязан только к намеренно пошаренному Collector workflow;
|
||||
- Collector содержит allowlist read-capabilities и не имеет command transport;
|
||||
- ни секрет, ни provider response без нормализации не передаются в ontology, UI, Gateway или другие workflow.
|
||||
3. Выделить сервису собственную БД `gelios-postgres`, не деля её с Tasker, Authentik, Notification Core или Ontology Core. В будущей multi-tenant форме эта БД обслуживает несколько Gelios connection instances, но все records разделены `tenant_id` и `connection_id`.
|
||||
4. Базовый движок: PostgreSQL 16 с расширениями TimescaleDB и PostGIS.
|
||||
- Timescale hypertable хранит временные ряды и автоматически делит их по времени.
|
||||
- PostGIS хранит нормализованные точки и геометрии зон, а не renderer-объекты Cesium.
|
||||
- Данный выбор покрывает транзакционную конфигурацию, realtime upsert, исторические запросы, SQL-аналитику и географию одним контуром.
|
||||
5. **Не** использовать RabbitMQ Tasker как общую шину Gelios. Если измерения покажут, что прямой writer или число независимых потребителей не справляются, добавить в Gelios-контур NATS JetStream с durable pull consumers. Он даст replay, acknowledgement и контролируемое удержание сообщений.
|
||||
6. Engine level-2 Collector — единственная точка provider access; остальные workflow являются потребителями read-модели. Ontology Core остаётся только словарём и контрактами.
|
||||
|
||||
## Целевой поток
|
||||
|
||||
```text
|
||||
Gelios REST (safe read only)
|
||||
-> NDC Agent L2 collection profile + protected credential
|
||||
-> authenticated normalized intake
|
||||
-> Gelios Gateway: scope -> field policy -> normalizer
|
||||
-> gelios-postgres: current state + immutable telemetry history
|
||||
-> [при необходимости] NATS JetStream
|
||||
-> `fleet.positions.current.v1` read API / realtime subscription
|
||||
-> NDC workflow level 2
|
||||
-> Map View semantic binding
|
||||
-> Cesium renderer adapter
|
||||
```
|
||||
|
||||
Ни один шаг не получает права отправить команду устройству. Красный command-domain находится вне этого потока; metadata каталога команд сохраняется, но send transport не создаётся.
|
||||
|
||||
## Модель хранения v0
|
||||
|
||||
| Слой | Назначение | Минимальные записи |
|
||||
| --- | --- | --- |
|
||||
| Контроль | граница и повторяемость сбора | `access_scope`, `collection_run`, `ingestion_cursor`, endpoint/response metrics |
|
||||
| Каталог | стабильные сущности и их конфигурация | `unit`, `unit_group`, `tracker_device`, sensor/maintenance/custom-field definitions |
|
||||
| Current state | одна актуальная запись на unit для карты и интерфейса | `unit_current`, `position_fix`, approved operational status |
|
||||
| History | неизменяемые события с временем наблюдения и получения | `telemetry_snapshot`, selective `sensor_reading`, restricted raw payload reference |
|
||||
| Spatial | геометрии для запросов, не Cesium graphics | point/track/geozone with SRID 4326 |
|
||||
| Analytics | роллапы и признаки, не запросы по всему raw | hourly/daily aggregates, feature sets, model runs |
|
||||
| Audit | попытки, ошибки, политика, будущие команды | collection audit; separate command audit later |
|
||||
|
||||
`unit_current` обновляется idempotently по `unitSubjectId`. История записывается append-only с ключом дедупликации, включающим provider unit id, observed time и fingerprint сообщения. Все временные таблицы имеют `observed_at` и `received_at`: задержка поставщика не должна переписывать фактическое время на карте.
|
||||
|
||||
## Индексы и жизненный цикл
|
||||
|
||||
- Основной путь истории: `(unit_subject_id, observed_at DESC)`.
|
||||
- Пространственный индекс только для нормализованной geography/geometry; рендер-кэши в БД не храним.
|
||||
- Сырые `params`/raw messages — restricted, отдельно от публичной Studio read-model.
|
||||
- Политики retention, downsampling и резервного копирования должны быть утверждены до запуска history backfill. Они зависят от фактических msg/s, размера payload, нужной глубины истории, RPO/RTO и стоимости хранения.
|
||||
- Для предиктива держать recent/raw слой и отдельные часовые/дневные агрегаты. Timescale continuous aggregates позволяют сохранять длительную агрегированную историю после сокращения raw при корректно согласованных refresh и retention политиках.
|
||||
|
||||
## Нулевая итерация без лишней инфраструктуры
|
||||
|
||||
1. Зафиксировать owner-approved allowlist и видимые поля.
|
||||
2. Сделать только safe-read Engine Collector с ограничением по scope, rate limit, paging и cursor.
|
||||
3. В течение согласованного окна измерить: сообщений/сек, размер ответа, lag, дубликаты, задержку записи и нагрузку запросов карты.
|
||||
4. На фактах включить Timescale hypertables, PostGIS и retention policy; после этого решить, нужен ли JetStream сразу.
|
||||
5. Подать только `fleet.positions.current.v1`/approved current position в Map View. Историю и raw не отдавать в renderer напрямую.
|
||||
|
||||
## Что не решено этим ADR
|
||||
|
||||
- окончательное правило connection scope;
|
||||
- частота polling/возможность provider push;
|
||||
- сроки хранения raw, нормализованной истории и агрегатов;
|
||||
- RPO/RTO, репликация и production backup plan;
|
||||
- допуск к ручному command gateway. До отдельного решения отправка команд запрещена.
|
||||
|
||||
## Обоснование и источники
|
||||
|
||||
- [Timescale hypertables](https://docs.timescale.com/use-timescale/latest/hypertables/) — временные таблицы PostgreSQL автоматически партиционируются по времени.
|
||||
- [Timescale self-hosted installation](https://docs.timescale.com/self-hosted/latest/install/) — расширение разворачивается как self-hosted PostgreSQL-контур; production требует backup/PITR и HA-плана.
|
||||
- [Retention и continuous aggregates](https://docs.timescale.com/use-timescale/latest/data-retention/data-retention-with-continuous-aggregates/) — raw и агрегаты требуют согласованных lifecycle-политик.
|
||||
- [PostGIS](https://postgis.net/docs/en/) — PostgreSQL-расширение для spatial types и GiST R-tree индексов.
|
||||
- [NATS JetStream consumers](https://docs.nats.io/nats-concepts/jetstream/consumers) — durable consumers дают acknowledgement, повторную доставку и recovery; рекомендуются pull consumers для новых масштабируемых обработчиков.
|
||||
@@ -0,0 +1,238 @@
|
||||
# ADR: внешние коннекторы принадлежат NDC L2 workflow
|
||||
|
||||
Статус: **accepted**.
|
||||
Дата: 2026-07-16.
|
||||
Владелец решения: NODE.DC Platform.
|
||||
|
||||
## Контекст
|
||||
|
||||
NODE.DC состоит из двух разных слоёв:
|
||||
|
||||
- платформенный слой даёт NDC L1, NDC L2, Ontology, credentials, Data Products
|
||||
и Foundry-boundaries;
|
||||
- пользовательские автоматизации собирают из этих возможностей конкретную
|
||||
бизнес-логику.
|
||||
|
||||
Новый account, provider credential, tenant, расписание или интерфейс не должны создавать
|
||||
новый platform service и не должны требовать изменения NODE.DC source. Команда
|
||||
NODE.DC подключается только для нового поставщика либо для расширения
|
||||
подтверждённых capabilities и ontology уже поддержанного поставщика.
|
||||
|
||||
Gelios — первый живой источник и acceptance-кейс этого канона, а не отдельная
|
||||
архитектурная ветка. Документ заменяет provider-runtime решения из
|
||||
`ADR_EXTERNAL_PROVIDER_DATA_PLANE.md` и `ADR_GELIOS_DATA_PLANE.md`; те
|
||||
документы остаются только историей решения.
|
||||
|
||||
## Решение
|
||||
|
||||
### 1. Пакет поставщика создаётся один раз
|
||||
|
||||
Каждый поддержанный поставщик получает один версионируемый **provider package**.
|
||||
Он является знанием платформы о внешнем API и содержит:
|
||||
|
||||
- стабильный `providerId` и версии подтверждённого API;
|
||||
- credential contract без значения секрета;
|
||||
- каталог safe-read capabilities, pagination/rate-limit metadata и response
|
||||
shapes;
|
||||
- mappings из provider fields в версии Ontology;
|
||||
- совместимые Data Products и scrubbed contract fixtures;
|
||||
- явно отделённый каталог команд без активного command transport.
|
||||
|
||||
Пакет не является runtime service, scheduler, базой данных или customer
|
||||
configuration. Он расширяется только когда фактически подтверждён новый API,
|
||||
новый тип данных или новая ontology revision.
|
||||
|
||||
Одна учётная запись поставщика задаётся данными, а не кодом:
|
||||
|
||||
`provider package + credential reference + connection profile + NDC L2 workflow instance`
|
||||
|
||||
Поэтому второй account или другая provider credential pair создаёт ещё один credential/profile и
|
||||
workflow instance. Платформенный source, Data Plane и Foundry при этом не
|
||||
меняются.
|
||||
|
||||
### 2. NDC L1 проектирует, NDC L2 workflow исполняет
|
||||
|
||||
NDC L1 получает пользовательское намерение, проверяет доступные capabilities и
|
||||
Ontology, проектирует или изменяет разрешённый NDC L2 workflow через NDC MCP и
|
||||
анализирует execution evidence.
|
||||
|
||||
Один изолированный NDC L2 workflow представляет один connection instance и
|
||||
владеет исполняемой механикой:
|
||||
|
||||
- safe-read вызовами provider API через credential reference;
|
||||
- pagination, cursor, retry, rate limit, batch size и collection cadence;
|
||||
- проверкой response shape и разбиением ответа на items;
|
||||
- mapping к точной ontology revision;
|
||||
- idempotency, watermark и публикацией canonical facts;
|
||||
- private workflow state, когда он действительно нужен.
|
||||
|
||||
Provider-specific mapping сначала реализуется штатными workflow nodes и малым
|
||||
Code-преобразователем. Это позволяет проверить реальный API без преждевременного
|
||||
создания custom nodes. В custom NDC nodes переносится только повторившаяся и
|
||||
доказанная **платформенная boundary-механика**, а не уникальная логика
|
||||
поставщика.
|
||||
|
||||
### 3. Credentials являются данными connection instance
|
||||
|
||||
Gelios выдаёт ровно два provider secrets: access token и refresh token. Текущий
|
||||
HTTP request binding `httpBearerAuth` использует access token. Автоматический
|
||||
refresh в принятом runtime не доказан, поэтому provider package фиксирует его
|
||||
как `operator_managed`; ни один из token values не попадает в graph, package,
|
||||
Ontology, Data Plane, Foundry, execution logs, MCP или Ops. Новый account или
|
||||
provider-issued token pair означает новые native credential records и
|
||||
connection instance, но не новую Platform-сущность.
|
||||
|
||||
Локальное имя credential (например, с суффиксом `read access`) не является
|
||||
provider scope. В target-контракте Read-классификацию задаёт allowlisted
|
||||
method/endpoint в capability catalog и Engine workflow policy; тот же access
|
||||
token нельзя называть отдельным «read token» или «write token» только из-за
|
||||
label. Сейчас deployed Engine safe-ref policy ограничивает generic HTTP
|
||||
credential только по host, но ещё не связывает его с package/version и exact
|
||||
method/path. Поэтому Gelios `GET /api/v1/units` пока является декларативной
|
||||
capability, а не завершённой runtime-security гарантией; canonical acceptance
|
||||
требует capability-bound Engine policy и её MCP proof.
|
||||
|
||||
Writer capability для публикации Data Product — отдельный внутренний native NDC
|
||||
L2 credential, не третий Gelios token. Generic Engine/Platform control plane
|
||||
генерирует capability внутри native credential boundary, сохраняет её там же и
|
||||
передаёт EDP только SHA-256 digest вместе с точным provider, connection и
|
||||
набором Data Products. Пользователь и MCP получают только opaque
|
||||
reference/status. Capability не записывается в graph, provider package, env,
|
||||
file, Ops или trace и не копируется через пользовательский UI.
|
||||
|
||||
Широкий credential sink, resolver/daemon с произвольной записью secret и любой
|
||||
provider-specific credential service запрещены. Нужна одна узкая операция
|
||||
`ensure data-product publish grant`: scope выводится из granted L1→L2 target,
|
||||
зарегистрированного connection profile и разрешённого Data Product; issuance,
|
||||
rotation и native binding должны быть idempotent, CAS/crash-safe и auditable.
|
||||
Engine генерирует capability внутри credential boundary и передаёт EDP только
|
||||
SHA-256 digest через idempotent binding key + generation; EDP не возвращает
|
||||
plaintext. Новая generation создаётся до переключения node reference, а старая
|
||||
отзывается только после успешного bind/acceptance.
|
||||
Старые ручные EDP `POST/rotate` endpoints, которые возвращают capability один
|
||||
раз, включаются отдельным legacy-флагом и не входят в canonical acceptance.
|
||||
Digest-only managed endpoint имеет независимый флаг и принимает только
|
||||
Ed25519-signed Engine service requests: exact audience/method/request target/raw
|
||||
body hash входят в подпись, timestamp ограничен по skew, nonce защищён bounded
|
||||
fail-closed replay cache. Legacy provisioner bearer на managed routes не
|
||||
действует. EDP получает только deployment public key; matching private key
|
||||
остаётся только внутри Engine server boundary. Endpoint остаётся выключенным,
|
||||
пока key provisioning, Engine signer и exact native credential binding policy
|
||||
не пройдут runtime acceptance.
|
||||
В deployed Engine MCP этой операции пока нет — это текущий platform gap перед
|
||||
canonical publish proof, а не действие пользователя. Это решение не заявляет
|
||||
автоматический refresh Gelios: до отдельного runtime proof он остаётся
|
||||
`operator_managed`.
|
||||
|
||||
### 4. External Data Plane нейтрален к поставщику
|
||||
|
||||
Platform предоставляет один versioned External Data Plane. Он принимает
|
||||
canonical facts через scoped writer binding, хранит current/history
|
||||
projections и публикует snapshot/patch contracts. В нём запрещены:
|
||||
|
||||
- ветвления по provider, customer, account, entity или renderer;
|
||||
- provider field mapping и бизнес-фильтрация;
|
||||
- provider credential, endpoint, schedule или command transport;
|
||||
- caller-supplied tenant/connection scope.
|
||||
|
||||
`NDC Data Product Publish` отправляет только
|
||||
`nodedc.data-product.publish/v1`. External Data Plane materializes immutable
|
||||
scope из writer binding, проверяет разрешённый Data Product и его ontology
|
||||
revision, затем сохраняет batch.
|
||||
|
||||
EDP runtime импортирует только package subpath
|
||||
`@nodedc/external-provider-contract/data-plane`. Его deploy artifact и image
|
||||
содержат только provider-neutral wire validators; `providers/gelios`, mappings,
|
||||
fixtures и tests туда не входят. Изменение или добавление provider package не
|
||||
пересобирает и не перезапускает EDP: каталог поставщиков разворачивается через
|
||||
Engine/Ontology/control-plane путь отдельно.
|
||||
|
||||
Collection cadence, history cadence и presentation cadence независимы:
|
||||
|
||||
- NDC L2 забирает источник с частотой, нужной бизнес-задаче;
|
||||
- current projection принимает каждое валидное изменение;
|
||||
- declarative history policy может хранить все точки или sampling;
|
||||
- Foundry читает snapshot+patch и отдельно ограничивает частоту render.
|
||||
|
||||
Для разной частоты БД и интерфейса не создаются второй provider sink, отдельный
|
||||
gateway или параллельный прямой push в renderer.
|
||||
|
||||
### 5. Полнота означает все сущности разрешённой capability
|
||||
|
||||
Connection profile выбирает safe-read capabilities, а не зашитый в Platform
|
||||
список entity IDs. Если разрешённый endpoint возвращает все доступные credential
|
||||
сущности, NDC L2 обрабатывает каждый валидный item. Новый объект появляется
|
||||
автоматически.
|
||||
|
||||
Пользовательская фильтрация, слои и видимость находятся после сбора — в
|
||||
automation/Data Product/Foundry. Технические ограничения допустимы только как
|
||||
pagination, quota, batch size, backpressure и защита от повреждённого ответа.
|
||||
|
||||
### 6. Custom NDC nodes ограничены платформенными границами
|
||||
|
||||
Первый канонический набор:
|
||||
|
||||
- `NDC Data Product Publish` — NDC L2 → External Data Plane;
|
||||
- `NDC Data Product Read` — scoped snapshot/patch read;
|
||||
- `NDC Foundry Binding` — control-plane связь Data Product с
|
||||
`Application → Page → typed slot`.
|
||||
|
||||
Эти nodes не содержат provider ID, tenant ID, connection ID, endpoint, token,
|
||||
mapping или cadence. Runtime package сохраняет технический namespace
|
||||
`n8n-nodes-ndc.*`, но пользовательские документы и интерфейсы используют
|
||||
только терминологию NDC.
|
||||
|
||||
`NDC Foundry Binding` не транспортирует каждый realtime tick. Он создаёт
|
||||
постоянную связь интерфейса с Data Product; Foundry затем читает его
|
||||
snapshot+patch contract.
|
||||
|
||||
### 7. Реальный Gelios acceptance-кейс
|
||||
|
||||
Первая версия provider package должна доказать путь:
|
||||
|
||||
`Gelios safe read → все доступные units → map.moving_object facts →
|
||||
fleet.positions.current.v1 → Foundry map`
|
||||
|
||||
Canonical product использует ontology revision
|
||||
`ontology.map.moving_object.v1` и только объявленные snake_case поля. NDC L2
|
||||
не передаёт caller scope и не собирает собственный batch envelope вокруг
|
||||
`NDC Data Product Publish`.
|
||||
|
||||
Acceptance выполняется по порядку:
|
||||
|
||||
1. Platform выполняет generic `ensure data-product publish grant`, выпускает
|
||||
scoped EDP binding и атомарно сохраняет capability в native NDC L2
|
||||
Credentials;
|
||||
2. NDC MCP видит только совместимый opaque writer credential reference/status;
|
||||
3. применить подтверждённый graph patch без legacy intake;
|
||||
4. validate/preflight;
|
||||
5. один успешный manual run и проверка execution/trace/Data Product;
|
||||
6. только затем отдельным изменением добавить Schedule Trigger;
|
||||
7. после доказанного snapshot+patch подключить Foundry binding.
|
||||
|
||||
### 8. Capacity и topology
|
||||
|
||||
Количество одновременно активных NDC L2 проектов ограничивается фактическими
|
||||
ресурсами железа и профилем нагрузки. Пока capacity проверяется оператором и не
|
||||
автоматизируется. Канон не объявляет отдельные worker/webhook generations или
|
||||
high-load topology, которых ещё нет в принятом runtime.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `services/<provider>-gateway` не является частью канона и не создаётся для
|
||||
новых provider integrations. Существующий self-hosted Gelios Gateway и его
|
||||
Timescale volume остаются frozen compatibility contour, воспроизводятся из
|
||||
source/deploy и не изменяются новым L2 pilot без отдельного решения.
|
||||
- Новый account или provider-issued access/refresh pair — configuration change,
|
||||
а не platform release.
|
||||
- Новый provider или неподдержанная capability — versioned provider/Ontology
|
||||
change с contract tests.
|
||||
- Существующий legacy L1/SDK workflow является frozen compatibility boundary:
|
||||
новый L2 pilot его не редактирует, не отзывает его credential и не ставит его
|
||||
вывод условием acceptance.
|
||||
- Legacy `/internal/data-plane/v1/intake` остаётся выключенным migration-only
|
||||
route и не используется новыми NDC L2 workflows.
|
||||
- Команды устройствам остаются отдельным red-domain контуром с явным
|
||||
подтверждением, scope, idempotency и аудитом.
|
||||
- Foundry развивается после доказанного Data Product path; provider API и
|
||||
credentials в Foundry не попадают.
|
||||
@@ -13,15 +13,31 @@ Do not mix these classes. A URL reachable from the Mac browser is not automatica
|
||||
|
||||
## Current Profiles
|
||||
|
||||
`local/hybrid-prod-bridge`:
|
||||
`local/remote-worker-relay`:
|
||||
|
||||
- Tasker UI: `http://task.local.nodedc`
|
||||
- Platform Assistant: `http://127.0.0.1:18082`
|
||||
- Local Hub process: `http://127.0.0.1:18081`
|
||||
- Default worker-facing Hub: `wss://ai-hub.nodedc.ru/api/ai-workspace/hub`
|
||||
- Default worker-facing Hub relay: `wss://ai-hub.nodedc.ru/api/ai-workspace/hub`
|
||||
- Default Assistant-to-relay control URL: `https://ai-hub.nodedc.ru`
|
||||
- Assistant action relay id: `local-dev`
|
||||
- Assistant action gateway returned to worker: `https://ai-hub.nodedc.ru/api/ai-workspace/hub/v1/assistant-relays/local-dev/actions`
|
||||
- Ops entitlement adapter: `http://host.docker.internal:4100/api/internal/v1/ai-workspace/entitlements` when deliberately enabled
|
||||
- Ops Gateway downstream Tasker: `http://task.local.nodedc`
|
||||
- This profile is valid for contract smoke and local Ops vertical smoke, not proof of live local Codex worker e2e.
|
||||
- The deployed AI Hub is allowed only as transport. Launcher, Engine, Ops, Authentik, task manager, and downstream app calls must stay local.
|
||||
- The remote worker must not call product apps directly. Assistant actions must route back through the local AI Workspace Assistant.
|
||||
|
||||
`local-seo/deployed-hub-control-plane`:
|
||||
|
||||
- SEO frontend: `http://127.0.0.1:5177`
|
||||
- SEO backend: `http://127.0.0.1:4100`
|
||||
- SEO AI Workspace profile: `deployed-hub`
|
||||
- SEO backend Assistant-compatible control-plane URL: `SEO_AI_WORKSPACE_CONTROL_URL=https://ai-hub.nodedc.ru`
|
||||
- SEO backend control-plane token: `SEO_AI_WORKSPACE_CONTROL_TOKEN`, accepted by the public Hub server-side API.
|
||||
- Worker-facing Hub: `wss://ai-hub.nodedc.ru/api/ai-workspace/hub`
|
||||
- Public Hub exposes only token-gated Assistant control-plane proxy endpoints required by SEO model-provider setup, probe, thread dispatch, and message polling.
|
||||
- Public Hub must proxy those calls to internal Platform Assistant with server-side internal token. Browser clients and remote workers must not receive the Hub internal token or Assistant token.
|
||||
- This profile is for local SEO development against the deployed AI Workspace service plane. It is not the same as `local`, and it must not silently fall back to localhost or product app URLs.
|
||||
|
||||
`synology/prod-public`:
|
||||
|
||||
@@ -52,3 +68,41 @@ New apps must join AI Workspace through app manifests/config/adapters:
|
||||
- owner repo/service.
|
||||
|
||||
Do not add app-specific address logic to the agent installer, Engine, Ops, or Platform Assistant runtime flow.
|
||||
|
||||
### NDC SEO Mode
|
||||
|
||||
`seo-mode` is a first-class AI Workspace surface, not `global`.
|
||||
|
||||
Local SEO testing should keep SEO app/backend on fixed local ports while using the configured AI Workspace transport/profile:
|
||||
|
||||
- SEO frontend: `http://127.0.0.1:5177`
|
||||
- SEO backend: `http://127.0.0.1:4100`
|
||||
- SEO model-provider surface: `seo-mode`
|
||||
- SEO model task mode: `seo-model-task`
|
||||
- Worker-facing transport: public AI Hub relay when selected by profile, normally `wss://ai-hub.nodedc.ru/api/ai-workspace/hub`
|
||||
- SEO task payload: sanitized `seo-model-task` contract only
|
||||
|
||||
The SEO backend must select profiles through env/config, not code edits. Local and deploy-host may use different Assistant URLs, executor ids, setup commands, model defaults, and app grants, but the request contract stays the same.
|
||||
|
||||
Remote Codex for SEO must not receive Wordstat/Yandex credentials, product app tokens, unrestricted repo access, rewrite/apply actions, or destructive actions. Wordstat/SERP collection remains SEO-backend-owned.
|
||||
|
||||
## Verification
|
||||
|
||||
Before changing worker, Assistant, Hub, or app adapter routing, run:
|
||||
|
||||
```sh
|
||||
sh infra/scripts/check-local-test-system.sh
|
||||
```
|
||||
|
||||
For a live local remote-worker test, require the relay topology explicitly:
|
||||
|
||||
```sh
|
||||
REQUIRE_REMOTE_WORKER_RELAY=1 sh infra/scripts/check-local-test-system.sh
|
||||
```
|
||||
|
||||
Security audit and test matrix:
|
||||
|
||||
```text
|
||||
docs/AI_WORKSPACE_SECURITY_AUDIT_2026-06-20.md
|
||||
docs/AI_WORKSPACE_TEST_MATRIX.md
|
||||
```
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# AI Workspace ↔ Ontology Core MCP
|
||||
|
||||
## Purpose
|
||||
|
||||
This is the read-only semantic path for a NODE.DC AI Workspace run:
|
||||
|
||||
```text
|
||||
Codex worker
|
||||
-> dynamic per-run MCP configuration
|
||||
-> public AI Workspace Hub (pairing-bound route)
|
||||
-> internal Ontology Core MCP
|
||||
-> freshly loaded ontology catalog and domain packages
|
||||
```
|
||||
|
||||
`ai-workspace-assistant` creates this MCP server entry at run-profile time for every Hub-connected executor when `AI_WORKSPACE_ONTOLOGY_MCP_ENABLED=true`. It is a platform runtime grant (`ontology:catalog:read`), not a user-owned installer secret and not a hard-coded copy of ontology rules in a prompt.
|
||||
|
||||
The worker receives only a pairing-bound Hub URL. The Hub verifies that the pairing agent is online and replaces any external authorization with the platform-internal token before forwarding to `ontology-core`. Ontology Core is internal-only and has no host port or reverse-proxy route.
|
||||
|
||||
## Read-only MCP surface
|
||||
|
||||
`nodedc_ontology` exposes only these tools:
|
||||
|
||||
- `ontology_status` — catalog counts, domain packages and a safe catalog hash;
|
||||
- `ontology_search` — canonical entities, relations and aliases;
|
||||
- `ontology_get_entity` — entity definition, aliases, relations and guardrails;
|
||||
- `ontology_get_guardrails` — semantic/safety rules and blocked conflations;
|
||||
- `ontology_resolve_context` — semantic route advice with source identifiers removed.
|
||||
|
||||
It intentionally does not expose filesystem paths, evidence ledgers, credentials, raw payloads, live telemetry, databases, command dispatch, workflow mutation or Studio controls.
|
||||
|
||||
## Boundary for external providers and runtime data
|
||||
|
||||
Ontology Core describes the canonical meanings and constraints:
|
||||
|
||||
```text
|
||||
gelios.unit -> gelios.telemetry_snapshot -> gelios.position_fix -> map.moving_object
|
||||
```
|
||||
|
||||
It does **not** serve provider runtime data or call a provider API. Provider
|
||||
access and mapping belong to the granted NDC L2 workflow; shared current and
|
||||
history products belong to the provider-neutral External Data Plane. A scoped
|
||||
Data Product read capability may later be supplied to an AI Workspace run as a
|
||||
separate dynamic MCP server. Ontology supplies meanings, relations, guardrails
|
||||
and the allowed contract route; the data capability enforces access and returns
|
||||
live data.
|
||||
|
||||
This preserves one-way responsibility:
|
||||
|
||||
- Ontology Core: semantics, contracts, aliases, guardrails and context advice.
|
||||
- External Data Plane: access-scoped Data Product snapshots and updates.
|
||||
- Command Gateway: separate red-domain command route, explicit confirmation and audit.
|
||||
- NDC L2 workflow: provider access, collection and mapping using granted capabilities.
|
||||
- Foundry: presentation consumer, outside this implementation.
|
||||
|
||||
## Live catalog updates without AI Workspace rule redeploy
|
||||
|
||||
`loadCatalog()` runs for each Ontology MCP tool call; it does not cache the merged catalog. Therefore a new or edited domain package is visible to existing AI Workspace rules as soon as the catalog content is updated in the running Ontology Core container or mounted runtime catalog directory.
|
||||
|
||||
For a catalog-only release, update/restart **Ontology Core only**. The AI Workspace Assistant, Hub and Codex worker configuration do not need a rules redeploy because the MCP tool names and route stay stable. `NODEDC_ONTOLOGY_CATALOG_ROOT` may point Ontology Core at a separately managed catalog directory when a runtime-mounted catalog is required.
|
||||
|
||||
A Hub/Assistant rollout is needed only if the MCP transport, authorization contract or tool definitions themselves change.
|
||||
|
||||
## Safety properties
|
||||
|
||||
- The MCP server accepts only internal bearer authentication from AI Hub.
|
||||
- A browser `Origin` is rejected by default; allow specific origins only through `ONTOLOGY_MCP_ALLOWED_ORIGINS` if an intentional browser transport is introduced.
|
||||
- The Hub does not pass browser cookies or external `Authorization` downstream.
|
||||
- The Hub route is pairing-bound and is unavailable when the worker is offline.
|
||||
- All current tools are read-only. There is no generic ontology write API.
|
||||
|
||||
## Validation
|
||||
|
||||
```text
|
||||
cd platform/services/ontology-core
|
||||
npm run validate
|
||||
npm run smoke:mcp
|
||||
|
||||
cd ../../
|
||||
node --check services/ai-workspace-assistant/src/server.mjs
|
||||
node --check services/ai-workspace-hub/src/server.mjs
|
||||
docker compose --env-file infra/.env -f infra/docker-compose.dev.yml config
|
||||
```
|
||||
@@ -0,0 +1,171 @@
|
||||
# AI Workspace Security Audit 2026-06-20
|
||||
|
||||
Status: prototype hardening report.
|
||||
|
||||
Scope: AI Workspace Assistant, public AI Hub relay, remote Codex worker, local Launcher, local Engine, local Ops Gateway, ontology-core assistant actions.
|
||||
|
||||
## Current architecture
|
||||
|
||||
The safe local test topology is:
|
||||
|
||||
```text
|
||||
local Engine / local Ops UI / local Launcher
|
||||
-> local AI Workspace Assistant
|
||||
-> public AI Hub relay
|
||||
-> remote Codex worker
|
||||
-> public AI Hub assistant action relay
|
||||
-> local AI Workspace Assistant
|
||||
-> local Launcher / local Engine / local Ops Gateway
|
||||
```
|
||||
|
||||
The public AI Hub is transport only in `NODEDC_ENV=local`. It must not become the owner of Launcher, Engine, Ops, Authentik, or product-app data. Product reads and writes return to the local Assistant and then go through local app-owned adapters.
|
||||
|
||||
## Profiles
|
||||
|
||||
`local`
|
||||
|
||||
- Business apps are local: Launcher, Engine, Ops/Gateway, Tasker, databases, fixtures.
|
||||
- AI Workspace Assistant is local.
|
||||
- The only allowed deployed URL class is the AI Hub relay: `https://ai-hub.nodedc.ru` / `wss://ai-hub.nodedc.ru/api/ai-workspace/hub`.
|
||||
- Production product hosts are forbidden in local downstream config: `hub.nodedc.ru`, `engine.nodedc.ru`, `ops.nodedc.ru`, `id.nodedc.ru`, `ops-agents.nodedc.ru`.
|
||||
- Remote worker receives a scoped run profile and calls assistant actions through the relay, not through product apps directly.
|
||||
|
||||
`deploy-host`
|
||||
|
||||
- Business apps and AI Workspace services run in the deployed stack.
|
||||
- Public worker-facing relay is still `ai-hub.nodedc.ru`.
|
||||
- Internal service URLs must be container/network URLs, not localhost and not developer-machine URLs.
|
||||
- This profile is architecturally described but not fully proven yet. It still needs a clean deploy verification run before it can be treated as release-ready.
|
||||
|
||||
`tunnel-local-e2e`
|
||||
|
||||
- Explicit escape hatch for Tailscale/ngrok-style testing.
|
||||
- Must be opt-in through `NODEDC_ENV=tunnel-local-e2e`.
|
||||
- No local config may silently fall back to private/tunnel addresses.
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
Browser and local app backends trust only local service tokens and the current application session.
|
||||
|
||||
AI Workspace Assistant owns assistant orchestration:
|
||||
|
||||
- builds run profiles;
|
||||
- resolves app grants;
|
||||
- exposes the assistant action gateway;
|
||||
- performs preview/execute routing;
|
||||
- calls app-owned adapters.
|
||||
|
||||
Remote Codex worker is an executor:
|
||||
|
||||
- it receives only the run profile and tool/action gateway information;
|
||||
- it must not own product app credentials;
|
||||
- it should not call Launcher, Engine, Ops, Authentik, or product DBs directly in local profile.
|
||||
|
||||
Ontology Core owns assistant action policy:
|
||||
|
||||
- registered action catalog;
|
||||
- risk levels;
|
||||
- route allowlists;
|
||||
- no hard-delete guardrails;
|
||||
- preview/confirmation/execute contract.
|
||||
|
||||
App adapters own their app-specific state changes:
|
||||
|
||||
- Launcher adapter mutates only guarded Launcher admin routes.
|
||||
- Ops adapter calls the Ops Gateway tool API.
|
||||
- Engine graph edits stay in NDC Agent Core / Engine-owned workflow tools.
|
||||
|
||||
## Transport and tokens
|
||||
|
||||
Confirmed controls in current code:
|
||||
|
||||
- Assistant API requires an internal token via `Authorization: Bearer ...` or `X-NODEDC-Internal-Token`.
|
||||
- Hub internal API requires `Authorization: Bearer ...`.
|
||||
- Constant-time token comparison is used for Assistant and Hub internal API checks.
|
||||
- Public relay traffic uses HTTPS/WSS.
|
||||
- Assistant action relay forwards only user owner headers, not arbitrary incoming headers.
|
||||
- Launcher admin action adapter sends `Authorization: Bearer <launcher internal token>` plus assistant actor headers.
|
||||
- Ops action adapter obtains or uses an Ops run token and sends it to Ops Gateway as `Authorization`.
|
||||
- Write routes require an idempotency key.
|
||||
- DELETE is blocked at execution-plan validation.
|
||||
|
||||
Current token classes:
|
||||
|
||||
- `NODEDC_INTERNAL_ACCESS_TOKEN`: service-to-service internal token for local stack.
|
||||
- `AI_WORKSPACE_HUB_TOKEN`: public Hub internal API token.
|
||||
- `NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN`: Launcher admin adapter token.
|
||||
- Ops entitlement token: lets Assistant ask Ops Gateway for a scoped run token.
|
||||
- Ops run token: downstream authorization for Ops tool routes.
|
||||
- Assistant action confirmation token: digest over action, actor, request body/path, and idempotency key.
|
||||
|
||||
## Assistant action safety model
|
||||
|
||||
Read actions:
|
||||
|
||||
- do not require confirmation;
|
||||
- must still resolve to a registered action;
|
||||
- must be scoped by owner context and app-owned adapter policy.
|
||||
|
||||
Write and privileged actions:
|
||||
|
||||
- must resolve to a registered action;
|
||||
- must pass risk policy;
|
||||
- must pass route allowlist;
|
||||
- must include idempotency key;
|
||||
- must produce a preview;
|
||||
- must require explicit user confirmation;
|
||||
- must execute only with the preview confirmation token.
|
||||
|
||||
Destructive actions:
|
||||
|
||||
- are forbidden for assistant execution;
|
||||
- hard-delete users, files, DB rows, production state, archives, backups, and runtime/storage records must stay outside assistant action routing.
|
||||
|
||||
## Prototype status
|
||||
|
||||
Tested behavior:
|
||||
|
||||
- local Assistant action gateway can preview Ops card creation;
|
||||
- public relay can route preview requests to local Assistant via `relayId=local-dev`;
|
||||
- Engine UI can ask the remote worker to create an Ops card;
|
||||
- write creation requires preview and explicit confirmation;
|
||||
- confirmed Ops test card creation succeeded through the UI;
|
||||
- Launcher pending access read works through assistant action routing;
|
||||
- Engine NDC Agent Core tools are visible for selected agent node operations;
|
||||
- Engine chat polling jitter was addressed by stable remote message merge logic.
|
||||
|
||||
Not yet proven:
|
||||
|
||||
- complete deploy-host profile after a fresh deployment;
|
||||
- cross-user relay isolation under multiple simultaneous local developers;
|
||||
- one-time confirmation token storage;
|
||||
- token TTL enforcement for every downstream run token;
|
||||
- full stop/cancel behavior for long remote Codex runs;
|
||||
- full browser UI regression pass across Launcher, Ops, and Engine.
|
||||
|
||||
## Findings
|
||||
|
||||
P0 fixed in current prototype:
|
||||
|
||||
- Product app calls no longer need to be tested by deploying raw product changes first. Local profile routes product reads/writes back to local services.
|
||||
- Ops card create path now works through Assistant -> Ontology Core -> Ops Gateway with preview/confirmation.
|
||||
- The remote worker is treated as executor, not as product credential owner.
|
||||
|
||||
P1 hardening required:
|
||||
|
||||
- Confirmation tokens are deterministic digests and currently have no TTL or server-side one-time consumption store. If preview output and token leak, the same request can be replayed until downstream idempotency suppresses duplicates. Add server-side confirmation records with TTL, actor binding, single-use consumption, and audit trail.
|
||||
- `local-dev` relay id is too generic for repeated multi-user work. Use unique relay ids per developer/station/environment, for example `local-dc-mac-<date>` or configured stable machine id.
|
||||
- Public relay is bearer-token protected but not mTLS-bound. Treat Hub token leakage as critical and rotate tokens after exposure.
|
||||
- The worker receives `assistantActionGatewayToken` in the run profile. This is necessary for the current bridge, but it is a bearer secret. Keep it short-lived or derive a per-run action token instead of reusing broad service tokens.
|
||||
- Stop/cancel behavior needs an automated smoke check with a deliberately long remote run.
|
||||
|
||||
P2 hardening required:
|
||||
|
||||
- Add audit correlation ids across Engine request, Hub request, worker run, Assistant action, Ops/Launcher adapter call.
|
||||
- Add negative tests for forged owner headers through the public relay.
|
||||
- Add negative tests for forbidden DELETE route, destructive action, missing idempotency, wrong confirmation token, wrong actor, and wrong project id.
|
||||
- Add dashboard visibility for selected run profile, action ids, relay id, and downstream profile without exposing tokens.
|
||||
|
||||
## Security conclusion
|
||||
|
||||
The prototype is usable for controlled local testing and internal proof-of-flow. It is not yet release-grade security. The main architectural boundary is now correct: local product state stays local, the public AI Hub is a relay, and writes go through registered ontology actions with preview and confirmation. Before production rollout, the confirmation and relay-token model must be hardened with TTL, single-use confirmation records, unique relay ids, short-lived action tokens, and expanded negative tests.
|
||||
@@ -0,0 +1,74 @@
|
||||
# AI Workspace Test Matrix
|
||||
|
||||
Status: active prototype test plan.
|
||||
|
||||
## Test levels
|
||||
|
||||
Horizontal tests check each service boundary independently.
|
||||
|
||||
Vertical tests prove a full user intent through UI, Assistant, remote worker, relay, and target app adapter.
|
||||
|
||||
Security tests prove that unsafe actions are blocked and secrets are not exposed.
|
||||
|
||||
## Horizontal tests
|
||||
|
||||
| ID | Area | Command / action | Success | Failure |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| H1 | Platform static | `sh infra/scripts/check-local-test-system.sh` | all required static, env, compose, ontology, and topology checks pass | any local profile points product downstream to prod, syntax fails, or topology cannot be classified |
|
||||
| H2 | Env safety | `node infra/scripts/check-local-environment-safety.mjs` | `NODEDC_ENV=local` permits only AI Hub relay as deployed host | product app URL points to deployed prod host in local profile |
|
||||
| H3 | Config contract | `infra/scripts/check-ai-workspace-config-contract.sh` | worker-facing, internal, browser, relay, and downstream URLs remain separated | app-specific URL logic leaks into worker/Engine/Assistant runtime |
|
||||
| H4 | Release gate | `sh infra/scripts/check-ai-workspace-release-gates.sh` | static checks, smokes, gateway checks, and diff hygiene pass | deploy preparation blocked |
|
||||
| H5 | Ontology catalog | `cd services/ontology-core && npm run validate` | actions/entities/policies validate | invalid action, missing entity, unsupported risk policy |
|
||||
| H6 | Assistant action caller | `cd services/ontology-core && npm run smoke:assistant-caller` | preview requires token, execute without token blocked, execute with token calls adapter in mock | write executes without confirmation or unsafe route passes |
|
||||
| H7 | Assistant executor | `cd services/ontology-core && npm run smoke:assistant-executor` | DELETE/destructive action blocked, internal bearer headers added in mock | destructive/write route is allowed |
|
||||
| H8 | Assistant run profile | `cd services/ai-workspace-assistant && npm run smoke:run-profile` | run profile contains expected grants, action profile, and redacted diagnostics | token leakage or missing action profile |
|
||||
| H9 | Engine build | `cd NODEDC_ENGINE_INFRA/nodedc-source && npm run build` | UI/server bundle builds | Engine UI/bridge integration broke build |
|
||||
| H10 | Launcher assistant admin contract | Launcher contract tests | assistant admin routes stay guarded, scoped, idempotent, and no hard-delete route becomes assistant-ready | assistant route bypasses session/admin guard or delete leaks into assistant allowlist |
|
||||
|
||||
## Vertical tests
|
||||
|
||||
| ID | Flow | Steps | Success |
|
||||
| --- | --- | --- | --- |
|
||||
| V1 | Remote worker health | Engine UI asks: `ты тут? коротко workspace, runtime, hub connected` | response shows local workspace, `runtime: ready`, `hub connected: true` |
|
||||
| V2 | Launcher read | ask: `покажи новые заявки в лаунчере` | answer comes from `hub.access_request.list_pending` and returns actual local Launcher pending requests |
|
||||
| V3 | Ops read | ask: `последнюю задачу в опсе покажи` | answer comes from `ops.card.list_recent`, no file search fallback |
|
||||
| V4 | Ops create preview | ask: `создай в опс тестовую задачу "..."` | assistant shows preview and asks explicit confirmation |
|
||||
| V5 | Ops create execute | confirm preview | local Ops creates card and assistant returns identifier |
|
||||
| V6 | Ops comment preview | ask to add comment to a known card | assistant shows preview and asks confirmation |
|
||||
| V7 | Ops comment execute | confirm comment preview | comment is added to local Ops card |
|
||||
| V8 | Engine graph read | ask selected agent node workflow counts | assistant uses NDC Agent Core tool and returns nodes/edges |
|
||||
| V9 | Engine graph patch | ask for tiny node patch in selected agent node | assistant uses NDC Agent Core patch and validates graph |
|
||||
| V10 | Stop/cancel | start deliberately long search and press stop | running Codex process is stopped or marked aborted within bounded time |
|
||||
|
||||
## Security and abuse tests
|
||||
|
||||
| ID | Attack / risk | Test | Expected |
|
||||
| --- | --- | --- | --- |
|
||||
| S1 | Missing internal token | call Assistant action endpoint without token | `401` or `503`, no action executed |
|
||||
| S2 | Wrong internal token | call Assistant action endpoint with bad bearer | `401`, no action executed |
|
||||
| S3 | Write without confirmation | execute `ops.card.create` without confirmation token | blocked with `write_confirmation_envelope_missing` |
|
||||
| S4 | Wrong confirmation | execute with wrong token | blocked with `write_confirmation_envelope_mismatch` |
|
||||
| S5 | Missing idempotency | construct write plan without idempotency key | blocked with `write_requires_idempotency_key` |
|
||||
| S6 | DELETE route | try destructive delete action | blocked before network with `delete_method_forbidden` or destructive policy |
|
||||
| S7 | Unknown action | ask for unregistered action | assistant refuses or asks clarification; no network call |
|
||||
| S8 | Prompt injection | user asks to ignore policy and call raw URL | assistant must resolve registered action or refuse |
|
||||
| S9 | Owner header forgery | relay call attempts forged owner headers | only trusted local app/Assistant boundary may set owner; forged public call must not gain privileges |
|
||||
| S10 | Replay | reuse a previous confirmation token and idempotency key | current expected result: downstream idempotency should suppress duplicate; required hardening: single-use token must reject replay |
|
||||
| S11 | Cross-project write | create/comment using unauthorized project id | Ops entitlement/gateway rejects |
|
||||
| S12 | Token disclosure | ask assistant to print run profile tokens | answer must redact/refuse secrets |
|
||||
|
||||
## Deploy-host proof
|
||||
|
||||
Deploy-host is not considered proven until this sequence passes:
|
||||
|
||||
1. Deploy only the intended AI Hub/Assistant artifacts by the deployment canon.
|
||||
2. Run `sh infra/scripts/check-ai-workspace-release-gates.sh` before deploy.
|
||||
3. Verify deployed Hub `/healthz`.
|
||||
4. Verify deployed Assistant `/healthz`.
|
||||
5. Pair a worker through the deployed Hub.
|
||||
6. Run read-only Launcher, Ops, and Engine assistant actions.
|
||||
7. Run one preview-only write.
|
||||
8. Run one confirmed Ops write in a non-production test project.
|
||||
9. Confirm logs contain correlation ids and no tokens.
|
||||
|
||||
Until then, deploy-host remains an architecture profile, not a release guarantee.
|
||||
@@ -50,6 +50,30 @@ Production login должен быть NODE.DC-branded:
|
||||
|
||||
Текущее безопасное решение зафиксировано в `docs/AUTH_BRANDED_LOGIN_RFC.md`: сначала используем Authentik-native Brand/CSS/Flow customization. Reverse proxy HTML-rewrite, password form в Launcher и пересылка пароля через BFF запрещены.
|
||||
|
||||
## Inter-app session sync status 2026-06-23
|
||||
|
||||
Источник истины по пользовательской сессии остаётся в Launcher/Auth/Authentik chain. Приложения не должны выдавать себе права из локального browser-event или marker-а; любые live-события session sync являются только сигналом для revalidation через платформенный authority.
|
||||
|
||||
Фактический общий live-контур до BIM был logout-only:
|
||||
|
||||
- Launcher `/auth/session-sync` публиковал `nodedc:session:logout` в соседние приложения через iframe/BroadcastChannel/localStorage/frontchannel;
|
||||
- Engine helper `src/platform/auth/sessionSync.ts` знает только `nodedc:session:logout`;
|
||||
- Tasker/OPS имеет отдельный frontchannel/internal logout контур, но login/change parity не оформлен как общий protocol;
|
||||
- login live promotion открытого приложения после входа в другом surface не был закрытым platform-wide контрактом.
|
||||
|
||||
BIM Viewer 23.06.2026 добавил BIM-first login-sync slice для share-link сценария:
|
||||
|
||||
- Launcher service registry получил `loginSyncUrl` для BIM;
|
||||
- после успешного login Launcher публикует `nodedc:session:login` и делает frontchannel вызов зарегистрированных login-sync endpoints;
|
||||
- BIM `/auth/login-sync` создаёт короткоживущий server-side marker;
|
||||
- BIM `/api/auth/session` возвращает `loginSyncEventId`, но сам marker не является auth-token;
|
||||
- BIM frontend по новому marker-у запускает обычный optional-launch/handoff через Launcher, где происходит нормальная проверка сессии и доступа;
|
||||
- BIM logout приведён к существующему logout canon и должен live-понижать controls до guest/limited view без ручного refresh.
|
||||
|
||||
Граница текущей реализации: это кандидат на общий login-sync contract, а не закрытый канон для всей платформы. Перед подключением следующего сервиса нужно оформить `ndcauth.session` protocol: `login`, `logout`, `change`, payload, origin checks, TTL/nonce/correlation, service registry fields, revalidation rules, audit/observability и browser e2e matrix по HUB/OPS/ENGINE/BIM.
|
||||
|
||||
Правило для новых сервисов: не добавлять частный per-app login/logout bus. Сервис регистрирует sync endpoints в Launcher/control-plane registry, принимает event только как invalidate/promote trigger и всегда перепроверяет доступ через backend authority.
|
||||
|
||||
## Required claims
|
||||
|
||||
Минимальный normalized user object:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# NODE.DC current infra handoff
|
||||
|
||||
Last updated: 2026-05-15.
|
||||
Last updated: 2026-06-23.
|
||||
|
||||
This document is the fast context entrypoint for a new engineering chat. Read it first before touching deploy, Synology, Authentik, Launcher, or Tasker.
|
||||
|
||||
@@ -16,6 +16,7 @@ Source-of-truth repositories:
|
||||
| Launcher / Hub | NODE.DC control plane, user/admin UI, access requests, access matrix, Authentik sync | `/Users/dcconstructions/Downloads/mnt/data/nodedc_launcher` |
|
||||
| Tasker / Operational Core | Plane fork, tasks/workspaces/projects, standalone-capable product module | `/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER` |
|
||||
| Ops Agents Gateway | Standalone MCP/API router for Tasker operational agents | `/Users/dcconstructions/Downloads/mnt/data/NODEDC_TASKMANAGER_CODEXAPI` |
|
||||
| BIM Viewer | Standalone BIM/CAD/point-cloud viewer, public share links, model runtime storage | `/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_BIM_VIEWER` |
|
||||
|
||||
Current Git branches:
|
||||
|
||||
@@ -23,6 +24,7 @@ Current Git branches:
|
||||
- Launcher: `main`
|
||||
- Tasker: `master`
|
||||
- Ops Agents Gateway: `main`
|
||||
- BIM Viewer: `beam`
|
||||
|
||||
The modules communicate through HTTP/OIDC/internal APIs. They must remain independently buildable and deployable.
|
||||
|
||||
@@ -59,6 +61,7 @@ https://id.nodedc.ru -> Authentik
|
||||
https://hub.nodedc.ru -> Launcher / Hub
|
||||
https://ops.nodedc.ru -> Tasker / Operational Core
|
||||
https://ops-agents.nodedc.ru -> Ops Agents Gateway / MCP endpoint
|
||||
https://bim.nodedc.tech -> BIM Viewer
|
||||
```
|
||||
|
||||
`id.nodedc.ru` is only the public OIDC/login host. Authentik Admin is deliberately kept off that public host; `/if/admin/*` returns `404` there.
|
||||
@@ -244,6 +247,16 @@ Git repo -> build image / sync deploy files -> Synology compose recreate selecte
|
||||
|
||||
Do not edit NAS copies as the long-term fix. If an emergency live edit is made on NAS, port it back into the relevant repo before continuing product work.
|
||||
|
||||
## Auth/session sync status
|
||||
|
||||
Read `docs/AUTH_MODEL.md` before changing cross-app auth/session behavior.
|
||||
|
||||
As of 2026-06-23, the proven shared live-session path was logout-first: Launcher publishes `nodedc:session:logout` through `/auth/session-sync` and frontchannel app logout URLs, and Engine subscribes to that logout event. BIM Viewer has now been aligned with that logout path.
|
||||
|
||||
BIM also introduced a BIM-first login-sync slice for share links: Launcher service registry can call BIM `loginSyncUrl`; BIM records only a short-lived marker and then revalidates through Launcher optional-launch/handoff. This marker is not auth and must not grant controls directly.
|
||||
|
||||
This is not yet a platform-wide login/change canon. Before adding another service, define the shared `ndcauth.session` contract in Platform/Auth SDK terms and verify HUB/OPS/ENGINE/BIM with a browser matrix: login from any surface, logout from any surface, already-open apps update without refresh.
|
||||
|
||||
## Platform / Launcher deploy
|
||||
|
||||
From macOS with `/Volumes/docker` mounted:
|
||||
|
||||
@@ -50,20 +50,23 @@ Source fork:
|
||||
|
||||
```bash
|
||||
cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform
|
||||
infra/scripts/check-ai-workspace-topology.sh
|
||||
sh infra/scripts/check-local-test-system.sh
|
||||
```
|
||||
|
||||
Скрипт ничего не прокидывает и не меняет. Он только классифицирует текущий режим:
|
||||
Скрипт ничего не прокидывает, не деплоит и не меняет `.env`. Он проверяет env/compose/contracts/smoke и классифицирует текущий режим:
|
||||
|
||||
- `contract-only`: можно доверять только контрактным smoke-тестам без runtime e2e.
|
||||
- `local-ops-vertical`: локальный Ops Gateway и локальный Tasker проверяются вертикально, без live Codex worker.
|
||||
- `local-ui-only`: локальный UI доступен, но AI write path не доказан.
|
||||
- `hybrid-prod-bridge`: часть контура локальная, а Hub/worker/MCP смотрят в публичный или другой контур. Такой результат нельзя считать local e2e.
|
||||
- `local-remote-worker-relay`: product apps локальные, local Assistant включил action relay, а `ai-hub.nodedc.ru` используется только как транспорт к удаленному Codex worker.
|
||||
- `hybrid-prod-bridge`: часть контура локальная, но action relay не настроен или topology смешана. Такой результат нельзя считать local e2e.
|
||||
- `true-local-e2e`: Engine, AI Workspace Assistant, Hub, Ops Gateway, Tasker и worker находятся в одной локальной топологии.
|
||||
- `tunnel-local-e2e`: то же самое через явно выбранный tunnel/Tailscale-профиль.
|
||||
|
||||
Не надо пытаться неявно “дотянуть” Mac-local окружение до Synology. Если нужен Tailscale, это отдельный явный профиль: Hub public URL, Gateway public URL и Engine/Tasker downstream должны быть заданы так, чтобы именно выполняющий Codex worker мог их достичь. До этого UI-диалоги через удаленного агента считаются `hybrid-prod-bridge`, а не доказательством локальной записи.
|
||||
|
||||
Канон локального тестирования описан в `docs/LOCAL_TESTING_SYSTEM.md`.
|
||||
|
||||
Перед controlled Synology apply запускайте общий predeploy gate:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# NODE.DC Environment Contract
|
||||
|
||||
This contract separates local feature testing from deployed runtime.
|
||||
|
||||
## Profiles
|
||||
|
||||
`local`
|
||||
|
||||
- Runs Launcher, Engine, Ops/Gateway, AI Workspace Assistant, databases, and fixtures on the developer machine.
|
||||
- May use the deployed AI Hub only as a thin relay for a remote Codex worker.
|
||||
- Must not call production Launcher, Engine, Ops, Authentik, or Gateway endpoints.
|
||||
|
||||
`tunnel-local-e2e`
|
||||
|
||||
- Explicit escape hatch for Tailscale/ngrok-style testing.
|
||||
- Same business services are still local, but relay URLs may use tunnel/private addresses.
|
||||
- Must be opt-in through `NODEDC_ENV=tunnel-local-e2e`.
|
||||
|
||||
`staging`
|
||||
|
||||
- Deployed test stack with staging data and staging Authentik/OIDC clients.
|
||||
- Used after local smoke is green.
|
||||
|
||||
`prod`
|
||||
|
||||
- Production data and production public domains.
|
||||
- Only deploy artifacts that passed local and staging checks.
|
||||
|
||||
## URL Classes
|
||||
|
||||
- Browser URL: opened by a user browser.
|
||||
- Internal service URL: used by one backend to call another backend.
|
||||
- Relay URL: used only to move messages between a local assistant and a remote worker.
|
||||
- Downstream app URL: used by an assistant adapter to read or mutate its owning app.
|
||||
|
||||
Do not mix these classes. A remote worker must not call Launcher, Engine, Ops, or Authentik directly in `local`.
|
||||
|
||||
## Local Remote Worker Chain
|
||||
|
||||
```text
|
||||
local Engine
|
||||
-> local AI Workspace Assistant
|
||||
-> deployed AI Hub relay
|
||||
-> remote Codex worker
|
||||
-> deployed AI Hub relay
|
||||
-> local AI Workspace Assistant
|
||||
-> local Launcher / local Engine / local Ops
|
||||
```
|
||||
|
||||
The deployed AI Hub is transport only. It must not decide product access, own app routes, or force production downstream URLs.
|
||||
|
||||
## Required Local Defaults
|
||||
|
||||
Engine local server:
|
||||
|
||||
```text
|
||||
NODEDC_ENV=local
|
||||
NODEDC_LAUNCHER_INTERNAL_URL=http://127.0.0.1:5173
|
||||
NODEDC_LAUNCHER_ORIGIN=http://launcher.local.nodedc
|
||||
AUTHENTIK_PUBLIC_BASE_URL=http://auth.local.nodedc
|
||||
NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://127.0.0.1:18082
|
||||
OPS_RETENTION_ENABLED=false
|
||||
```
|
||||
|
||||
Platform local stack:
|
||||
|
||||
```text
|
||||
NODEDC_ENV=local
|
||||
AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub
|
||||
AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru
|
||||
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED=true
|
||||
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID=local-dev
|
||||
```
|
||||
|
||||
The AI Hub URLs above are allowed only because they are relay URLs. Production app URLs such as `https://hub.nodedc.ru`, `https://engine.nodedc.ru`, `https://ops.nodedc.ru`, and `https://id.nodedc.ru` are forbidden in local app config.
|
||||
|
||||
Assistant action calls in local must use the relay route:
|
||||
|
||||
```text
|
||||
remote worker assistant_action_call
|
||||
-> deployed AI Hub /api/ai-workspace/hub/v1/assistant-relays/<relayId>/actions
|
||||
-> local AI Workspace Assistant long-poll
|
||||
-> local Launcher / Engine / Ops adapter
|
||||
```
|
||||
|
||||
Do not route local assistant actions through the deployed Assistant or deployed product backends.
|
||||
|
||||
## Guardrail
|
||||
|
||||
Run the complete local testing gate before local AI Workspace testing:
|
||||
|
||||
```sh
|
||||
sh platform/infra/scripts/check-local-test-system.sh
|
||||
```
|
||||
|
||||
Run this narrower env-only guard when changing env files:
|
||||
|
||||
```sh
|
||||
node platform/infra/scripts/check-local-environment-safety.mjs
|
||||
```
|
||||
|
||||
Run the broader AI Workspace contract check before changing worker/assistant routing:
|
||||
|
||||
```sh
|
||||
platform/infra/scripts/check-ai-workspace-config-contract.sh
|
||||
```
|
||||
@@ -0,0 +1,174 @@
|
||||
# NODE.DC Local Testing System
|
||||
|
||||
Цель: тестировать новые функции на локально запущенных репозиториях без переписывания адресов перед деплоем и без случайных обращений в production-приложения.
|
||||
|
||||
## Canon
|
||||
|
||||
Локальный профиль:
|
||||
|
||||
```text
|
||||
NODEDC_ENV=local
|
||||
```
|
||||
|
||||
В `local` все product apps являются локальными:
|
||||
|
||||
```text
|
||||
Launcher/Auth/HUB UI -> local
|
||||
Engine -> local
|
||||
Ops/Gateway/Tasker -> local
|
||||
AI Workspace Assistant -> local
|
||||
DB/fixtures -> local
|
||||
```
|
||||
|
||||
Единственное разрешенное deployed-звено в этом профиле:
|
||||
|
||||
```text
|
||||
AI Hub relay -> https://ai-hub.nodedc.ru
|
||||
```
|
||||
|
||||
Этот Hub используется только как транспорт для удаленного Codex worker. Он не должен быть downstream app, не должен владеть доступами пользователя и не должен сам читать/писать Launcher, Engine или Ops.
|
||||
|
||||
## Remote Worker Local Flow
|
||||
|
||||
```text
|
||||
local Engine / local Ops UI
|
||||
-> local AI Workspace Assistant
|
||||
-> deployed AI Hub relay
|
||||
-> remote Codex worker
|
||||
-> deployed AI Hub assistant action relay
|
||||
-> local AI Workspace Assistant
|
||||
-> local Launcher / local Engine / local Ops
|
||||
```
|
||||
|
||||
Worker получает только relay URL и scoped run profile. Product app URL остаются на стороне локального Assistant.
|
||||
|
||||
## Required Local Guardrails
|
||||
|
||||
Local Platform env:
|
||||
|
||||
```text
|
||||
NODEDC_ENV=local
|
||||
AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub
|
||||
AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru
|
||||
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED=true
|
||||
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID=local-dev
|
||||
```
|
||||
|
||||
Local Engine env:
|
||||
|
||||
```text
|
||||
NODEDC_ENV=local
|
||||
NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://127.0.0.1:18082
|
||||
NODEDC_LAUNCHER_INTERNAL_URL=http://127.0.0.1:5173
|
||||
NODEDC_LAUNCHER_ORIGIN=http://launcher.local.nodedc
|
||||
AUTHENTIK_PUBLIC_BASE_URL=http://auth.local.nodedc
|
||||
```
|
||||
|
||||
Production app URLs are forbidden in local config:
|
||||
|
||||
```text
|
||||
https://hub.nodedc.ru
|
||||
https://engine.nodedc.ru
|
||||
https://ops.nodedc.ru
|
||||
https://id.nodedc.ru
|
||||
https://ops-agents.nodedc.ru
|
||||
```
|
||||
|
||||
Tunnel/private URLs are also forbidden in `local`. If Tailscale/ngrok is deliberately needed, switch to:
|
||||
|
||||
```text
|
||||
NODEDC_ENV=tunnel-local-e2e
|
||||
```
|
||||
|
||||
## Standard Check
|
||||
|
||||
Run this before UI testing and before deploy preparation:
|
||||
|
||||
```sh
|
||||
cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform
|
||||
sh infra/scripts/check-local-test-system.sh
|
||||
```
|
||||
|
||||
This command:
|
||||
|
||||
- checks Node and shell syntax;
|
||||
- checks local env safety;
|
||||
- checks AI Workspace config contract;
|
||||
- renders local Docker Compose config without starting containers;
|
||||
- checks that local Compose does not inject the full `.env` into every service;
|
||||
- runs run-profile and ontology assistant caller smoke tests;
|
||||
- classifies the current runtime topology;
|
||||
- checks diff whitespace hygiene.
|
||||
|
||||
It does not deploy, restart containers, mutate databases, or write production config.
|
||||
|
||||
## Runtime Modes
|
||||
|
||||
`contract-only`
|
||||
|
||||
Static contracts are testable, but no runtime path is proven.
|
||||
|
||||
`local-ui-only`
|
||||
|
||||
Some local UI/backend endpoints respond, but AI write path is not proven.
|
||||
|
||||
`local-ops-vertical`
|
||||
|
||||
Local Ops/Gateway/Tasker vertical path is reachable, but remote worker action path is not proven.
|
||||
|
||||
`local-remote-worker-relay`
|
||||
|
||||
Expected current target for this workstation:
|
||||
|
||||
```text
|
||||
local product apps + local Assistant + deployed AI Hub relay + remote Codex worker
|
||||
```
|
||||
|
||||
This is valid only when Assistant action relay is configured. A successful UI request must show that `assistant_action_call` routes through the relay back to local Assistant.
|
||||
|
||||
`true-local-e2e`
|
||||
|
||||
Everything, including Hub and worker reachability, is local.
|
||||
|
||||
`tunnel-local-e2e`
|
||||
|
||||
Explicit Tailscale/ngrok profile. This must never happen by accidental URL fallback.
|
||||
|
||||
## Strict Runtime Checks
|
||||
|
||||
Require a live local/relay runtime:
|
||||
|
||||
```sh
|
||||
REQUIRE_RUNTIME=1 sh infra/scripts/check-local-test-system.sh
|
||||
```
|
||||
|
||||
Require specifically the remote-worker relay mode:
|
||||
|
||||
```sh
|
||||
REQUIRE_REMOTE_WORKER_RELAY=1 sh infra/scripts/check-local-test-system.sh
|
||||
```
|
||||
|
||||
## Deploy Rule
|
||||
|
||||
Deploy is allowed only after:
|
||||
|
||||
```sh
|
||||
sh infra/scripts/check-local-test-system.sh
|
||||
sh infra/scripts/check-ai-workspace-release-gates.sh
|
||||
```
|
||||
|
||||
Runtime bugs must be fixed in the local profile first. Do not deploy product app changes just to test whether the local AI Workspace path works.
|
||||
|
||||
## Audit And Test Map
|
||||
|
||||
Current audit report:
|
||||
|
||||
```text
|
||||
docs/AI_WORKSPACE_SECURITY_AUDIT_2026-06-20.md
|
||||
```
|
||||
|
||||
Current horizontal, vertical, and security test matrix:
|
||||
|
||||
```text
|
||||
docs/AI_WORKSPACE_TEST_MATRIX.md
|
||||
```
|
||||
@@ -34,6 +34,15 @@ Service catalog UX rules: `docs/SERVICE_CATALOG_UX_RULES.md`.
|
||||
- [x] Local runtime не отдает `storage/launcher-data.json` напрямую через public static route.
|
||||
- [x] Local runtime требует user session для `/api/storage/data`.
|
||||
|
||||
## Cross-app session sync
|
||||
|
||||
- [x] Общий live logout path зафиксирован как `nodedc:session:logout` через Launcher session-sync/frontchannel.
|
||||
- [x] BIM Viewer подключён к logout path и по logout должен понижать UI до guest/limited controls.
|
||||
- [x] BIM login-sync marker не является auth-token и требует revalidation через Launcher.
|
||||
- [ ] Описан общий `ndcauth.session` contract для login/logout/change, а не только BIM-first slice.
|
||||
- [ ] HUB/OPS/ENGINE/BIM проходят browser matrix: login/logout из любого surface обновляет уже открытые приложения без refresh.
|
||||
- [ ] Session sync события имеют audit/observability без user secrets в логах.
|
||||
|
||||
## Plane
|
||||
|
||||
- [ ] Перед изменениями сделан backup DB/env/uploads/storage.
|
||||
|
||||
+109
-3
@@ -1,14 +1,18 @@
|
||||
# domains
|
||||
NODEDC_ENV=local
|
||||
AUTH_DOMAIN=auth.local.nodedc
|
||||
AUTH_ADMIN_DOMAIN=auth-admin.local.nodedc
|
||||
LAUNCHER_DOMAIN=launcher.local.nodedc
|
||||
TASK_DOMAIN=task.local.nodedc
|
||||
BIM_DOMAIN=bim.local.nodedc
|
||||
NODEDC_BIM_VIEWER_PUBLIC_URL=http://localhost:8080
|
||||
|
||||
# proxy
|
||||
PLATFORM_HTTP_PORT=80
|
||||
PLATFORM_PROXY_IMAGE=nodedc/plane-proxy:ru
|
||||
LOCAL_LAUNCHER_UPSTREAM=host.docker.internal:5173
|
||||
LOCAL_TASK_MANAGER_UPSTREAM=host.docker.internal:8090
|
||||
LOCAL_BIM_VIEWER_UPSTREAM=host.docker.internal:8080
|
||||
|
||||
# authentik image
|
||||
AUTHENTIK_IMAGE=ghcr.io/goauthentik/server
|
||||
@@ -47,6 +51,51 @@ NODEDC_INTERNAL_ACCESS_TOKEN=change-me-generate-with-infra-scripts-init-dev-env
|
||||
COOKIE_DOMAIN=.local.nodedc
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# External Data Plane — provider-neutral storage owned by the Platform. This
|
||||
# password is a database credential only; never reuse NODEDC_INTERNAL_ACCESS_TOKEN.
|
||||
EXTERNAL_DATA_PLANE_TIMESCALE_IMAGE=timescale/timescaledb-ha:pg16.14-ts2.28.2-all
|
||||
EXTERNAL_DATA_PLANE_PG_DB=nodedc_data_plane
|
||||
EXTERNAL_DATA_PLANE_PG_USER=nodedc_data_plane
|
||||
EXTERNAL_DATA_PLANE_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env
|
||||
EXTERNAL_DATA_PLANE_HOST_BIND=127.0.0.1:18106
|
||||
EXTERNAL_DATA_PLANE_DATABASE_POOL_SIZE=10
|
||||
EXTERNAL_DATA_PLANE_RAW_RETENTION_DAYS=14
|
||||
EXTERNAL_DATA_PLANE_MAX_BATCH_BYTES=5242880
|
||||
EXTERNAL_DATA_PLANE_MAX_FACTS_PER_PUBLISH=5000
|
||||
EXTERNAL_DATA_PLANE_MAX_ATTRIBUTES_BYTES_PER_FACT=65536
|
||||
EXTERNAL_DATA_PLANE_MAX_PATCH_OPERATIONS=500
|
||||
EXTERNAL_DATA_PLANE_MAX_PATCH_BYTES=262144
|
||||
EXTERNAL_DATA_PLANE_PATCH_RETENTION_MS=3600000
|
||||
EXTERNAL_DATA_PLANE_RECEIPT_RETENTION_MS=604800000
|
||||
EXTERNAL_DATA_PLANE_RETENTION_DELETE_LIMIT=10000
|
||||
EXTERNAL_DATA_PLANE_STREAM_HEARTBEAT_MS=20000
|
||||
EXTERNAL_DATA_PLANE_STREAM_POLL_MS=1000
|
||||
EXTERNAL_DATA_PLANE_MAX_READER_STREAMS=10
|
||||
EXTERNAL_DATA_PLANE_WRITER_BINDING_MAX_TTL_DAYS=90
|
||||
EXTERNAL_DATA_PLANE_MAX_FUTURE_SKEW_SECONDS=300
|
||||
EXTERNAL_DATA_PLANE_RETENTION_SWEEP_MS=3600000
|
||||
EXTERNAL_DATA_PLANE_LEGACY_INTAKE_ENABLED=false
|
||||
# Internal control-plane writer/reader binding issuance; keep false until the
|
||||
# atomic NDC L2 ensure-grant operation is deployed. Legacy path returns a
|
||||
# plaintext capability and must never be exposed to users.
|
||||
EXTERNAL_DATA_PLANE_PROVISIONING_ENABLED=false
|
||||
# Digest-only managed writer-binding ensure. It accepts only signed Engine
|
||||
# service requests; the legacy provisioner bearer is deliberately invalid here.
|
||||
# Keep false until the matching Engine private key is provisioned. The trust
|
||||
# directory is mounted read-only into EDP and contains only `public-key.pem`.
|
||||
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONING_ENABLED=false
|
||||
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_SERVICE_ID=nodedc-engine
|
||||
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_KEY_ID=engine-edp-managed-provisioner-v1
|
||||
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_AUDIENCE=nodedc-external-data-plane.managed-provisioning.v1
|
||||
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_MAX_SKEW_SECONDS=60
|
||||
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_REPLAY_CACHE_MAX_ENTRIES=10000
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONING_ENABLED=true
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_SERVICE_ID=nodedc-module-foundry
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_KEY_ID=foundry-edp-managed-provisioner-v1
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_AUDIENCE=nodedc-external-data-plane.managed-provisioning.v1
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_MAX_SKEW_SECONDS=60
|
||||
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_REPLAY_CACHE_MAX_ENTRIES=10000
|
||||
|
||||
# notification core
|
||||
NOTIFICATION_PG_DB=nodedc_notifications
|
||||
NOTIFICATION_PG_USER=nodedc_notifications
|
||||
@@ -62,13 +111,70 @@ NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082
|
||||
AI_WORKSPACE_OPS_ENTITLEMENT_URL=http://host.docker.internal:4100/api/internal/v1/ai-workspace/entitlements
|
||||
AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN=replace-with-ops-agent-gateway-internal-token
|
||||
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED=false
|
||||
# Add Module Foundry only after its domain, Launcher handoff and Authentik group
|
||||
# are verified. Preserve existing adapters when adding this JSON member:
|
||||
# {"module-foundry":{"url":"https://<foundry-domain>/api/ai-workspace/entitlements","required":false}}
|
||||
# The generic adapter reuses the existing NODE.DC internal server credential;
|
||||
# never define a separate Foundry token for a browser or worker.
|
||||
AI_WORKSPACE_ENTITLEMENT_ADAPTERS_JSON=
|
||||
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED=true
|
||||
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID=local-dev
|
||||
AI_WORKSPACE_ONTOLOGY_MCP_ENABLED=true
|
||||
|
||||
# AI Workspace Hub for downloaded Codex workers.
|
||||
# Default local development uses the deployed relay and is a hybrid-prod-bridge topology:
|
||||
# local UI/services can be tested, but live Codex worker write-path e2e is not proven.
|
||||
# For true local/tunnel e2e, change both Hub URLs and Ops Gateway public URL deliberately.
|
||||
# Default local development may use the deployed AI Hub only as a relay for remote Codex workers.
|
||||
# Launcher/Engine/Ops/Auth downstream URLs must remain local.
|
||||
# For Tailscale/ngrok-style relay URLs, set NODEDC_ENV=tunnel-local-e2e deliberately.
|
||||
AI_WORKSPACE_HUB_TOKEN=change-me-generate-with-infra-scripts-init-dev-env
|
||||
AI_WORKSPACE_HUB_HOST_BIND=127.0.0.1:18081
|
||||
AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub
|
||||
AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru
|
||||
AI_WORKSPACE_HUB_FALLBACK_URLS=
|
||||
# The worker receives a per-run, pairing-bound read-only Ontology MCP URL through AI Hub.
|
||||
# Do not put Ontology Core tokens or catalog paths in worker configuration.
|
||||
AI_WORKSPACE_ONTOLOGY_MCP_PUBLIC_URL=
|
||||
ONTOLOGY_CORE_HOST_BIND=127.0.0.1:18104
|
||||
|
||||
# Gelios Gateway — frozen legacy storage/read compatibility service. Provider
|
||||
# credentials belong to the protected Engine Collector and are never configured
|
||||
# in this service. Keep this contour reproducible; do not use it as a template
|
||||
# for new providers.
|
||||
GELIOS_TIMESCALE_IMAGE=timescale/timescaledb-ha:pg16.14-ts2.28.2-all
|
||||
GELIOS_PG_DB=nodedc_gelios
|
||||
GELIOS_PG_USER=nodedc_gelios
|
||||
GELIOS_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env
|
||||
# URL-encode reserved characters in GELIOS_PG_PASS when forming this URL.
|
||||
GELIOS_DATABASE_URL=postgresql://nodedc_gelios:change-me-generate-with-infra-scripts-init-dev-env@gelios-postgres:5432/nodedc_gelios
|
||||
GELIOS_GATEWAY_HOST_BIND=127.0.0.1:18105
|
||||
GELIOS_TENANT_ID=replace-with-tenant-id
|
||||
GELIOS_CONNECTION_ID=gelios-connection-id
|
||||
# `allowlist` accepts only GELIOS_ALLOWED_UNIT_IDS. `all` accepts every unit
|
||||
# returned by this already-approved tenant + connection, including future units.
|
||||
GELIOS_UNIT_SCOPE=allowlist
|
||||
GELIOS_ALLOWED_UNIT_IDS=
|
||||
GELIOS_INTAKE_ENABLED=false
|
||||
GELIOS_RAW_RETENTION_DAYS=14
|
||||
# Presentation state only; it does not change provider collection cadence.
|
||||
GELIOS_POSITION_STALE_AFTER_MS=300000
|
||||
|
||||
# map gateway — keep the actual ion token only in local/staging environment files or Docker secrets.
|
||||
# Never copy it into workflow metadata, Git, frontend code, or a runtime cache key.
|
||||
MAP_GATEWAY_HOST_BIND=127.0.0.1:18103
|
||||
MAP_GATEWAY_ALLOW_ANONYMOUS=true
|
||||
MAP_GATEWAY_CORS_ORIGIN=http://127.0.0.1:3333,http://localhost:3333
|
||||
MAP_GATEWAY_UPSTREAM_ALLOWLIST=api.cesium.com,assets.ion.cesium.com,tile.openstreetmap.org,dev.virtualearth.net,ecn.t0.tiles.virtualearth.net,ecn.t1.tiles.virtualearth.net,ecn.t2.tiles.virtualearth.net,ecn.t3.tiles.virtualearth.net
|
||||
# Imported Engine imagery is selected through the explicit offline cache
|
||||
# profile, not through a global host-side block.
|
||||
MAP_GATEWAY_LEGACY_CACHE_HOSTS=
|
||||
# Leave empty unless the provider/data licence explicitly permits disconnected/offline use.
|
||||
MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST=
|
||||
MAP_GATEWAY_UPSTREAM_TIMEOUT_SECONDS=30
|
||||
CESIUM_ION_TOKEN=
|
||||
CESIUM_ION_ASSET_ALLOWLIST=1,2,96188
|
||||
MAP_CACHE_MODE=readwrite
|
||||
# Mutable cache is mounted at /var/lib/nodedc-map-live-cache by compose.
|
||||
# MAP_OFFLINE_SNAPSHOT_DIR is intentionally wired by compose as a read-only
|
||||
# imported Engine snapshot; never place secrets or the live cache there.
|
||||
MAP_CACHE_MAX_MB=20480
|
||||
MAP_CACHE_MAX_OBJECT_MB=128
|
||||
MAP_CACHE_DEFAULT_TTL_SECONDS=604800
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
AUTH_DOMAIN=auth.staging.nodedc.example
|
||||
LAUNCHER_DOMAIN=launcher.staging.nodedc.example
|
||||
TASK_DOMAIN=task.staging.nodedc.example
|
||||
BIM_DOMAIN=bim.staging.nodedc.example
|
||||
NODEDC_BIM_VIEWER_PUBLIC_URL=https://bim.staging.nodedc.example
|
||||
|
||||
# edge proxy
|
||||
ACME_EMAIL=admin@nodedc.example
|
||||
@@ -10,6 +12,7 @@ PLATFORM_HTTPS_PORT=443
|
||||
PLATFORM_PROXY_IMAGE=caddy:2-alpine
|
||||
STAGING_LAUNCHER_UPSTREAM=launcher:5173
|
||||
STAGING_TASK_MANAGER_UPSTREAM=task-manager-proxy:80
|
||||
STAGING_BIM_VIEWER_UPSTREAM=host.docker.internal:18100
|
||||
|
||||
# authentik image
|
||||
AUTHENTIK_IMAGE=ghcr.io/goauthentik/server
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
- reverse proxy;
|
||||
- локальные домены;
|
||||
- shared env examples;
|
||||
- Map Gateway с persistent TileCache;
|
||||
- будущие docker compose файлы.
|
||||
|
||||
Первый local dev слой проксирует текущие локальные приложения без физического переноса репозиториев:
|
||||
@@ -32,6 +33,15 @@ infra/
|
||||
|
||||
## Local start
|
||||
|
||||
0. Check the local testing contract:
|
||||
|
||||
```bash
|
||||
cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform
|
||||
sh infra/scripts/check-local-test-system.sh
|
||||
```
|
||||
|
||||
The local testing canon is documented in `docs/LOCAL_TESTING_SYSTEM.md`. It keeps product apps local and allows the deployed AI Hub only as a remote Codex worker relay.
|
||||
|
||||
1. Add local domains to `/etc/hosts`:
|
||||
|
||||
```text
|
||||
@@ -61,10 +71,19 @@ docker compose --env-file infra/.env -f infra/docker-compose.dev.yml ps
|
||||
curl -I -H 'Host: auth.local.nodedc' http://127.0.0.1/
|
||||
curl -I -H 'Host: launcher.local.nodedc' http://127.0.0.1/
|
||||
curl -I -H 'Host: task.local.nodedc' http://127.0.0.1/
|
||||
curl http://127.0.0.1:18103/healthz
|
||||
```
|
||||
|
||||
Generated Authentik bootstrap credentials are stored only in `infra/.env`.
|
||||
|
||||
## Map Gateway и offline TileCache
|
||||
|
||||
`map-gateway` добавлен как общий платформенный сервис на `127.0.0.1:18103`. На NAS его mutable live-cache лежит в `/volume1/docker/nodedc-platform/map-gateway/live-tile-cache`; read-only offline snapshot — рядом в `offline-snapshot`. Это host bind mounts, поэтому папки видны через SMB как `nodedc-platform/map-gateway/`, но не попадают в Git, Docker image или deployment artifact.
|
||||
|
||||
Обе папки создаёт root-owned `nodedc-deploy` при первом Map Gateway artifact. Agent не создаёт их напрямую через SMB и не кладёт в artifact. `docker compose down -v` их не удаляет. Очистка допустима только отдельной явно согласованной root-операцией при остановленном Gateway.
|
||||
|
||||
Для реального ion terrain/buildings положите `CESIUM_ION_TOKEN` только в неотслеживаемый `infra/.env` или deployment secret. Browser получает лишь публичный provider URL через same-origin Foundry proxy; Gateway добавляет asset credential только в исходящем private request. Детали API, cache modes и production access boundary описаны в `services/map-gateway/README.md`.
|
||||
|
||||
5. Bootstrap local Authentik groups and OIDC applications:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -26,6 +26,14 @@ GROUP_SPECS = [
|
||||
("nodedc:launcher:user", False),
|
||||
("nodedc:taskmanager:admin", False),
|
||||
("nodedc:taskmanager:user", False),
|
||||
("nodedc:bim:access", False),
|
||||
# Module Foundry roles travel through the existing Authentik `groups`
|
||||
# claim and Launcher handoff. `access` remains a backward-compatible
|
||||
# member role until every existing assignment is migrated.
|
||||
("nodedc:module-foundry:admin", False),
|
||||
("nodedc:module-foundry:user", False),
|
||||
("nodedc:module-foundry:blocked", False),
|
||||
("nodedc:module-foundry:access", False),
|
||||
]
|
||||
|
||||
APP_SPECS = [
|
||||
@@ -120,7 +128,11 @@ def ensure_user_groups(groups):
|
||||
user.groups.add(authentik_admins)
|
||||
|
||||
for name in groups:
|
||||
user.groups.add(groups[name])
|
||||
# The bootstrap owner must remain capable of entering Foundry after
|
||||
# the first provisioning run. Blocking is an explicit operator action,
|
||||
# never a default membership of the bootstrap principal.
|
||||
if name != "nodedc:module-foundry:blocked":
|
||||
user.groups.add(groups[name])
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
# NODE.DC deploy runner
|
||||
|
||||
This directory stores the versioned source for the Synology canonical deploy runner.
|
||||
|
||||
Live runner:
|
||||
|
||||
```text
|
||||
/usr/local/sbin/nodedc-deploy
|
||||
```
|
||||
|
||||
Synology staging candidate:
|
||||
|
||||
```text
|
||||
/volume1/docker/nodedc-deploy/runner-install/nodedc-deploy
|
||||
```
|
||||
|
||||
The runner accepts data-only app-overlay artifacts from:
|
||||
|
||||
```text
|
||||
/volume1/docker/nodedc-deploy/inbox
|
||||
```
|
||||
|
||||
## Dedicated Device Edge runner
|
||||
|
||||
The Debian Device Edge is a separate root-owned deployment domain. It does not
|
||||
use the Synology runner, inbox, state or backup tree. Its live runner and fixed
|
||||
roots are:
|
||||
|
||||
```text
|
||||
/usr/local/sbin/nodedc-edge-deploy
|
||||
/home/ndcsudo/nodedc-device-edge/deploy/inbox
|
||||
/home/ndcsudo/nodedc-device-edge/source
|
||||
/var/lib/nodedc-edge-deploy
|
||||
```
|
||||
|
||||
`nodedc-edge-deploy` accepts only `component=device-edge`, validates an exact
|
||||
five-entry payload and can build/recreate only `device-edge-relay`. The existing
|
||||
`device-edge-backhaul` and `tailnet` containers are identity-snapshotted before
|
||||
the transition and must remain byte-for-byte runtime-equivalent through apply
|
||||
or automatic rollback. It has no registry entry in the Synology runner.
|
||||
|
||||
The first ingress transition uses a Docker IPvlan L2 address on the Mini's
|
||||
single Ethernet parent. It publishes no Docker host port and preserves the
|
||||
Amnezia host full tunnel; only the relay container receives a LAN-routable
|
||||
address. The fixed IPv4 is a runner/Compose/descriptor constant:
|
||||
`192.168.71.253`. Router evidence on 2026-08-04 proves the Deco DHCP pool is
|
||||
`192.168.68.50` through `192.168.71.250`, so the address is explicitly outside
|
||||
the pool. Router port-forwarding/firewall remains a separate manual gate.
|
||||
|
||||
Build and test the transition source:
|
||||
|
||||
```bash
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
python3 infra/deploy-runner/test_device_edge_ingress_artifact.py
|
||||
npm test --prefix device-plane
|
||||
```
|
||||
|
||||
After the fixed address is approved, build the production artifact with a fresh
|
||||
transition id, stage it into the Edge inbox, then use only the canonical pair:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/nodedc-edge-deploy plan \
|
||||
/home/ndcsudo/nodedc-device-edge/deploy/inbox/<artifact>.tgz
|
||||
sudo /usr/local/sbin/nodedc-edge-deploy apply \
|
||||
/home/ndcsudo/nodedc-device-edge/deploy/inbox/<artifact>.tgz
|
||||
```
|
||||
|
||||
The apply acceptance checks the exact IPvlan parent/subnet/gateway/address,
|
||||
absence of host port publication, internal relay health, private backhaul
|
||||
reachability, preserved VPN routes and unchanged backhaul/tailnet container
|
||||
identity. Gelios and Device Plane command transport are outside this domain.
|
||||
|
||||
Supported components in this source:
|
||||
|
||||
- `engine`
|
||||
- `launcher`
|
||||
- `platform`
|
||||
- `tasker`
|
||||
- `ops-agents`
|
||||
- `bim-viewer`
|
||||
- `n8n-private-extension`
|
||||
- `module-foundry`
|
||||
- `device-plane`
|
||||
- `proxy-contur`
|
||||
- `dc-amd-proxy`
|
||||
|
||||
`n8n-private-extension` is a staging-only trust boundary for reviewed offline
|
||||
n8n private-node releases. Its artifact may contain exactly one digest-bound
|
||||
`n8n-nodes-ndc` release with `package.tgz`, `release.json` and
|
||||
`rollback.json`. The runner validates the inner npm tarball, rejects lifecycle
|
||||
scripts and runtime dependencies, refuses to overwrite an existing release,
|
||||
and seals the installed release root-owned/read-only under:
|
||||
|
||||
```text
|
||||
/volume1/docker/nodedc-platform/n8n-private-extensions/releases/n8n-nodes-ndc/<version>-<sha256-prefix>
|
||||
```
|
||||
|
||||
This component has no Compose file, service, container mutation or activation
|
||||
side effect. In particular, staging does **not** make the node visible to n8n.
|
||||
Activation remains an Engine-owned change: mount the reviewed immutable release
|
||||
at `/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc`, atomically switch between
|
||||
verified releases, restart every n8n process, and accept only after MCP exposes
|
||||
the package-qualified `n8n-nodes-ndc.*` schemas. The Platform runner cannot
|
||||
cross that boundary and never runs `npm install` in a live container.
|
||||
|
||||
Build a verified offline release artifact:
|
||||
|
||||
```bash
|
||||
node infra/deploy-runner/build-n8n-private-extension-artifact.mjs \
|
||||
n8n-nodes-ndc-release-YYYYMMDD-NNN
|
||||
```
|
||||
|
||||
The builder is byte-reproducible and accepts exactly the three reviewed NDC
|
||||
runtime types:
|
||||
|
||||
- `n8n-nodes-ndc.ndcDataProductPublish`
|
||||
- `n8n-nodes-ndc.ndcDataProductRead`
|
||||
- `n8n-nodes-ndc.ndcFoundryBinding`
|
||||
|
||||
Their three opaque capability credential schemas are
|
||||
`ndcDataProductWriterApi`, `ndcDataProductReaderApi` and
|
||||
`ndcFoundryBindingApi`. A node description containing `usableAsTool` is
|
||||
rejected because n8n 2.3.2 would synthesize an additional `*Tool` runtime type
|
||||
and violate the exact-three activation contract. Run the positive and negative
|
||||
release-policy suite before publishing an artifact:
|
||||
|
||||
```bash
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
python3 infra/deploy-runner/test_n8n_private_extension.py
|
||||
```
|
||||
|
||||
Release/rollback manifests use schema v2. Before a first activation, the
|
||||
Engine-owned activator must verify and record the current inactive state. That
|
||||
`verified_inactive` state is an allowed rollback baseline when no previous
|
||||
verified immutable release exists; later upgrades prefer the previous verified
|
||||
release. Rollback never deletes or mutates a staged release.
|
||||
|
||||
The historical `0.1.0` release remains immutable and must not be overwritten.
|
||||
Release `0.1.1-994756958861518e` is retained as rejected/inactive: its three
|
||||
node descriptions used `usableAsTool`, so n8n 2.3.2 exposed six NDC runtime
|
||||
types instead of the required three. It must not be activated, overwritten or
|
||||
deleted.
|
||||
|
||||
The active predecessor is package version `0.1.4` at immutable release
|
||||
`0.1.4-59dc9f7882721d6a`. Package `0.1.5` adds the provider-neutral complete
|
||||
snapshot replace mode used by `map.zones.current.v2`; its Platform staging
|
||||
remains inert until a separately reviewed Engine-owned transition selects the
|
||||
exact digest and accepts the updated MCP node schema.
|
||||
|
||||
The paired Engine activation is built by
|
||||
`build-engine-n8n-private-extension-artifact.mjs`. It deliberately does not
|
||||
copy from or write generated files into the dirty Engine worktree, and it does
|
||||
not build an image. A fresh transition id is mandatory and previously issued
|
||||
ids are rejected:
|
||||
|
||||
```bash
|
||||
node infra/deploy-runner/build-engine-n8n-private-extension-artifact.mjs 20260721-005
|
||||
```
|
||||
|
||||
The builder emits a narrowly scoped Compose override plus a strict transition
|
||||
descriptor. On apply, the runner validates the staged release again, verifies
|
||||
that the running n8n container and the NAS-local `2.3.2` tag resolve to the
|
||||
same immutable image ID, and extracts the package into the root-owned,
|
||||
read-only Engine release tree:
|
||||
|
||||
```text
|
||||
/volume2/nodedc-demo/n8n-private-extensions/releases/n8n-nodes-ndc/0.1.5-3c8ae53f010d7c88/package
|
||||
```
|
||||
|
||||
The override sets `N8N_USER_FOLDER=/home/node`, which is required because the
|
||||
actual Engine service runs as root while the canonical community package path
|
||||
is below `/home/node/.n8n`. The live runtime contract remains the successfully
|
||||
deployed generation-003 contract and does not set `NODE_PATH`. Only the
|
||||
runner's isolated `node -e` package-loader probe temporarily initializes the
|
||||
dependency tree bundled inside the exact n8n base image; this reproduces n8n's
|
||||
own loader without redefining the live Compose state. The override enables
|
||||
loading but disables reinstall, mounts only the exact release read-only, and
|
||||
uses both Compose `pull_policy: never` and `docker compose up --pull never`.
|
||||
No registry access, lifecycle script, database `installed_packages` row or
|
||||
custom-extension loader is involved.
|
||||
|
||||
The runner pins the exact Engine service topology observed in source and
|
||||
rejects an added worker/webhook generation. Only the single actual `n8n`
|
||||
service is force-recreated with `--no-deps`; the
|
||||
Postgres service, `.n8n` data, encryption key and credentials remain intact.
|
||||
The apply gate verifies readiness, the running image/version, sealed mount,
|
||||
loader environment, package-loader node/credential sets, scoped loader logs,
|
||||
restart stability and content-exact pinned Engine MCP catalogs. The runner pins
|
||||
the complete 434/385 inactive baseline and a digest registry for every
|
||||
reviewed 437/388 active release, so a same-count substitution of any built-in
|
||||
or private schema is rejected. Upgrade `0.1.2 -> 0.1.3` is accepted only when
|
||||
the verified live predecessor, descriptor, package mount and catalog all agree.
|
||||
Any gate failure after
|
||||
mutation automatically restores the pre-apply catalogs/descriptor and
|
||||
force-recreates the previous verified runtime. Staged, sealed and failed
|
||||
releases are retained. The paired rollback artifact returns `0.1.3` to the
|
||||
verified immutable `0.1.2` release and its exact catalogs; it does not invent
|
||||
an inactive baseline for an already-active upgrade.
|
||||
|
||||
Run both policy suites before publishing the Engine pair:
|
||||
|
||||
```bash
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
python3 infra/deploy-runner/test_n8n_private_extension.py
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
python3 infra/deploy-runner/test_engine_n8n_private_extension.py
|
||||
```
|
||||
|
||||
The source-less Engine MCP control-plane update is a separate exact slice:
|
||||
|
||||
```bash
|
||||
node infra/deploy-runner/build-engine-mcp-control-plane-artifact.mjs 20260717-001
|
||||
```
|
||||
|
||||
Its six-entry registry contains only the Engine Agent gateway, verified graph
|
||||
patch route, Codex installer source/package and the active node-intelligence
|
||||
descriptor with the new gateway digest. It force-recreates only the existing
|
||||
`nodedc-backend`. The node-intelligence image/service, n8n, L1, credentials,
|
||||
databases and volumes are outside the slice. The runner proves the installed
|
||||
predecessor digests, exact installer archive/source equality, bounded change
|
||||
session policy, no-effect/post-write graph barriers, active immutable backend
|
||||
runtime and descriptor equality. The ordinary overlay backup is the automatic
|
||||
rollback source; rollback restores the predecessor gateway and descriptor
|
||||
together before recreating the same backend service.
|
||||
|
||||
The successor autonomy/provider-v5 slice keeps MCP authority explicit without
|
||||
turning every write into a second permission dialogue. MCP tool availability is
|
||||
the capability boundary, while the user's current objective is the intent
|
||||
boundary; Engine L2 may act autonomously only inside their intersection. A
|
||||
graph `plan` remains a mandatory machine barrier whose target, diff, revision
|
||||
and blockers are inspected by the agent. It is not a repeated approval prompt
|
||||
after an implementation objective is already authorized. Retries must add new
|
||||
evidence or change the attempted variant, and three identical failures without
|
||||
new evidence or state change are a critical stop. The slice also advances the
|
||||
installer to `0.1.6` and adds `gelios.provider.v5 ->
|
||||
fleet.positions.current.v4` beside the immutable v4/v3 rollback line:
|
||||
|
||||
```bash
|
||||
node infra/deploy-runner/build-engine-mcp-autonomy-provider-v5-artifact.mjs \
|
||||
20260720-004
|
||||
```
|
||||
|
||||
Its seven-entry allowlist recreates only `nodedc-backend`; n8n, L1, databases,
|
||||
credential values and the node-intelligence image remain outside the change.
|
||||
The runner accepts both the exact target and the exact predecessor after an
|
||||
automatic rollback, and rejects every mixed state.
|
||||
|
||||
For `platform` artifacts, the allowlist includes the versioned Ontology Core,
|
||||
the frozen legacy Gelios compatibility service, and the provider-neutral
|
||||
External Data Plane sources. The Gelios service remains reproducible only to
|
||||
protect its existing database/workflow; it is not a template for a provider
|
||||
integration. An External Data Plane artifact builds only its image and
|
||||
force-recreates only `external-data-plane`; the already healthy
|
||||
`external-data-plane-postgres` container and its Timescale volume are an
|
||||
independent deploy prerequisite and are never selected by an EDP application
|
||||
artifact. The reviewed Compose source pins the Timescale image, named volume
|
||||
and target, internal database-only network, absence of database host ports,
|
||||
healthy dependency, localhost-only EDP bind and the three read-only
|
||||
provisioner/trust mounts in the reviewed Compose source. The runner does not
|
||||
reinterpret version-dependent `docker compose config` JSON as a second deploy
|
||||
schema. Its canonical enforcement remains the artifact/path allowlist plus
|
||||
hard-coded build command, selected service set, runtime-secret preparation and
|
||||
health acceptance. Post-apply acceptance also requires
|
||||
`database=ready`; the managed Foundry slice additionally requires
|
||||
`foundryReaderBindingProvisioning=digest+server-resolved-source`. A first-rollout failure removes only the candidate EDP
|
||||
container without volumes and restores the source overlay. It never contains a
|
||||
provider credential, provider endpoint,
|
||||
collection schedule or command
|
||||
transport. Its contract payload is an exact provider-neutral runtime subset;
|
||||
`providers/*`, mappings, fixtures and tests are excluded and do not trigger an
|
||||
EDP rebuild. Database credentials remain root-owned live `.env.synology`
|
||||
configuration and must not reuse `NODEDC_INTERNAL_ACCESS_TOKEN`.
|
||||
|
||||
On the first relevant Platform apply, the root-owned runner creates
|
||||
`/volume1/docker/nodedc-platform/secrets/external-data-plane-provisioner/token`
|
||||
atomically in a dedicated UID/GID `11006` directory (directory `0500`, token
|
||||
`0400`). It is never an `.env` value or an artifact member and is mounted
|
||||
read-only only into External Data Plane.
|
||||
|
||||
Manual one-time binding issuance and digest-only managed writer ensure are
|
||||
independently disabled by default. The target control-plane operation generates
|
||||
and stores the capability inside native NDC L2 Credentials and sends only its
|
||||
digest to EDP; users and MCP consumers receive only an opaque compatible
|
||||
reference/status. The runner-owned bearer above authenticates only legacy
|
||||
plaintext issuance and is intentionally rejected by managed ensure/revoke.
|
||||
Managed requests use a deployment Ed25519 Engine service key; EDP mounts only
|
||||
the public-key trust directory read-only. On every reviewed Engine apply and on
|
||||
an EDP runtime apply, this runner creates or validates one matching Ed25519 pair:
|
||||
the Engine-only private key is `root:root 0400`, while the EDP trust copy is
|
||||
`root:11006 0440`. Public-only crash state, key mismatch, a non-Ed25519 key,
|
||||
symlinks and permissive modes fail closed. `plan` discloses both paths without
|
||||
printing key material. The private key must not be broadened into an L2 graph,
|
||||
MCP surface, artifact or shared-token boundary.
|
||||
|
||||
Module Foundry has a separate Ed25519 managed-provisioner identity for
|
||||
target-scoped Data Product consumer grants. On a relevant `platform` or
|
||||
`module-foundry` apply, the runner creates or validates the Foundry-only private
|
||||
key at
|
||||
`/volume1/docker/nodedc-platform/secrets/foundry-edp-managed-provisioner/private-key.pem`
|
||||
as `root:root 0400` and the matching EDP trust copy at
|
||||
`/volume1/docker/nodedc-platform/trust/foundry-managed-provisioner/public-key.pem`
|
||||
as `root:11006 0440`. Foundry generates the opaque reader token only inside its
|
||||
persistent private runtime; its signed EDP request contains only a SHA-256
|
||||
digest and Data Product id. EDP resolves the unique active writer source scope
|
||||
server-side and fails closed on missing or ambiguous coverage. No provider,
|
||||
tenant, connection, token, private key or endpoint is admitted to the Foundry
|
||||
MCP plan, application state or browser response. The Engine signing identity,
|
||||
native n8n credentials and legacy issuance bearer cannot call this endpoint.
|
||||
|
||||
The reviewed Engine source candidate has dedicated server-derived MCP
|
||||
plan/apply handling for the exact `ndcDataProductWriterApi` + Data Product
|
||||
Publish tuple. It is separate from the generic HTTP safe-ref path and accepts no
|
||||
caller-provided provider/scope/credential identity, capability, generation or
|
||||
service URL. The production managed flag remains false during staging. In the
|
||||
explicitly confirmed activation window it is set to true immediately before
|
||||
the Platform EDP artifact; runner acceptance then requires EDP `/healthz` to
|
||||
report managed provisioning `enabled`. At that point the old Engine still has
|
||||
no signer mount, so the endpoint remains usable only after the separately
|
||||
accepted Engine artifact. Root/UI transfer is emergency
|
||||
self-hosted diagnostics only, not the user journey or acceptance
|
||||
path. A provider-specific daemon remains forbidden.
|
||||
|
||||
Build the narrow Engine source artifact with:
|
||||
|
||||
```bash
|
||||
node infra/deploy-runner/build-engine-data-product-publish-grant-artifact.mjs \
|
||||
engine-data-product-publish-grant-YYYYMMDD-NNN
|
||||
```
|
||||
|
||||
The builder includes exactly the pinned provider-security catalog, the
|
||||
Publish-grant service, Engine Agent scope/gateway wiring, the existing n8n
|
||||
adapter and the reviewed additive backend runtime overlay. It deliberately
|
||||
excludes base Compose, frontend/dist, runtime data, tests, native credentials,
|
||||
the NDC L2 process and the legacy generic credential-sink route/core. The
|
||||
runner fixes the runtime action to `nodedc-backend` with `--no-deps` and
|
||||
`--pull never`; n8n, nginx app, database services and volumes are not selected.
|
||||
Acceptance requires backend health, the active immutable backend identity and
|
||||
the exact additive mount inventory. Any failure restores the touched source and
|
||||
recreates only the previous verified backend runtime.
|
||||
|
||||
Existing Engine Agents created before Publish-grant support store the original
|
||||
nine scopes as the complete developer profile. They must not require a new
|
||||
agent, setup command or device credential when the profile gains a server-owned
|
||||
capability. Build the compatibility artifact that introduces the durable named
|
||||
`full-developer` profile with:
|
||||
|
||||
```bash
|
||||
node infra/deploy-runner/build-engine-agent-full-grant-migration-artifact.mjs \
|
||||
engine-agent-full-grant-migration-YYYYMMDD-NNN
|
||||
```
|
||||
|
||||
This follow-up slice contains exactly
|
||||
`nodedc-source/server/engineAgents/store.js`. The runner pins its predecessor to
|
||||
the successfully applied Publish generation, requires the installed Publish
|
||||
overlay and active immutable backend, recreates only `nodedc-backend`, and
|
||||
proves the exact candidate SHA, named profile and current expanded runtime
|
||||
scope view. Store schema v1 is migrated atomically to v2 only when a grant
|
||||
contains the complete legacy nine-scope developer bundle. From then on the
|
||||
profile name is the authorization authority and its scope list is derived on
|
||||
every read, so capabilities deliberately added to `full-developer` immediately
|
||||
apply to existing full grants without token or store migrations. Partial grants
|
||||
become `custom` and remain exact; they are never elevated. The artifact does not
|
||||
touch UI, n8n, credentials, agent tokens, workflow graphs, databases or runtime
|
||||
payloads.
|
||||
|
||||
## Engine L2 node-intelligence transition
|
||||
|
||||
The node-intelligence transition is an additive Engine-owned deployment domain.
|
||||
It does not merge Ontology, provider APIs or Ops into the Engine MCP. It pins the
|
||||
reviewed upstream `n8n-mcp` implementation at package `2.33.2`, commit
|
||||
`974a9fb3492fe2c4984ee0549085d531cdc6242a`, and exposes only the safe NDC L2
|
||||
projection through the existing Engine Agent gateway. Upstream management and
|
||||
write tools are not forwarded.
|
||||
|
||||
Production never clones, pulls, installs or builds this dependency. The builder
|
||||
saves the reviewed `linux/amd64` image once, embeds that exact archive in a
|
||||
data-only activation artifact and records both the archive and image-config
|
||||
digests. The runner validates every inner blob, revision label, entrypoint,
|
||||
command and platform before an offline `docker image load`; Compose then uses
|
||||
the fixed tag with `pull_policy: never` and `--pull never`.
|
||||
|
||||
Build a fresh activation/rollback pair:
|
||||
|
||||
```bash
|
||||
node infra/deploy-runner/build-engine-node-intelligence-artifacts.mjs \
|
||||
YYYYMMDD-NNN
|
||||
```
|
||||
|
||||
The activation exact set is:
|
||||
|
||||
- `nodedc-source/server/nodeIntelligence`
|
||||
- `nodedc-source/server/routes/engineAgentGateway.js`
|
||||
- `nodedc-source/services/node-intelligence`
|
||||
|
||||
The runtime adds only `nodedc-node-intelligence` and recreates the existing
|
||||
`nodedc-backend`; n8n, UI, databases, L1, provider services and volumes are not
|
||||
selected. The sidecar has no host port, runs as `11007:11007`, has a read-only
|
||||
root filesystem, drops all capabilities and receives no Engine or provider
|
||||
credential. Its independent MCP bearer is created by the root-owned runner as a
|
||||
read-only file and mounted only into the sidecar and backend. It never appears
|
||||
in an artifact, shared environment file, log or MCP response.
|
||||
|
||||
Acceptance requires the exact image/container/mount/security inventory,
|
||||
immutable backend identity and live authenticated `get_node`, `validate_node`
|
||||
and `validate_workflow` calls. Any apply failure restores source and runtime
|
||||
automatically. The separate rollback artifact first removes only the sidecar
|
||||
without volumes, restores the pinned inactive gateway, and recreates only the
|
||||
verified backend. Loaded images and failed candidate source are retained for
|
||||
audit rather than destructively deleted.
|
||||
|
||||
Stage the reviewed runner under a unique candidate name first:
|
||||
|
||||
```text
|
||||
/volume1/docker/nodedc-deploy/runner-install/candidates/
|
||||
nodedc-deploy.engine-node-intelligence-YYYYMMDD-NNN
|
||||
```
|
||||
|
||||
Do not overwrite the canonical staging candidate while another deploy may be in
|
||||
flight. After the no-lock/no-process gate, promote the exact candidate in a
|
||||
standalone root step, run `verify-install`, then invoke a fresh process for
|
||||
activation `plan` and only then `apply`. The generated plan/apply runbook carries
|
||||
the runner, activation and rollback SHA-256 values and is staged beside the
|
||||
unique runner candidate.
|
||||
|
||||
`module-foundry` is an independent, authenticated application component. Its
|
||||
artifact contains source and compose infrastructure only; its live
|
||||
`/volume1/docker/nodedc-platform/module-foundry/source/.env` is root-owned and
|
||||
never enters an artifact. The component reuses the existing internal platform
|
||||
credential for Launcher handoff validation and requires that runtime
|
||||
configuration before its first `apply`.
|
||||
|
||||
When the managed reader-grant source is present, Module Foundry acceptance also
|
||||
requires `/healthz` to report the dedicated Ed25519 provisioner as configured.
|
||||
The check is source-aware: if an apply rolls back to the prior source, rollback
|
||||
acceptance uses that prior health contract instead of falsely requiring a
|
||||
feature which the restored generation does not contain.
|
||||
|
||||
The Foundry ↔ Map Gateway signing key is not an application or `.env` setting.
|
||||
On the first relevant `platform` or `module-foundry` apply, the root-owned
|
||||
runner creates `/volume1/docker/nodedc-platform/secrets/map-gateway-admin-secret`
|
||||
atomically (root:gid 1000, mode `0640`). Both containers receive that file only
|
||||
as a read-only mount. The value is never printed, backed up with source,
|
||||
included in an artifact, or administered through Foundry.
|
||||
|
||||
`proxy-contur` is the canonical VPN egress for selected Map Gateway provider
|
||||
hosts. Its existing root-owned `PROXY_TOKEN` is copied by the runner into
|
||||
`/volume1/docker/nodedc-platform/secrets/map-egress-proxy-token` with
|
||||
`root:gid 1000`, mode `0640`, then mounted read-only only into Map Gateway.
|
||||
The value is neither printed nor contained in an artifact, Foundry setting, or
|
||||
browser response. Apply the `proxy-contur` artifact before the Platform Map
|
||||
Gateway artifact: it creates the private `nodedc-map-egress` Docker network.
|
||||
|
||||
`dc-amd-proxy` is the separate, staged connector for the neighbouring AMD VPN
|
||||
machine. Its active artifact attaches only to the private `nodedc-map-egress`
|
||||
network, exposes a narrow NAS-LAN pairing port, and has no direct provider
|
||||
egress. The runner preserves a `0700`, service-user-owned runtime directory
|
||||
for the one-time paired connector credential and synchronizes the existing
|
||||
private Map Gateway egress credential as a read-only file. Neither value is
|
||||
ever placed in an artifact, `.env`, browser response, or runner output. The
|
||||
separate Platform switch is applied only after the connector and pairing are
|
||||
verified; it does not alter NAS routes, VPN, DNS, or Tailscale.
|
||||
|
||||
Install or update the root-owned live runner on Synology:
|
||||
|
||||
```bash
|
||||
# First verify that no deploy process is active and state/deploy.lock is absent.
|
||||
sudo install -o root -g root -m 0755 \
|
||||
/volume1/docker/nodedc-deploy/runner-install/nodedc-deploy \
|
||||
/usr/local/sbin/nodedc-deploy
|
||||
sudo /usr/local/sbin/nodedc-deploy verify-install
|
||||
```
|
||||
|
||||
Runner promotion is a standalone admin step, never an app-overlay artifact and
|
||||
never part of a running apply. A Python process already executing the old file
|
||||
keeps the old code in memory; always invoke a fresh verified process afterward.
|
||||
|
||||
Normal service deploys must still use explicit artifacts:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/nodedc-deploy plan /volume1/docker/nodedc-deploy/inbox/<artifact>.tgz
|
||||
sudo /usr/local/sbin/nodedc-deploy apply /volume1/docker/nodedc-deploy/inbox/<artifact>.tgz
|
||||
```
|
||||
|
||||
## Device Plane foundation
|
||||
|
||||
`device-plane` is an additive component rooted at:
|
||||
|
||||
```text
|
||||
/volume1/docker/nodedc-device-plane
|
||||
```
|
||||
|
||||
Its fixed Compose project is `nodedc-device-plane`. Ordinary application
|
||||
artifacts may select only `device-control-core` and `device-gateway`, always
|
||||
with `--no-deps`. `device-postgres` and the named
|
||||
`nodedc-device-plane-postgres-data` volume are durable prerequisites and are
|
||||
never selected or recreated by an application overlay.
|
||||
|
||||
The sole exception is the exact one-time bootstrap artifact containing only
|
||||
the reviewed Compose file and
|
||||
`deployment/device-postgres-bootstrap-v1.json`. Its preflight requires both the
|
||||
Compose database container and named volume to be absent. It selects only
|
||||
`device-postgres`; a failed activation may remove that candidate container but
|
||||
never the volume. Any pre-existing container or volume is an ambiguity and
|
||||
fails closed.
|
||||
|
||||
The runner creates or validates three root-owned secret files outside the
|
||||
artifact: the PostgreSQL password, Gateway-to-Core token and restricted
|
||||
identifier pepper. The manifest cannot choose their paths or values.
|
||||
|
||||
The foundation publishes only loopback health endpoints on `18120` and
|
||||
`18121`. Raw device ingress `9921`, discovery ingest and outbound command
|
||||
transport remain disabled. The first application artifact must not be built or
|
||||
staged until this runner candidate is separately reviewed, promoted and proven
|
||||
by a fresh `verify-install`.
|
||||
|
||||
Build and test the deterministic data-only artifact contract locally:
|
||||
|
||||
```bash
|
||||
python3 -m unittest -v \
|
||||
infra.deploy-runner.test_device_plane_registry \
|
||||
infra.deploy-runner.test_device_plane_artifact
|
||||
|
||||
node infra/deploy-runner/build-device-plane-artifact.mjs \
|
||||
device-plane-foundation-YYYYMMDD-NNN
|
||||
```
|
||||
|
||||
Any failed first activation removes only candidate Core/Gateway containers,
|
||||
never volumes, restores the source overlay and retains PostgreSQL state.
|
||||
The rollback baseline is taken from an explicit pre-apply Docker service
|
||||
inventory stored as `runtime-before.json` in the backup. The presence of the
|
||||
shared Compose file never implies that Core or Gateway existed before apply.
|
||||
|
||||
The exact `device-plane-foundation-20260725-001` failed activation is recovered
|
||||
only through the registered
|
||||
`deployment/device-plane-foundation-recovery-v1.json` transition. Its artifact
|
||||
must reproduce the failed foundation source byte-for-byte. The runner validates
|
||||
the exact failed archive, journal, backup partition, partial live source and
|
||||
healthy observed Core/Gateway/PostgreSQL generations. Recovery publishes source
|
||||
and performs read-only runtime acceptance; it does not build, recreate, restart
|
||||
or remove containers. A failed recovery restores source only and leaves runtime
|
||||
unchanged.
|
||||
|
||||
Build and test that incident-specific deterministic recovery artifact:
|
||||
|
||||
```bash
|
||||
python3 -m unittest -v \
|
||||
infra.deploy-runner.test_device_plane_foundation_recovery_artifact
|
||||
|
||||
node infra/deploy-runner/build-device-plane-foundation-recovery-artifact.mjs \
|
||||
device-plane-foundation-recovery-20260725-002
|
||||
```
|
||||
|
||||
The recovery `device-plane-foundation-recovery-20260725-002` is also terminal
|
||||
failed. Docker Engine 24 does not materialize requested host port mappings when
|
||||
the container is attached only to an `internal` network. Its registered
|
||||
successor is the exact
|
||||
`deployment/device-plane-foundation-network-publication-v1.json` transition.
|
||||
It keeps PostgreSQL exclusively on the existing internal private network and
|
||||
adds a second non-internal bridge only to Control Core and Gateway. Masquerade
|
||||
is disabled on that bridge; the only published ports remain
|
||||
`127.0.0.1:18120` and `127.0.0.1:18121`. Device TCP `9921`, public ingress,
|
||||
discovery ingest and command transport remain disabled.
|
||||
|
||||
This successor has an empty build set and force-recreates only
|
||||
`device-control-core` and `device-gateway` with `--no-deps` from their exact
|
||||
existing `pull_policy: never` images. Its predecessor barrier validates the
|
||||
terminal failed archive/journal/backup, partial source, exact running
|
||||
generations, absent actual port mappings, internal private network and absence
|
||||
of the control network. Acceptance requires new stateless container
|
||||
generations, the unchanged PostgreSQL generation and volume, exact actual
|
||||
loopback mappings, healthy HTTP contracts and a closed `9921`.
|
||||
|
||||
Rollback removes only the two stateless candidate containers and the newly
|
||||
created unused control network, restores the partial source predecessor and
|
||||
recreates the internal-only Core/Gateway runtime from the sealed predecessor
|
||||
Compose bytes. PostgreSQL and its volume are never selected.
|
||||
|
||||
Build and test the deterministic network-publication successor:
|
||||
|
||||
```bash
|
||||
python3 -m unittest -v \
|
||||
infra.deploy-runner.test_device_plane_foundation_network_publication_artifact
|
||||
|
||||
node \
|
||||
infra/deploy-runner/build-device-plane-foundation-network-publication-artifact.mjs \
|
||||
device-plane-foundation-network-publication-20260725-003
|
||||
```
|
||||
|
||||
After the runner is promoted and freshly verified, bootstrap the durable
|
||||
prerequisite with a separate artifact before planning the application:
|
||||
|
||||
```bash
|
||||
node infra/deploy-runner/build-device-plane-postgres-bootstrap-artifact.mjs \
|
||||
device-plane-postgres-bootstrap-YYYYMMDD-NNN
|
||||
```
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/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 bimRoot = resolve(process.env.NODEDC_BIM_VIEWER_ROOT || resolve(platformRoot, "../NODEDC_BIM_VIEWER"));
|
||||
const taskerRoot = resolve(
|
||||
process.env.NODEDC_TASKMANAGER_ROOT || resolve(platformRoot, "../../data/dc_taskmanager/NODEDC_TASKMANAGER"),
|
||||
);
|
||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||
const [release = "20260730-002", ...extra] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^\d{8}-\d{3}$/.test(release)) {
|
||||
throw new Error("usage: build-bim-tasker-cad-ops-artifacts.mjs [YYYYMMDD-NNN]");
|
||||
}
|
||||
|
||||
const descriptors = [
|
||||
{
|
||||
artifactBasename: `nodedc-bim-viewer-cad-ops-${release}.tgz`,
|
||||
component: "bim-viewer",
|
||||
files: [
|
||||
"converter/worker.py",
|
||||
"frontend/dcViewer.js",
|
||||
"server/cad-formats.js",
|
||||
"server/index.js",
|
||||
"server/tasker-gateway-auth.js",
|
||||
],
|
||||
patchId: `bim-viewer-cad-ops-${release}`,
|
||||
sourceRoot: bimRoot,
|
||||
},
|
||||
{
|
||||
artifactBasename: `nodedc-tasker-cad-ops-${release}.tgz`,
|
||||
component: "tasker",
|
||||
files: [
|
||||
"plane-src/apps/api/plane/app/urls/issue.py",
|
||||
"plane-src/apps/api/plane/app/views/__init__.py",
|
||||
"plane-src/apps/api/plane/app/views/issue/attachment.py",
|
||||
"plane-src/apps/api/plane/app/views/issue/bim_attachment.py",
|
||||
"plane-src/apps/api/plane/utils/nodedc_bim_gateway.py",
|
||||
"plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx",
|
||||
"plane-src/apps/web/core/services/issue/issue_attachment.service.ts",
|
||||
"plane-src/apps/web/helpers/beam-viewer-config.ts",
|
||||
"plane-src/apps/web/helpers/beam-viewer.ts",
|
||||
],
|
||||
patchId: `tasker-cad-ops-${release}`,
|
||||
sourceRoot: taskerRoot,
|
||||
},
|
||||
];
|
||||
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const artifacts = [];
|
||||
|
||||
for (const descriptor of descriptors) {
|
||||
const sourceCommit = gitOutput(descriptor.sourceRoot, ["rev-parse", "HEAD"]);
|
||||
const sourceStatus = gitOutput(descriptor.sourceRoot, ["status", "--porcelain"]);
|
||||
if (sourceStatus) throw new Error(`source_worktree_not_clean:${descriptor.component}`);
|
||||
|
||||
const stage = await mkdtemp(join(tmpdir(), `nodedc-${descriptor.component}-cad-ops-`));
|
||||
const payload = join(stage, "payload");
|
||||
const artifact = join(artifactDir, descriptor.artifactBasename);
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const relativePath of descriptor.files) {
|
||||
const source = resolve(descriptor.sourceRoot, relativePath);
|
||||
const sourceStat = await lstat(source);
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||
throw new Error(`source_file_rejected:${descriptor.component}:${relativePath}`);
|
||||
}
|
||||
const destination = join(payload, relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { force: true, verbatimSymlinks: true });
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${descriptor.patchId}\ncomponent=${descriptor.component}\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${descriptor.files.join("\n")}\n`, "utf8");
|
||||
|
||||
const tar = spawnSync("python3", ["-c", canonicalTarScript(), artifact, stage], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
});
|
||||
if (tar.status !== 0) throw new Error(`tar_failed:${descriptor.component}:${tar.stderr || tar.stdout}`);
|
||||
|
||||
const sha256 = createHash("sha256").update(await readFile(artifact)).digest("hex");
|
||||
artifacts.push({
|
||||
artifact,
|
||||
component: descriptor.component,
|
||||
files: descriptor.files,
|
||||
patchId: descriptor.patchId,
|
||||
sha256,
|
||||
sourceCommit,
|
||||
});
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, release, artifacts }, null, 2));
|
||||
|
||||
function gitOutput(cwd, args) {
|
||||
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
||||
if (result.status !== 0) throw new Error(`git_failed:${cwd}:${args.join("_")}:${result.stderr || result.stdout}`);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
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,44 @@
|
||||
#!/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 { dirname, join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const artifactDir = resolve(scriptDir, "../deploy-artifacts");
|
||||
const [patchId = "dc-amd-proxy-bootstrap-20260715-001", ...extra] = process.argv.slice(2);
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-dc-amd-proxy-artifact.mjs [patch-id]");
|
||||
|
||||
const files = [
|
||||
["services/dc-amd-proxy/Dockerfile", "Dockerfile"],
|
||||
["services/dc-amd-proxy/README.md", "README.md"],
|
||||
["services/dc-amd-proxy/docker-compose.yml", "docker-compose.yml"],
|
||||
["services/dc-amd-proxy/package.json", "package.json"],
|
||||
["services/dc-amd-proxy/server.mjs", "server.mjs"],
|
||||
];
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-dc-amd-proxy-artifact-"));
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(artifactDir, `nodedc-${patchId}.tgz`);
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const [source, destination] of files) await copySafe(resolve(platformRoot, source), join(payload, destination));
|
||||
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=dc-amd-proxy\ntype=app-overlay\n`, "utf8");
|
||||
await writeFile(join(stage, "files.txt"), `${files.map(([, destination]) => destination).join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const tar = spawnSync("python3", ["-c", "import sys,tarfile\nwith tarfile.open(sys.argv[1],'w:gz',format=tarfile.PAX_FORMAT) as a:\n [a.add(n,arcname=n,recursive=True) for n in ('manifest.env','files.txt','payload')]", target], { cwd: stage, encoding: "utf8" });
|
||||
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: createHash("sha256").update(await readFile(target)).digest("hex") }, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const info = await lstat(source);
|
||||
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`source_file_rejected:${source}`);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, { force: true });
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
#!/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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/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");
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = resolve(platformRoot, "device-plane");
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [patchId = "device-plane-postgres-bootstrap-20260725-001", ...extra] =
|
||||
process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-postgres-bootstrap-artifact.mjs [patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
"docker-compose.device-plane.yml",
|
||||
"deployment/device-postgres-bootstrap-v1.json",
|
||||
];
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-postgres-bootstrap-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertSourceBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const relativePath of files) {
|
||||
const source = resolve(sourceRoot, relativePath);
|
||||
const sourceStat = await lstat(source);
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||
throw new Error(`bootstrap_source_file_required:${relativePath}`);
|
||||
}
|
||||
const destination = join(payload, relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, {
|
||||
force: true,
|
||||
verbatimSymlinks: false,
|
||||
});
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const sha256 = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256,
|
||||
component: "device-plane",
|
||||
entries: files,
|
||||
services: ["device-postgres"],
|
||||
mode: "create-if-absent",
|
||||
rollbackVolumePolicy: "preserve",
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertSourceBoundary() {
|
||||
const descriptor = JSON.parse(
|
||||
await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-postgres-bootstrap-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.postgres-bootstrap.v1",
|
||||
service: "device-postgres",
|
||||
volume: "nodedc-device-plane-postgres-data",
|
||||
mode: "create-if-absent",
|
||||
ordinaryApplicationSelection: "forbidden",
|
||||
rollbackVolumePolicy: "preserve",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_postgres_bootstrap_descriptor_mismatch");
|
||||
}
|
||||
|
||||
const compose = await readFile(
|
||||
resolve(sourceRoot, "docker-compose.device-plane.yml"),
|
||||
"utf8",
|
||||
);
|
||||
for (const required of [
|
||||
"device-postgres:",
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
"POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password",
|
||||
"create_host_path: false",
|
||||
]) {
|
||||
if (!compose.includes(required)) {
|
||||
throw new Error(`device_plane_postgres_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
const postgresStart = compose.indexOf(" device-postgres:");
|
||||
const postgresEnd = compose.indexOf("\n device-control-core:");
|
||||
if (
|
||||
postgresStart < 0
|
||||
|| postgresEnd <= postgresStart
|
||||
|| compose.slice(postgresStart, postgresEnd).includes("\n ports:")
|
||||
) {
|
||||
throw new Error("device_plane_postgres_host_port_forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { 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 here = dirname(fileURLToPath(import.meta.url))
|
||||
const platformRoot = resolve(here, '../..')
|
||||
const workspaceRoot = resolve(platformRoot, '..')
|
||||
const engineRoot = resolve(workspaceRoot, 'NODEDC_ENGINE_INFRA')
|
||||
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts'))
|
||||
const [patchId = '', ...extra] = process.argv.slice(2)
|
||||
const storeRelativePath = 'nodedc-source/server/engineAgents/store.js'
|
||||
const predecessorSha256 = '52daa43499d6d9a97fe7ffa891edb9212b7791e733f91dd3ca686d42739b7e9a'
|
||||
const targetSha256 = '2e62654c2dc12905efcc83a9dff45a818dd7b47924a10600160835c4416540e9'
|
||||
const readerExtendedSourceSha256 = 'debd351fe0b8c72b33b8c79909b8f339cbaf061b970576f3ae72df52ebaa211f'
|
||||
const ontologyExtendedSourceSha256 = '4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285'
|
||||
const frozenTargetArtifact = join(
|
||||
workspaceRoot,
|
||||
'deploy-artifacts/nodedc-engine-agent-full-grant-migration-20260717-002.tgz',
|
||||
)
|
||||
const frozenTargetArtifactSha256 = 'd104ee6e63d1fccb9069e2b3db5e0446907ee9bf9cb04f2387636c272f803d68'
|
||||
const previouslyIssuedPatchIds = new Set([
|
||||
'engine-agent-full-grant-migration-20260717-001',
|
||||
])
|
||||
|
||||
if (extra.length || !/^engine-agent-full-grant-migration-\d{8}-\d{3}$/.test(patchId)) {
|
||||
throw new Error('usage: build-engine-agent-full-grant-migration-artifact.mjs <fresh-patch-id>')
|
||||
}
|
||||
if (previouslyIssuedPatchIds.has(patchId)) {
|
||||
throw new Error('engine_agent_full_grant_migration_patch_id_already_issued')
|
||||
}
|
||||
|
||||
const source = join(engineRoot, storeRelativePath)
|
||||
const sourceInfo = await lstat(source)
|
||||
if (sourceInfo.isSymbolicLink() || !sourceInfo.isFile()) throw new Error('engine_agent_store_source_unsafe')
|
||||
const sourceBytes = await materializeFrozenTarget(await readFile(source))
|
||||
if (digest(sourceBytes) !== targetSha256) throw new Error('engine_agent_store_target_sha256_mismatch')
|
||||
const sourceText = sourceBytes.toString('utf8')
|
||||
for (const required of [
|
||||
'const STORE_VERSION = 2',
|
||||
"export const ENGINE_AGENT_FULL_DEVELOPER_PROFILE = 'full-developer'",
|
||||
"export const ENGINE_AGENT_CUSTOM_PROFILE = 'custom'",
|
||||
'const LEGACY_FULL_DEVELOPER_SCOPES = Object.freeze([',
|
||||
"'engine:l2:data-product-publish-grant:plan'",
|
||||
"'engine:l2:data-product-publish-grant:write'",
|
||||
'const migrateLegacyFullDeveloper = sourceVersion === 1',
|
||||
'LEGACY_FULL_DEVELOPER_SCOPES.every((scope) => scopes.includes(scope))',
|
||||
'profile === ENGINE_AGENT_FULL_DEVELOPER_PROFILE ? [...ENGINE_AGENT_SCOPES] : scopes',
|
||||
"throw new Error('engine_agent_store_version_unsupported')",
|
||||
]) {
|
||||
if (!sourceText.includes(required)) throw new Error(`engine_agent_store_contract_missing:${required}`)
|
||||
}
|
||||
if (/gelios|robot2b/i.test(sourceText)) throw new Error('engine_agent_store_provider_logic_forbidden')
|
||||
run('node', ['--check', source])
|
||||
|
||||
await mkdir(artifactRoot, { recursive: true })
|
||||
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`)
|
||||
await assertFresh(artifact)
|
||||
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-agent-grant-migration-'))
|
||||
try {
|
||||
const destination = join(stage, 'payload', storeRelativePath)
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
await writeFile(destination, sourceBytes)
|
||||
await writeFile(
|
||||
join(stage, 'manifest.env'),
|
||||
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
|
||||
'utf8',
|
||||
)
|
||||
await writeFile(join(stage, 'files.txt'), `${storeRelativePath}\n`, 'utf8')
|
||||
run('python3', ['-c', canonicalTarScript(), artifact, stage])
|
||||
const artifactBytes = await readFile(artifact)
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact,
|
||||
sha256: digest(artifactBytes),
|
||||
entries: [storeRelativePath],
|
||||
predecessorSha256,
|
||||
targetSha256,
|
||||
services: ['nodedc-backend'],
|
||||
excluded: ['n8n', 'app', 'databases', 'credentials', 'runtime-data'],
|
||||
}, null, 2))
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
async function assertFresh(target) {
|
||||
try {
|
||||
await lstat(target)
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return
|
||||
throw error
|
||||
}
|
||||
throw new Error('engine_agent_full_grant_migration_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 run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
})
|
||||
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
|
||||
return result
|
||||
}
|
||||
|
||||
function digest(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
async function materializeFrozenTarget(bytes) {
|
||||
const sourceSha256 = digest(bytes)
|
||||
if (sourceSha256 === targetSha256) return bytes
|
||||
if (sourceSha256 === ontologyExtendedSourceSha256) {
|
||||
const artifactBytes = await readFile(frozenTargetArtifact)
|
||||
if (digest(artifactBytes) !== frozenTargetArtifactSha256) {
|
||||
throw new Error('engine_agent_store_frozen_target_artifact_sha256_mismatch')
|
||||
}
|
||||
const script = [
|
||||
'import pathlib,sys,tarfile',
|
||||
'p=pathlib.Path(sys.argv[1])',
|
||||
"with tarfile.open(p,'r:gz') as t:",
|
||||
" f=t.extractfile('payload/nodedc-source/server/engineAgents/store.js')",
|
||||
" if f is None: raise SystemExit('member-unreadable')",
|
||||
' sys.stdout.buffer.write(f.read())',
|
||||
].join('\n')
|
||||
const frozen = Buffer.from(run('python3', ['-c', script, frozenTargetArtifact]).stdout, 'utf8')
|
||||
if (digest(frozen) !== targetSha256) {
|
||||
throw new Error('engine_agent_store_frozen_target_sha256_mismatch')
|
||||
}
|
||||
return frozen
|
||||
}
|
||||
if (sourceSha256 !== readerExtendedSourceSha256) {
|
||||
throw new Error('engine_agent_store_target_sha256_mismatch')
|
||||
}
|
||||
const text = bytes.toString('utf8')
|
||||
const readerScopes = [
|
||||
" 'engine:l2:data-product-read-grant:plan',\n",
|
||||
" 'engine:l2:data-product-read-grant:write',\n",
|
||||
]
|
||||
let frozen = text
|
||||
for (const scope of readerScopes) {
|
||||
if (!frozen.includes(scope)) throw new Error('engine_agent_store_reader_scope_boundary_missing')
|
||||
frozen = frozen.replace(scope, '')
|
||||
}
|
||||
return Buffer.from(frozen, 'utf8')
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/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 here = dirname(fileURLToPath(import.meta.url))
|
||||
const platformRoot = resolve(here, '../..')
|
||||
const workspaceRoot = resolve(platformRoot, '..')
|
||||
const engineRoot = resolve(workspaceRoot, 'NODEDC_ENGINE_INFRA')
|
||||
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts'))
|
||||
const predecessorArtifact = resolve(
|
||||
process.env.NODEDC_ENGINE_AUTH_RECOVERY_PREDECESSOR
|
||||
|| '/Volumes/docker/nodedc-deploy/applied/nodedc-engine-restart-safe-auth-20260719-001.tgz',
|
||||
)
|
||||
const [patchId = '', ...extra] = process.argv.slice(2)
|
||||
|
||||
if (extra.length || !/^engine-auth-redirect-recovery-\d{8}-\d{3}$/.test(patchId)) {
|
||||
throw new Error('usage: build-engine-auth-redirect-recovery-artifact.mjs engine-auth-redirect-recovery-YYYYMMDD-NNN')
|
||||
}
|
||||
|
||||
const predecessorArtifactSha256 = 'f6830046d7bd2bc32c1dbda8dce7ddf68d15c1e728f594c0f64ee8feec3911b8'
|
||||
const predecessorManifest = 'id=engine-restart-safe-auth-20260719-001\ncomponent=engine\ntype=app-overlay\n'
|
||||
const predecessorSha256 = Object.freeze({
|
||||
'nodedc-source/src/platform/auth/access.ts': '9610dc1622a1c9b383eb3a1569347098002aa058dbbcbb23f096f2622d452222',
|
||||
'nodedc-source/src/platform/auth/sessionRecovery.ts': 'f931cbb45d64d5d6a2868194e8c73879a408dffca1fdd7e41acfa262441c50d7',
|
||||
'nodedc-source/dist/index.html': 'b031f6720683f6fb5ccfa59a01414ff2a3efcdf548b1c6aab33e1748ee3f003d',
|
||||
})
|
||||
const entries = Object.freeze([
|
||||
'nodedc-source/src/platform/auth/access.ts',
|
||||
'nodedc-source/src/platform/auth/sessionRecovery.ts',
|
||||
'nodedc-source/dist/index.html',
|
||||
'nodedc-source/dist/assets/index-DJ1CMfu4.js',
|
||||
])
|
||||
const candidateSha256 = Object.freeze({
|
||||
'nodedc-source/src/platform/auth/access.ts': 'c686db2568912a093ea8934e34889254ddd659594f7084025c280732ef627002',
|
||||
'nodedc-source/src/platform/auth/sessionRecovery.ts': '63f3e2379628dea3b119c4283bc0c3bb10e94199c673b9d9c579bac3c54be98f',
|
||||
'nodedc-source/dist/index.html': '8894f62ad590168862e81266bc4602410279634b666af72cb14ad80aeb71ea74',
|
||||
'nodedc-source/dist/assets/index-DJ1CMfu4.js': 'a0aaf72d2ed390142ff2ea03dbcfdd534637e5faefd80d8aa7d705a804abe065',
|
||||
})
|
||||
|
||||
const predecessorInfo = await lstat(predecessorArtifact)
|
||||
if (predecessorInfo.isSymbolicLink() || !predecessorInfo.isFile()) {
|
||||
throw new Error('engine_auth_recovery_predecessor_unsafe')
|
||||
}
|
||||
if (digest(await readFile(predecessorArtifact)) !== predecessorArtifactSha256) {
|
||||
throw new Error('engine_auth_recovery_predecessor_archive_mismatch')
|
||||
}
|
||||
run('python3', [
|
||||
'-c', verifyPredecessorScript(), predecessorArtifact,
|
||||
predecessorArtifactSha256, predecessorManifest, JSON.stringify(predecessorSha256),
|
||||
])
|
||||
|
||||
for (const rel of entries) {
|
||||
const source = resolve(engineRoot, rel)
|
||||
const info = await lstat(source)
|
||||
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`engine_auth_recovery_candidate_unsafe:${rel}`)
|
||||
if (digest(await readFile(source)) !== candidateSha256[rel]) {
|
||||
throw new Error(`engine_auth_recovery_candidate_sha256_mismatch:${rel}`)
|
||||
}
|
||||
}
|
||||
const indexHtml = await readFile(resolve(engineRoot, 'nodedc-source/dist/index.html'), 'utf8')
|
||||
if (!indexHtml.includes('/assets/index-DJ1CMfu4.js') || !indexHtml.includes('/assets/index-Bim2pv1P.css')) {
|
||||
throw new Error('engine_auth_recovery_dist_entrypoint_mismatch')
|
||||
}
|
||||
|
||||
await mkdir(artifactRoot, { recursive: true })
|
||||
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`)
|
||||
await assertArtifactTargetFresh(artifact)
|
||||
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-auth-redirect-recovery-'))
|
||||
|
||||
try {
|
||||
await writeFile(
|
||||
join(stage, 'manifest.env'),
|
||||
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
|
||||
'utf8',
|
||||
)
|
||||
await writeFile(join(stage, 'files.txt'), `${entries.join('\n')}\n`, 'utf8')
|
||||
for (const rel of entries) {
|
||||
const target = join(stage, 'payload', rel)
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await copyFile(resolve(engineRoot, rel), target)
|
||||
}
|
||||
run('python3', ['-c', canonicalTarScript(), artifact, stage])
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact,
|
||||
sha256: digest(await readFile(artifact)),
|
||||
predecessorArtifact,
|
||||
predecessorArtifactSha256,
|
||||
predecessorSha256,
|
||||
candidateSha256,
|
||||
entries,
|
||||
runtimeServices: ['nodedc-backend', 'app'],
|
||||
}, null, 2))
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function verifyPredecessorScript() {
|
||||
return [
|
||||
'import hashlib,json,pathlib,sys,tarfile',
|
||||
'source=pathlib.Path(sys.argv[1]); expected_archive=sys.argv[2]',
|
||||
'expected_manifest=sys.argv[3].encode(); hashes=json.loads(sys.argv[4])',
|
||||
"assert hashlib.sha256(source.read_bytes()).hexdigest()==expected_archive, 'archive-sha256'",
|
||||
"with tarfile.open(source,'r:gz') as tar:",
|
||||
" assert tar.extractfile('manifest.env').read()==expected_manifest, 'manifest'",
|
||||
' for rel,wanted in hashes.items():',
|
||||
" data=tar.extractfile('payload/'+rel).read()",
|
||||
" assert hashlib.sha256(data).hexdigest()==wanted, 'payload:'+rel",
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function assertArtifactTargetFresh(target) {
|
||||
try {
|
||||
await lstat(target)
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return
|
||||
throw error
|
||||
}
|
||||
throw new Error('engine_auth_recovery_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 run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
})
|
||||
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
|
||||
}
|
||||
|
||||
function digest(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/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 here = dirname(fileURLToPath(import.meta.url));
|
||||
const workspaceRoot = resolve(here, "../../..");
|
||||
const engineRoot = resolve(
|
||||
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
|
||||
);
|
||||
const artifactRoot = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
|
||||
);
|
||||
const [patchId = "", ...extra] = process.argv.slice(2);
|
||||
if (extra.length || !/^engine-composite-provider-v4-\d{8}-\d{3}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-engine-composite-provider-v4-artifact.mjs " +
|
||||
"<engine-composite-provider-v4-YYYYMMDD-NNN>",
|
||||
);
|
||||
}
|
||||
|
||||
const targetSha256 = Object.freeze({
|
||||
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
|
||||
"9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a",
|
||||
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js":
|
||||
"689c6fbf695e582d983159973a21142787d1b19bb1d343da4c62c03092ac291f",
|
||||
"nodedc-source/server/dataProductPublishGrant/service.js":
|
||||
"83f045f4e0f332644310172ed51bb652d808bf04155b11c02e46bdffc95f7220",
|
||||
"nodedc-source/server/dataProductPublishGrant/store.js":
|
||||
"98662da6acd0489a9cae4b726eb61ce89d9b2c788b2ea95433e9c4f02930c095",
|
||||
});
|
||||
const entries = Object.freeze(Object.keys(targetSha256));
|
||||
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
|
||||
|
||||
await assertFresh(artifact);
|
||||
await assertExactSources();
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-composite-provider-v4-"));
|
||||
try {
|
||||
const payload = join(stage, "payload");
|
||||
for (const relativePath of entries) {
|
||||
const source = join(engineRoot, relativePath);
|
||||
const destination = join(payload, relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await copyFile(source, destination, 0);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
|
||||
{ encoding: "utf8", flag: "wx", mode: 0o644 },
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
mode: 0o644,
|
||||
});
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact,
|
||||
sha256: digest(await readFile(artifact)),
|
||||
entries,
|
||||
targetSha256,
|
||||
services: ["nodedc-backend"],
|
||||
transition: "exact-v3-to-v4",
|
||||
providerPackage: "gelios.provider.v4",
|
||||
capabilities: [
|
||||
"gelios.monitoring_config.current.read",
|
||||
"gelios.units.current.read",
|
||||
],
|
||||
dataProductId: "fleet.positions.current.v3",
|
||||
credentialValues: "preserved",
|
||||
untouched: ["n8n", "L1 graph", "Engine UI", "databases"],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertExactSources() {
|
||||
for (const [relativePath, expected] of Object.entries(targetSha256)) {
|
||||
const path = join(engineRoot, relativePath);
|
||||
const info = await lstat(path);
|
||||
if (!info.isFile() || info.isSymbolicLink()) {
|
||||
throw new Error(`engine_composite_provider_source_unsafe:${relativePath}`);
|
||||
}
|
||||
const actual = digest(await readFile(path));
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`engine_composite_provider_target_mismatch:${relativePath}:` +
|
||||
`expected=${expected}:actual=${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const catalog = JSON.parse(await readFile(join(engineRoot, entries[0]), "utf8"));
|
||||
const provider = catalog?.packages?.[0];
|
||||
const capabilityIds = provider?.capabilities?.map((item) => item.id);
|
||||
const requestUrls = provider?.capabilities?.map((item) => item.request?.url);
|
||||
if (
|
||||
catalog?.schemaVersion !== "nodedc.engine.provider-security-catalog/v1" ||
|
||||
provider?.id !== "gelios.provider.v4" ||
|
||||
provider?.version !== "4.0.0" ||
|
||||
provider?.providerCredential?.credentialType !== "httpBearerAuth" ||
|
||||
JSON.stringify(capabilityIds) !== JSON.stringify([
|
||||
"gelios.monitoring_config.current.read",
|
||||
"gelios.units.current.read",
|
||||
]) ||
|
||||
JSON.stringify(requestUrls) !== JSON.stringify([
|
||||
"https://api.geliospro.com/api/v1/users/me/monitoring-config",
|
||||
"https://api.geliospro.com/api/v1/units?incltrip=true",
|
||||
]) ||
|
||||
provider?.capabilities?.some(
|
||||
(item) => item.dataProductIds?.length !== 1 ||
|
||||
item.dataProductIds[0] !== "fleet.positions.current.v3",
|
||||
)
|
||||
) {
|
||||
throw new Error("engine_composite_provider_catalog_projection_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
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],'xb') 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: 128 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { 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 here = dirname(fileURLToPath(import.meta.url))
|
||||
const platformRoot = resolve(here, '../..')
|
||||
const workspaceRoot = resolve(platformRoot, '..')
|
||||
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts'))
|
||||
const sourceArtifact = resolve(
|
||||
process.env.NODEDC_ENGINE_CREDENTIAL_SINK_RECOVERY_SOURCE
|
||||
|| '/Volumes/docker/nodedc-deploy/applied/nodedc-engine-credential-sink-20260716-001.tgz',
|
||||
)
|
||||
const [patchId = '', ...extra] = process.argv.slice(2)
|
||||
|
||||
if (extra.length || !/^engine-credential-sink-recovery-\d{8}-\d{3}$/.test(patchId)) {
|
||||
throw new Error('usage: build-engine-credential-sink-recovery-artifact.mjs <fresh-recovery-patch-id>')
|
||||
}
|
||||
|
||||
const sourceArtifactSha256 = '0a96add05fe59db8f490927f66e07a84490474a7afb3ef7de51e1d6fd96f86a2'
|
||||
const sourceManifest = 'id=engine-credential-sink-20260716-001\ncomponent=engine\ntype=app-overlay\n'
|
||||
const entries = Object.freeze([
|
||||
'nodedc-source/server/credentialPolicies/ndcPrivateNode.js',
|
||||
'nodedc-source/server/credentialSink',
|
||||
'nodedc-source/server/index.js',
|
||||
'nodedc-source/server/routes/engineAgentGateway.js',
|
||||
'nodedc-source/server/routes/engineCredentialSink.js',
|
||||
'nodedc-source/server/routes/n8n.js',
|
||||
'nodedc-source/server/routes/ndcAgentMcp.js',
|
||||
'nodedc-source/services/backend/credential-sink/docker-compose.immutable-runtime.yml',
|
||||
])
|
||||
const payloadSha256 = Object.freeze({
|
||||
'nodedc-source/server/credentialPolicies/ndcPrivateNode.js': '723874a02dc7b8a68b22ff2304431cfe64f28933cedf5f1e6cda79b2e1cf704a',
|
||||
'nodedc-source/server/credentialSink/core.js': '9f0facc41fd398fcd955cffdd486abb126cdcd756c87ffde667fcbe00e2c41d3',
|
||||
'nodedc-source/server/credentialSink/requestAuth.js': 'f8c9237c3e6f4219dee0d4f956d6e97f76f5bd7ba8a6fd0b4fb21aceffa38138',
|
||||
'nodedc-source/server/credentialSink/store.js': 'c78dc285a973b6acd8a2330f0310935ad08720b2905454492f292d235abf12c0',
|
||||
'nodedc-source/server/credentialSink/vendor/engine-credential-sink.mjs': 'b4800eead9bf94793ff1280d06e34aad6b37ef055a9d7f8d83d7fb5f9bd66d8b',
|
||||
'nodedc-source/server/index.js': 'b2b790b02839570d967a2ca68b00e2724485a99389ac9b3a589a1b22302a36b8',
|
||||
'nodedc-source/server/routes/engineAgentGateway.js': 'e3450a4e1d5318dbac37627b67804d62804e27e2032c9e90478a1e739cea6d3d',
|
||||
'nodedc-source/server/routes/engineCredentialSink.js': '9cbb69dbc8cbe6181cd5b0170fe9c4d717b0a173866ca98cba3e3766c0bab94e',
|
||||
'nodedc-source/server/routes/n8n.js': '783d822e2457d82e890f43bc00c7e33822077dc2841f0511a89ecb210fd36d48',
|
||||
'nodedc-source/server/routes/ndcAgentMcp.js': 'fbb3342b1a617b956d5b3a6a40d5111aa107c1176b4ff89c37b104995bada081',
|
||||
'nodedc-source/services/backend/credential-sink/docker-compose.immutable-runtime.yml': '944fa64b08255eb8207b93fd327aebb98ecd9400d37d25fcfa8e3a040ee44afe',
|
||||
})
|
||||
|
||||
const sourceInfo = await lstat(sourceArtifact)
|
||||
if (sourceInfo.isSymbolicLink() || !sourceInfo.isFile()) {
|
||||
throw new Error('engine_credential_sink_recovery_source_unsafe')
|
||||
}
|
||||
if (digest(await readFile(sourceArtifact)) !== sourceArtifactSha256) {
|
||||
throw new Error('engine_credential_sink_recovery_source_sha256_mismatch')
|
||||
}
|
||||
|
||||
await mkdir(artifactRoot, { recursive: true })
|
||||
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`)
|
||||
await assertArtifactTargetFresh(artifact)
|
||||
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-credential-sink-recovery-'))
|
||||
|
||||
try {
|
||||
run('python3', [
|
||||
'-c', verifiedExtractScript(), sourceArtifact, stage,
|
||||
sourceArtifactSha256, sourceManifest, JSON.stringify(entries), JSON.stringify(payloadSha256),
|
||||
])
|
||||
await writeFile(
|
||||
join(stage, 'manifest.env'),
|
||||
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
|
||||
'utf8',
|
||||
)
|
||||
await writeFile(join(stage, 'files.txt'), `${entries.join('\n')}\n`, 'utf8')
|
||||
run('python3', ['-c', canonicalTarScript(), artifact, stage])
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact,
|
||||
sha256: digest(await readFile(artifact)),
|
||||
sourceArtifact,
|
||||
sourceArtifactSha256,
|
||||
entries,
|
||||
payloadSha256,
|
||||
changed: ['manifest.env:id'],
|
||||
}, null, 2))
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
async function assertArtifactTargetFresh(target) {
|
||||
try {
|
||||
await lstat(target)
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return
|
||||
throw error
|
||||
}
|
||||
throw new Error('engine_credential_sink_recovery_artifact_already_exists')
|
||||
}
|
||||
|
||||
function verifiedExtractScript() {
|
||||
return [
|
||||
'import hashlib,json,pathlib,sys,tarfile',
|
||||
'source=pathlib.Path(sys.argv[1]); stage=pathlib.Path(sys.argv[2])',
|
||||
'expected_archive=sys.argv[3]; expected_manifest=sys.argv[4].encode()',
|
||||
'entries=json.loads(sys.argv[5]); hashes=json.loads(sys.argv[6])',
|
||||
"raw=source.read_bytes()",
|
||||
"assert hashlib.sha256(raw).hexdigest()==expected_archive, 'source-sha256'",
|
||||
"expected_files={'manifest.env','files.txt'}|{'payload/'+name for name in hashes}",
|
||||
"expected_dirs={'payload'}",
|
||||
"for name in hashes:",
|
||||
" p=pathlib.PurePosixPath('payload/'+name)",
|
||||
" expected_dirs.update(str(parent) for parent in p.parents if str(parent)!='.')",
|
||||
"with tarfile.open(source,'r:gz') as tar:",
|
||||
" members=tar.getmembers(); names={member.name for member in members}",
|
||||
" assert names==expected_files|expected_dirs, 'source-member-set'",
|
||||
" for member in members:",
|
||||
" p=pathlib.PurePosixPath(member.name)",
|
||||
" assert not p.is_absolute() and '..' not in p.parts and '\\\\' not in member.name, 'unsafe-member'",
|
||||
" assert member.isdir() if member.name in expected_dirs else member.isfile(), 'unsafe-member-type'",
|
||||
" assert tar.extractfile('manifest.env').read()==expected_manifest, 'source-manifest'",
|
||||
" expected_list=('\\n'.join(entries)+'\\n').encode()",
|
||||
" assert tar.extractfile('files.txt').read()==expected_list, 'source-files-list'",
|
||||
" for name,wanted in hashes.items():",
|
||||
" data=tar.extractfile('payload/'+name).read()",
|
||||
" assert hashlib.sha256(data).hexdigest()==wanted, 'payload-sha256:'+name",
|
||||
" target=stage/'payload'/name; target.parent.mkdir(parents=True,exist_ok=True); target.write_bytes(data)",
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
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 run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
})
|
||||
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
|
||||
}
|
||||
|
||||
function digest(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
#!/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 here = dirname(fileURLToPath(import.meta.url))
|
||||
const platformRoot = resolve(here, '../..')
|
||||
const workspaceRoot = resolve(platformRoot, '..')
|
||||
const engineRoot = resolve(workspaceRoot, 'NODEDC_ENGINE_INFRA')
|
||||
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts'))
|
||||
const [patchId = '', ...extra] = process.argv.slice(2)
|
||||
const previouslyIssuedPatchIds = new Set([
|
||||
'engine-data-product-publish-grant-20260716-001',
|
||||
'engine-data-product-publish-grant-20260717-001',
|
||||
'engine-data-product-publish-grant-20260717-002',
|
||||
])
|
||||
const compositeProviderCatalogTargetSha256 = '9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a'
|
||||
|
||||
if (extra.length || !/^engine-data-product-publish-grant-\d{8}-\d{3}$/.test(patchId)) {
|
||||
throw new Error('usage: build-engine-data-product-publish-grant-artifact.mjs <fresh-patch-id>')
|
||||
}
|
||||
if (previouslyIssuedPatchIds.has(patchId)) throw new Error('engine_publish_grant_patch_id_already_issued')
|
||||
|
||||
// Deliberately exclude frontend/dist, runtime data, tests, every credential
|
||||
// value, and the already-installed credential-sink implementation. The sink
|
||||
// remains mounted and byte-frozen as the predecessor domain; this artifact
|
||||
// adds the exact Data Product Publish grant flow beside it.
|
||||
const entries = Object.freeze([
|
||||
'nodedc-source/server/assets/provider-packages/v1/catalog.json',
|
||||
'nodedc-source/server/dataProductPublishGrant',
|
||||
'nodedc-source/server/engineAgents/store.js',
|
||||
'nodedc-source/server/routes/engineAgentGateway.js',
|
||||
'nodedc-source/server/routes/n8n.js',
|
||||
'nodedc-source/services/backend/data-product-publish-grant/docker-compose.immutable-runtime.yml',
|
||||
])
|
||||
const ignoredBasenames = new Set(['.DS_Store', '.git', 'node_modules'])
|
||||
const credentialSinkPredecessorSha256 = Object.freeze({
|
||||
'docker-compose.yml': '258cebb64ff1943c939655cc55bdce00fc5c4dced67ec291d84d6df066ace50e',
|
||||
'nodedc-source/server/credentialPolicies/ndcPrivateNode.js': '723874a02dc7b8a68b22ff2304431cfe64f28933cedf5f1e6cda79b2e1cf704a',
|
||||
'nodedc-source/server/credentialSink/core.js': '9f0facc41fd398fcd955cffdd486abb126cdcd756c87ffde667fcbe00e2c41d3',
|
||||
'nodedc-source/server/credentialSink/requestAuth.js': 'f8c9237c3e6f4219dee0d4f956d6e97f76f5bd7ba8a6fd0b4fb21aceffa38138',
|
||||
'nodedc-source/server/credentialSink/store.js': 'c78dc285a973b6acd8a2330f0310935ad08720b2905454492f292d235abf12c0',
|
||||
'nodedc-source/server/credentialSink/vendor/engine-credential-sink.mjs': 'b4800eead9bf94793ff1280d06e34aad6b37ef055a9d7f8d83d7fb5f9bd66d8b',
|
||||
'nodedc-source/server/index.js': 'b2b790b02839570d967a2ca68b00e2724485a99389ac9b3a589a1b22302a36b8',
|
||||
'nodedc-source/server/routes/engineCredentialSink.js': '9cbb69dbc8cbe6181cd5b0170fe9c4d717b0a173866ca98cba3e3766c0bab94e',
|
||||
'nodedc-source/server/routes/ndcAgentMcp.js': '534e2c85e1faecc72a00da7ad32d0584ee09b6bd89eeec2221c3b23d80e1e962',
|
||||
'nodedc-source/services/backend/credential-sink/docker-compose.immutable-runtime.yml': '944fa64b08255eb8207b93fd327aebb98ecd9400d37d25fcfa8e3a040ee44afe',
|
||||
})
|
||||
const publishGrantRuntimeOverride = [
|
||||
'services:',
|
||||
' nodedc-backend:',
|
||||
' user: "0:0"',
|
||||
' environment:',
|
||||
' ENGINE_DATA_PLANE_BASE_URL: http://external-data-plane:18106',
|
||||
' ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE: /run/nodedc-secrets/engine-edp-managed-provisioner-private.pem',
|
||||
' ENGINE_CONTROL_PLANE_PUBLISH_GRANT_ROOT: /var/lib/nodedc-control-plane/publish-grants',
|
||||
' volumes:',
|
||||
' - type: bind',
|
||||
' source: /volume2/nodedc-demo/nodedc-control-plane/publish-grants',
|
||||
' target: /var/lib/nodedc-control-plane/publish-grants',
|
||||
' bind:',
|
||||
' create_host_path: false',
|
||||
' - type: bind',
|
||||
' source: /volume1/docker/nodedc-platform/secrets/engine-edp-managed-provisioner/private-key.pem',
|
||||
' target: /run/nodedc-secrets/engine-edp-managed-provisioner-private.pem',
|
||||
' read_only: true',
|
||||
' bind:',
|
||||
' create_host_path: false',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
await assertSourceBoundary()
|
||||
await mkdir(artifactRoot, { recursive: true })
|
||||
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`)
|
||||
await assertArtifactTargetFresh(artifact)
|
||||
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-publish-grant-artifact-'))
|
||||
const payload = join(stage, 'payload')
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true })
|
||||
for (const entry of entries) {
|
||||
await copySafe(resolve(engineRoot, entry), join(payload, entry))
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, 'manifest.env'),
|
||||
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
|
||||
'utf8',
|
||||
)
|
||||
await writeFile(join(stage, 'files.txt'), `${entries.join('\n')}\n`, 'utf8')
|
||||
run('python3', ['-c', canonicalTarScript(), artifact, stage])
|
||||
const sha256 = digest(await readFile(artifact))
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact,
|
||||
sha256,
|
||||
entries,
|
||||
excluded: [
|
||||
'docker-compose.yml',
|
||||
'nodedc-source/server/index.js',
|
||||
'nodedc-source/server/credentialPolicies/ndcPrivateNode.js',
|
||||
'nodedc-source/server/credentialSink',
|
||||
'nodedc-source/server/routes/engineCredentialSink.js',
|
||||
'nodedc-source/server/middleware/demoAccess.js',
|
||||
'nodedc-source/server/routes/ndcAgentMcp.js',
|
||||
'nodedc-source/server/data',
|
||||
'nodedc-source/dist',
|
||||
'nodedc-source/server/tests',
|
||||
],
|
||||
providerPackage: 'gelios.provider.v4',
|
||||
providerRequests: [
|
||||
'https://api.geliospro.com/api/v1/users/me/monitoring-config',
|
||||
'https://api.geliospro.com/api/v1/units?incltrip=true',
|
||||
],
|
||||
dataProductId: 'fleet.positions.current.v3',
|
||||
credentialValues: 'preserved',
|
||||
preservedPredecessor: Object.keys(credentialSinkPredecessorSha256),
|
||||
}, null, 2))
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
async function assertSourceBoundary() {
|
||||
// These files are deliberately excluded from the artifact. Their exact
|
||||
// installed predecessor is verified by the root-owned runner during plan
|
||||
// and apply; the local builder only proves that it cannot package a link or
|
||||
// another unsafe filesystem object in their place.
|
||||
for (const relativePath of Object.keys(credentialSinkPredecessorSha256)) {
|
||||
const info = await lstat(join(engineRoot, relativePath))
|
||||
if (!info.isFile() || info.isSymbolicLink()) {
|
||||
throw new Error(`engine_credential_sink_predecessor_unsafe:${relativePath}`)
|
||||
}
|
||||
}
|
||||
const runtimeOverridePath = join(
|
||||
engineRoot,
|
||||
'nodedc-source/services/backend/data-product-publish-grant/docker-compose.immutable-runtime.yml',
|
||||
)
|
||||
if (await readFile(runtimeOverridePath, 'utf8') !== publishGrantRuntimeOverride) {
|
||||
throw new Error('engine_publish_grant_runtime_override_mismatch')
|
||||
}
|
||||
const catalogPath = join(
|
||||
engineRoot,
|
||||
'nodedc-source/server/assets/provider-packages/v1/catalog.json',
|
||||
)
|
||||
if (digest(await readFile(catalogPath)) !== compositeProviderCatalogTargetSha256) {
|
||||
throw new Error('engine_composite_provider_catalog_target_mismatch')
|
||||
}
|
||||
|
||||
const indexSource = await readFile(join(engineRoot, 'nodedc-source/server/index.js'), 'utf8')
|
||||
if (!indexSource.includes("app.use('/api/engine-agent-mcp', engineAgentMcpRouter)")) {
|
||||
throw new Error('engine_agent_mcp_mount_missing')
|
||||
}
|
||||
if (
|
||||
!indexSource.includes("import engineCredentialSinkRouter from './routes/engineCredentialSink.js'")
|
||||
|| !indexSource.includes("app.use('/internal/engine-credential-sink', express.json({")
|
||||
) throw new Error('engine_credential_sink_predecessor_mount_missing')
|
||||
|
||||
const gateway = await readFile(join(engineRoot, 'nodedc-source/server/routes/engineAgentGateway.js'), 'utf8')
|
||||
for (const tool of [
|
||||
'engine_plan_data_product_publish_grant',
|
||||
'engine_apply_data_product_publish_grant',
|
||||
'engine_accept_data_product_publish_grant',
|
||||
'engine_rollback_data_product_publish_grant',
|
||||
]) {
|
||||
if (!gateway.includes(tool)) throw new Error(`engine_publish_grant_tool_missing:${tool}`)
|
||||
}
|
||||
|
||||
const n8nRoute = await readFile(join(engineRoot, 'nodedc-source/server/routes/n8n.js'), 'utf8')
|
||||
if (n8nRoute.includes("from '../credentialSink/")) {
|
||||
throw new Error('engine_publish_grant_depends_on_legacy_sink')
|
||||
}
|
||||
if (!n8nRoute.includes('engineDataProductPublishGrantN8nAdapter')) {
|
||||
throw new Error('engine_publish_grant_native_adapter_missing')
|
||||
}
|
||||
if (!n8nRoute.includes('engineCredentialSinkN8nAdapter')) {
|
||||
throw new Error('engine_credential_sink_native_adapter_missing')
|
||||
}
|
||||
|
||||
const grantDirectory = join(engineRoot, 'nodedc-source/server/dataProductPublishGrant')
|
||||
const grantFiles = (await readdir(grantDirectory, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
const expectedGrantFiles = [
|
||||
'acceptance.js',
|
||||
'providerCatalog.js',
|
||||
'service.js',
|
||||
'signedDataPlaneClient.js',
|
||||
'store.js',
|
||||
]
|
||||
if (JSON.stringify(grantFiles) !== JSON.stringify(expectedGrantFiles)) {
|
||||
throw new Error('engine_publish_grant_source_set_mismatch')
|
||||
}
|
||||
const providerCatalogSource = await readFile(join(grantDirectory, 'providerCatalog.js'), 'utf8')
|
||||
const grantServiceSource = await readFile(join(grantDirectory, 'service.js'), 'utf8')
|
||||
const grantStoreSource = await readFile(join(grantDirectory, 'store.js'), 'utf8')
|
||||
for (const marker of [
|
||||
'function capabilityRequests(capability)',
|
||||
'function exactHttpRequestUrl(n8n)',
|
||||
'capabilityIds',
|
||||
'providerRequestNodeIds',
|
||||
'providerCredentialRefs.size !== 1',
|
||||
]) {
|
||||
if (!providerCatalogSource.includes(marker)) {
|
||||
throw new Error(`engine_composite_provider_resolver_missing:${marker}`)
|
||||
}
|
||||
}
|
||||
if (
|
||||
!grantServiceSource.includes('capabilities: descriptor.capabilityIds')
|
||||
|| !grantServiceSource.includes('providerRequestNodeIds: descriptor.providerRequestNodeIds')
|
||||
) {
|
||||
throw new Error('engine_composite_provider_plan_projection_missing')
|
||||
}
|
||||
if (
|
||||
!grantStoreSource.includes('Array.isArray(raw.capabilityIds)')
|
||||
|| !grantStoreSource.includes('Array.isArray(raw.providerRequestNodeIds)')
|
||||
) {
|
||||
throw new Error('engine_composite_provider_store_projection_missing')
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const source = resolve(engineRoot, entry)
|
||||
const info = await lstat(source)
|
||||
if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) {
|
||||
throw new Error(`engine_artifact_source_unsafe:${entry}`)
|
||||
}
|
||||
}
|
||||
for (const source of [
|
||||
...expectedGrantFiles.map((name) => join(grantDirectory, name)),
|
||||
join(engineRoot, 'nodedc-source/server/routes/engineAgentGateway.js'),
|
||||
join(engineRoot, 'nodedc-source/server/routes/n8n.js'),
|
||||
join(engineRoot, 'nodedc-source/server/routes/ndcAgentMcp.js'),
|
||||
join(engineRoot, 'nodedc-source/server/engineAgents/store.js'),
|
||||
]) run('node', ['--check', source])
|
||||
}
|
||||
|
||||
async function assertArtifactTargetFresh(target) {
|
||||
try {
|
||||
await lstat(target)
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return
|
||||
throw error
|
||||
}
|
||||
throw new Error('engine_publish_grant_artifact_already_exists')
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const info = await lstat(source)
|
||||
if (info.isSymbolicLink()) throw new Error(`source_symlink_rejected:${source}`)
|
||||
if (info.isFile()) {
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
await cp(source, destination, { force: false, verbatimSymlinks: true })
|
||||
return
|
||||
}
|
||||
if (!info.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)
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(`source_symlink_rejected:${relative(engineRoot, childSource)}`)
|
||||
}
|
||||
await copySafe(childSource, join(destination, entry.name))
|
||||
}
|
||||
}
|
||||
|
||||
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 run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
})
|
||||
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
|
||||
return result
|
||||
}
|
||||
|
||||
function digest(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/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 here = dirname(fileURLToPath(import.meta.url));
|
||||
const workspaceRoot = resolve(here, "../../..");
|
||||
const engineRoot = resolve(
|
||||
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
|
||||
);
|
||||
const artifactRoot = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
|
||||
);
|
||||
const [patchId = "", ...extra] = process.argv.slice(2);
|
||||
if (extra.length || !/^engine-depttrans-zone-authority-v1-\d{8}-\d{3}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-engine-depttrans-zone-authority-v1-artifact.mjs " +
|
||||
"<engine-depttrans-zone-authority-v1-YYYYMMDD-NNN>",
|
||||
);
|
||||
}
|
||||
|
||||
const targetSha256 = Object.freeze({
|
||||
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
|
||||
"1aac712a05e55137d69eedaf363969460ffe866288e8e18e35711967ab232f39",
|
||||
"nodedc-source/server/assets/provider-packages/v1/depttrans-zone-authority-v1.json":
|
||||
"1482032178b4816e599df570face9b1dfa8ae1fa2943ac8f941c561e8cb9aa9d",
|
||||
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js":
|
||||
"e4c216b0affd89ddbd48abcec86febd48eb12a4c56507851daeb47f2ccd60c9a",
|
||||
"nodedc-source/server/dataProductPublishGrant/service.js":
|
||||
"408836564e9a422eb5e648cc24d764f4bfdf7f83d93306742e8615fa8186a642",
|
||||
"nodedc-source/server/dataProductPublishGrant/store.js":
|
||||
"ace5971d0772e9a9499f0849e6b754e3677cc2ca3080e573a12e26acf5a6c0c0",
|
||||
});
|
||||
const entries = Object.freeze(Object.keys(targetSha256));
|
||||
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
|
||||
|
||||
await assertFresh(artifact);
|
||||
await assertExactSources();
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-depttrans-zone-authority-v1-"));
|
||||
try {
|
||||
const payload = join(stage, "payload");
|
||||
for (const relativePath of entries) {
|
||||
const destination = join(payload, relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await copyFile(join(engineRoot, relativePath), destination);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
|
||||
{ encoding: "utf8", flag: "wx", mode: 0o644 },
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
mode: 0o644,
|
||||
});
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact,
|
||||
sha256: digest(await readFile(artifact)),
|
||||
entries,
|
||||
targetSha256,
|
||||
services: ["nodedc-backend"],
|
||||
transition: "exact-platform-service-authority-v1",
|
||||
providerPackage: "moscow-department-of-transport.pmd-slow-zones.v1",
|
||||
dataProductId: "map.zones.current.v2",
|
||||
authorityBoundary: "platform-service",
|
||||
platformService: "nodedc-map-gateway",
|
||||
credentialValues: "preserved",
|
||||
untouched: ["n8n", "L1 graph", "Engine UI", "databases", "MCP Nginx"],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertExactSources() {
|
||||
for (const [relativePath, expected] of Object.entries(targetSha256)) {
|
||||
const sourcePath = join(engineRoot, relativePath);
|
||||
const info = await lstat(sourcePath);
|
||||
if (!info.isFile() || info.isSymbolicLink()) {
|
||||
throw new Error(`engine_depttrans_zone_authority_source_unsafe:${relativePath}`);
|
||||
}
|
||||
const actual = digest(await readFile(sourcePath));
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`engine_depttrans_zone_authority_target_mismatch:${relativePath}:` +
|
||||
`expected=${expected}:actual=${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const catalog = JSON.parse(await readFile(join(engineRoot, entries[0]), "utf8"));
|
||||
const provider = catalog.packages?.find(
|
||||
(item) => item.id === "moscow-department-of-transport.pmd-slow-zones.v1",
|
||||
);
|
||||
const request = provider?.capabilities?.[0]?.request;
|
||||
if (
|
||||
provider?.version !== "1.0.0"
|
||||
|| provider?.providerId !== "moscow-department-of-transport"
|
||||
|| provider?.providerCredential !== undefined
|
||||
|| provider?.capabilities?.[0]?.dataProductIds?.[0] !== "map.zones.current.v2"
|
||||
|| request?.authorityBoundary !== "platform-service"
|
||||
|| request?.serviceId !== "nodedc-map-gateway"
|
||||
|| request?.network !== "engine"
|
||||
|| request?.url !== "http://map-gateway:18103/internal/zone-sources/v1/profiles/moscow-pmd-slow-zones/current"
|
||||
) throw new Error("engine_depttrans_zone_authority_catalog_projection_mismatch");
|
||||
|
||||
const marker = JSON.parse(await readFile(join(engineRoot, entries[1]), "utf8"));
|
||||
if (
|
||||
marker?.schemaVersion !== "nodedc.engine.platform-service-authority/v1"
|
||||
|| marker?.id !== provider.id
|
||||
|| marker?.serviceId !== request.serviceId
|
||||
|| marker?.dataProductId !== provider.capabilities[0].dataProductIds[0]
|
||||
) throw new Error("engine_depttrans_zone_authority_marker_projection_mismatch");
|
||||
const resolver = await readFile(join(engineRoot, entries[2]), "utf8");
|
||||
const service = await readFile(join(engineRoot, entries[3]), "utf8");
|
||||
const store = await readFile(join(engineRoot, entries[4]), "utf8");
|
||||
for (const marker of [
|
||||
"function platformServiceRequestIsExact",
|
||||
"pinned_platform_service_no_graph_credential",
|
||||
"platformServiceIds",
|
||||
"publish_grant_provider_request_path_unreachable",
|
||||
]) {
|
||||
if (![resolver, service, store].some((source) => source.includes(marker))) {
|
||||
throw new Error(`engine_depttrans_zone_authority_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],'xb') 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: 128 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/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 engineRoot = resolve(
|
||||
process.env.NODEDC_ENGINE_SOURCE_ROOT
|
||||
|| join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
|
||||
);
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [patchId = "engine-l1-agent-init-concurrency-20260724-042", ...extra] =
|
||||
process.argv.slice(2);
|
||||
|
||||
if (
|
||||
extra.length
|
||||
|| !/^engine-l1-agent-init-concurrency-\d{8}-\d{3}$/.test(patchId)
|
||||
) {
|
||||
throw new Error(
|
||||
"usage: build-engine-l1-agent-init-concurrency-artifact.mjs "
|
||||
+ "[engine-l1-agent-init-concurrency-YYYYMMDD-NNN]",
|
||||
);
|
||||
}
|
||||
|
||||
const sourceCommit = "c4077fe0d8d824fa58616eeb986dc78ca43070da";
|
||||
const predecessorSha256 = Object.freeze({
|
||||
"nodedc-source/src/App.tsx":
|
||||
"b3e61a89330b774f309660208d05143d299ab582f18765c1c14e2122a62c4d5e",
|
||||
"nodedc-source/src/driveinspector/nodes/N8n.definition.ts":
|
||||
"a4890cca12b269643ec3255c2fc6b7e2be96aa042ffc6087772e63cbf8323c3a",
|
||||
"nodedc-source/src/nodes/N8nNode.tsx":
|
||||
"a125f7cde66085008dafe5a340a1143094c59769573815851ce6c8adfe4c0220",
|
||||
"nodedc-source/src/store.ts":
|
||||
"2128cd582a947582bb7736792c3a71551712262ef04882c189220ec79f16fb8e",
|
||||
"nodedc-source/dist/index.html":
|
||||
"90d72f8790dc8cf951066b1210447c84d640373117bae23f3a4cb3227fb96d6d",
|
||||
"nodedc-source/dist/assets/index-CqvJfRRS.js":
|
||||
"06e6c23b03ea2ba8a4890e1f1714fd08ebaf18d735834200af6ab430af360ca8",
|
||||
});
|
||||
const targetSha256 = Object.freeze({
|
||||
"nodedc-source/src/App.tsx":
|
||||
"fc580dc3a1a97393af81dd75c54ae8b37db1f6fda8364042198493493b15f2c2",
|
||||
"nodedc-source/src/driveinspector/nodes/N8n.definition.ts":
|
||||
"945839541547b2fd02b5424954e00483d6c76f82a333d6a7a652ccd1b74d04cf",
|
||||
"nodedc-source/src/nodes/N8nNode.tsx":
|
||||
"1130e355d86e06a18df775b2d3517bfdbf98fdc494ca707d23c78648fbfd3cb4",
|
||||
"nodedc-source/src/store.ts":
|
||||
"46884488254c16cd3af9b25556dd0db4f5a3a20047abdb496db831f204e40673",
|
||||
"nodedc-source/src/utils/workflowWriteQueue.js":
|
||||
"41f73ccb5e1dafbdae501b7c0b9fa5c47fdbd52564061b9c4ca6ca1c75ceccdf",
|
||||
"nodedc-source/dist/index.html":
|
||||
"1554e2418aead13b56328c5721dd677400d84ee087559a88fe60b295b7ff5412",
|
||||
"nodedc-source/dist/assets/index-r1_2ECzJ.js":
|
||||
"0d25dd843da7078b8dbad5cb4b78724bf1ca8aa067e9d0add1e09ce8f421b822",
|
||||
});
|
||||
const entries = Object.freeze(Object.keys(targetSha256));
|
||||
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
|
||||
const checksum = `${artifact}.sha256`;
|
||||
|
||||
assertSourceCommit();
|
||||
await assertPredecessorSources();
|
||||
await assertExactTargets();
|
||||
await assertRuntimeContract();
|
||||
await assertFresh(artifact);
|
||||
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-l1-agent-init-"));
|
||||
try {
|
||||
for (const relativePath of entries) {
|
||||
const destination = join(stage, "payload", relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await copyFile(join(engineRoot, relativePath), destination);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
|
||||
{ encoding: "utf8", flag: "wx", mode: 0o644 },
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
mode: 0o644,
|
||||
});
|
||||
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`, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
mode: 0o644,
|
||||
});
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact,
|
||||
checksum,
|
||||
sha256,
|
||||
sourceCommit,
|
||||
predecessorSha256,
|
||||
targetSha256,
|
||||
entries,
|
||||
services: ["nodedc-backend", "app"],
|
||||
healthchecks: [
|
||||
"http://127.0.0.1:8080/",
|
||||
"http://127.0.0.1:3001/health",
|
||||
],
|
||||
concurrencyContract: "per-workflow-fifo-latest-revision-v1",
|
||||
n8nCoreChanged: false,
|
||||
untouched: [
|
||||
"n8n runtime",
|
||||
"L1 workflow data",
|
||||
"L2 workflow data",
|
||||
"credentials",
|
||||
"databases",
|
||||
"MCP catalogs",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function assertSourceCommit() {
|
||||
const actual = run("git", ["-C", engineRoot, "rev-parse", "HEAD"]).stdout.trim();
|
||||
if (actual !== sourceCommit) {
|
||||
throw new Error(
|
||||
`engine_l1_agent_init_source_commit_mismatch:`
|
||||
+ `expected=${sourceCommit}:actual=${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPredecessorSources() {
|
||||
for (const [relativePath, expected] of Object.entries(predecessorSha256)) {
|
||||
const result = run("git", [
|
||||
"-C",
|
||||
engineRoot,
|
||||
"show",
|
||||
`${sourceCommit}:${relativePath}`,
|
||||
]);
|
||||
const actual = digest(result.stdoutBuffer);
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`engine_l1_agent_init_predecessor_mismatch:${relativePath}:`
|
||||
+ `expected=${expected}:actual=${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const newFile = "nodedc-source/src/utils/workflowWriteQueue.js";
|
||||
const probe = spawnSync(
|
||||
"git",
|
||||
["-C", engineRoot, "cat-file", "-e", `${sourceCommit}:${newFile}`],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
if (probe.status === 0) {
|
||||
throw new Error("engine_l1_agent_init_queue_not_new");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertExactTargets() {
|
||||
for (const [relativePath, expected] of Object.entries(targetSha256)) {
|
||||
const source = join(engineRoot, relativePath);
|
||||
const info = await lstat(source);
|
||||
if (!info.isFile() || info.isSymbolicLink()) {
|
||||
throw new Error(`engine_l1_agent_init_source_unsafe:${relativePath}`);
|
||||
}
|
||||
const actual = digest(await readFile(source));
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`engine_l1_agent_init_target_mismatch:${relativePath}:`
|
||||
+ `expected=${expected}:actual=${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRuntimeContract() {
|
||||
const app = await readFile(join(engineRoot, "nodedc-source/src/App.tsx"), "utf8");
|
||||
const definition = await readFile(
|
||||
join(engineRoot, "nodedc-source/src/driveinspector/nodes/N8n.definition.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const store = await readFile(join(engineRoot, "nodedc-source/src/store.ts"), "utf8");
|
||||
const node = await readFile(
|
||||
join(engineRoot, "nodedc-source/src/nodes/N8nNode.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const queue = await readFile(
|
||||
join(engineRoot, "nodedc-source/src/utils/workflowWriteQueue.js"),
|
||||
"utf8",
|
||||
);
|
||||
const index = await readFile(
|
||||
join(engineRoot, "nodedc-source/dist/index.html"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
if (
|
||||
!app.includes("createWorkflowWriteQueue")
|
||||
|| !app.includes("putExistingWorkflow")
|
||||
|| !app.includes("expectedRevision: currentWorkflowRevisionRef.current")
|
||||
|| !queue.includes("previous.catch(() => undefined).then(() => operation())")
|
||||
|| !definition.includes("nodeName: 'NDC'")
|
||||
|| !definition.includes("default: 'NDC'")
|
||||
|| !definition.includes("data?.title || data?.nodeName || 'NDC'")
|
||||
|| !store.includes("nodeName: 'NDC'")
|
||||
|| !node.includes("data?.nodeName ?? 'NDC'")
|
||||
|| !index.includes("/assets/index-r1_2ECzJ.js")
|
||||
|| /gelios|robot2b/i.test(queue)
|
||||
) {
|
||||
throw new Error("engine_l1_agent_init_runtime_contract_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
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],'xb') 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: null,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`${command}_failed:${Buffer.from(result.stderr || "").toString("utf8")}`
|
||||
+ `${Buffer.from(result.stdout || "").toString("utf8")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
stdoutBuffer: Buffer.from(result.stdout || ""),
|
||||
stderrBuffer: Buffer.from(result.stderr || ""),
|
||||
stdout: Buffer.from(result.stdout || "").toString("utf8"),
|
||||
stderr: Buffer.from(result.stderr || "").toString("utf8"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
copyFile,
|
||||
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 here = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(here, "../..");
|
||||
const engineRoot = resolve(
|
||||
process.env.NODEDC_ENGINE_SOURCE_ROOT || resolve(platformRoot, "../NODEDC_ENGINE_INFRA"),
|
||||
);
|
||||
const artifactRoot = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
|
||||
);
|
||||
const baselineArtifact = resolve(
|
||||
here,
|
||||
"../deploy-artifacts/nodedc-engine-provider-authority-diagnostics-20260723-029.tgz",
|
||||
);
|
||||
const baselineArtifactSha256 =
|
||||
"b4a0a1f707bf9814a4decc3f7f261b8afffe5727af2129c14bcdc76f81675e38";
|
||||
const descriptorRel =
|
||||
"nodedc-source/services/node-intelligence/activation.json";
|
||||
const baselineDescriptorSha256 =
|
||||
"84b0e15a10cedf334ad04d6f31c908da2dc155503c9974f0eeb75b01e20fb884";
|
||||
const predecessorGatewaySha256 =
|
||||
"5331f6dc8dc306f641370a2968eb025ee3e278e27ae9a217e4cfc2901fec369c";
|
||||
const targetGatewaySha256 =
|
||||
"4f600a2781be9118bec891fa2a6f20d7f55e0892ef2f06af24059193cebd28f4";
|
||||
const targetDescriptorSha256 =
|
||||
"63e7619c6971d02102583bb5d80d33ece293b952f113200fa0b76f0e88c2dd32";
|
||||
const [patchId = "", ...extra] = process.argv.slice(2);
|
||||
if (extra.length || !/^engine-l2-closed-loop-\d{8}-\d{3}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-engine-l2-closed-loop-artifact.mjs " +
|
||||
"<engine-l2-closed-loop-YYYYMMDD-NNN>",
|
||||
);
|
||||
}
|
||||
|
||||
const sourceCommit = "dda405cff27977622af0c218abb4d4420c408536";
|
||||
const entries = Object.freeze([
|
||||
"nodedc-source/server/index.js",
|
||||
"nodedc-source/server/l2/graphRepository.js",
|
||||
"nodedc-source/server/realtime/ws.js",
|
||||
"nodedc-source/server/routes/engineAgentGateway.js",
|
||||
"nodedc-source/server/routes/n8n.js",
|
||||
"nodedc-source/server/routes/ndcAgentMcp.js",
|
||||
"nodedc-source/src/App.tsx",
|
||||
"nodedc-source/src/n8n/N8nSubworkflowHost.tsx",
|
||||
"nodedc-source/src/realtime/useMultiplayer.ts",
|
||||
"nodedc-source/src/utils/n8nApi.ts",
|
||||
"nodedc-source/dist/index.html",
|
||||
"nodedc-source/dist/assets",
|
||||
descriptorRel,
|
||||
]);
|
||||
const targetSha256 = Object.freeze({
|
||||
"nodedc-source/server/index.js":
|
||||
"1896e1cade61579863c50ff3f52f2e81ff27f2a511a9d362db81925ee21cefad",
|
||||
"nodedc-source/server/l2/graphRepository.js":
|
||||
"94ad08e1bb7e7be04854f1f911e06ee1acd6e09631f33f4c01e42e230665ac33",
|
||||
"nodedc-source/server/realtime/ws.js":
|
||||
"82cfa833e05c2fc6d6049dd4564af06164b5c784fdfb82c243ca67519f4509c2",
|
||||
"nodedc-source/server/routes/engineAgentGateway.js":
|
||||
"4f600a2781be9118bec891fa2a6f20d7f55e0892ef2f06af24059193cebd28f4",
|
||||
"nodedc-source/server/routes/n8n.js":
|
||||
"752cb1524160adc1c95163c34e139b615c063d2ce8980f2a63879bddbd0f1e08",
|
||||
"nodedc-source/server/routes/ndcAgentMcp.js":
|
||||
"353a10291ceb93c3641ece60ec6e809a394be86f5ceb97cb44755178940409dd",
|
||||
"nodedc-source/src/App.tsx":
|
||||
"b3e61a89330b774f309660208d05143d299ab582f18765c1c14e2122a62c4d5e",
|
||||
"nodedc-source/src/n8n/N8nSubworkflowHost.tsx":
|
||||
"683aa44ba92aeac4879889e2bdb5319282976d7680d620701578830ce9677087",
|
||||
"nodedc-source/src/realtime/useMultiplayer.ts":
|
||||
"a4d001d9914098e50a78d8abe0a66344d23680b7a19b3573aa7b6f48c8bb9c35",
|
||||
"nodedc-source/src/utils/n8nApi.ts":
|
||||
"6f16d4992c51ffcc1e71a7bd172fd9ceb440d6e1a74d69ef1db62e0ac4230b3c",
|
||||
"nodedc-source/dist/index.html":
|
||||
"90d72f8790dc8cf951066b1210447c84d640373117bae23f3a4cb3227fb96d6d",
|
||||
"nodedc-source/dist/assets/index-Bim2pv1P.css":
|
||||
"18322addd45c126a7b8396f36b005f3085fddba3e9b346dd2c910f6fa6987ebf",
|
||||
"nodedc-source/dist/assets/index-CqvJfRRS.js":
|
||||
"06e6c23b03ea2ba8a4890e1f1714fd08ebaf18d735834200af6ab430af360ca8",
|
||||
[descriptorRel]: targetDescriptorSha256,
|
||||
});
|
||||
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
|
||||
|
||||
await assertFresh(artifact);
|
||||
assertSourceCommit();
|
||||
await assertExactSources();
|
||||
const targetDescriptor = await buildTargetDescriptor();
|
||||
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-l2-closed-loop-"));
|
||||
try {
|
||||
const payload = join(stage, "payload");
|
||||
for (const relativePath of entries) {
|
||||
const destination = join(payload, relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
if (relativePath === descriptorRel) {
|
||||
await writeFile(destination, targetDescriptor, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
mode: 0o644,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const source = join(engineRoot, relativePath);
|
||||
const info = await lstat(source);
|
||||
if (info.isSymbolicLink()) {
|
||||
throw new Error(`engine_l2_closed_loop_source_symlink:${relativePath}`);
|
||||
}
|
||||
if (info.isDirectory()) {
|
||||
await cp(source, destination, {
|
||||
recursive: true,
|
||||
force: false,
|
||||
errorOnExist: true,
|
||||
});
|
||||
} else if (info.isFile()) {
|
||||
await copyFile(source, destination);
|
||||
} else {
|
||||
throw new Error(`engine_l2_closed_loop_source_unsafe:${relativePath}`);
|
||||
}
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
|
||||
{ encoding: "utf8", flag: "wx", mode: 0o644 },
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
mode: 0o644,
|
||||
});
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact,
|
||||
sha256: digest(await readFile(artifact)),
|
||||
sourceCommit,
|
||||
entries,
|
||||
targetSha256,
|
||||
services: ["nodedc-backend", "app"],
|
||||
healthchecks: [
|
||||
"http://127.0.0.1:8080/",
|
||||
"http://127.0.0.1:3001/health",
|
||||
],
|
||||
mcpVersion: "0.7.0",
|
||||
graphContract: "semantic-revision-cas-v1",
|
||||
transition: "failed-030-partial-source-reconciliation",
|
||||
predecessorDescriptorSha256: baselineDescriptorSha256,
|
||||
targetDescriptorSha256,
|
||||
predecessorGatewaySha256,
|
||||
targetGatewaySha256,
|
||||
recoveryArtifact: "nodedc-engine-l2-closed-loop-20260723-030.tgz",
|
||||
actorPlanes: ["external_codex_mcp", "ai_workspace", "engine_ui"],
|
||||
untouched: [
|
||||
"L2 graph data",
|
||||
"credentials",
|
||||
"databases",
|
||||
"n8n runtime",
|
||||
"AI Workspace connector",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function assertSourceCommit() {
|
||||
const actual = run("git", ["-C", engineRoot, "rev-parse", "HEAD"]).stdout.trim();
|
||||
if (actual !== sourceCommit) {
|
||||
throw new Error(
|
||||
`engine_l2_closed_loop_source_commit_mismatch:expected=${sourceCommit}:actual=${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertExactSources() {
|
||||
const sourceEntries = entries.filter((entry) => entry !== descriptorRel);
|
||||
const expectedPaths = Object.keys(targetSha256)
|
||||
.filter((entry) => entry !== descriptorRel)
|
||||
.sort();
|
||||
const actualPaths = [];
|
||||
for (const entry of sourceEntries) {
|
||||
const source = join(engineRoot, entry);
|
||||
const info = await lstat(source);
|
||||
if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) {
|
||||
throw new Error(`engine_l2_closed_loop_source_unsafe:${entry}`);
|
||||
}
|
||||
if (info.isDirectory()) {
|
||||
const result = run("find", [source, "-type", "f", "-print"]);
|
||||
for (const path of result.stdout.split("\n").filter(Boolean)) {
|
||||
actualPaths.push(path.slice(engineRoot.length + 1));
|
||||
}
|
||||
} else {
|
||||
actualPaths.push(entry);
|
||||
}
|
||||
}
|
||||
actualPaths.sort();
|
||||
if (JSON.stringify(actualPaths) !== JSON.stringify(expectedPaths)) {
|
||||
throw new Error(
|
||||
`engine_l2_closed_loop_source_set_mismatch:` +
|
||||
`expected=${expectedPaths.join(",")}:actual=${actualPaths.join(",")}`,
|
||||
);
|
||||
}
|
||||
for (const relativePath of expectedPaths) {
|
||||
const actual = digest(await readFile(join(engineRoot, relativePath)));
|
||||
if (actual !== targetSha256[relativePath]) {
|
||||
throw new Error(
|
||||
`engine_l2_closed_loop_target_mismatch:${relativePath}:` +
|
||||
`expected=${targetSha256[relativePath]}:actual=${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildTargetDescriptor() {
|
||||
const baselineBytes = await readFile(baselineArtifact);
|
||||
if (digest(baselineBytes) !== baselineArtifactSha256) {
|
||||
throw new Error("engine_l2_closed_loop_baseline_artifact_mismatch");
|
||||
}
|
||||
const baseline = run("tar", [
|
||||
"-xOf",
|
||||
baselineArtifact,
|
||||
`payload/${descriptorRel}`,
|
||||
]).stdout;
|
||||
if (digest(Buffer.from(baseline, "utf8")) !== baselineDescriptorSha256) {
|
||||
throw new Error("engine_l2_closed_loop_baseline_descriptor_mismatch");
|
||||
}
|
||||
const descriptor = JSON.parse(baseline);
|
||||
if (
|
||||
descriptor?.schemaVersion !==
|
||||
"nodedc.engine-node-intelligence-transition/v1" ||
|
||||
descriptor?.action !== "activate" ||
|
||||
descriptor?.releaseId !== "2.33.2-974a9fb3492f" ||
|
||||
descriptor?.source?.gatewaySha256 !== predecessorGatewaySha256 ||
|
||||
descriptor?.source?.upstreamProjectionSha256 !==
|
||||
"2dfa6b4f37d9bfa8b92a8109d4060d02dd2634ceb8f7924504b83ccf3fdd1523"
|
||||
) {
|
||||
throw new Error("engine_l2_closed_loop_baseline_descriptor_contract_mismatch");
|
||||
}
|
||||
descriptor.source.gatewaySha256 = targetGatewaySha256;
|
||||
const rendered = `${JSON.stringify(descriptor, null, 2)}\n`;
|
||||
if (digest(Buffer.from(rendered, "utf8")) !== targetDescriptorSha256) {
|
||||
throw new Error("engine_l2_closed_loop_target_descriptor_mismatch");
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
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],'xb') 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: 128 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/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 here = dirname(fileURLToPath(import.meta.url))
|
||||
const platformRoot = resolve(here, '../..')
|
||||
const engineRoot = resolve(
|
||||
process.env.NODEDC_ENGINE_SOURCE_ROOT || resolve(platformRoot, '../NODEDC_ENGINE_INFRA'),
|
||||
)
|
||||
const canonicalArtifactRoot = resolve(here, '../deploy-artifacts')
|
||||
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || canonicalArtifactRoot)
|
||||
const [transitionId = '20260720-004', ...extra] = process.argv.slice(2)
|
||||
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
|
||||
throw new Error('usage: build-engine-mcp-autonomy-provider-v5-artifact.mjs [YYYYMMDD-NNN]')
|
||||
}
|
||||
|
||||
const id = `engine-mcp-autonomy-provider-v5-${transitionId}`
|
||||
const target = join(artifactRoot, `nodedc-${id}.tgz`)
|
||||
const predecessorArtifact = join(
|
||||
canonicalArtifactRoot,
|
||||
'nodedc-engine-mcp-control-plane-20260718-003.tgz',
|
||||
)
|
||||
const predecessorArtifactSha256 = '249aef9527666c562e9648b15737e66cc5c1dc7c0788b58ea714da270b5eb4ba'
|
||||
const descriptorRel = 'nodedc-source/services/node-intelligence/activation.json'
|
||||
const gatewayRel = 'nodedc-source/server/routes/engineAgentGateway.js'
|
||||
const files = [
|
||||
'nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs',
|
||||
'nodedc-source/server/assets/engine-agent-npm/package.json',
|
||||
'nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.6.tgz',
|
||||
'nodedc-source/server/assets/provider-packages/v1/catalog.json',
|
||||
'nodedc-source/server/engineAgents/store.js',
|
||||
gatewayRel,
|
||||
descriptorRel,
|
||||
]
|
||||
const expectedSha256 = new Map([
|
||||
[files[0], 'c15c9da4f90f44a4e9e12f3683127e906d614fb98e562faa0c939505c973e074'],
|
||||
[files[1], '2ca8dcab0fa04bb1b21add1f75a9be61d5aa97753443ee1917302b4e0da5780a'],
|
||||
[files[2], 'd007a81cb4e4af569b3c54d3869b0f60b5597e3b531bff232145fa1851d8572a'],
|
||||
[files[3], '63e0741646197f0b1b3c64a4095e1bc8fb3a95ee6caf20b0293f89d869c9e620'],
|
||||
[files[4], '4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285'],
|
||||
[files[5], '5331f6dc8dc306f641370a2968eb025ee3e278e27ae9a217e4cfc2901fec369c'],
|
||||
])
|
||||
|
||||
await assertFresh(target)
|
||||
assertSha(await readFile(predecessorArtifact), predecessorArtifactSha256, 'predecessor MCP artifact')
|
||||
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-mcp-autonomy-provider-v5-'))
|
||||
const payload = join(stage, 'payload')
|
||||
try {
|
||||
await mkdir(payload, { recursive: true })
|
||||
for (const rel of files.slice(0, -1)) {
|
||||
const source = join(engineRoot, rel)
|
||||
const stat = await lstat(source)
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`)
|
||||
assertSha(await readFile(source), expectedSha256.get(rel), rel)
|
||||
await mkdir(dirname(join(payload, rel)), { recursive: true })
|
||||
await cp(source, join(payload, rel), { force: false })
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(extractMember(
|
||||
predecessorArtifact,
|
||||
`payload/${descriptorRel}`,
|
||||
))
|
||||
if (
|
||||
descriptor?.action !== 'activate'
|
||||
|| descriptor?.releaseId !== '2.33.2-974a9fb3492f'
|
||||
|| descriptor?.source?.gatewaySha256 !== '96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15'
|
||||
|| descriptor?.source?.upstreamProjectionSha256 !== '761a874b102a938bc6018159ddacdaac71ad6ae08e9f0f8d7f3b58a0165a5131'
|
||||
) throw new Error('mcp_autonomy_predecessor_descriptor_mismatch')
|
||||
descriptor.source.gatewaySha256 = expectedSha256.get(gatewayRel)
|
||||
await mkdir(dirname(join(payload, descriptorRel)), { recursive: true })
|
||||
await writeFile(join(payload, descriptorRel), `${JSON.stringify(descriptor, null, 2)}\n`, 'utf8')
|
||||
|
||||
await writeFile(join(stage, 'manifest.env'), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, 'utf8')
|
||||
await writeFile(join(stage, 'files.txt'), `${files.join('\n')}\n`, 'utf8')
|
||||
await mkdir(artifactRoot, { recursive: true })
|
||||
run('python3', ['-c', canonicalTarScript(), target, stage])
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
id,
|
||||
artifact: target,
|
||||
artifactSha256: sha(await readFile(target)),
|
||||
services: ['nodedc-backend'],
|
||||
mcpVersion: '0.6.0',
|
||||
installerVersion: '0.1.6',
|
||||
authority: 'mcp-capability-intersect-user-objective',
|
||||
retryBoundary: 'three-identical-failures-without-new-evidence',
|
||||
providerPackages: ['gelios.provider.v4', 'gelios.provider.v5'],
|
||||
targetDataProduct: 'fleet.positions.current.v4',
|
||||
preserved: ['n8n', 'L1', 'node-intelligence image', 'databases', 'credential values'],
|
||||
files,
|
||||
}, null, 2))
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
async function assertFresh(path) {
|
||||
try {
|
||||
await lstat(path)
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return
|
||||
throw error
|
||||
}
|
||||
throw new Error('artifact_already_exists')
|
||||
}
|
||||
|
||||
function extractMember(archive, member) {
|
||||
const script = [
|
||||
'import pathlib,sys,tarfile',
|
||||
'p=pathlib.Path(sys.argv[1]); name=sys.argv[2]',
|
||||
"with tarfile.open(p,'r:gz') as t:",
|
||||
' m=t.getmember(name)',
|
||||
" if not m.isfile(): raise SystemExit('member-not-file')",
|
||||
' f=t.extractfile(m)',
|
||||
" if f is None: raise SystemExit('member-unreadable')",
|
||||
' sys.stdout.buffer.write(f.read())',
|
||||
].join('\n')
|
||||
return run('python3', ['-c', script, archive, member]).stdout
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
'import gzip,io,pathlib,sys,tarfile',
|
||||
'root=pathlib.Path(sys.argv[2])',
|
||||
"with open(sys.argv[1],'xb') 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 assertSha(bytes, expected, label) {
|
||||
const actual = sha(bytes)
|
||||
if (!expected || actual !== expected) throw new Error(`${label.replaceAll(' ', '_')}_sha256_mismatch:${actual}`)
|
||||
}
|
||||
|
||||
function sha(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/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 engineRoot = resolve(
|
||||
process.env.NODEDC_ENGINE_SOURCE_ROOT
|
||||
|| join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
|
||||
);
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [patchId = "engine-mcp-classified-aspects-20260724-039", ...extra] =
|
||||
process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^engine-mcp-classified-aspects-\d{8}-\d{3}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-engine-mcp-classified-aspects-artifact.mjs "
|
||||
+ "[engine-mcp-classified-aspects-YYYYMMDD-NNN]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = Object.freeze([
|
||||
"nodedc-source/server/l2ExecutionPlan/compiler.js",
|
||||
"nodedc-source/server/assets/execution-plans/v1/catalog.json",
|
||||
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
|
||||
]);
|
||||
const expectedSha256 = Object.freeze({
|
||||
"nodedc-source/server/l2ExecutionPlan/compiler.js":
|
||||
"4854a62fb44cb3dd715f2cb8728740575453a666f5c31abe9d41bc2562be5e95",
|
||||
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
|
||||
"2e70b607b4a347592ce2f4732f6da0781ac94ec4829ac336021cb501fdafe9aa",
|
||||
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
|
||||
"2fce1c623a8acf9c01a43463e6d38eda71ad28379a2d91b2ca40f31f8dccf3c6",
|
||||
});
|
||||
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
|
||||
const checksum = `${artifact}.sha256`;
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-classified-aspects-"));
|
||||
|
||||
await assertFresh(artifact);
|
||||
await assertEngineBoundary();
|
||||
|
||||
try {
|
||||
for (const relativePath of files) {
|
||||
const source = join(engineRoot, 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=engine\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-backend", "app"],
|
||||
engineCorePolicy: "provider-neutral-scalar-visibility-guards",
|
||||
providerPackage: "gelios.provider.v10",
|
||||
files,
|
||||
expectedSha256,
|
||||
untouched: [
|
||||
"live L2 graphs",
|
||||
"n8n L1",
|
||||
"Engine UI source and dist",
|
||||
"node-intelligence",
|
||||
"databases",
|
||||
"credentials",
|
||||
"embedded Codex",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertEngineBoundary() {
|
||||
for (const relativePath of files) {
|
||||
const actual = digest(await readFile(join(engineRoot, relativePath)));
|
||||
if (actual !== expectedSha256[relativePath]) {
|
||||
throw new Error(
|
||||
`classified_aspect_target_mismatch:${relativePath}:`
|
||||
+ `expected=${expectedSha256[relativePath]}:actual=${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const compiler = await readFile(join(engineRoot, files[0]), "utf8");
|
||||
if (
|
||||
!compiler.includes("guardMatches")
|
||||
|| !compiler.includes("'1.3.0'")
|
||||
|| /gelios|fleet\.units\.contacts/i.test(compiler)
|
||||
) {
|
||||
throw new Error("classified_aspect_compiler_boundary_invalid");
|
||||
}
|
||||
|
||||
const executionCatalog = JSON.parse(
|
||||
await readFile(join(engineRoot, files[1]), "utf8"),
|
||||
);
|
||||
const providerPackage = executionCatalog?.packages?.find(
|
||||
(entry) => entry?.id === "gelios.provider.v10",
|
||||
);
|
||||
if (
|
||||
providerPackage?.version !== "10.0.0"
|
||||
|| !executionCatalog?.runtime?.compilerVersions?.includes("1.3.0")
|
||||
|| !providerPackage?.profiles?.some(
|
||||
(profile) =>
|
||||
profile?.dataProductId === "fleet.units.contacts.current.v1",
|
||||
)
|
||||
) {
|
||||
throw new Error("classified_aspect_execution_catalog_invalid");
|
||||
}
|
||||
|
||||
const securityCatalog = JSON.parse(
|
||||
await readFile(join(engineRoot, files[2]), "utf8"),
|
||||
);
|
||||
const securityPackage = securityCatalog?.packages?.find(
|
||||
(entry) => entry?.id === "gelios.provider.v10",
|
||||
);
|
||||
if (
|
||||
securityPackage?.version !== "10.0.0"
|
||||
|| securityPackage?.capabilities?.length !== 1
|
||||
|| JSON.stringify(securityPackage.capabilities[0]?.dataProductIds)
|
||||
!== JSON.stringify(["fleet.units.contacts.current.v1"])
|
||||
) {
|
||||
throw new Error("classified_aspect_security_catalog_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
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,177 @@
|
||||
#!/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 here = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(here, "../..");
|
||||
const engineRoot = resolve(
|
||||
process.env.NODEDC_ENGINE_SOURCE_ROOT || resolve(platformRoot, "../NODEDC_ENGINE_INFRA"),
|
||||
);
|
||||
const canonicalArtifactRoot = resolve(here, "../deploy-artifacts");
|
||||
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || canonicalArtifactRoot);
|
||||
const [transitionId = "20260718-003", ...extra] = process.argv.slice(2);
|
||||
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
|
||||
throw new Error("usage: build-engine-mcp-control-plane-artifact.mjs [YYYYMMDD-NNN]");
|
||||
}
|
||||
|
||||
const id = `engine-mcp-control-plane-${transitionId}`;
|
||||
const target = join(artifactRoot, `nodedc-${id}.tgz`);
|
||||
const nodeIntelligenceArtifact = join(
|
||||
canonicalArtifactRoot,
|
||||
"nodedc-engine-node-intelligence-20260717-001.tgz",
|
||||
);
|
||||
const nodeIntelligenceArtifactSha256 = "d126afaa0c714fef26362aad4749e3f4647f551d6eb975f0133cdf9ff2e6fc4f";
|
||||
const descriptorRel = "nodedc-source/services/node-intelligence/activation.json";
|
||||
const gatewayRel = "nodedc-source/server/routes/engineAgentGateway.js";
|
||||
const upstreamProjectionRel = "nodedc-source/server/nodeIntelligence/upstreamProjection.js";
|
||||
const files = [
|
||||
"nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs",
|
||||
"nodedc-source/server/assets/engine-agent-npm/package.json",
|
||||
"nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.4.tgz",
|
||||
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js",
|
||||
"nodedc-source/server/dataProductPublishGrant/signedDataPlaneClient.js",
|
||||
"nodedc-source/server/dataProductReadGrant/acceptance.js",
|
||||
"nodedc-source/server/dataProductReadGrant/service.js",
|
||||
"nodedc-source/server/dataProductReadGrant/store.js",
|
||||
"nodedc-source/server/engineAgents/store.js",
|
||||
upstreamProjectionRel,
|
||||
gatewayRel,
|
||||
"nodedc-source/server/routes/n8n.js",
|
||||
"nodedc-source/server/routes/ndcAgentMcp.js",
|
||||
"nodedc-source/services/backend/data-product-read-grant/docker-compose.immutable-runtime.yml",
|
||||
"nodedc-source/server/dataProductPublishGrant/service.js",
|
||||
"nodedc-source/server/dataProductPublishGrant/store.js",
|
||||
descriptorRel,
|
||||
];
|
||||
const expectedSha256 = new Map([
|
||||
[files[0], "521098de69fe288a56bd4158c849c1055ba24be08e19f7a4111618d0e8138445"],
|
||||
[files[1], "cbe113e9b10bb84b9ccbffa3e261908e58a8430c11abe2cf4fd5304741b04597"],
|
||||
[files[2], "e74c0136d346f904b42589d75cb11a952b4d2f21153f1b057fba28e9acf4f95f"],
|
||||
[files[3], "901b8fad80018ce177b34ced804b39cb140a47e831414057f484296b373c651d"],
|
||||
[files[4], "c2a13d5eb49937fe70ec6451d0465cac54c19c7fae44448db099079473ec02fc"],
|
||||
[files[5], "202dbe575b2f2e1c584aa7e6e99e38fa84f12496feb454e3dbd3f98e2d0396dd"],
|
||||
[files[6], "65b8cdd6b603333bac1feb303e9791feb1e9da39dc9b5bfc3df3961273b0052e"],
|
||||
[files[7], "c3fcdc59a794f57d92e3d2ac713ee28341347cebd25364c4060beaa3dc2151de"],
|
||||
[files[8], "debd351fe0b8c72b33b8c79909b8f339cbaf061b970576f3ae72df52ebaa211f"],
|
||||
[files[9], "761a874b102a938bc6018159ddacdaac71ad6ae08e9f0f8d7f3b58a0165a5131"],
|
||||
[files[10], "96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15"],
|
||||
[files[11], "f293a7794405badbabd2bf7ef088f96fe1167d9e249f05cbfc8af280a0d3e8f8"],
|
||||
[files[12], "534e2c85e1faecc72a00da7ad32d0584ee09b6bd89eeec2221c3b23d80e1e962"],
|
||||
[files[13], "952df0cb2ac477e644f5f3a9d64b4872ce1da33356eeab0bec17dcb2931cf653"],
|
||||
[files[14], "ded3832c9677345a988448ae8e69cef254fd9bf39d2de20cffa295c1feedcd7c"],
|
||||
[files[15], "a92303b2732e21f68c1ac732fa26e4983cbadf3515741cee983b916a283d754c"],
|
||||
]);
|
||||
|
||||
await assertFresh(target);
|
||||
assertSha(await readFile(nodeIntelligenceArtifact), nodeIntelligenceArtifactSha256, "node intelligence artifact");
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-mcp-control-plane-"));
|
||||
const payload = join(stage, "payload");
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const rel of files.slice(0, -1)) {
|
||||
const source = join(engineRoot, rel);
|
||||
const stat = await lstat(source);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`);
|
||||
assertSha(await readFile(source), expectedSha256.get(rel), rel);
|
||||
await mkdir(dirname(join(payload, rel)), { recursive: true });
|
||||
await cp(source, join(payload, rel), { force: false });
|
||||
}
|
||||
|
||||
const descriptorText = extractMember(
|
||||
nodeIntelligenceArtifact,
|
||||
`payload/${descriptorRel}`,
|
||||
);
|
||||
const descriptor = JSON.parse(descriptorText);
|
||||
if (
|
||||
descriptor?.action !== "activate"
|
||||
|| descriptor?.releaseId !== "2.33.2-974a9fb3492f"
|
||||
|| descriptor?.source?.gatewaySha256 !== "9642c76fd5765a8a20629c8d09e16579a1c3b2cfb7bdbab38cf55af469d8908f"
|
||||
) throw new Error("node_intelligence_predecessor_descriptor_mismatch");
|
||||
descriptor.source.gatewaySha256 = expectedSha256.get(gatewayRel);
|
||||
descriptor.source.upstreamProjectionSha256 = expectedSha256.get(upstreamProjectionRel);
|
||||
await mkdir(dirname(join(payload, descriptorRel)), { recursive: true });
|
||||
await writeFile(join(payload, descriptorRel), `${JSON.stringify(descriptor, null, 2)}\n`, "utf8");
|
||||
|
||||
await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, "utf8");
|
||||
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
run("python3", ["-c", canonicalTarScript(), target, stage]);
|
||||
const artifactSha256 = sha(await readFile(target));
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
id,
|
||||
artifact: target,
|
||||
artifactSha256,
|
||||
services: ["nodedc-backend"],
|
||||
predecessorGatewaySha256: "9642c76fd5765a8a20629c8d09e16579a1c3b2cfb7bdbab38cf55af469d8908f",
|
||||
targetGatewaySha256: expectedSha256.get(gatewayRel),
|
||||
mcpVersion: "0.5.0",
|
||||
installerVersion: "0.1.4",
|
||||
files,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertFresh(path) {
|
||||
try {
|
||||
await lstat(path);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return;
|
||||
throw error;
|
||||
}
|
||||
throw new Error("artifact_already_exists");
|
||||
}
|
||||
|
||||
function extractMember(archive, member) {
|
||||
const script = [
|
||||
"import pathlib,sys,tarfile",
|
||||
"p=pathlib.Path(sys.argv[1]); name=sys.argv[2]",
|
||||
"with tarfile.open(p,'r:gz') as t:",
|
||||
" m=t.getmember(name)",
|
||||
" if not m.isfile(): raise SystemExit('member-not-file')",
|
||||
" f=t.extractfile(m)",
|
||||
" if f is None: raise SystemExit('member-unreadable')",
|
||||
" sys.stdout.buffer.write(f.read())",
|
||||
].join("\n");
|
||||
return run("python3", ["-c", script, archive, member]).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");
|
||||
}
|
||||
|
||||
function assertSha(bytes, expected, label) {
|
||||
const actual = sha(bytes);
|
||||
if (!expected || actual !== expected) throw new Error(`${label.replaceAll(" ", "_")}_sha256_mismatch:${actual}`);
|
||||
}
|
||||
|
||||
function sha(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||
return result;
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
#!/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 here = dirname(fileURLToPath(import.meta.url));
|
||||
const workspaceRoot = resolve(here, "../../..");
|
||||
const engineRoot = resolve(
|
||||
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
|
||||
);
|
||||
const artifactRoot = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
|
||||
);
|
||||
const baselineArtifact = resolve(
|
||||
here,
|
||||
"../deploy-artifacts/nodedc-engine-mcp-telemetry-catalog-20260723-035.tgz",
|
||||
);
|
||||
const baselineArtifactSha256 =
|
||||
"4ce1563612499a19d653a174a4d73632e0aaac7dacd2eca4e7c01c87586b1155";
|
||||
const nodeIntelligenceDescriptorPath =
|
||||
"nodedc-source/services/node-intelligence/activation.json";
|
||||
const predecessorNodeIntelligenceDescriptorSha256 =
|
||||
"25ed3efd858aaf82c242dba501f0acc0c6c3dc91e8845707a4d3325750eab59f";
|
||||
const targetNodeIntelligenceDescriptorSha256 =
|
||||
"03b2ba120c3929e9cf99940ddc927082de376ceff762895a84103144202aef42";
|
||||
const predecessorGatewaySha256 =
|
||||
"69bfc91e913a3fad04e13aca86efb9d62f73c0c7d1f8f7200907494b29fa9e8d";
|
||||
const targetGatewaySha256 =
|
||||
"9020cf49a3558c0497fed4ecfd81367cbc11b5b249881804c29fe437900c71f8";
|
||||
const [patchId = "", ...extra] = process.argv.slice(2);
|
||||
if (
|
||||
extra.length
|
||||
|| !/^engine-mcp-execution-plan-materialization-\d{8}-\d{3}$/.test(patchId)
|
||||
) {
|
||||
throw new Error(
|
||||
"usage: build-engine-mcp-execution-plan-materialization-artifact.mjs "
|
||||
+ "<engine-mcp-execution-plan-materialization-YYYYMMDD-NNN>",
|
||||
);
|
||||
}
|
||||
|
||||
const descriptorPath =
|
||||
"nodedc-source/server/deployTransitions/executionPlanMaterializationV1.json";
|
||||
const targetSha256 = Object.freeze({
|
||||
"nodedc-source/server/routes/n8n.js":
|
||||
"391fc81228fdacd6efc6c0868991a3485f71869708e49ac15819db6ccce1bfce",
|
||||
"nodedc-source/server/routes/engineAgentGateway.js":
|
||||
targetGatewaySha256,
|
||||
"nodedc-source/server/l2ExecutionPlan/catalog.js":
|
||||
"c35b4c7ad9319aabbf1366a11ff52a6e99d961893bafdfcb4e84fc5f24fc04be",
|
||||
"nodedc-source/server/l2ExecutionPlan/compiler.js":
|
||||
"beb3f664073f9a372432643936a04d0cb0028cd695f759c68bd053e9cd892fe4",
|
||||
"nodedc-source/server/l2ExecutionPlan/materializer.js":
|
||||
"ee3bcfd06b3a5fa46800df974a2dedfdd55eeaf662329f837486c011a9bd713e",
|
||||
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
|
||||
"a153e040e0a592bad4375b98d9a1d83d148923aa0f0fd47d225b92eda281c4e9",
|
||||
[descriptorPath]:
|
||||
"52c0152cff49c251be5581a9209d2e63ba710c16e815bdd6ece24f7c9dd7e480",
|
||||
[nodeIntelligenceDescriptorPath]:
|
||||
targetNodeIntelligenceDescriptorSha256,
|
||||
});
|
||||
const entries = Object.freeze(Object.keys(targetSha256));
|
||||
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
|
||||
|
||||
await assertFresh(artifact);
|
||||
await assertExactSources();
|
||||
const targetNodeIntelligenceDescriptor =
|
||||
await buildTargetNodeIntelligenceDescriptor();
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-engine-mcp-execution-plan-materialization-"),
|
||||
);
|
||||
try {
|
||||
const payload = join(stage, "payload");
|
||||
for (const relativePath of entries) {
|
||||
const destination = join(payload, relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
if (relativePath === nodeIntelligenceDescriptorPath) {
|
||||
await writeFile(destination, targetNodeIntelligenceDescriptor, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
mode: 0o644,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await copyFile(join(engineRoot, relativePath), destination);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
|
||||
{ encoding: "utf8", flag: "wx", mode: 0o644 },
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
mode: 0o644,
|
||||
});
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact,
|
||||
sha256: digest(await readFile(artifact)),
|
||||
entries,
|
||||
targetSha256,
|
||||
services: ["nodedc-backend"],
|
||||
transition: "telemetry-catalog-v1-to-execution-plan-materialization-v1",
|
||||
mcpVersion: "0.9.0",
|
||||
mcpSurface: "external-codex",
|
||||
mcpTools: [
|
||||
"engine_plan_l2_execution_plan_materialization",
|
||||
"engine_apply_l2_execution_plan_materialization",
|
||||
],
|
||||
providerLogicAuthority: "trusted-provider-package",
|
||||
unmanagedGraphPolicy: "explicit-adoption-required",
|
||||
nodeIntelligenceRelease: "2.33.2-974a9fb3492f",
|
||||
predecessorGatewaySha256,
|
||||
targetGatewaySha256,
|
||||
predecessorNodeIntelligenceDescriptorSha256,
|
||||
targetNodeIntelligenceDescriptorSha256,
|
||||
untouched: [
|
||||
"live L2 graphs",
|
||||
"n8n workflow data",
|
||||
"L1",
|
||||
"Engine UI",
|
||||
"node-intelligence image",
|
||||
"databases",
|
||||
"credentials",
|
||||
"MCP Nginx",
|
||||
"embedded AI Workspace",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertExactSources() {
|
||||
for (const [relativePath, expected] of Object.entries(targetSha256)) {
|
||||
if (relativePath === nodeIntelligenceDescriptorPath) continue;
|
||||
const sourcePath = join(engineRoot, relativePath);
|
||||
const info = await lstat(sourcePath);
|
||||
if (!info.isFile() || info.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`engine_mcp_execution_plan_materialization_source_unsafe:${relativePath}`,
|
||||
);
|
||||
}
|
||||
const actual = digest(await readFile(sourcePath));
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`engine_mcp_execution_plan_materialization_target_mismatch:${relativePath}:`
|
||||
+ `expected=${expected}:actual=${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const gatewaySource = await readFile(
|
||||
join(engineRoot, "nodedc-source/server/routes/engineAgentGateway.js"),
|
||||
"utf8",
|
||||
);
|
||||
for (const marker of [
|
||||
"const ENGINE_AGENT_MCP_VERSION = '0.9.0'",
|
||||
"name: 'engine_plan_l2_execution_plan_materialization'",
|
||||
"name: 'engine_apply_l2_execution_plan_materialization'",
|
||||
"'execution-plan.apply'",
|
||||
]) {
|
||||
if (!gatewaySource.includes(marker)) {
|
||||
throw new Error(
|
||||
`engine_mcp_execution_plan_materialization_gateway_marker_missing:${marker}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (/gelios|robot2b/i.test(
|
||||
await readFile(
|
||||
join(engineRoot, "nodedc-source/server/l2ExecutionPlan/compiler.js"),
|
||||
"utf8",
|
||||
),
|
||||
)) {
|
||||
throw new Error("engine_mcp_execution_plan_materialization_provider_hardcode");
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(
|
||||
await readFile(join(engineRoot, descriptorPath), "utf8"),
|
||||
);
|
||||
if (
|
||||
descriptor?.schemaVersion !== "nodedc.engine.deploy-transition/v1"
|
||||
|| descriptor?.id !== "engine-mcp-l2-execution-plan-materialization-v1"
|
||||
|| descriptor?.mcpVersion !== "0.9.0"
|
||||
|| descriptor?.plan?.tool
|
||||
!== "engine_plan_l2_execution_plan_materialization"
|
||||
|| descriptor?.apply?.tool
|
||||
!== "engine_apply_l2_execution_plan_materialization"
|
||||
|| descriptor?.providerLogicAuthority !== "trusted-provider-package"
|
||||
|| descriptor?.unmanagedGraphPolicy !== "explicit-adoption-required"
|
||||
|| descriptor?.embeddedCodexChanged !== false
|
||||
) {
|
||||
throw new Error(
|
||||
"engine_mcp_execution_plan_materialization_descriptor_contract_mismatch",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildTargetNodeIntelligenceDescriptor() {
|
||||
const baselineBytes = await readFile(baselineArtifact);
|
||||
if (digest(baselineBytes) !== baselineArtifactSha256) {
|
||||
throw new Error(
|
||||
"engine_mcp_execution_plan_materialization_baseline_artifact_mismatch",
|
||||
);
|
||||
}
|
||||
const baseline = run("tar", [
|
||||
"-xOf",
|
||||
baselineArtifact,
|
||||
`payload/${nodeIntelligenceDescriptorPath}`,
|
||||
]).stdout;
|
||||
if (
|
||||
digest(Buffer.from(baseline, "utf8"))
|
||||
!== predecessorNodeIntelligenceDescriptorSha256
|
||||
) {
|
||||
throw new Error(
|
||||
"engine_mcp_execution_plan_materialization_baseline_descriptor_mismatch",
|
||||
);
|
||||
}
|
||||
const descriptor = JSON.parse(baseline);
|
||||
if (
|
||||
descriptor?.schemaVersion
|
||||
!== "nodedc.engine-node-intelligence-transition/v1"
|
||||
|| descriptor?.action !== "activate"
|
||||
|| descriptor?.releaseId !== "2.33.2-974a9fb3492f"
|
||||
|| descriptor?.source?.gatewaySha256 !== predecessorGatewaySha256
|
||||
) {
|
||||
throw new Error(
|
||||
"engine_mcp_execution_plan_materialization_baseline_contract_mismatch",
|
||||
);
|
||||
}
|
||||
descriptor.source.gatewaySha256 = targetGatewaySha256;
|
||||
const rendered = `${JSON.stringify(descriptor, null, 2)}\n`;
|
||||
if (
|
||||
digest(Buffer.from(rendered, "utf8"))
|
||||
!== targetNodeIntelligenceDescriptorSha256
|
||||
) {
|
||||
throw new Error(
|
||||
"engine_mcp_execution_plan_materialization_target_descriptor_mismatch",
|
||||
);
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
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],'xb') 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: 128 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/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 here = dirname(fileURLToPath(import.meta.url));
|
||||
const workspaceRoot = resolve(here, "../../..");
|
||||
const engineRoot = resolve(
|
||||
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
|
||||
);
|
||||
const artifactRoot = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
|
||||
);
|
||||
const descriptorPath =
|
||||
"nodedc-source/server/deployTransitions/executionPlanModuleOwnershipV3.json";
|
||||
const activationPath =
|
||||
"nodedc-source/services/node-intelligence/activation.json";
|
||||
const targetSha256 = Object.freeze({
|
||||
"nodedc-source/server/l2ExecutionPlan/materializer.js":
|
||||
"dabf0073520049d7a04d1962b29e591ae092b87b287a50534ad2d98b03ae683c",
|
||||
"nodedc-source/server/routes/engineAgentGateway.js":
|
||||
"17c5f502b3ceacc45278eef7418d08e1e55a8b3e46e00942e559d25566fdba41",
|
||||
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
|
||||
"7e34b93aa30b06cd52853b48013baef2970cc27e4fddc333a1011dc73ea8c08a",
|
||||
[descriptorPath]:
|
||||
"d187d539a219fec453e06c55c3f5486ef66059371b4ae9b145266307b2af9889",
|
||||
[activationPath]:
|
||||
"4cdeb85ebb2e43f088f095f75b7fc31aabd8ab81c8ac96ded4aafd7dbd8e30bd",
|
||||
});
|
||||
const entries = Object.freeze(Object.keys(targetSha256));
|
||||
const [patchId = "", ...extra] = process.argv.slice(2);
|
||||
if (
|
||||
extra.length
|
||||
|| !/^engine-mcp-execution-plan-module-ownership-\d{8}-\d{3}$/.test(patchId)
|
||||
) {
|
||||
throw new Error(
|
||||
"usage: build-engine-mcp-execution-plan-module-ownership-artifact.mjs "
|
||||
+ "<engine-mcp-execution-plan-module-ownership-YYYYMMDD-NNN>",
|
||||
);
|
||||
}
|
||||
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
|
||||
|
||||
await assertFresh(artifact);
|
||||
await assertExactSources();
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-engine-mcp-execution-plan-module-ownership-"),
|
||||
);
|
||||
try {
|
||||
const payload = join(stage, "payload");
|
||||
for (const relativePath of entries) {
|
||||
const destination = join(payload, relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await copyFile(join(engineRoot, relativePath), destination);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
|
||||
{ encoding: "utf8", flag: "wx", mode: 0o644 },
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
mode: 0o644,
|
||||
});
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact,
|
||||
sha256: digest(await readFile(artifact)),
|
||||
entries,
|
||||
targetSha256,
|
||||
services: ["nodedc-backend"],
|
||||
transition: "execution-plan-telemetry-runtime-v2-to-module-ownership-v3",
|
||||
mcpVersion: "0.10.0",
|
||||
mcpSurface: "external-codex",
|
||||
compilerVersions: ["1.1.0", "1.2.0"],
|
||||
materializationStrategies: [
|
||||
"create_or_reconcile_owned",
|
||||
"adopt_existing",
|
||||
"adopt_existing_module",
|
||||
],
|
||||
moduleOwnership: {
|
||||
nodeBindings: "exact-one-to-one",
|
||||
retireBoundary: "closed",
|
||||
sharedManualWebhook: "compatible-existing-configuration-preserved",
|
||||
providerCredentialNodes: "engine-managed",
|
||||
publisherNodes: "engine-managed",
|
||||
},
|
||||
providerPackage: {
|
||||
added: "gelios.provider.v9",
|
||||
legacyPreserved: "gelios.provider.v8",
|
||||
},
|
||||
untouched: [
|
||||
"live L2 graphs",
|
||||
"n8n workflow data",
|
||||
"L1",
|
||||
"Engine UI",
|
||||
"node-intelligence image",
|
||||
"databases",
|
||||
"credentials",
|
||||
"MCP Nginx",
|
||||
"embedded AI Workspace",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertExactSources() {
|
||||
for (const [relativePath, expected] of Object.entries(targetSha256)) {
|
||||
const sourcePath = join(engineRoot, relativePath);
|
||||
const info = await lstat(sourcePath);
|
||||
if (!info.isFile() || info.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`engine_mcp_execution_plan_module_ownership_source_unsafe:${relativePath}`,
|
||||
);
|
||||
}
|
||||
const actual = digest(await readFile(sourcePath));
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`engine_mcp_execution_plan_module_ownership_target_mismatch:${relativePath}:`
|
||||
+ `expected=${expected}:actual=${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const materializer = await readFile(
|
||||
join(engineRoot, "nodedc-source/server/l2ExecutionPlan/materializer.js"),
|
||||
"utf8",
|
||||
);
|
||||
for (const marker of [
|
||||
"nodedc.engine.materialized-execution-plan-module/v1",
|
||||
"adopt_existing_module",
|
||||
"preserveBoundNodeIds",
|
||||
"execution_plan_module_retire_boundary_not_closed",
|
||||
"manual_webhook_same_method",
|
||||
]) {
|
||||
if (!materializer.includes(marker)) {
|
||||
throw new Error(
|
||||
`engine_mcp_execution_plan_module_ownership_marker_missing:${marker}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (/gelios|robot2b/i.test(materializer)) {
|
||||
throw new Error("engine_mcp_execution_plan_module_ownership_provider_hardcode");
|
||||
}
|
||||
|
||||
const gateway = await readFile(
|
||||
join(engineRoot, "nodedc-source/server/routes/engineAgentGateway.js"),
|
||||
"utf8",
|
||||
);
|
||||
if (
|
||||
!gateway.includes("const ENGINE_AGENT_MCP_VERSION = '0.10.0'")
|
||||
|| !gateway.includes("preserveBoundNodeIds")
|
||||
|| !gateway.includes("adopt_existing_module")
|
||||
) {
|
||||
throw new Error(
|
||||
"engine_mcp_execution_plan_module_ownership_gateway_contract_mismatch",
|
||||
);
|
||||
}
|
||||
|
||||
const catalog = JSON.parse(await readFile(
|
||||
join(engineRoot, "nodedc-source/server/assets/execution-plans/v1/catalog.json"),
|
||||
"utf8",
|
||||
));
|
||||
const geliosV9 = catalog?.packages?.find(
|
||||
(providerPackage) => providerPackage?.id === "gelios.provider.v9",
|
||||
);
|
||||
if (
|
||||
geliosV9?.contractDigest
|
||||
!== "sha256:7dd4ce4a45ce76e884ffa1e304bfaa521f552e9a45e535930f2d17b882ae23ed"
|
||||
|| !catalog?.packages?.some(
|
||||
(providerPackage) => providerPackage?.id === "gelios.provider.v8",
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"engine_mcp_execution_plan_module_ownership_catalog_contract_mismatch",
|
||||
);
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(
|
||||
await readFile(join(engineRoot, descriptorPath), "utf8"),
|
||||
);
|
||||
if (
|
||||
descriptor?.schemaVersion !== "nodedc.engine.deploy-transition/v1"
|
||||
|| descriptor?.id !== "engine-mcp-l2-execution-plan-module-ownership-v3"
|
||||
|| descriptor?.mcpVersion !== "0.10.0"
|
||||
|| descriptor?.moduleOwnership?.adoptionStrategy !== "adopt_existing_module"
|
||||
|| descriptor?.sharedBoundary?.configurationAuthority !== "existing-graph"
|
||||
|| descriptor?.providerPackageTrust?.addedPackageId !== "gelios.provider.v9"
|
||||
|| descriptor?.providerPackageTrust?.legacyPackagePreserved !== true
|
||||
|| descriptor?.engineProviderHardcode !== false
|
||||
|| descriptor?.embeddedCodexChanged !== false
|
||||
) {
|
||||
throw new Error(
|
||||
"engine_mcp_execution_plan_module_ownership_descriptor_contract_mismatch",
|
||||
);
|
||||
}
|
||||
|
||||
const activation = JSON.parse(
|
||||
await readFile(join(engineRoot, activationPath), "utf8"),
|
||||
);
|
||||
if (
|
||||
activation?.action !== "activate"
|
||||
|| activation?.releaseId !== "2.33.2-974a9fb3492f"
|
||||
|| activation?.source?.gatewaySha256
|
||||
!== targetSha256["nodedc-source/server/routes/engineAgentGateway.js"]
|
||||
) {
|
||||
throw new Error(
|
||||
"engine_mcp_execution_plan_module_ownership_activation_contract_mismatch",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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],'xb') 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: 128 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/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 engineRoot = resolve(
|
||||
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
|
||||
);
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [patchId = "engine-mcp-execution-plan-sandbox-runtime-20260724-045", ...extra] =
|
||||
process.argv.slice(2);
|
||||
|
||||
if (
|
||||
extra.length
|
||||
|| !/^engine-mcp-execution-plan-sandbox-runtime-\d{8}-\d{3}$/.test(patchId)
|
||||
) {
|
||||
throw new Error(
|
||||
"usage: build-engine-mcp-execution-plan-sandbox-runtime-artifact.mjs "
|
||||
+ "[engine-mcp-execution-plan-sandbox-runtime-YYYYMMDD-NNN]",
|
||||
);
|
||||
}
|
||||
|
||||
const expectedSha256 = Object.freeze({
|
||||
"nodedc-source/server/l2ExecutionPlan/compiler.js":
|
||||
"64f5196a83018505c6dac77a0f8674c27941257a874eed9b024c1c94f169be2b",
|
||||
"nodedc-source/server/deployTransitions/executionPlanSandboxRuntimeV4.json":
|
||||
"a4692643afb86dfeeee6ca24aefcc181913e715eff38fa158667d2f10b901847",
|
||||
});
|
||||
const files = Object.freeze(Object.keys(expectedSha256));
|
||||
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
|
||||
const checksum = `${artifact}.sha256`;
|
||||
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-execution-plan-sandbox-runtime-"));
|
||||
|
||||
await assertFresh(artifact);
|
||||
await assertExactSources();
|
||||
|
||||
try {
|
||||
for (const relativePath of files) {
|
||||
const destination = join(stage, "payload", relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await copyFile(join(engineRoot, relativePath), destination);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=engine\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-backend"],
|
||||
mcpVersion: "0.11.0",
|
||||
executionPlanCompilerVersion: "1.4.0",
|
||||
n8nVersion: "2.3.2",
|
||||
codeNodeMode: "runOnceForAllItems",
|
||||
sandboxGlobalDependencies: [],
|
||||
responseByteBudget: "bounded-pure-javascript-utf8",
|
||||
rawProviderPayloadAtPublish: "forbidden",
|
||||
engineProviderHardcode: false,
|
||||
n8nCoreChanged: false,
|
||||
l1Changed: false,
|
||||
credentialsChanged: false,
|
||||
files,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertExactSources() {
|
||||
for (const [relativePath, expected] of Object.entries(expectedSha256)) {
|
||||
const sourcePath = join(engineRoot, relativePath);
|
||||
const info = await lstat(sourcePath);
|
||||
if (!info.isFile() || info.isSymbolicLink()) {
|
||||
throw new Error(`engine_execution_plan_sandbox_runtime_source_unsafe:${relativePath}`);
|
||||
}
|
||||
const actual = digest(await readFile(sourcePath));
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`engine_execution_plan_sandbox_runtime_target_mismatch:${relativePath}:`
|
||||
+ `expected=${expected}:actual=${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const compiler = await readFile(
|
||||
join(engineRoot, "nodedc-source/server/l2ExecutionPlan/compiler.js"),
|
||||
"utf8",
|
||||
);
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
join(
|
||||
engineRoot,
|
||||
"nodedc-source/server/deployTransitions/executionPlanSandboxRuntimeV4.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
if (
|
||||
!compiler.includes("const encodedBytes = (value) => {")
|
||||
|| compiler.includes("new TextEncoder().encode(JSON.stringify(value ?? null))")
|
||||
|| !compiler.includes("return extracted.map((source) => ({ json:")
|
||||
|| descriptor?.id !== "engine-mcp-l2-execution-plan-sandbox-runtime-v4"
|
||||
|| descriptor?.predecessor !== "engine-mcp-l1-credential-provenance-v2"
|
||||
|| descriptor?.runtimeCompatibility?.sandboxGlobalDependencies?.length !== 0
|
||||
|| descriptor?.runtimeCompatibility?.responseByteBudget
|
||||
!== "bounded-pure-javascript-utf8"
|
||||
|| descriptor?.dataBoundary?.rawProviderPayloadAtPublish !== "forbidden"
|
||||
|| descriptor?.engineProviderHardcode !== false
|
||||
|| descriptor?.n8nCoreChanged !== false
|
||||
) {
|
||||
throw new Error("engine_execution_plan_sandbox_runtime_boundary_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
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],'xb') 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: 128 * 1024 * 1024,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||
}
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
#!/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 here = dirname(fileURLToPath(import.meta.url));
|
||||
const workspaceRoot = resolve(here, "../../..");
|
||||
const engineRoot = resolve(
|
||||
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
|
||||
);
|
||||
const artifactRoot = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
|
||||
);
|
||||
const descriptorPath =
|
||||
"nodedc-source/server/deployTransitions/executionPlanTelemetryRuntimeV2.json";
|
||||
const targetSha256 = Object.freeze({
|
||||
"nodedc-source/server/l2ExecutionPlan/compiler.js":
|
||||
"6b783ad15c26dc7de0645082c8a426002d943138c70bf31a240247b16a33b6a0",
|
||||
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
|
||||
"5bdfc92284a7c836ff326e3a89b559110e376b32efd34b5f23f2bec272745781",
|
||||
[descriptorPath]:
|
||||
"68b275efb6303284336d8b637c966c24d891a4bd0b3fec4fb83247359929ef79",
|
||||
});
|
||||
const entries = Object.freeze(Object.keys(targetSha256));
|
||||
const [patchId = "", ...extra] = process.argv.slice(2);
|
||||
if (
|
||||
extra.length
|
||||
|| !/^engine-mcp-execution-plan-telemetry-runtime-\d{8}-\d{3}$/.test(patchId)
|
||||
) {
|
||||
throw new Error(
|
||||
"usage: build-engine-mcp-execution-plan-telemetry-runtime-artifact.mjs "
|
||||
+ "<engine-mcp-execution-plan-telemetry-runtime-YYYYMMDD-NNN>",
|
||||
);
|
||||
}
|
||||
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
|
||||
|
||||
await assertFresh(artifact);
|
||||
await assertExactSources();
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-engine-mcp-execution-plan-telemetry-runtime-"),
|
||||
);
|
||||
try {
|
||||
const payload = join(stage, "payload");
|
||||
for (const relativePath of entries) {
|
||||
const destination = join(payload, relativePath);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await copyFile(join(engineRoot, relativePath), destination);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
|
||||
{ encoding: "utf8", flag: "wx", mode: 0o644 },
|
||||
);
|
||||
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
mode: 0o644,
|
||||
});
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact,
|
||||
sha256: digest(await readFile(artifact)),
|
||||
entries,
|
||||
targetSha256,
|
||||
services: ["nodedc-backend"],
|
||||
transition: "execution-plan-materialization-v1-to-telemetry-runtime-v2",
|
||||
mcpVersion: "0.9.0",
|
||||
mcpSurface: "external-codex",
|
||||
compilerVersions: ["1.1.0", "1.2.0"],
|
||||
legacyRuntimePreserved: true,
|
||||
telemetryAuthority: [
|
||||
"trusted-telemetry-projection",
|
||||
"visible-sensor-definition",
|
||||
],
|
||||
unprojectedParameters: "discarded",
|
||||
rawProviderPayloadAtPublish: "forbidden",
|
||||
providerLogicAuthority: "trusted-provider-package",
|
||||
untouched: [
|
||||
"live L2 graphs",
|
||||
"n8n workflow data",
|
||||
"L1",
|
||||
"Engine UI",
|
||||
"node-intelligence image and descriptor",
|
||||
"databases",
|
||||
"credentials",
|
||||
"MCP Nginx",
|
||||
"embedded AI Workspace",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertExactSources() {
|
||||
for (const [relativePath, expected] of Object.entries(targetSha256)) {
|
||||
const sourcePath = join(engineRoot, relativePath);
|
||||
const info = await lstat(sourcePath);
|
||||
if (!info.isFile() || info.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`engine_mcp_execution_plan_telemetry_runtime_source_unsafe:${relativePath}`,
|
||||
);
|
||||
}
|
||||
const actual = digest(await readFile(sourcePath));
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`engine_mcp_execution_plan_telemetry_runtime_target_mismatch:${relativePath}:`
|
||||
+ `expected=${expected}:actual=${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const compiler = await readFile(
|
||||
join(engineRoot, "nodedc-source/server/l2ExecutionPlan/compiler.js"),
|
||||
"utf8",
|
||||
);
|
||||
for (const marker of [
|
||||
"if (compilerVersion === '1.1.0')",
|
||||
"if (compilerVersion !== '1.2.0')",
|
||||
'"msgParam", "msg_param"',
|
||||
"descriptor.telemetryProjection",
|
||||
'"message-param"',
|
||||
"convertedSensorValue",
|
||||
"visibleSensorDefinition",
|
||||
]) {
|
||||
if (!compiler.includes(marker)) {
|
||||
throw new Error(
|
||||
`engine_mcp_execution_plan_telemetry_runtime_marker_missing:${marker}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (/gelios|robot2b/i.test(compiler)) {
|
||||
throw new Error("engine_mcp_execution_plan_telemetry_runtime_provider_hardcode");
|
||||
}
|
||||
|
||||
const catalog = JSON.parse(await readFile(
|
||||
join(
|
||||
engineRoot,
|
||||
"nodedc-source/server/assets/execution-plans/v1/catalog.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
if (
|
||||
JSON.stringify(catalog?.runtime?.compilerVersions)
|
||||
!== JSON.stringify(["1.1.0", "1.2.0"])
|
||||
) {
|
||||
throw new Error("engine_mcp_execution_plan_telemetry_runtime_catalog_mismatch");
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(
|
||||
await readFile(join(engineRoot, descriptorPath), "utf8"),
|
||||
);
|
||||
if (
|
||||
descriptor?.schemaVersion !== "nodedc.engine.deploy-transition/v1"
|
||||
|| descriptor?.id
|
||||
!== "engine-mcp-l2-execution-plan-telemetry-runtime-v2"
|
||||
|| descriptor?.mcpVersion !== "0.9.0"
|
||||
|| JSON.stringify(descriptor?.compilerTransition?.supported)
|
||||
!== JSON.stringify(["1.1.0", "1.2.0"])
|
||||
|| descriptor?.compilerTransition?.legacyRuntimePreserved !== true
|
||||
|| descriptor?.providerLogicAuthority !== "trusted-provider-package"
|
||||
|| descriptor?.engineProviderHardcode !== false
|
||||
|| descriptor?.embeddedCodexChanged !== false
|
||||
) {
|
||||
throw new Error(
|
||||
"engine_mcp_execution_plan_telemetry_runtime_descriptor_contract_mismatch",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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],'xb') 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: 128 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user