From bb0724c52c733006e5563d262e1fa86eef3c35ef Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 11 Aug 2026 21:54:49 +0300 Subject: [PATCH] fix(deploy): keep manager releases on stable runner --- .../services/device-control-core/Dockerfile | 15 +- .../device-control-core/package-lock.json | 164 +++++ .../services/device-control-core/package.json | 2 +- .../src/device-gateway-core-runtime.mjs | 606 +++++++++++++++++ .../src/edge-channel-supervisor.mjs | 2 +- .../device-gateway-core/src/runtime.mjs | 608 +----------------- ...-device-manager-control-plane-artifact.mjs | 6 +- infra/deploy-runner/nodedc-deploy | 7 - ..._device_manager_control_plane_artifacts.py | 20 +- .../test_device_plane_registry.py | 6 +- 10 files changed, 802 insertions(+), 634 deletions(-) create mode 100644 device-plane/services/device-control-core/package-lock.json create mode 100644 device-plane/services/device-control-core/src/device-gateway-core-runtime.mjs diff --git a/device-plane/services/device-control-core/Dockerfile b/device-plane/services/device-control-core/Dockerfile index 67ea7aa..b194456 100644 --- a/device-plane/services/device-control-core/Dockerfile +++ b/device-plane/services/device-control-core/Dockerfile @@ -1,19 +1,16 @@ FROM node:22-alpine +WORKDIR /app/services/device-control-core + +COPY services/device-control-core/package.json services/device-control-core/package-lock.json ./ + +RUN npm ci --omit=dev --ignore-scripts + WORKDIR /app -COPY package.json package-lock.json ./ COPY packages/device-protocol-contract ./packages/device-protocol-contract COPY packages/device-edge-channel-contract ./packages/device-edge-channel-contract -COPY packages/device-adapter-runtime/package.json ./packages/device-adapter-runtime/package.json -COPY packages/device-adapter-catalog/package.json ./packages/device-adapter-catalog/package.json -COPY packages/arusnavi-b2-adapter/package.json ./packages/arusnavi-b2-adapter/package.json 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 -COPY services/device-gateway-core ./services/device-gateway-core - -RUN npm ci --omit=dev --ignore-scripts USER node diff --git a/device-plane/services/device-control-core/package-lock.json b/device-plane/services/device-control-core/package-lock.json new file mode 100644 index 0000000..565b6a7 --- /dev/null +++ b/device-plane/services/device-control-core/package-lock.json @@ -0,0 +1,164 @@ +{ + "name": "@nodedc/device-control-core", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nodedc/device-control-core", + "version": "0.1.0", + "dependencies": { + "pg": "8.22.0" + }, + "engines": { + "node": ">=20" + } + }, + "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.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "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" + } + } + } +} diff --git a/device-plane/services/device-control-core/package.json b/device-plane/services/device-control-core/package.json index 295e62f..4ba0ce5 100644 --- a/device-plane/services/device-control-core/package.json +++ b/device-plane/services/device-control-core/package.json @@ -8,7 +8,7 @@ "test": "node --test test/*.test.mjs" }, "dependencies": { - "pg": "^8.18.0" + "pg": "8.22.0" }, "engines": { "node": ">=20" diff --git a/device-plane/services/device-control-core/src/device-gateway-core-runtime.mjs b/device-plane/services/device-control-core/src/device-gateway-core-runtime.mjs new file mode 100644 index 0000000..bf855e3 --- /dev/null +++ b/device-plane/services/device-control-core/src/device-gateway-core-runtime.mjs @@ -0,0 +1,606 @@ +import { randomUUID } from "node:crypto"; +import { connect as connectHttp2 } from "node:http2"; + +import { + DEVICE_EDGE_CHANNEL_LIMITS, + DEVICE_EDGE_CHANNEL_PATH, + createChannelEnvelope, + createChannelEnvelopeDecoder, + encodeChannelEnvelope, + nextReconnectDelay, + normalizeCertificateIdentities, + normalizeCertificateFingerprint, +} from "../../../packages/device-edge-channel-contract/src/index.mjs"; +import { + normalizeAdapterMessage, + normalizeDiscoverySignal, +} from "../../../packages/device-protocol-contract/src/index.mjs"; + +// Runtime-owned transport implementation; kept inside the deployable Core context. +const CHANNEL_TRACKER_SESSION_ID = "channel:control"; +const CHANNEL_PROFILE_REF = "channel.control.v1"; + +export function createDeviceGatewayCoreChannelClient(options = {}) { + const config = normalizeConfig(options); + const readyWaiters = new Set(); + let running = false; + let state = null; + let reconnectTimer = null; + let reconnectAttempt = 0; + let connectionSerial = 0; + let totalConnectionAttempts = 0; + let totalChannelsAccepted = 0; + let totalReconnects = 0; + let totalEventsAccepted = 0; + let totalEventsRejected = 0; + let totalProtocolFailures = 0; + let lastErrorCode = null; + + return Object.freeze({ + async start() { + if (running) return; + running = true; + void connectNow(); + }, + async stop() { + running = false; + clearTimeout(reconnectTimer); + reconnectTimer = null; + const current = state; + state = null; + if (current) closeConnection(current, false); + rejectReadyWaiters("device_gateway_core_channel_stopped"); + }, + waitForReady(timeoutMs = 5_000) { + if (state?.ready && !state.closed) return Promise.resolve(status()); + const normalizedTimeout = normalizeInteger( + timeoutMs, + 10, + 120_000, + 5_000, + "ready_timeout", + ); + return new Promise((resolve, reject) => { + const waiter = { resolve, reject, timer: null }; + waiter.timer = setTimeout(() => { + readyWaiters.delete(waiter); + reject(new Error("device_gateway_core_channel_ready_timeout")); + }, normalizedTimeout); + waiter.timer.unref?.(); + readyWaiters.add(waiter); + }); + }, + status, + disconnect() { + if (state) closeConnection(state, true); + }, + }); + + function status() { + return Object.freeze({ + running, + channel: state?.ready ? "accepted" : state ? "connecting" : "absent", + edgeRegistrationId: state?.registration?.edgeRegistrationId ?? null, + channelGeneration: state?.channelGeneration ?? null, + edgeTrustGeneration: state?.observedEdgeIdentity?.generationRef ?? null, + edgeCertificateFingerprint: + state?.observedEdgeIdentity?.fingerprint ?? null, + activeTrackerSessionChains: state?.sessionChains.size ?? 0, + connectionAttempts: totalConnectionAttempts, + channelsAccepted: totalChannelsAccepted, + reconnects: totalReconnects, + eventsAccepted: totalEventsAccepted, + eventsRejected: totalEventsRejected, + protocolFailures: totalProtocolFailures, + lastErrorCode, + trackerIngress: "remote-edge-only", + commandTransport: "disabled", + }); + } + + async function connectNow() { + if (!running || state) return; + totalConnectionAttempts += 1; + const serial = ++connectionSerial; + let registration; + try { + registration = normalizeRegistration(await config.registrationProvider()); + if (registration.lifecycleState !== "active") { + throw new Error("device_gateway_core_edge_registration_inactive"); + } + } catch (error) { + lastErrorCode = safeErrorCode(error); + scheduleReconnect(); + return; + } + + const connection = { + serial, + registration, + session: null, + request: null, + decoder: createChannelEnvelopeDecoder({ + direction: "edge-to-core", + maxEnvelopeBytes: config.maxEnvelopeBytes, + }), + sessionChains: new Map(), + channelGeneration: null, + observedEdgeIdentity: null, + edgeSequence: 0, + coreSequence: 0, + lastEdgeActivityAt: config.clock(), + heartbeatTimer: null, + ready: false, + closed: false, + }; + state = connection; + const endpoint = new URL(registration.endpoint); + const authority = `${endpoint.protocol}//${endpoint.host}`; + const session = connectHttp2(authority, { + key: config.tls.key, + cert: config.tls.cert, + ca: config.tls.ca, + minVersion: "TLSv1.3", + maxVersion: "TLSv1.3", + rejectUnauthorized: true, + servername: registration.servername, + ALPNProtocols: ["h2"], + settings: { + enablePush: false, + initialWindowSize: 1024 * 1024, + }, + }); + connection.session = session; + session.once("error", (error) => failConnection(connection, error)); + session.once("close", () => closeConnection(connection, true)); + session.once("connect", () => { + try { + verifyEdgePeer(connection); + openChannelStream(connection); + } catch (error) { + failConnection(connection, error); + } + }); + } + + function openChannelStream(connection) { + assertCurrent(connection); + const request = connection.session.request({ + ":method": "POST", + ":path": DEVICE_EDGE_CHANNEL_PATH, + "content-type": "application/x-ndjson", + "cache-control": "no-store", + }, { endStream: false }); + connection.request = request; + request.once("response", (headers) => { + if (Number(headers[":status"]) !== 200) { + failConnection(connection, new Error( + `device_gateway_core_channel_http_status_${headers[":status"]}`, + )); + } + }); + let processing = Promise.resolve(); + request.on("data", (chunk) => { + request.pause(); + processing = processing + .then(async () => { + const envelopes = connection.decoder.push(chunk); + for (const envelope of envelopes) { + await handleEdgeEnvelope(connection, envelope); + } + }) + .catch((error) => failConnection(connection, error)) + .finally(() => { + if (!connection.closed) request.resume(); + }); + }); + request.once("aborted", () => closeConnection(connection, true)); + request.once("close", () => closeConnection(connection, true)); + request.once("error", (error) => failConnection(connection, error)); + connection.heartbeatTimer = setInterval( + () => checkChannelHealth(connection), + config.keepaliveMs, + ); + connection.heartbeatTimer.unref?.(); + } + + async function handleEdgeEnvelope(connection, envelope) { + assertCurrent(connection); + if ( + envelope.edgeRegistrationId !== connection.registration.edgeRegistrationId + || envelope.sequence !== connection.edgeSequence + 1 + ) { + throw new Error("device_gateway_core_edge_envelope_mismatch"); + } + if ( + connection.channelGeneration + && envelope.channelGeneration !== connection.channelGeneration + ) { + throw new Error("device_gateway_core_channel_generation_mismatch"); + } + connection.edgeSequence = envelope.sequence; + connection.lastEdgeActivityAt = config.clock(); + + if (!connection.ready) { + if (envelope.messageKind !== "channel.hello") { + throw new Error("device_gateway_core_channel_hello_required"); + } + if ( + envelope.channelGeneration !== connection.registration.channelGeneration + ) { + throw new Error("device_gateway_core_channel_generation_mismatch"); + } + if ( + envelope.payload?.status !== "ready" + || envelope.payload?.transport !== "http2-mtls" + || envelope.payload?.trustGeneration + !== connection.observedEdgeIdentity?.generationRef + || envelope.payload?.commandTransport !== "disabled" + ) { + throw new Error("device_gateway_core_channel_hello_invalid"); + } + connection.channelGeneration = connection.registration.channelGeneration; + send(connection, "channel.accepted", { + status: "accepted", + coreIdentity: config.coreIdentity, + commandTransport: "disabled", + }, { + trackerSessionId: CHANNEL_TRACKER_SESSION_ID, + adapterProfileRef: CHANNEL_PROFILE_REF, + correlationId: envelope.correlationId, + }); + connection.ready = true; + reconnectAttempt = 0; + totalChannelsAccepted += 1; + lastErrorCode = null; + resolveReadyWaiters(); + return; + } + if (envelope.messageKind === "channel.heartbeat") return; + if (["discovery.observed", "adapter.message"].includes(envelope.messageKind)) { + scheduleTrackerEvent(connection, envelope); + return; + } + throw new Error("device_gateway_core_edge_message_unhandled"); + } + + function scheduleTrackerEvent(connection, envelope) { + if (envelope.trackerSessionId === CHANNEL_TRACKER_SESSION_ID) { + throw new Error("device_gateway_core_tracker_session_invalid"); + } + const previous = connection.sessionChains.get(envelope.trackerSessionId); + if (!previous && connection.sessionChains.size >= 128) { + throw new Error("device_gateway_core_tracker_session_limit_reached"); + } + const work = (previous ?? Promise.resolve()) + .then(() => envelope.messageKind === "discovery.observed" + ? acceptDiscovery(connection, envelope) + : acceptAdapterMessage(connection, envelope)) + .catch((error) => failConnection(connection, error)) + .finally(() => { + if (connection.sessionChains.get(envelope.trackerSessionId) === work) { + connection.sessionChains.delete(envelope.trackerSessionId); + } + }); + connection.sessionChains.set(envelope.trackerSessionId, work); + } + + async function acceptDiscovery(connection, envelope) { + try { + const signal = normalizeDiscoverySignal(envelope.payload?.signal); + const discovery = await config.observeDiscovery(signal); + sendEventResult(connection, envelope, { discovery }); + totalEventsAccepted += 1; + } catch (error) { + sendEventRejection(connection, envelope, error); + totalEventsRejected += 1; + } + } + + async function acceptAdapterMessage(connection, envelope) { + try { + const message = normalizeAdapterMessage(envelope.payload?.message, { + maxBytes: config.maxEnvelopeBytes, + }); + const acceptance = await config.acceptMessage(message); + sendEventResult(connection, envelope, { acceptance }); + totalEventsAccepted += 1; + } catch (error) { + sendEventRejection(connection, envelope, error); + totalEventsRejected += 1; + } + } + + function sendEventResult(connection, envelope, result) { + send(connection, "event.accepted", { result }, { + trackerSessionId: envelope.trackerSessionId, + adapterProfileRef: envelope.adapterProfileRef, + correlationId: envelope.correlationId, + }); + } + + function sendEventRejection(connection, envelope, error) { + send(connection, "event.rejected", { + errorCode: safeErrorCode(error), + }, { + trackerSessionId: envelope.trackerSessionId, + adapterProfileRef: envelope.adapterProfileRef, + correlationId: envelope.correlationId, + }); + } + + function send(connection, messageKind, payload, metadata) { + assertCurrent(connection); + if (!connection.channelGeneration) { + throw new Error("device_gateway_core_channel_generation_absent"); + } + connection.coreSequence += 1; + const now = config.now(); + const envelope = createChannelEnvelope({ + edgeRegistrationId: connection.registration.edgeRegistrationId, + channelGeneration: connection.channelGeneration, + trackerSessionId: metadata.trackerSessionId, + adapterProfileRef: metadata.adapterProfileRef, + sequence: connection.coreSequence, + eventAt: metadata.eventAt ?? now, + receivedAt: now, + messageKind, + correlationId: metadata.correlationId, + payload, + }, { + direction: "core-to-edge", + maxEnvelopeBytes: config.maxEnvelopeBytes, + }); + connection.request.write(encodeChannelEnvelope(envelope, { + direction: "core-to-edge", + maxEnvelopeBytes: config.maxEnvelopeBytes, + })); + } + + function checkChannelHealth(connection) { + if (connection.closed || state !== connection) return; + if (config.clock() - connection.lastEdgeActivityAt >= config.deadPeerMs) { + failConnection(connection, new Error("device_gateway_core_edge_dead_peer")); + return; + } + if (connection.ready) { + try { + send(connection, "channel.heartbeat", { status: "alive" }, { + trackerSessionId: CHANNEL_TRACKER_SESSION_ID, + adapterProfileRef: CHANNEL_PROFILE_REF, + correlationId: `correlation:${randomUUID()}`, + }); + } catch (error) { + failConnection(connection, error); + } + } + } + + function verifyEdgePeer(connection) { + const socket = connection.session.socket; + if (!socket?.authorized || socket.alpnProtocol !== "h2") { + throw new Error("device_gateway_core_edge_tls_unauthorized"); + } + const observed = normalizeCertificateFingerprint( + socket.getPeerCertificate()?.fingerprint256, + ); + const identity = connection.registration.certificateIdentities.find( + (candidate) => candidate.fingerprint === observed, + ); + if (!identity) { + throw new Error("device_gateway_core_edge_identity_mismatch"); + } + connection.observedEdgeIdentity = identity; + } + + function failConnection(connection, error) { + if (connection.closed) return; + totalProtocolFailures += 1; + lastErrorCode = safeErrorCode(error); + closeConnection(connection, true); + } + + function closeConnection(connection, reconnect) { + if (connection.closed) return; + connection.closed = true; + clearInterval(connection.heartbeatTimer); + connection.heartbeatTimer = null; + connection.sessionChains.clear(); + try { + connection.request?.close(); + } catch {} + try { + connection.session?.close(); + } catch {} + if (state === connection) state = null; + if (reconnect && running) scheduleReconnect(); + } + + function scheduleReconnect() { + if (!running || reconnectTimer || state) return; + const delay = nextReconnectDelay(reconnectAttempt, { + minimumMs: config.reconnectMinimumMs, + maximumMs: config.reconnectMaximumMs, + random: config.random, + }); + reconnectAttempt += 1; + totalReconnects += 1; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + void connectNow(); + }, delay); + reconnectTimer.unref?.(); + } + + function assertCurrent(connection) { + if (!running || connection.closed || state !== connection) { + throw new Error("device_gateway_core_channel_unavailable"); + } + } + + function resolveReadyWaiters() { + const value = status(); + for (const waiter of readyWaiters) { + clearTimeout(waiter.timer); + waiter.resolve(value); + } + readyWaiters.clear(); + } + + function rejectReadyWaiters(code) { + for (const waiter of readyWaiters) { + clearTimeout(waiter.timer); + waiter.reject(new Error(code)); + } + readyWaiters.clear(); + } +} + +function normalizeConfig(options) { + if (typeof options.observeDiscovery !== "function") { + throw new TypeError("device_gateway_core_observe_discovery_invalid"); + } + if (typeof options.acceptMessage !== "function") { + throw new TypeError("device_gateway_core_accept_message_invalid"); + } + const registrationProvider = typeof options.registrationProvider === "function" + ? options.registrationProvider + : async () => options.registration; + const tls = normalizeTls(options.tls); + const keepaliveMs = normalizeInteger( + options.keepaliveMs, + 10, + 120_000, + DEVICE_EDGE_CHANNEL_LIMITS.keepaliveMs, + "keepalive", + ); + const deadPeerMs = normalizeInteger( + options.deadPeerMs, + keepaliveMs * 2, + 120_000, + DEVICE_EDGE_CHANNEL_LIMITS.deadPeerMs, + "dead_peer", + ); + const reconnectMinimumMs = normalizeInteger( + options.reconnectMinimumMs, + 10, + 120_000, + DEVICE_EDGE_CHANNEL_LIMITS.reconnectMinimumMs, + "reconnect_minimum", + ); + const reconnectMaximumMs = normalizeInteger( + options.reconnectMaximumMs, + 10, + 120_000, + DEVICE_EDGE_CHANNEL_LIMITS.reconnectMaximumMs, + "reconnect_maximum", + ); + if (reconnectMaximumMs < reconnectMinimumMs) { + throw new TypeError("device_gateway_core_reconnect_range_invalid"); + } + return Object.freeze({ + registrationProvider, + tls, + coreIdentity: normalizeRef(options.coreIdentity, "core_identity"), + observeDiscovery: options.observeDiscovery, + acceptMessage: options.acceptMessage, + keepaliveMs, + deadPeerMs, + reconnectMinimumMs, + reconnectMaximumMs, + maxEnvelopeBytes: normalizeInteger( + options.maxEnvelopeBytes, + 256, + DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes, + DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes, + "max_envelope_bytes", + ), + random: typeof options.random === "function" ? options.random : Math.random, + clock: typeof options.clock === "function" ? options.clock : Date.now, + now: typeof options.now === "function" + ? () => new Date(options.now()).toISOString() + : () => new Date().toISOString(), + }); +} + +function normalizeRegistration(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("device_gateway_core_edge_registration_invalid"); + } + let endpoint; + try { + endpoint = new URL(String(value.endpoint || "")); + } catch { + throw new TypeError("device_gateway_core_edge_endpoint_invalid"); + } + if ( + endpoint.protocol !== "https:" + || endpoint.username + || endpoint.password + || endpoint.pathname !== "/" + || endpoint.search + || endpoint.hash + ) { + throw new TypeError("device_gateway_core_edge_endpoint_invalid"); + } + if (!["active", "revoked", "disabled"].includes(value.lifecycleState)) { + throw new TypeError("device_gateway_core_edge_lifecycle_invalid"); + } + const servername = String(value.servername || ""); + if (!/^[A-Za-z0-9.-]{1,253}$/.test(servername)) { + throw new TypeError("device_gateway_core_edge_servername_invalid"); + } + return Object.freeze({ + edgeRegistrationId: normalizeRef( + value.edgeRegistrationId, + "edge_registration_id", + ), + channelGeneration: normalizeRef( + value.channelGeneration, + "channel_generation", + ), + endpoint: endpoint.toString(), + servername, + certificateIdentities: normalizeCertificateIdentities( + value.certificateIdentities, + ), + lifecycleState: value.lifecycleState, + }); +} + +function normalizeTls(value) { + if (!value || typeof value !== "object") { + throw new TypeError("device_gateway_core_channel_tls_invalid"); + } + for (const key of ["key", "cert", "ca"]) { + if (!(typeof value[key] === "string" || Buffer.isBuffer(value[key]))) { + throw new TypeError(`device_gateway_core_channel_tls_${key}_invalid`); + } + } + return Object.freeze({ key: value.key, cert: value.cert, ca: value.ca }); +} + +function safeErrorCode(error) { + const value = String(error?.message || error || "device_gateway_core_error") + .toLowerCase() + .replaceAll(/[^a-z0-9._:-]/g, "_") + .slice(0, 128); + return /^[a-z][a-z0-9._:-]{2,127}$/.test(value) + ? value + : "device_gateway_core_event_rejected"; +} + +function normalizeRef(value, field) { + if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) { + throw new TypeError(`device_gateway_core_${field}_invalid`); + } + return value; +} + +function normalizeInteger(value, minimum, maximum, fallback, field) { + const number = value == null ? fallback : Number(value); + if (!Number.isSafeInteger(number) || number < minimum || number > maximum) { + throw new TypeError(`device_gateway_core_${field}_invalid`); + } + return number; +} diff --git a/device-plane/services/device-control-core/src/edge-channel-supervisor.mjs b/device-plane/services/device-control-core/src/edge-channel-supervisor.mjs index a9ac683..a7bddec 100644 --- a/device-plane/services/device-control-core/src/edge-channel-supervisor.mjs +++ b/device-plane/services/device-control-core/src/edge-channel-supervisor.mjs @@ -8,7 +8,7 @@ import { } from "../../../packages/device-edge-channel-contract/src/index.mjs"; import { createDeviceGatewayCoreChannelClient, -} from "../../device-gateway-core/src/runtime.mjs"; +} from "./device-gateway-core-runtime.mjs"; const DEFAULT_TRUST_ROOT = "/run/nodedc-secrets/device-edge-channel/peers"; diff --git a/device-plane/services/device-gateway-core/src/runtime.mjs b/device-plane/services/device-gateway-core/src/runtime.mjs index f07e92f..4fa1255 100644 --- a/device-plane/services/device-gateway-core/src/runtime.mjs +++ b/device-plane/services/device-gateway-core/src/runtime.mjs @@ -1,605 +1,3 @@ -import { randomUUID } from "node:crypto"; -import { connect as connectHttp2 } from "node:http2"; - -import { - DEVICE_EDGE_CHANNEL_LIMITS, - DEVICE_EDGE_CHANNEL_PATH, - createChannelEnvelope, - createChannelEnvelopeDecoder, - encodeChannelEnvelope, - nextReconnectDelay, - normalizeCertificateIdentities, - normalizeCertificateFingerprint, -} from "../../../packages/device-edge-channel-contract/src/index.mjs"; -import { - normalizeAdapterMessage, - normalizeDiscoverySignal, -} from "../../../packages/device-protocol-contract/src/index.mjs"; - -const CHANNEL_TRACKER_SESSION_ID = "channel:control"; -const CHANNEL_PROFILE_REF = "channel.control.v1"; - -export function createDeviceGatewayCoreChannelClient(options = {}) { - const config = normalizeConfig(options); - const readyWaiters = new Set(); - let running = false; - let state = null; - let reconnectTimer = null; - let reconnectAttempt = 0; - let connectionSerial = 0; - let totalConnectionAttempts = 0; - let totalChannelsAccepted = 0; - let totalReconnects = 0; - let totalEventsAccepted = 0; - let totalEventsRejected = 0; - let totalProtocolFailures = 0; - let lastErrorCode = null; - - return Object.freeze({ - async start() { - if (running) return; - running = true; - void connectNow(); - }, - async stop() { - running = false; - clearTimeout(reconnectTimer); - reconnectTimer = null; - const current = state; - state = null; - if (current) closeConnection(current, false); - rejectReadyWaiters("device_gateway_core_channel_stopped"); - }, - waitForReady(timeoutMs = 5_000) { - if (state?.ready && !state.closed) return Promise.resolve(status()); - const normalizedTimeout = normalizeInteger( - timeoutMs, - 10, - 120_000, - 5_000, - "ready_timeout", - ); - return new Promise((resolve, reject) => { - const waiter = { resolve, reject, timer: null }; - waiter.timer = setTimeout(() => { - readyWaiters.delete(waiter); - reject(new Error("device_gateway_core_channel_ready_timeout")); - }, normalizedTimeout); - waiter.timer.unref?.(); - readyWaiters.add(waiter); - }); - }, - status, - disconnect() { - if (state) closeConnection(state, true); - }, - }); - - function status() { - return Object.freeze({ - running, - channel: state?.ready ? "accepted" : state ? "connecting" : "absent", - edgeRegistrationId: state?.registration?.edgeRegistrationId ?? null, - channelGeneration: state?.channelGeneration ?? null, - edgeTrustGeneration: state?.observedEdgeIdentity?.generationRef ?? null, - edgeCertificateFingerprint: - state?.observedEdgeIdentity?.fingerprint ?? null, - activeTrackerSessionChains: state?.sessionChains.size ?? 0, - connectionAttempts: totalConnectionAttempts, - channelsAccepted: totalChannelsAccepted, - reconnects: totalReconnects, - eventsAccepted: totalEventsAccepted, - eventsRejected: totalEventsRejected, - protocolFailures: totalProtocolFailures, - lastErrorCode, - trackerIngress: "remote-edge-only", - commandTransport: "disabled", - }); - } - - async function connectNow() { - if (!running || state) return; - totalConnectionAttempts += 1; - const serial = ++connectionSerial; - let registration; - try { - registration = normalizeRegistration(await config.registrationProvider()); - if (registration.lifecycleState !== "active") { - throw new Error("device_gateway_core_edge_registration_inactive"); - } - } catch (error) { - lastErrorCode = safeErrorCode(error); - scheduleReconnect(); - return; - } - - const connection = { - serial, - registration, - session: null, - request: null, - decoder: createChannelEnvelopeDecoder({ - direction: "edge-to-core", - maxEnvelopeBytes: config.maxEnvelopeBytes, - }), - sessionChains: new Map(), - channelGeneration: null, - observedEdgeIdentity: null, - edgeSequence: 0, - coreSequence: 0, - lastEdgeActivityAt: config.clock(), - heartbeatTimer: null, - ready: false, - closed: false, - }; - state = connection; - const endpoint = new URL(registration.endpoint); - const authority = `${endpoint.protocol}//${endpoint.host}`; - const session = connectHttp2(authority, { - key: config.tls.key, - cert: config.tls.cert, - ca: config.tls.ca, - minVersion: "TLSv1.3", - maxVersion: "TLSv1.3", - rejectUnauthorized: true, - servername: registration.servername, - ALPNProtocols: ["h2"], - settings: { - enablePush: false, - initialWindowSize: 1024 * 1024, - }, - }); - connection.session = session; - session.once("error", (error) => failConnection(connection, error)); - session.once("close", () => closeConnection(connection, true)); - session.once("connect", () => { - try { - verifyEdgePeer(connection); - openChannelStream(connection); - } catch (error) { - failConnection(connection, error); - } - }); - } - - function openChannelStream(connection) { - assertCurrent(connection); - const request = connection.session.request({ - ":method": "POST", - ":path": DEVICE_EDGE_CHANNEL_PATH, - "content-type": "application/x-ndjson", - "cache-control": "no-store", - }, { endStream: false }); - connection.request = request; - request.once("response", (headers) => { - if (Number(headers[":status"]) !== 200) { - failConnection(connection, new Error( - `device_gateway_core_channel_http_status_${headers[":status"]}`, - )); - } - }); - let processing = Promise.resolve(); - request.on("data", (chunk) => { - request.pause(); - processing = processing - .then(async () => { - const envelopes = connection.decoder.push(chunk); - for (const envelope of envelopes) { - await handleEdgeEnvelope(connection, envelope); - } - }) - .catch((error) => failConnection(connection, error)) - .finally(() => { - if (!connection.closed) request.resume(); - }); - }); - request.once("aborted", () => closeConnection(connection, true)); - request.once("close", () => closeConnection(connection, true)); - request.once("error", (error) => failConnection(connection, error)); - connection.heartbeatTimer = setInterval( - () => checkChannelHealth(connection), - config.keepaliveMs, - ); - connection.heartbeatTimer.unref?.(); - } - - async function handleEdgeEnvelope(connection, envelope) { - assertCurrent(connection); - if ( - envelope.edgeRegistrationId !== connection.registration.edgeRegistrationId - || envelope.sequence !== connection.edgeSequence + 1 - ) { - throw new Error("device_gateway_core_edge_envelope_mismatch"); - } - if ( - connection.channelGeneration - && envelope.channelGeneration !== connection.channelGeneration - ) { - throw new Error("device_gateway_core_channel_generation_mismatch"); - } - connection.edgeSequence = envelope.sequence; - connection.lastEdgeActivityAt = config.clock(); - - if (!connection.ready) { - if (envelope.messageKind !== "channel.hello") { - throw new Error("device_gateway_core_channel_hello_required"); - } - if ( - envelope.channelGeneration !== connection.registration.channelGeneration - ) { - throw new Error("device_gateway_core_channel_generation_mismatch"); - } - if ( - envelope.payload?.status !== "ready" - || envelope.payload?.transport !== "http2-mtls" - || envelope.payload?.trustGeneration - !== connection.observedEdgeIdentity?.generationRef - || envelope.payload?.commandTransport !== "disabled" - ) { - throw new Error("device_gateway_core_channel_hello_invalid"); - } - connection.channelGeneration = connection.registration.channelGeneration; - send(connection, "channel.accepted", { - status: "accepted", - coreIdentity: config.coreIdentity, - commandTransport: "disabled", - }, { - trackerSessionId: CHANNEL_TRACKER_SESSION_ID, - adapterProfileRef: CHANNEL_PROFILE_REF, - correlationId: envelope.correlationId, - }); - connection.ready = true; - reconnectAttempt = 0; - totalChannelsAccepted += 1; - lastErrorCode = null; - resolveReadyWaiters(); - return; - } - if (envelope.messageKind === "channel.heartbeat") return; - if (["discovery.observed", "adapter.message"].includes(envelope.messageKind)) { - scheduleTrackerEvent(connection, envelope); - return; - } - throw new Error("device_gateway_core_edge_message_unhandled"); - } - - function scheduleTrackerEvent(connection, envelope) { - if (envelope.trackerSessionId === CHANNEL_TRACKER_SESSION_ID) { - throw new Error("device_gateway_core_tracker_session_invalid"); - } - const previous = connection.sessionChains.get(envelope.trackerSessionId); - if (!previous && connection.sessionChains.size >= 128) { - throw new Error("device_gateway_core_tracker_session_limit_reached"); - } - const work = (previous ?? Promise.resolve()) - .then(() => envelope.messageKind === "discovery.observed" - ? acceptDiscovery(connection, envelope) - : acceptAdapterMessage(connection, envelope)) - .catch((error) => failConnection(connection, error)) - .finally(() => { - if (connection.sessionChains.get(envelope.trackerSessionId) === work) { - connection.sessionChains.delete(envelope.trackerSessionId); - } - }); - connection.sessionChains.set(envelope.trackerSessionId, work); - } - - async function acceptDiscovery(connection, envelope) { - try { - const signal = normalizeDiscoverySignal(envelope.payload?.signal); - const discovery = await config.observeDiscovery(signal); - sendEventResult(connection, envelope, { discovery }); - totalEventsAccepted += 1; - } catch (error) { - sendEventRejection(connection, envelope, error); - totalEventsRejected += 1; - } - } - - async function acceptAdapterMessage(connection, envelope) { - try { - const message = normalizeAdapterMessage(envelope.payload?.message, { - maxBytes: config.maxEnvelopeBytes, - }); - const acceptance = await config.acceptMessage(message); - sendEventResult(connection, envelope, { acceptance }); - totalEventsAccepted += 1; - } catch (error) { - sendEventRejection(connection, envelope, error); - totalEventsRejected += 1; - } - } - - function sendEventResult(connection, envelope, result) { - send(connection, "event.accepted", { result }, { - trackerSessionId: envelope.trackerSessionId, - adapterProfileRef: envelope.adapterProfileRef, - correlationId: envelope.correlationId, - }); - } - - function sendEventRejection(connection, envelope, error) { - send(connection, "event.rejected", { - errorCode: safeErrorCode(error), - }, { - trackerSessionId: envelope.trackerSessionId, - adapterProfileRef: envelope.adapterProfileRef, - correlationId: envelope.correlationId, - }); - } - - function send(connection, messageKind, payload, metadata) { - assertCurrent(connection); - if (!connection.channelGeneration) { - throw new Error("device_gateway_core_channel_generation_absent"); - } - connection.coreSequence += 1; - const now = config.now(); - const envelope = createChannelEnvelope({ - edgeRegistrationId: connection.registration.edgeRegistrationId, - channelGeneration: connection.channelGeneration, - trackerSessionId: metadata.trackerSessionId, - adapterProfileRef: metadata.adapterProfileRef, - sequence: connection.coreSequence, - eventAt: metadata.eventAt ?? now, - receivedAt: now, - messageKind, - correlationId: metadata.correlationId, - payload, - }, { - direction: "core-to-edge", - maxEnvelopeBytes: config.maxEnvelopeBytes, - }); - connection.request.write(encodeChannelEnvelope(envelope, { - direction: "core-to-edge", - maxEnvelopeBytes: config.maxEnvelopeBytes, - })); - } - - function checkChannelHealth(connection) { - if (connection.closed || state !== connection) return; - if (config.clock() - connection.lastEdgeActivityAt >= config.deadPeerMs) { - failConnection(connection, new Error("device_gateway_core_edge_dead_peer")); - return; - } - if (connection.ready) { - try { - send(connection, "channel.heartbeat", { status: "alive" }, { - trackerSessionId: CHANNEL_TRACKER_SESSION_ID, - adapterProfileRef: CHANNEL_PROFILE_REF, - correlationId: `correlation:${randomUUID()}`, - }); - } catch (error) { - failConnection(connection, error); - } - } - } - - function verifyEdgePeer(connection) { - const socket = connection.session.socket; - if (!socket?.authorized || socket.alpnProtocol !== "h2") { - throw new Error("device_gateway_core_edge_tls_unauthorized"); - } - const observed = normalizeCertificateFingerprint( - socket.getPeerCertificate()?.fingerprint256, - ); - const identity = connection.registration.certificateIdentities.find( - (candidate) => candidate.fingerprint === observed, - ); - if (!identity) { - throw new Error("device_gateway_core_edge_identity_mismatch"); - } - connection.observedEdgeIdentity = identity; - } - - function failConnection(connection, error) { - if (connection.closed) return; - totalProtocolFailures += 1; - lastErrorCode = safeErrorCode(error); - closeConnection(connection, true); - } - - function closeConnection(connection, reconnect) { - if (connection.closed) return; - connection.closed = true; - clearInterval(connection.heartbeatTimer); - connection.heartbeatTimer = null; - connection.sessionChains.clear(); - try { - connection.request?.close(); - } catch {} - try { - connection.session?.close(); - } catch {} - if (state === connection) state = null; - if (reconnect && running) scheduleReconnect(); - } - - function scheduleReconnect() { - if (!running || reconnectTimer || state) return; - const delay = nextReconnectDelay(reconnectAttempt, { - minimumMs: config.reconnectMinimumMs, - maximumMs: config.reconnectMaximumMs, - random: config.random, - }); - reconnectAttempt += 1; - totalReconnects += 1; - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - void connectNow(); - }, delay); - reconnectTimer.unref?.(); - } - - function assertCurrent(connection) { - if (!running || connection.closed || state !== connection) { - throw new Error("device_gateway_core_channel_unavailable"); - } - } - - function resolveReadyWaiters() { - const value = status(); - for (const waiter of readyWaiters) { - clearTimeout(waiter.timer); - waiter.resolve(value); - } - readyWaiters.clear(); - } - - function rejectReadyWaiters(code) { - for (const waiter of readyWaiters) { - clearTimeout(waiter.timer); - waiter.reject(new Error(code)); - } - readyWaiters.clear(); - } -} - -function normalizeConfig(options) { - if (typeof options.observeDiscovery !== "function") { - throw new TypeError("device_gateway_core_observe_discovery_invalid"); - } - if (typeof options.acceptMessage !== "function") { - throw new TypeError("device_gateway_core_accept_message_invalid"); - } - const registrationProvider = typeof options.registrationProvider === "function" - ? options.registrationProvider - : async () => options.registration; - const tls = normalizeTls(options.tls); - const keepaliveMs = normalizeInteger( - options.keepaliveMs, - 10, - 120_000, - DEVICE_EDGE_CHANNEL_LIMITS.keepaliveMs, - "keepalive", - ); - const deadPeerMs = normalizeInteger( - options.deadPeerMs, - keepaliveMs * 2, - 120_000, - DEVICE_EDGE_CHANNEL_LIMITS.deadPeerMs, - "dead_peer", - ); - const reconnectMinimumMs = normalizeInteger( - options.reconnectMinimumMs, - 10, - 120_000, - DEVICE_EDGE_CHANNEL_LIMITS.reconnectMinimumMs, - "reconnect_minimum", - ); - const reconnectMaximumMs = normalizeInteger( - options.reconnectMaximumMs, - 10, - 120_000, - DEVICE_EDGE_CHANNEL_LIMITS.reconnectMaximumMs, - "reconnect_maximum", - ); - if (reconnectMaximumMs < reconnectMinimumMs) { - throw new TypeError("device_gateway_core_reconnect_range_invalid"); - } - return Object.freeze({ - registrationProvider, - tls, - coreIdentity: normalizeRef(options.coreIdentity, "core_identity"), - observeDiscovery: options.observeDiscovery, - acceptMessage: options.acceptMessage, - keepaliveMs, - deadPeerMs, - reconnectMinimumMs, - reconnectMaximumMs, - maxEnvelopeBytes: normalizeInteger( - options.maxEnvelopeBytes, - 256, - DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes, - DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes, - "max_envelope_bytes", - ), - random: typeof options.random === "function" ? options.random : Math.random, - clock: typeof options.clock === "function" ? options.clock : Date.now, - now: typeof options.now === "function" - ? () => new Date(options.now()).toISOString() - : () => new Date().toISOString(), - }); -} - -function normalizeRegistration(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new TypeError("device_gateway_core_edge_registration_invalid"); - } - let endpoint; - try { - endpoint = new URL(String(value.endpoint || "")); - } catch { - throw new TypeError("device_gateway_core_edge_endpoint_invalid"); - } - if ( - endpoint.protocol !== "https:" - || endpoint.username - || endpoint.password - || endpoint.pathname !== "/" - || endpoint.search - || endpoint.hash - ) { - throw new TypeError("device_gateway_core_edge_endpoint_invalid"); - } - if (!["active", "revoked", "disabled"].includes(value.lifecycleState)) { - throw new TypeError("device_gateway_core_edge_lifecycle_invalid"); - } - const servername = String(value.servername || ""); - if (!/^[A-Za-z0-9.-]{1,253}$/.test(servername)) { - throw new TypeError("device_gateway_core_edge_servername_invalid"); - } - return Object.freeze({ - edgeRegistrationId: normalizeRef( - value.edgeRegistrationId, - "edge_registration_id", - ), - channelGeneration: normalizeRef( - value.channelGeneration, - "channel_generation", - ), - endpoint: endpoint.toString(), - servername, - certificateIdentities: normalizeCertificateIdentities( - value.certificateIdentities, - ), - lifecycleState: value.lifecycleState, - }); -} - -function normalizeTls(value) { - if (!value || typeof value !== "object") { - throw new TypeError("device_gateway_core_channel_tls_invalid"); - } - for (const key of ["key", "cert", "ca"]) { - if (!(typeof value[key] === "string" || Buffer.isBuffer(value[key]))) { - throw new TypeError(`device_gateway_core_channel_tls_${key}_invalid`); - } - } - return Object.freeze({ key: value.key, cert: value.cert, ca: value.ca }); -} - -function safeErrorCode(error) { - const value = String(error?.message || error || "device_gateway_core_error") - .toLowerCase() - .replaceAll(/[^a-z0-9._:-]/g, "_") - .slice(0, 128); - return /^[a-z][a-z0-9._:-]{2,127}$/.test(value) - ? value - : "device_gateway_core_event_rejected"; -} - -function normalizeRef(value, field) { - if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) { - throw new TypeError(`device_gateway_core_${field}_invalid`); - } - return value; -} - -function normalizeInteger(value, minimum, maximum, fallback, field) { - const number = value == null ? fallback : Number(value); - if (!Number.isSafeInteger(number) || number < minimum || number > maximum) { - throw new TypeError(`device_gateway_core_${field}_invalid`); - } - return number; -} +export { + createDeviceGatewayCoreChannelClient, +} from "../../device-control-core/src/device-gateway-core-runtime.mjs"; diff --git a/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs b/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs index 172f931..0a1c62e 100644 --- a/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs +++ b/infra/deploy-runner/build-device-manager-control-plane-artifact.mjs @@ -12,7 +12,7 @@ const devicePlaneRoot = resolve(platformRoot, "device-plane"); const designRoot = resolve(process.env.NODEDC_DEVICE_MANAGER_SOURCE_ROOT || resolve(platformRoot, "../NODEDC_DESIGN_GUIDELINE")); const managerRoot = resolve(designRoot, "apps/device-manager"); const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); -const [patchId = "device-manager-release-20260811-015", ...extra] = process.argv.slice(2); +const [patchId = "device-manager-release-20260811-016", ...extra] = process.argv.slice(2); if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-device-manager-control-plane-artifact.mjs [patch-id]"); const entries = [ @@ -22,13 +22,10 @@ const entries = [ "docker-compose.device-manager.yml", "packages/device-protocol-contract", "packages/device-edge-channel-contract", - "packages/device-adapter-runtime/package.json", - "packages/device-adapter-catalog/package.json", "packages/arusnavi-b2-adapter", "services/device-control-core", "services/device-gateway/package.json", "services/device-edge-relay/package.json", - "services/device-gateway-core", "services/device-manager", "deployment/device-manager-release-v2.json", ]; @@ -75,6 +72,7 @@ try { ); for (const modulePath of [ "services/device-control-core/src/sensitive-reference-management.mjs", + "services/device-control-core/src/device-gateway-core-runtime.mjs", "packages/device-edge-channel-contract/src/index.mjs", ]) { const coreImport = spawnSync( diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index 0f21819..4816cb6 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -299,13 +299,10 @@ DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES = ( DEVICE_PLANE_MANAGER_COMPOSE_REL, "packages/device-protocol-contract", "packages/device-edge-channel-contract", - "packages/device-adapter-runtime/package.json", - "packages/device-adapter-catalog/package.json", "packages/arusnavi-b2-adapter", "services/device-control-core", "services/device-gateway/package.json", "services/device-edge-relay/package.json", - "services/device-gateway-core", "services/device-manager", DEVICE_PLANE_MANAGER_RELEASE_V2_REL, ) @@ -3536,13 +3533,10 @@ def allowed_payload_path(component, rel): DEVICE_PLANE_MANAGER_V2_RECONCILIATION_REL, "packages/device-protocol-contract", "packages/device-edge-channel-contract", - "packages/device-adapter-runtime/package.json", - "packages/device-adapter-catalog/package.json", "packages/arusnavi-b2-adapter", "services/device-control-core", "services/device-gateway", "services/device-edge-relay/package.json", - "services/device-gateway-core", "services/device-manager", "services/device-backhaul-target", ): @@ -3553,7 +3547,6 @@ def allowed_payload_path(component, rel): "packages/arusnavi-b2-adapter/", "services/device-control-core/", "services/device-gateway/", - "services/device-gateway-core/", "services/device-manager/", "services/device-backhaul-target/", )): diff --git a/infra/deploy-runner/test_device_manager_control_plane_artifacts.py b/infra/deploy-runner/test_device_manager_control_plane_artifacts.py index 15a4249..b47b5f3 100644 --- a/infra/deploy-runner/test_device_manager_control_plane_artifacts.py +++ b/infra/deploy-runner/test_device_manager_control_plane_artifacts.py @@ -211,17 +211,29 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase): ) self.assertIn("packages/device-edge-channel-contract", entries) self.assertIn( - "payload/packages/device-adapter-runtime/package.json", + "payload/services/device-control-core/package-lock.json", names, ) self.assertIn( - "payload/packages/device-adapter-catalog/package.json", + "payload/services/device-control-core/src/device-gateway-core-runtime.mjs", names, ) + self.assertFalse(any( + name.startswith("payload/packages/device-adapter-runtime/") + or name.startswith("payload/packages/device-adapter-catalog/") + or name.startswith("payload/services/device-gateway-core/") + for name in names + )) + core_dockerfile = ( + PLATFORM_ROOT / "device-plane/services/device-control-core/Dockerfile" + ).read_text(encoding="utf-8") self.assertIn( - "payload/services/device-gateway-core/src/runtime.mjs", - names, + "COPY services/device-control-core/package.json " + "services/device-control-core/package-lock.json ./", + core_dockerfile, ) + self.assertNotIn("COPY package.json package-lock.json ./", core_dockerfile) + self.assertNotIn("COPY services/device-gateway-core", core_dockerfile) self.assertFalse(any(name.endswith(".test.mjs") for name in names)) self.assertEqual(result["services"], ["device-control-core", "device-manager"]) self.assertNotIn("device-postgres", result["services"]) diff --git a/infra/deploy-runner/test_device_plane_registry.py b/infra/deploy-runner/test_device_plane_registry.py index 7837e70..629acd6 100644 --- a/infra/deploy-runner/test_device_plane_registry.py +++ b/infra/deploy-runner/test_device_plane_registry.py @@ -108,11 +108,8 @@ class DevicePlaneRegistryTest(unittest.TestCase): "docker-compose.device-plane.yml", "packages/device-protocol-contract/src/index.mjs", "packages/device-edge-channel-contract/src/index.mjs", - "packages/device-adapter-runtime/package.json", - "packages/device-adapter-catalog/package.json", "services/device-control-core/Dockerfile", "services/device-gateway/src/runtime.mjs", - "services/device-gateway-core/src/runtime.mjs", ): self.assertTrue( RUNNER.allowed_payload_path("device-plane", allowed), @@ -123,6 +120,9 @@ class DevicePlaneRegistryTest(unittest.TestCase): "secrets/postgres-password", "runtime/postgres/data", "services/unknown/server.mjs", + "packages/device-adapter-runtime/package.json", + "packages/device-adapter-catalog/package.json", + "services/device-gateway-core/src/runtime.mjs", "docker-compose.yml", "services/device-control-core/start.sh", "services/device-control-core/test/app.test.mjs",