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,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";