fix(deploy): keep manager releases on stable runner
This commit is contained in:
@@ -1,19 +1,16 @@
|
|||||||
FROM node:22-alpine
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
COPY packages/device-protocol-contract ./packages/device-protocol-contract
|
COPY packages/device-protocol-contract ./packages/device-protocol-contract
|
||||||
COPY packages/device-edge-channel-contract ./packages/device-edge-channel-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-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
|
USER node
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"test": "node --test test/*.test.mjs"
|
"test": "node --test test/*.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg": "^8.18.0"
|
"pg": "8.22.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "../../../packages/device-edge-channel-contract/src/index.mjs";
|
} from "../../../packages/device-edge-channel-contract/src/index.mjs";
|
||||||
import {
|
import {
|
||||||
createDeviceGatewayCoreChannelClient,
|
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";
|
const DEFAULT_TRUST_ROOT = "/run/nodedc-secrets/device-edge-channel/peers";
|
||||||
|
|
||||||
|
|||||||
@@ -1,605 +1,3 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
export {
|
||||||
import { connect as connectHttp2 } from "node:http2";
|
createDeviceGatewayCoreChannelClient,
|
||||||
|
} from "../../device-control-core/src/device-gateway-core-runtime.mjs";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 designRoot = resolve(process.env.NODEDC_DEVICE_MANAGER_SOURCE_ROOT || resolve(platformRoot, "../NODEDC_DESIGN_GUIDELINE"));
|
||||||
const managerRoot = resolve(designRoot, "apps/device-manager");
|
const managerRoot = resolve(designRoot, "apps/device-manager");
|
||||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
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]");
|
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 = [
|
const entries = [
|
||||||
@@ -22,13 +22,10 @@ const entries = [
|
|||||||
"docker-compose.device-manager.yml",
|
"docker-compose.device-manager.yml",
|
||||||
"packages/device-protocol-contract",
|
"packages/device-protocol-contract",
|
||||||
"packages/device-edge-channel-contract",
|
"packages/device-edge-channel-contract",
|
||||||
"packages/device-adapter-runtime/package.json",
|
|
||||||
"packages/device-adapter-catalog/package.json",
|
|
||||||
"packages/arusnavi-b2-adapter",
|
"packages/arusnavi-b2-adapter",
|
||||||
"services/device-control-core",
|
"services/device-control-core",
|
||||||
"services/device-gateway/package.json",
|
"services/device-gateway/package.json",
|
||||||
"services/device-edge-relay/package.json",
|
"services/device-edge-relay/package.json",
|
||||||
"services/device-gateway-core",
|
|
||||||
"services/device-manager",
|
"services/device-manager",
|
||||||
"deployment/device-manager-release-v2.json",
|
"deployment/device-manager-release-v2.json",
|
||||||
];
|
];
|
||||||
@@ -75,6 +72,7 @@ try {
|
|||||||
);
|
);
|
||||||
for (const modulePath of [
|
for (const modulePath of [
|
||||||
"services/device-control-core/src/sensitive-reference-management.mjs",
|
"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",
|
"packages/device-edge-channel-contract/src/index.mjs",
|
||||||
]) {
|
]) {
|
||||||
const coreImport = spawnSync(
|
const coreImport = spawnSync(
|
||||||
|
|||||||
@@ -299,13 +299,10 @@ DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES = (
|
|||||||
DEVICE_PLANE_MANAGER_COMPOSE_REL,
|
DEVICE_PLANE_MANAGER_COMPOSE_REL,
|
||||||
"packages/device-protocol-contract",
|
"packages/device-protocol-contract",
|
||||||
"packages/device-edge-channel-contract",
|
"packages/device-edge-channel-contract",
|
||||||
"packages/device-adapter-runtime/package.json",
|
|
||||||
"packages/device-adapter-catalog/package.json",
|
|
||||||
"packages/arusnavi-b2-adapter",
|
"packages/arusnavi-b2-adapter",
|
||||||
"services/device-control-core",
|
"services/device-control-core",
|
||||||
"services/device-gateway/package.json",
|
"services/device-gateway/package.json",
|
||||||
"services/device-edge-relay/package.json",
|
"services/device-edge-relay/package.json",
|
||||||
"services/device-gateway-core",
|
|
||||||
"services/device-manager",
|
"services/device-manager",
|
||||||
DEVICE_PLANE_MANAGER_RELEASE_V2_REL,
|
DEVICE_PLANE_MANAGER_RELEASE_V2_REL,
|
||||||
)
|
)
|
||||||
@@ -3536,13 +3533,10 @@ def allowed_payload_path(component, rel):
|
|||||||
DEVICE_PLANE_MANAGER_V2_RECONCILIATION_REL,
|
DEVICE_PLANE_MANAGER_V2_RECONCILIATION_REL,
|
||||||
"packages/device-protocol-contract",
|
"packages/device-protocol-contract",
|
||||||
"packages/device-edge-channel-contract",
|
"packages/device-edge-channel-contract",
|
||||||
"packages/device-adapter-runtime/package.json",
|
|
||||||
"packages/device-adapter-catalog/package.json",
|
|
||||||
"packages/arusnavi-b2-adapter",
|
"packages/arusnavi-b2-adapter",
|
||||||
"services/device-control-core",
|
"services/device-control-core",
|
||||||
"services/device-gateway",
|
"services/device-gateway",
|
||||||
"services/device-edge-relay/package.json",
|
"services/device-edge-relay/package.json",
|
||||||
"services/device-gateway-core",
|
|
||||||
"services/device-manager",
|
"services/device-manager",
|
||||||
"services/device-backhaul-target",
|
"services/device-backhaul-target",
|
||||||
):
|
):
|
||||||
@@ -3553,7 +3547,6 @@ def allowed_payload_path(component, rel):
|
|||||||
"packages/arusnavi-b2-adapter/",
|
"packages/arusnavi-b2-adapter/",
|
||||||
"services/device-control-core/",
|
"services/device-control-core/",
|
||||||
"services/device-gateway/",
|
"services/device-gateway/",
|
||||||
"services/device-gateway-core/",
|
|
||||||
"services/device-manager/",
|
"services/device-manager/",
|
||||||
"services/device-backhaul-target/",
|
"services/device-backhaul-target/",
|
||||||
)):
|
)):
|
||||||
|
|||||||
@@ -211,17 +211,29 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn("packages/device-edge-channel-contract", entries)
|
self.assertIn("packages/device-edge-channel-contract", entries)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"payload/packages/device-adapter-runtime/package.json",
|
"payload/services/device-control-core/package-lock.json",
|
||||||
names,
|
names,
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"payload/packages/device-adapter-catalog/package.json",
|
"payload/services/device-control-core/src/device-gateway-core-runtime.mjs",
|
||||||
names,
|
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(
|
self.assertIn(
|
||||||
"payload/services/device-gateway-core/src/runtime.mjs",
|
"COPY services/device-control-core/package.json "
|
||||||
names,
|
"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.assertFalse(any(name.endswith(".test.mjs") for name in names))
|
||||||
self.assertEqual(result["services"], ["device-control-core", "device-manager"])
|
self.assertEqual(result["services"], ["device-control-core", "device-manager"])
|
||||||
self.assertNotIn("device-postgres", result["services"])
|
self.assertNotIn("device-postgres", result["services"])
|
||||||
|
|||||||
@@ -108,11 +108,8 @@ class DevicePlaneRegistryTest(unittest.TestCase):
|
|||||||
"docker-compose.device-plane.yml",
|
"docker-compose.device-plane.yml",
|
||||||
"packages/device-protocol-contract/src/index.mjs",
|
"packages/device-protocol-contract/src/index.mjs",
|
||||||
"packages/device-edge-channel-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-control-core/Dockerfile",
|
||||||
"services/device-gateway/src/runtime.mjs",
|
"services/device-gateway/src/runtime.mjs",
|
||||||
"services/device-gateway-core/src/runtime.mjs",
|
|
||||||
):
|
):
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
RUNNER.allowed_payload_path("device-plane", allowed),
|
RUNNER.allowed_payload_path("device-plane", allowed),
|
||||||
@@ -123,6 +120,9 @@ class DevicePlaneRegistryTest(unittest.TestCase):
|
|||||||
"secrets/postgres-password",
|
"secrets/postgres-password",
|
||||||
"runtime/postgres/data",
|
"runtime/postgres/data",
|
||||||
"services/unknown/server.mjs",
|
"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",
|
"docker-compose.yml",
|
||||||
"services/device-control-core/start.sh",
|
"services/device-control-core/start.sh",
|
||||||
"services/device-control-core/test/app.test.mjs",
|
"services/device-control-core/test/app.test.mjs",
|
||||||
|
|||||||
Reference in New Issue
Block a user