fix(deploy): keep manager releases on stable runner

This commit is contained in:
Codex
2026-08-11 21:54:49 +03:00
parent 094cf7143a
commit bb0724c52c
10 changed files with 802 additions and 634 deletions
@@ -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
@@ -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"
},
"dependencies": {
"pg": "^8.18.0"
"pg": "8.22.0"
},
"engines": {
"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";
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";
@@ -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";
@@ -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(
-7
View File
@@ -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/",
)):
@@ -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"])
@@ -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",