Add delivery metadata support, preferred channels, and enhanced agent validation
This commit introduces delivery metadata tracking for gateway commands, updates, and shells. Adds preferred delivery channel handling, refined validation for edge agents, improved relay management logic, and broker presence reporting. Includes schema changes, enhanced shell handling, and test coverage.
This commit is contained in:
Vendored
+382
-22
@@ -16,6 +16,7 @@ const DEFAULT_EDGE_AGENT_SERVICE_NAME = "truckwash-edge-agent.service";
|
||||
const DEFAULT_UPDATE_VERIFICATION_TIMEOUT_SECONDS = 45;
|
||||
const DEFAULT_UPDATE_VERIFY_INTERVAL_MS = 500;
|
||||
const DEFAULT_UPDATE_RESTART_GRACE_MS = 150;
|
||||
const DEFAULT_BROKER_RECONNECT_DELAY_MS = 1500;
|
||||
const UPDATE_VERIFY_COMMAND = "post-update-verify";
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
@@ -130,16 +131,69 @@ function createUpdateErrorMessage(error, fallback = "Edge agent update failed")
|
||||
return error instanceof Error ? error.message : String(error || fallback);
|
||||
}
|
||||
|
||||
function buildTransportHeartbeatState() {
|
||||
function buildTransportHeartbeatState(brokerState = {}) {
|
||||
const brokerConnected = Boolean(brokerState.connected);
|
||||
return {
|
||||
status: "ONLINE",
|
||||
metadata: {
|
||||
command_transport: "API_POLLING",
|
||||
command_transport: brokerConnected ? "BROKER_FAST_PATH" : "API_POLLING",
|
||||
shell_transport: "API_POLLING",
|
||||
broker_connected: brokerConnected,
|
||||
broker_url: brokerState.url || null,
|
||||
broker_last_error: brokerState.lastError || null,
|
||||
broker_last_connected_at: brokerState.lastConnectedAt || null,
|
||||
broker_last_disconnected_at: brokerState.lastDisconnectedAt || null,
|
||||
broker_disconnect_reason: brokerState.disconnectReason || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBrokerBaseUrl(value) {
|
||||
const trimmed = String(value || "").trim().replace(/\/+$/, "");
|
||||
if (trimmed === "") {
|
||||
return null;
|
||||
}
|
||||
if (trimmed.startsWith("ws://") || trimmed.startsWith("wss://")) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.startsWith("https://")) {
|
||||
return `wss://${trimmed.slice("https://".length)}`;
|
||||
}
|
||||
if (trimmed.startsWith("http://")) {
|
||||
return `ws://${trimmed.slice("http://".length)}`;
|
||||
}
|
||||
return `ws://${trimmed}`;
|
||||
}
|
||||
|
||||
function buildBrokerSocketUrl(brokerUrl, gatewayId, token) {
|
||||
const baseUrl = normalizeBrokerBaseUrl(brokerUrl);
|
||||
if (!baseUrl || !gatewayId || !token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const search = new URLSearchParams({
|
||||
gatewayId: String(gatewayId),
|
||||
token: String(token),
|
||||
});
|
||||
return `${baseUrl}/ws/agent?${search.toString()}`;
|
||||
}
|
||||
|
||||
async function readSocketMessageText(data) {
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return Buffer.from(data).toString("utf8");
|
||||
}
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
|
||||
}
|
||||
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
||||
return data.text();
|
||||
}
|
||||
return String(data || "");
|
||||
}
|
||||
|
||||
export async function loadConfig(configPath) {
|
||||
const raw = await fs.readFile(configPath, "utf8");
|
||||
return JSON.parse(raw);
|
||||
@@ -160,7 +214,8 @@ export function buildStatusReport(configPath, config) {
|
||||
state: claimed ? "CLAIMED" : "PENDING_CLAIM",
|
||||
gatewayId: config.gatewayId ?? null,
|
||||
apiUrl: config.apiUrl ?? null,
|
||||
transport: "API_POLLING",
|
||||
brokerUrl: config.brokerUrl ?? null,
|
||||
transport: config.brokerUrl ? "HYBRID" : "API_POLLING",
|
||||
hostname: config.hostname || os.hostname(),
|
||||
releaseChannel: config.releaseChannel || "stable",
|
||||
installedVersion: config.installedVersion || DEFAULT_VERSION,
|
||||
@@ -364,6 +419,9 @@ export async function claimIfNeeded(config, configPath, fetchImpl = fetch) {
|
||||
agentToken: claimed.agent_token,
|
||||
releaseChannel: claimed.release_channel || config.releaseChannel || "stable",
|
||||
};
|
||||
if (claimed.broker_url || config.brokerUrl) {
|
||||
nextConfig.brokerUrl = claimed.broker_url || config.brokerUrl;
|
||||
}
|
||||
await saveConfig(configPath, nextConfig);
|
||||
return nextConfig;
|
||||
}
|
||||
@@ -691,6 +749,69 @@ function buildUpdateRestartPlan(payload, configPath, config = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildUninstallPlan(payload = {}, configPath, config = {}) {
|
||||
const paths = resolveUpdatePaths(configPath, config);
|
||||
return {
|
||||
configPath: paths.configPath,
|
||||
serviceName: String(payload.serviceName || config.serviceName || paths.serviceName || DEFAULT_EDGE_AGENT_SERVICE_NAME),
|
||||
restartMode: String(config.restartMode || (process.platform === "win32" ? "spawn" : "systemd")),
|
||||
cleanupDelayMs: DEFAULT_UPDATE_RESTART_GRACE_MS,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeShellArgument(value) {
|
||||
return `'${String(value || "").replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
async function persistUninstallState(configPath, config, { liveConfig = null } = {}) {
|
||||
const currentConfig = cloneJson(config || (await loadConfig(configPath)));
|
||||
const nextConfig = {
|
||||
...currentConfig,
|
||||
gatewayId: null,
|
||||
agentToken: null,
|
||||
installToken: null,
|
||||
brokerUrl: null,
|
||||
pendingUpdate: null,
|
||||
lastUninstall: {
|
||||
state: "SCHEDULED",
|
||||
completedAt: formatUpdateTimestamp(),
|
||||
},
|
||||
};
|
||||
|
||||
await saveConfig(configPath, nextConfig);
|
||||
if (liveConfig) {
|
||||
replaceObjectContents(liveConfig, cloneJson(nextConfig));
|
||||
}
|
||||
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
async function scheduleAgentUninstall(uninstallPlan, {
|
||||
spawnImpl = spawnCallback,
|
||||
waitImpl = wait,
|
||||
exitProcessImpl = (code) => process.exit(code),
|
||||
} = {}) {
|
||||
if (uninstallPlan.restartMode === "systemd" && process.platform !== "win32") {
|
||||
const serviceName = escapeShellArgument(uninstallPlan.serviceName || DEFAULT_EDGE_AGENT_SERVICE_NAME);
|
||||
const uninstallProcess = spawnImpl(
|
||||
"/bin/sh",
|
||||
[
|
||||
"-lc",
|
||||
`sleep 1; systemctl disable --now ${serviceName} >/dev/null 2>&1 || true; systemctl reset-failed ${serviceName} >/dev/null 2>&1 || true`,
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
}
|
||||
);
|
||||
uninstallProcess.unref?.();
|
||||
}
|
||||
|
||||
await waitImpl(uninstallPlan.cleanupDelayMs ?? DEFAULT_UPDATE_RESTART_GRACE_MS);
|
||||
exitProcessImpl(0);
|
||||
}
|
||||
|
||||
async function startDetachedUpdateVerifier(configPath, config, {
|
||||
spawnImpl = spawnCallback,
|
||||
} = {}) {
|
||||
@@ -895,6 +1016,29 @@ export async function runUpdate(payload, fetchImpl = fetch, deps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runUninstall(payload = {}, deps = {}) {
|
||||
const configPath = deps.configPath;
|
||||
if (!configPath) {
|
||||
throw new Error("Missing config path for edge agent uninstall");
|
||||
}
|
||||
|
||||
const liveConfig = deps.liveConfig && typeof deps.liveConfig === "object" ? deps.liveConfig : null;
|
||||
const currentConfig = cloneJson(deps.config || liveConfig || (await loadConfig(configPath)));
|
||||
|
||||
return {
|
||||
__agentCommandEnvelope: true,
|
||||
payload: {
|
||||
uninstall_scheduled: true,
|
||||
scheduled_at: formatUpdateTimestamp(),
|
||||
service_name: String(payload.serviceName || currentConfig.serviceName || DEFAULT_EDGE_AGENT_SERVICE_NAME),
|
||||
},
|
||||
followUp: {
|
||||
type: "UNINSTALL_AGENT",
|
||||
uninstallPlan: buildUninstallPlan(payload, configPath, currentConfig),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defaultShellCommand() {
|
||||
if (process.platform === "win32") {
|
||||
return { command: process.env.ComSpec || "cmd.exe", args: [] };
|
||||
@@ -1038,6 +1182,8 @@ export async function handleAgentCommand(command, deps = {}) {
|
||||
return await setRelayState(command.payload || {}, fetchImpl);
|
||||
case "RUN_UPDATE":
|
||||
return await runUpdate(command.payload || {}, fetchImpl, deps);
|
||||
case "UNINSTALL_AGENT":
|
||||
return await runUninstall(command.payload || {}, deps);
|
||||
case "RESTART_AGENT":
|
||||
return { restarted: true };
|
||||
case "REBOOT_HOST":
|
||||
@@ -1189,49 +1335,95 @@ export async function submitShellSessionEvents(config, sessionId, events = [], f
|
||||
);
|
||||
}
|
||||
|
||||
export async function processPolledCommand(config, command, fetchImpl = fetch) {
|
||||
const jobId = command?.id;
|
||||
async function executeAgentCommandEnvelope(config, command, fetchImpl = fetch, deps = {}) {
|
||||
const commandType = command?.commandType || command?.command_type;
|
||||
const payload = command?.payload || {};
|
||||
|
||||
if (!jobId || !commandType) {
|
||||
if (!commandType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let followUp = null;
|
||||
try {
|
||||
const result = await handleAgentCommand(
|
||||
{ commandType, payload },
|
||||
{
|
||||
...deps,
|
||||
fetchImpl,
|
||||
config,
|
||||
configPath: payload.configPath || config.configPath || null,
|
||||
liveConfig: config,
|
||||
}
|
||||
);
|
||||
const responsePayload =
|
||||
result && result.__agentCommandEnvelope === true
|
||||
? (followUp = result.followUp || null, result.payload || {})
|
||||
: result;
|
||||
await submitCommandJobResult(config, jobId, {
|
||||
ok: true,
|
||||
payload: responsePayload,
|
||||
}, fetchImpl);
|
||||
|
||||
if (commandType === "RUN_UPDATE" && followUp?.type === "RUN_UPDATE") {
|
||||
return {
|
||||
commandType,
|
||||
payload,
|
||||
followUp: result && result.__agentCommandEnvelope === true ? (followUp = result.followUp || null, followUp) : null,
|
||||
responsePayload: result && result.__agentCommandEnvelope === true ? result.payload || {} : result,
|
||||
};
|
||||
}
|
||||
|
||||
async function runAgentCommandFollowUp(
|
||||
config,
|
||||
execution,
|
||||
followUpErrorMessage = "Edge agent restart failed after update",
|
||||
deps = {}
|
||||
) {
|
||||
if (!execution || !execution.followUp?.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (execution.commandType === "RUN_UPDATE" && execution.followUp.type === "RUN_UPDATE") {
|
||||
try {
|
||||
await startDetachedUpdateVerifier(config.configPath || payload.configPath || null, config);
|
||||
await restartAgentAfterUpdate(followUp.restartPlan, {});
|
||||
await startDetachedUpdateVerifier(config.configPath || execution.payload?.configPath || null, config, {
|
||||
spawnImpl: deps.spawnImpl,
|
||||
});
|
||||
await restartAgentAfterUpdate(execution.followUp.restartPlan, {
|
||||
spawnImpl: deps.spawnImpl,
|
||||
waitImpl: deps.waitImpl,
|
||||
exitProcessImpl: deps.exitProcessImpl,
|
||||
});
|
||||
} catch (followUpError) {
|
||||
await rollbackPendingUpdate(config.configPath || payload.configPath || null, {
|
||||
await rollbackPendingUpdate(config.configPath || execution.payload?.configPath || null, {
|
||||
config,
|
||||
liveConfig: config,
|
||||
reason: createUpdateErrorMessage(followUpError, "Edge agent restart failed after update"),
|
||||
reason: createUpdateErrorMessage(followUpError, followUpErrorMessage),
|
||||
}).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return { ok: true, payload: responsePayload };
|
||||
if (execution.commandType === "UNINSTALL_AGENT" && execution.followUp.type === "UNINSTALL_AGENT") {
|
||||
await persistUninstallState(config.configPath || execution.followUp.uninstallPlan?.configPath, config, {
|
||||
liveConfig: config,
|
||||
});
|
||||
await scheduleAgentUninstall(execution.followUp.uninstallPlan, {
|
||||
spawnImpl: deps.spawnImpl,
|
||||
waitImpl: deps.waitImpl,
|
||||
exitProcessImpl: deps.exitProcessImpl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function processPolledCommand(config, command, fetchImpl = fetch, deps = {}) {
|
||||
const jobId = command?.id;
|
||||
if (!jobId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const execution = await executeAgentCommandEnvelope(config, command, fetchImpl, deps);
|
||||
if (!execution) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await submitCommandJobResult(config, jobId, {
|
||||
ok: true,
|
||||
payload: execution.responsePayload,
|
||||
}, fetchImpl);
|
||||
await runAgentCommandFollowUp(config, execution, "Edge agent restart failed after update", deps);
|
||||
|
||||
return { ok: true, payload: execution.responsePayload };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await submitCommandJobResult(config, jobId, {
|
||||
@@ -1395,6 +1587,162 @@ export async function processPolledShellAction(config, action, shell, fetchImpl
|
||||
}
|
||||
}
|
||||
|
||||
function createBrokerBridge({
|
||||
config,
|
||||
shell,
|
||||
fetchImpl = fetch,
|
||||
reconnectDelayMs = DEFAULT_BROKER_RECONNECT_DELAY_MS,
|
||||
} = {}) {
|
||||
const state = {
|
||||
url: config?.brokerUrl || null,
|
||||
connected: false,
|
||||
lastError: null,
|
||||
disconnectReason: null,
|
||||
lastConnectedAt: null,
|
||||
lastDisconnectedAt: null,
|
||||
};
|
||||
|
||||
let stopped = false;
|
||||
let reconnectTimer = null;
|
||||
let socket = null;
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const send = (message) => {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify(message));
|
||||
return true;
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (stopped || reconnectTimer || !state.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, Math.max(250, reconnectDelayMs));
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socketUrl = buildBrokerSocketUrl(state.url, config?.gatewayId, config?.agentToken);
|
||||
if (!socketUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
socket = new WebSocket(socketUrl);
|
||||
} catch (error) {
|
||||
state.connected = false;
|
||||
state.lastError = error instanceof Error ? error.message : String(error);
|
||||
state.lastDisconnectedAt = formatUpdateTimestamp();
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
socket.onopen = () => {
|
||||
state.connected = true;
|
||||
state.lastError = null;
|
||||
state.disconnectReason = null;
|
||||
state.lastConnectedAt = formatUpdateTimestamp();
|
||||
};
|
||||
|
||||
socket.onmessage = async (event) => {
|
||||
try {
|
||||
const raw = await readSocketMessageText(event.data);
|
||||
const message = JSON.parse(raw);
|
||||
|
||||
if (message.type === "COMMAND") {
|
||||
try {
|
||||
const execution = await executeAgentCommandEnvelope(config, message, fetchImpl);
|
||||
if (!execution) {
|
||||
return;
|
||||
}
|
||||
|
||||
send({
|
||||
type: "COMMAND_RESULT",
|
||||
commandId: message.commandId,
|
||||
ok: true,
|
||||
payload: execution.responsePayload,
|
||||
});
|
||||
await runAgentCommandFollowUp(config, execution);
|
||||
} catch (error) {
|
||||
send({
|
||||
type: "COMMAND_RESULT",
|
||||
commandId: message.commandId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "OPEN_ROOT_SHELL") {
|
||||
await shell.open(message.payload || {});
|
||||
return;
|
||||
}
|
||||
if (message.type === "SHELL_INPUT") {
|
||||
shell.input(message.payload || {});
|
||||
return;
|
||||
}
|
||||
if (message.type === "RESIZE_ROOT_SHELL") {
|
||||
shell.resize(message.payload || {});
|
||||
return;
|
||||
}
|
||||
if (message.type === "CLOSE_ROOT_SHELL") {
|
||||
shell.close(message.payload || {});
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed or unsupported broker messages so polling remains authoritative.
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
state.lastError = "Broker connection failed";
|
||||
};
|
||||
|
||||
socket.onclose = (event) => {
|
||||
socket = null;
|
||||
state.connected = false;
|
||||
state.disconnectReason = event.reason || "broker_disconnected";
|
||||
state.lastDisconnectedAt = formatUpdateTimestamp();
|
||||
if (!stopped) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
start() {
|
||||
connect();
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearReconnectTimer();
|
||||
if (socket && socket.readyState <= WebSocket.OPEN) {
|
||||
socket.close();
|
||||
}
|
||||
socket = null;
|
||||
state.connected = false;
|
||||
},
|
||||
send,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startAgent({
|
||||
configPath,
|
||||
fetchImpl = fetch,
|
||||
@@ -1418,17 +1766,27 @@ export async function startAgent({
|
||||
const shellActionPollTimeoutSeconds = Number(config.shellActionPollTimeoutSeconds || 20);
|
||||
const commandPollRetryDelayMs = Number(config.commandPollRetryDelayMs || 1000);
|
||||
const shellActionPollRetryDelayMs = Number(config.shellActionPollRetryDelayMs || 1000);
|
||||
const brokerReconnectDelayMs = Number(config.brokerReconnectDelayMs || DEFAULT_BROKER_RECONNECT_DELAY_MS);
|
||||
|
||||
let stopped = false;
|
||||
let cpuSnapshot = null;
|
||||
let lastHeartbeatLatencyMs = null;
|
||||
const shellEventPublisher = createShellEventPublisher(config, fetchImpl);
|
||||
let brokerBridge = null;
|
||||
const shell = createShellBridgeImpl((message) => {
|
||||
shellEventPublisher.publish(message);
|
||||
brokerBridge?.send(message);
|
||||
});
|
||||
brokerBridge = createBrokerBridge({
|
||||
config,
|
||||
shell,
|
||||
fetchImpl,
|
||||
reconnectDelayMs: brokerReconnectDelayMs,
|
||||
});
|
||||
brokerBridge.start();
|
||||
|
||||
const sendTransportHeartbeat = async (extra = {}) => {
|
||||
const transportState = buildTransportHeartbeatState();
|
||||
const transportState = buildTransportHeartbeatState(brokerBridge?.state || { url: config.brokerUrl || null });
|
||||
const collectedMetrics = await collectMetricsImpl({
|
||||
previousCpuSnapshot: cpuSnapshot,
|
||||
latencyMs: lastHeartbeatLatencyMs,
|
||||
@@ -1518,12 +1876,14 @@ export async function startAgent({
|
||||
const stop = async () => {
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
brokerBridge?.stop();
|
||||
shell.dispose();
|
||||
await shellEventPublisher.drain().catch(() => {});
|
||||
await Promise.allSettled([commandPollPromise, shellActionPollPromise]);
|
||||
};
|
||||
|
||||
return {
|
||||
brokerBridge,
|
||||
commandPollPromise,
|
||||
shellActionPollPromise,
|
||||
timer,
|
||||
|
||||
@@ -14,11 +14,14 @@ import {
|
||||
finalizePendingUpdateOnStartup,
|
||||
getAgentStatus,
|
||||
getRelayStatus,
|
||||
loadConfig,
|
||||
parseCliArgs,
|
||||
processPolledCommand,
|
||||
runCli,
|
||||
runUpdate,
|
||||
setRelayState,
|
||||
startAgent,
|
||||
handleAgentCommand,
|
||||
verifyPendingUpdate,
|
||||
} from "../dist/agent.mjs";
|
||||
|
||||
@@ -68,6 +71,7 @@ test("claimIfNeeded persists claimed gateway credentials", async () => {
|
||||
gateway: { id: 9001 },
|
||||
agent_token: "agent-token",
|
||||
release_channel: "stable",
|
||||
broker_url: "https://broker.example.test",
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -82,7 +86,7 @@ test("claimIfNeeded persists claimed gateway credentials", async () => {
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(config.gatewayId, 9001);
|
||||
assert.equal(config.agentToken, "agent-token");
|
||||
assert.equal("brokerUrl" in config, false);
|
||||
assert.equal(config.brokerUrl, "https://broker.example.test");
|
||||
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -179,6 +183,110 @@ test("runUpdate stages a pending verification restart after installing new artif
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("handleAgentCommand returns an uninstall follow-up envelope for gateway removal", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-uninstall-envelope-"));
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
await writeFile(configPath, JSON.stringify({
|
||||
apiUrl: "https://api.example.test",
|
||||
gatewayId: 42,
|
||||
agentToken: "agent-token",
|
||||
installToken: "install-token",
|
||||
brokerUrl: "https://broker.example.test",
|
||||
restartMode: "spawn",
|
||||
}));
|
||||
|
||||
const result = await handleAgentCommand(
|
||||
{
|
||||
commandType: "UNINSTALL_AGENT",
|
||||
payload: {
|
||||
serviceName: "truckwash-edge-agent.service",
|
||||
},
|
||||
},
|
||||
{
|
||||
configPath,
|
||||
config: await loadConfig(configPath),
|
||||
liveConfig: null,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.__agentCommandEnvelope, true);
|
||||
assert.equal(result.payload.uninstall_scheduled, true);
|
||||
assert.equal(result.followUp.type, "UNINSTALL_AGENT");
|
||||
assert.equal(result.followUp.uninstallPlan.configPath, configPath);
|
||||
assert.equal(result.followUp.uninstallPlan.serviceName, "truckwash-edge-agent.service");
|
||||
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("processPolledCommand acknowledges uninstall before clearing credentials and exiting", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-uninstall-"));
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
const config = {
|
||||
apiUrl: "https://api.example.test",
|
||||
gatewayId: 42,
|
||||
agentToken: "agent-token",
|
||||
installToken: "install-token",
|
||||
brokerUrl: "https://broker.example.test",
|
||||
restartMode: "spawn",
|
||||
};
|
||||
await writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
const commandResultPosts = [];
|
||||
const fakeFetch = async (url, options = {}) => {
|
||||
const body = options.body ? JSON.parse(options.body) : {};
|
||||
|
||||
if (String(url).endsWith("/commands/77/result")) {
|
||||
commandResultPosts.push({ url, body });
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { data: { acknowledged: true } };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
const exitCodes = [];
|
||||
const result = await processPolledCommand(
|
||||
{
|
||||
...config,
|
||||
configPath,
|
||||
},
|
||||
{
|
||||
id: 77,
|
||||
commandType: "UNINSTALL_AGENT",
|
||||
payload: {
|
||||
serviceName: "truckwash-edge-agent.service",
|
||||
},
|
||||
},
|
||||
fakeFetch,
|
||||
{
|
||||
waitImpl: async () => {},
|
||||
exitProcessImpl: (code) => {
|
||||
exitCodes.push(code);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const persisted = JSON.parse(await readFile(configPath, "utf8"));
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.payload.uninstall_scheduled, true);
|
||||
assert.equal(commandResultPosts.length, 1);
|
||||
assert.equal(commandResultPosts[0].body.ok, true);
|
||||
assert.equal(commandResultPosts[0].body.payload.uninstall_scheduled, true);
|
||||
assert.deepEqual(exitCodes, [0]);
|
||||
assert.equal(persisted.gatewayId, null);
|
||||
assert.equal(persisted.agentToken, null);
|
||||
assert.equal(persisted.installToken, null);
|
||||
assert.equal(persisted.brokerUrl, null);
|
||||
assert.equal(persisted.lastUninstall.state, "SCHEDULED");
|
||||
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("finalizePendingUpdateOnStartup promotes the target version and clears the pending update marker", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-startup-"));
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
@@ -564,7 +672,9 @@ test("startAgent reports API polling metadata, executes polled commands, and upl
|
||||
},
|
||||
});
|
||||
|
||||
const agent = await startAgent({
|
||||
let agent = null;
|
||||
try {
|
||||
agent = await startAgent({
|
||||
configPath,
|
||||
fetchImpl: fakeFetch,
|
||||
collectMetricsImpl,
|
||||
@@ -578,7 +688,8 @@ test("startAgent reports API polling metadata, executes polled commands, and upl
|
||||
assert.equal(heartbeats[0].body.metadata.system_metrics.memory_usage_pct, 48.2);
|
||||
assert.equal(heartbeats[0].body.metadata.system_metrics.disk_usage_pct, 61.4);
|
||||
assert.equal(heartbeats[0].body.metadata.system_metrics.latency_ms, null);
|
||||
assert.equal("broker_connected" in heartbeats[0].body.metadata, false);
|
||||
assert.equal(heartbeats[0].body.metadata.broker_connected, false);
|
||||
assert.equal(heartbeats[0].body.metadata.broker_url, null);
|
||||
assert.equal("discovery_status" in heartbeats[0].body, false);
|
||||
|
||||
await waitFor(
|
||||
@@ -625,9 +736,10 @@ test("startAgent reports API polling metadata, executes polled commands, and upl
|
||||
typeof heartbeat.body.metadata.system_metrics.latency_ms === "number"
|
||||
)
|
||||
);
|
||||
|
||||
await agent.stop();
|
||||
} finally {
|
||||
await agent?.stop();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("status helpers report config without exposing the agent token", async () => {
|
||||
@@ -641,6 +753,7 @@ test("status helpers report config without exposing the agent token", async () =
|
||||
installedVersion: "1.2.3",
|
||||
targetVersion: "1.2.4",
|
||||
releaseChannel: "stable",
|
||||
brokerUrl: "https://broker.example.test",
|
||||
heartbeatIntervalSeconds: 30,
|
||||
}));
|
||||
|
||||
@@ -650,6 +763,7 @@ test("status helpers report config without exposing the agent token", async () =
|
||||
installToken: "install-token",
|
||||
gatewayId: 42,
|
||||
agentToken: "agent-token",
|
||||
brokerUrl: "https://broker.example.test",
|
||||
});
|
||||
|
||||
assert.equal(report.command, "status");
|
||||
@@ -657,11 +771,13 @@ test("status helpers report config without exposing the agent token", async () =
|
||||
assert.equal(report.claimed, true);
|
||||
assert.equal(report.state, "CLAIMED");
|
||||
assert.equal(report.gatewayId, 42);
|
||||
assert.equal(report.transport, "API_POLLING");
|
||||
assert.equal(report.transport, "HYBRID");
|
||||
assert.equal(report.brokerUrl, "https://broker.example.test");
|
||||
assert.equal(report.hasInstallToken, true);
|
||||
assert.equal("agentToken" in report, false);
|
||||
assert.equal(built.state, "CLAIMED");
|
||||
assert.equal(built.transport, "API_POLLING");
|
||||
assert.equal(built.transport, "HYBRID");
|
||||
assert.equal(built.brokerUrl, "https://broker.example.test");
|
||||
assert.equal("agentToken" in built, false);
|
||||
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
|
||||
+134
-29
@@ -24,17 +24,112 @@ function jsonResponse(res, statusCode, body) {
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value) {
|
||||
return String(value || "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response) {
|
||||
const text = await response.text();
|
||||
if (text === "") {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { error: text };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveManagerUrl(options = {}) {
|
||||
return trimTrailingSlash(options.managerUrl || process.env.EDGE_MANAGER_URL || process.env.EDGE_PUBLIC_API_URL || "");
|
||||
}
|
||||
|
||||
function resolveAuthMode(options = {}, managerUrl = "") {
|
||||
if (options.authMode) {
|
||||
return options.authMode;
|
||||
}
|
||||
if (process.env.EDGE_AUTH_MODE) {
|
||||
return process.env.EDGE_AUTH_MODE;
|
||||
}
|
||||
return managerUrl ? "manager" : "stub";
|
||||
}
|
||||
|
||||
export function createBrokerServer(options = {}) {
|
||||
const authMode = options.authMode || process.env.EDGE_AUTH_MODE || "stub";
|
||||
const sharedSecret = options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "";
|
||||
const managerUrl = resolveManagerUrl(options);
|
||||
const authMode = resolveAuthMode(options, managerUrl);
|
||||
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
|
||||
const agents = new Map();
|
||||
const pendingCommands = new Map();
|
||||
const browserSessions = new Map();
|
||||
|
||||
const validateAgent = options.validateAgent || (async ({ gatewayId }) => ({ id: gatewayId }));
|
||||
const validateShellSession = options.validateShellSession || (async ({ token }) => ({ id: token, gateway_id: 1, reason: "stub" }));
|
||||
const closeShellSession = options.closeShellSession || (async () => ({}));
|
||||
const managerRequest = async (path, body = {}) => {
|
||||
if (!managerUrl) {
|
||||
throw new Error("Edge manager URL is not configured");
|
||||
}
|
||||
|
||||
const response = await fetch(`${managerUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(sharedSecret ? { "x-edge-broker-secret": sharedSecret } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(json?.data?.message || json?.message || json?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return json?.data ?? json;
|
||||
};
|
||||
|
||||
const validateAgent =
|
||||
options.validateAgent ||
|
||||
(authMode === "stub"
|
||||
? async ({ gatewayId }) => ({ id: gatewayId, gateway_id: gatewayId })
|
||||
: async ({ gatewayId, token }) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/validate`, { token }));
|
||||
const validateShellSession =
|
||||
options.validateShellSession ||
|
||||
(authMode === "stub"
|
||||
? async ({ token }) => ({ id: token, gateway_id: 1, reason: "stub" })
|
||||
: async ({ token }) => managerRequest("/edge-agent/internal/shell-sessions/validate", { token }));
|
||||
const closeShellSession =
|
||||
options.closeShellSession ||
|
||||
(authMode === "stub"
|
||||
? async () => ({})
|
||||
: async (_id, token, transcript, reason) =>
|
||||
managerRequest("/edge-agent/internal/shell-sessions/close", {
|
||||
token,
|
||||
transcript,
|
||||
reason,
|
||||
}));
|
||||
const reportGatewayPresence =
|
||||
options.reportGatewayPresence ||
|
||||
(authMode === "stub"
|
||||
? async () => ({})
|
||||
: async (gatewayId, { status, connectionId, reason = null, metadata = {} } = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/presence`, {
|
||||
status,
|
||||
connection_id: connectionId,
|
||||
reason,
|
||||
metadata,
|
||||
}));
|
||||
|
||||
const closeBrowserSession = async (sessionRecord, reason) => {
|
||||
try {
|
||||
await closeShellSession(
|
||||
sessionRecord.session.id,
|
||||
sessionRecord.ws.sessionToken,
|
||||
sessionRecord.transcript,
|
||||
reason
|
||||
);
|
||||
} catch {
|
||||
// Preserve socket teardown even when the manager callback is unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
const markBrowserSessionsClosed = (gatewayId, reason) => {
|
||||
for (const sessionRecord of browserSessions.values()) {
|
||||
@@ -43,7 +138,6 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
|
||||
sessionRecord.closedReason = reason;
|
||||
|
||||
if (sessionRecord.ws.readyState < 2) {
|
||||
sessionRecord.ws.close();
|
||||
}
|
||||
@@ -85,6 +179,7 @@ export function createBrokerServer(options = {}) {
|
||||
commandId,
|
||||
commandType: body.commandType,
|
||||
payload: body.payload || {},
|
||||
jobId: body.jobId ?? null,
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -113,12 +208,25 @@ export function createBrokerServer(options = {}) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (authMode !== "stub") {
|
||||
await validateAgent({ gatewayId, token, headers: req.headers });
|
||||
}
|
||||
|
||||
const gatewayInfo = await validateAgent({ gatewayId, token, headers: req.headers });
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
const existing = agents.get(gatewayId);
|
||||
if (existing && existing.readyState < 2) {
|
||||
existing.close();
|
||||
}
|
||||
|
||||
ws.gatewayId = gatewayId;
|
||||
ws.gatewayInfo = gatewayInfo;
|
||||
ws.connectionId = randomUUID();
|
||||
agents.set(gatewayId, ws);
|
||||
reportGatewayPresence(gatewayId, {
|
||||
status: "connected",
|
||||
connectionId: ws.connectionId,
|
||||
metadata: {
|
||||
remote_address: req.socket.remoteAddress || null,
|
||||
},
|
||||
}).catch(() => {});
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
return;
|
||||
@@ -130,10 +238,8 @@ export function createBrokerServer(options = {}) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const session = authMode === "stub"
|
||||
? await validateShellSession({ token })
|
||||
: await validateShellSession({ token, headers: req.headers });
|
||||
|
||||
const session = await validateShellSession({ token, headers: req.headers });
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ws.sessionToken = token;
|
||||
ws.sessionInfo = session;
|
||||
@@ -143,6 +249,7 @@ export function createBrokerServer(options = {}) {
|
||||
transcript: "",
|
||||
closedReason: null,
|
||||
});
|
||||
|
||||
const agent = agents.get(String(session.gateway_id));
|
||||
if (agent && agent.readyState === 1) {
|
||||
agent.send(JSON.stringify({
|
||||
@@ -150,6 +257,8 @@ export function createBrokerServer(options = {}) {
|
||||
payload: {
|
||||
sessionId: String(session.id),
|
||||
reason: session.reason,
|
||||
cols: session.metadata?.cols ?? null,
|
||||
rows: session.metadata?.rows ?? null,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
@@ -205,7 +314,7 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
if (message.type === "SHELL_EXIT") {
|
||||
sessionRecord.ws.send(JSON.stringify({ type: "closed", code: message.code ?? 0 }));
|
||||
await closeShellSession(sessionRecord.session.id, sessionRecord.ws.sessionToken, sessionRecord.transcript, "agent_exit");
|
||||
await closeBrowserSession(sessionRecord, "agent_exit");
|
||||
browserSessions.delete(String(message.sessionId));
|
||||
if (sessionRecord.ws.readyState < 2) {
|
||||
sessionRecord.ws.close();
|
||||
@@ -249,10 +358,19 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", async () => {
|
||||
ws.on("close", async (_code, buffer) => {
|
||||
const closeReason = buffer?.toString?.("utf8") || null;
|
||||
|
||||
if (ws.gatewayId) {
|
||||
if (agents.get(String(ws.gatewayId)) === ws) {
|
||||
agents.delete(String(ws.gatewayId));
|
||||
}
|
||||
markBrowserSessionsClosed(String(ws.gatewayId), "agent_disconnected");
|
||||
reportGatewayPresence(String(ws.gatewayId), {
|
||||
status: "disconnected",
|
||||
connectionId: ws.connectionId || null,
|
||||
reason: closeReason || "agent_disconnected",
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -267,12 +385,7 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
const sessionRecord = browserSessions.get(sessionId);
|
||||
if (sessionRecord) {
|
||||
await closeShellSession(
|
||||
sessionRecord.session.id,
|
||||
ws.sessionToken,
|
||||
sessionRecord.transcript,
|
||||
sessionRecord.closedReason || "browser_closed"
|
||||
);
|
||||
await closeBrowserSession(sessionRecord, sessionRecord.closedReason || "browser_closed");
|
||||
browserSessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
@@ -315,16 +428,8 @@ export function createBrokerServer(options = {}) {
|
||||
agents,
|
||||
browserSessions,
|
||||
pendingCommands,
|
||||
managerUrl,
|
||||
authMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const broker = createBrokerServer();
|
||||
broker.listen().then(() => {
|
||||
console.log("TruckWash edge broker listening");
|
||||
}).catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -108,6 +108,7 @@ class edge_gateway_schema_bootstrap
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
||||
request_json JSON NULL,
|
||||
response_json JSON NULL,
|
||||
delivery_json JSON NULL,
|
||||
correlation_id VARCHAR(128) NOT NULL,
|
||||
requested_by INT NULL,
|
||||
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -133,6 +134,7 @@ class edge_gateway_schema_bootstrap
|
||||
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
delivery_json JSON NULL,
|
||||
result_json JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -171,6 +173,7 @@ class edge_gateway_schema_bootstrap
|
||||
action_type VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
||||
payload_json JSON NULL,
|
||||
delivery_json JSON NULL,
|
||||
requested_by INT NULL,
|
||||
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME NULL,
|
||||
@@ -225,6 +228,27 @@ class edge_gateway_schema_bootstrap
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::tableHasColumn('edge_gateway_command_jobs', 'delivery_json')) {
|
||||
$db->query(
|
||||
"ALTER TABLE edge_gateway_command_jobs
|
||||
ADD COLUMN delivery_json JSON NULL AFTER response_json"
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::tableHasColumn('edge_gateway_shell_action_jobs', 'delivery_json')) {
|
||||
$db->query(
|
||||
"ALTER TABLE edge_gateway_shell_action_jobs
|
||||
ADD COLUMN delivery_json JSON NULL AFTER payload_json"
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::tableHasColumn('edge_gateway_update_jobs', 'delivery_json')) {
|
||||
$db->query(
|
||||
"ALTER TABLE edge_gateway_update_jobs
|
||||
ADD COLUMN delivery_json JSON NULL AFTER completed_at"
|
||||
);
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class edge_gateway_command_jobs_o extends db
|
||||
public object_property $status;
|
||||
public object_property $request_json;
|
||||
public object_property $response_json;
|
||||
public object_property $delivery_json;
|
||||
public object_property $correlation_id;
|
||||
public object_property $requested_by;
|
||||
public object_property $requested_at;
|
||||
@@ -38,6 +39,7 @@ class edge_gateway_command_jobs_o extends db
|
||||
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
|
||||
$this->request_json = new object_property($this->table, $this->id, 'request_json', 'json', false);
|
||||
$this->response_json = new object_property($this->table, $this->id, 'response_json', 'json', false);
|
||||
$this->delivery_json = new object_property($this->table, $this->id, 'delivery_json', 'json', false);
|
||||
$this->correlation_id = new object_property($this->table, $this->id, 'correlation_id', 'string', false);
|
||||
$this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false);
|
||||
$this->requested_at = new object_property($this->table, $this->id, 'requested_at', 'string', false);
|
||||
@@ -63,6 +65,7 @@ class edge_gateway_command_jobs_o extends db
|
||||
'status' => (string)$this->status->value(),
|
||||
'request' => (array)($this->request_json->value() ?? []),
|
||||
'response' => (array)($this->response_json->value() ?? []),
|
||||
'delivery' => (array)($this->delivery_json->value() ?? []),
|
||||
'correlation_id' => (string)$this->correlation_id->value(),
|
||||
'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(),
|
||||
'requested_at' => (string)$this->requested_at->value(),
|
||||
|
||||
@@ -55,6 +55,7 @@ class edge_gateway_relay_bindings_o extends db
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
$metadata = (array)($this->metadata_json->value() ?? []);
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
@@ -67,7 +68,13 @@ class edge_gateway_relay_bindings_o extends db
|
||||
'binding_source' => (string)$this->binding_source->value(),
|
||||
'approved_by' => $this->approved_by->value() === null ? null : (int)$this->approved_by->value(),
|
||||
'approved_at' => $this->approved_at->value() === null ? null : (string)$this->approved_at->value(),
|
||||
'metadata' => (array)($this->metadata_json->value() ?? []),
|
||||
'metadata' => $metadata,
|
||||
'fallback_mode' => isset($metadata['fallback_mode']) ? (string)$metadata['fallback_mode'] : 'PREFER_LOCAL',
|
||||
'last_resolution' => isset($metadata['last_resolution']) && is_array($metadata['last_resolution'])
|
||||
? (array)$metadata['last_resolution']
|
||||
: null,
|
||||
'last_success_at' => isset($metadata['last_success_at']) ? (string)$metadata['last_success_at'] : null,
|
||||
'last_error' => isset($metadata['last_error']) ? (string)$metadata['last_error'] : null,
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
||||
];
|
||||
|
||||
@@ -16,6 +16,7 @@ class edge_gateway_shell_action_jobs_o extends db
|
||||
public object_property $action_type;
|
||||
public object_property $status;
|
||||
public object_property $payload_json;
|
||||
public object_property $delivery_json;
|
||||
public object_property $requested_by;
|
||||
public object_property $requested_at;
|
||||
public object_property $completed_at;
|
||||
@@ -37,6 +38,7 @@ class edge_gateway_shell_action_jobs_o extends db
|
||||
$this->action_type = new object_property($this->table, $this->id, 'action_type', 'string', false);
|
||||
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
|
||||
$this->payload_json = new object_property($this->table, $this->id, 'payload_json', 'json', false);
|
||||
$this->delivery_json = new object_property($this->table, $this->id, 'delivery_json', 'json', false);
|
||||
$this->requested_by = new object_property($this->table, $this->id, 'requested_by', 'int', false);
|
||||
$this->requested_at = new object_property($this->table, $this->id, 'requested_at', 'string', false);
|
||||
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'string', false);
|
||||
@@ -61,6 +63,7 @@ class edge_gateway_shell_action_jobs_o extends db
|
||||
'action_type' => (string)$this->action_type->value(),
|
||||
'status' => (string)$this->status->value(),
|
||||
'payload' => (array)($this->payload_json->value() ?? []),
|
||||
'delivery' => (array)($this->delivery_json->value() ?? []),
|
||||
'requested_by' => $this->requested_by->value() === null ? null : (int)$this->requested_by->value(),
|
||||
'requested_at' => (string)$this->requested_at->value(),
|
||||
'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
|
||||
|
||||
@@ -20,6 +20,7 @@ class edge_gateway_update_jobs_o extends db
|
||||
public object_property $requested_at;
|
||||
public object_property $started_at;
|
||||
public object_property $completed_at;
|
||||
public object_property $delivery_json;
|
||||
public object_property $result_json;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
@@ -42,6 +43,7 @@ class edge_gateway_update_jobs_o extends db
|
||||
$this->requested_at = new object_property($this->table, $this->id, 'requested_at', 'string', false);
|
||||
$this->started_at = new object_property($this->table, $this->id, 'started_at', 'string', false);
|
||||
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'string', false);
|
||||
$this->delivery_json = new object_property($this->table, $this->id, 'delivery_json', 'json', false);
|
||||
$this->result_json = new object_property($this->table, $this->id, 'result_json', 'json', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false);
|
||||
@@ -67,6 +69,7 @@ class edge_gateway_update_jobs_o extends db
|
||||
'requested_at' => (string)$this->requested_at->value(),
|
||||
'started_at' => $this->started_at->value() === null ? null : (string)$this->started_at->value(),
|
||||
'completed_at' => $this->completed_at->value() === null ? null : (string)$this->completed_at->value(),
|
||||
'delivery' => (array)($this->delivery_json->value() ?? []),
|
||||
'result' => (array)($this->result_json->value() ?? []),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
||||
|
||||
@@ -123,6 +123,22 @@ class edgeGatewaysRoute
|
||||
'modules_shelly_config' => 'Queue an automatic edge gateway update',
|
||||
]);
|
||||
|
||||
$this->post('/edge-gateways/{id}/uninstall', function () {
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
self::requireParameterIntPositive($gatewayId, 'id');
|
||||
$manager = new edge_gateway_manager();
|
||||
$gateway = $manager->getGateway($gatewayId);
|
||||
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
$response->success($manager->queueUninstall($gatewayId, $user ? (int)$user->id : null), 202);
|
||||
}, [
|
||||
'modules_shelly_config' => 'Queue an edge gateway uninstall on the Raspberry Pi',
|
||||
]);
|
||||
|
||||
$this->post('/edge-gateways/{id}/shell-sessions', function () {
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
@@ -181,6 +197,22 @@ class edgeGatewaysRoute
|
||||
'modules_shelly_config' => 'Rotate edge gateway agent credentials',
|
||||
]);
|
||||
|
||||
$this->delete('/edge-gateways/{id}', function () {
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
self::requireParameterIntPositive($gatewayId, 'id');
|
||||
$manager = new edge_gateway_manager();
|
||||
$gateway = $manager->getGateway($gatewayId);
|
||||
$this->requireDepartmentAccess((int)$gateway['department_id']);
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
$response->success($manager->deleteGateway($gatewayId, $user ? (int)$user->id : null));
|
||||
}, [
|
||||
'modules_shelly_config' => 'Delete an edge gateway registration',
|
||||
]);
|
||||
|
||||
$this->post('/departments/{id}/gateway-cutover', function () {
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
@@ -211,6 +243,10 @@ class edgeGatewaysRoute
|
||||
$this->post('/edge-agent/gateways/{id}/shell-actions/poll', fn() => $this->handleAgentShellActionPoll());
|
||||
$this->post('/edge-agent/gateways/{id}/shell-actions/{actionId}/result', fn() => $this->handleAgentShellActionResult());
|
||||
$this->post('/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events', fn() => $this->handleAgentShellEvents());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/validate', fn() => $this->handleBrokerGatewayValidate());
|
||||
$this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellValidate());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/presence', fn() => $this->handleBrokerGatewayPresence());
|
||||
$this->post('/edge-agent/internal/shell-sessions/close', fn() => $this->handleBrokerShellSessionClose());
|
||||
}
|
||||
|
||||
private function renderInstallScript(): void
|
||||
@@ -476,4 +512,79 @@ class edgeGatewaysRoute
|
||||
$events
|
||||
));
|
||||
}
|
||||
|
||||
private function handleBrokerGatewayValidate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSharedSecret();
|
||||
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
self::requireParameterIntPositive($gatewayId, 'id');
|
||||
$payload = self::getParametersAsArray();
|
||||
self::requireParameters(['token']);
|
||||
|
||||
$response->success((new edge_gateway_manager())->validateBrokerAgentConnection(
|
||||
$gatewayId,
|
||||
(string)$payload['token']
|
||||
));
|
||||
}
|
||||
|
||||
private function handleBrokerShellValidate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSharedSecret();
|
||||
|
||||
$payload = self::getParametersAsArray();
|
||||
self::requireParameters(['token']);
|
||||
|
||||
$response->success((new edge_gateway_manager())->validateShellSessionToken((string)$payload['token']));
|
||||
}
|
||||
|
||||
private function handleBrokerGatewayPresence(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSharedSecret();
|
||||
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
self::requireParameterIntPositive($gatewayId, 'id');
|
||||
$payload = self::getParametersAsArray();
|
||||
self::requireParameters(['status']);
|
||||
|
||||
$response->success((new edge_gateway_manager())->recordBrokerPresence(
|
||||
$gatewayId,
|
||||
(string)$payload['status'],
|
||||
isset($payload['connection_id']) ? (string)$payload['connection_id'] : null,
|
||||
isset($payload['reason']) ? (string)$payload['reason'] : null,
|
||||
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
|
||||
));
|
||||
}
|
||||
|
||||
private function handleBrokerShellSessionClose(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSharedSecret();
|
||||
|
||||
$payload = self::getParametersAsArray();
|
||||
self::requireParameters(['token']);
|
||||
|
||||
$response->success((new edge_gateway_manager())->closeShellSession(
|
||||
(string)$payload['token'],
|
||||
isset($payload['transcript']) ? (string)$payload['transcript'] : '',
|
||||
isset($payload['reason']) ? (string)$payload['reason'] : 'broker_closed'
|
||||
));
|
||||
}
|
||||
|
||||
private function requireBrokerSharedSecret(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$expected = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
|
||||
if ($expected === '') {
|
||||
$response->error('Edge broker secret is not configured', 503);
|
||||
}
|
||||
|
||||
$provided = trim((string)($_SERVER['HTTP_X_EDGE_BROKER_SECRET'] ?? $this->fromRequest('broker_secret')));
|
||||
if ($provided === '' || !hash_equals($expected, $provided)) {
|
||||
$response->error('Forbidden', 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ it('queues admin commands, exposes agent poll/result handlers, and keeps heartbe
|
||||
expect($source)->toContain("\$gateway->discovery_status->set('READY');");
|
||||
expect($source)->toContain("\$gateway->discovery_status->set('FAILED');");
|
||||
expect($source)->toContain("\$this->buildUpdateCommandPayload(\$targetVersion, \$releaseChannel)");
|
||||
expect($source)->toContain("'UNINSTALL_AGENT'");
|
||||
expect($source)->toContain("public function queueUninstall");
|
||||
expect($source)->toContain("public function deleteGateway");
|
||||
expect($source)->toContain("private function softDeleteGatewayRelations");
|
||||
expect($source)->toContain("\$updateJob->status->set('DISPATCHING');");
|
||||
expect($source)->toContain("\$updateJob->status->set('VERIFYING');");
|
||||
expect($source)->toContain("\$updateJob->status->set(\$finalStatus ?? (\$ok ? 'COMPLETED' : 'FAILED'));");
|
||||
@@ -33,6 +37,8 @@ it('defines the dispatchable gateway guard and api-polled shell queue on the loa
|
||||
expect($reflection->getMethod('requireDispatchableGateway')->isPrivate())->toBeTrue();
|
||||
expect($reflection->hasMethod('queueDiscovery'))->toBeTrue();
|
||||
expect($reflection->hasMethod('queueUpdate'))->toBeTrue();
|
||||
expect($reflection->hasMethod('queueUninstall'))->toBeTrue();
|
||||
expect($reflection->hasMethod('deleteGateway'))->toBeTrue();
|
||||
expect($reflection->hasMethod('buildUpdateCommandPayload'))->toBeTrue();
|
||||
expect($reflection->hasMethod('applyHeartbeatUpdateLifecycle'))->toBeTrue();
|
||||
expect($reflection->hasMethod('dispatchRelayStatus'))->toBeTrue();
|
||||
|
||||
@@ -67,3 +67,75 @@ it('merges incoming heartbeat metadata with existing gateway metadata', function
|
||||
expect($source)->toContain("\$existingMetadata = (array)(\$gateway->metadata_json->value() ?? []);");
|
||||
expect($source)->toContain("\$gateway->metadata_json->set(array_merge(\$existingMetadata, (array)(\$payload['metadata'] ?? [])));");
|
||||
});
|
||||
|
||||
it('derives relay fallback and transport health details for degraded hybrid control planes', function (): void {
|
||||
$gateway = edge_gateway_manager::deriveGatewayRuntimeState([
|
||||
'status' => edge_gateway_manager::STATUS_ONLINE,
|
||||
'last_heartbeat_at' => '2026-04-08 10:04:30',
|
||||
'department_transport_mode' => edge_gateway_manager::TRANSPORT_MODE_GATEWAY,
|
||||
'metadata' => [
|
||||
'broker_presence' => [
|
||||
'connected' => false,
|
||||
'last_seen_at' => '2026-04-08 10:03:00',
|
||||
'last_error' => 'broker timeout',
|
||||
],
|
||||
],
|
||||
'operational_snapshot' => [
|
||||
'command_backlog' => 2,
|
||||
'shell_backlog' => 1,
|
||||
'update_backlog' => 1,
|
||||
],
|
||||
'bindings' => [
|
||||
[
|
||||
'id' => 1,
|
||||
'relay_id' => 'M-7',
|
||||
'device_id' => 'shelly-plus-01',
|
||||
'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL,
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'relay_id' => 'M-7-CANARY',
|
||||
'device_id' => 'missing-device',
|
||||
'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_LOCAL_ONLY,
|
||||
],
|
||||
],
|
||||
'inventory' => [
|
||||
[
|
||||
'device_id' => 'shelly-plus-01',
|
||||
'online' => true,
|
||||
'last_seen_at' => '2026-04-08 09:55:00',
|
||||
],
|
||||
],
|
||||
'recent_commands' => [
|
||||
[
|
||||
'status' => 'COMPLETED',
|
||||
'command_type' => 'DISCOVER_SHELLY',
|
||||
'completed_at' => '2026-04-08 10:02:00',
|
||||
],
|
||||
],
|
||||
'recent_shell_sessions' => [
|
||||
[
|
||||
'opened_at' => '2026-04-08 10:03:00',
|
||||
],
|
||||
],
|
||||
], strtotime('2026-04-08 10:05:00'));
|
||||
|
||||
expect($gateway['channel_status']['command']['active'])->toBe(edge_gateway_manager::DELIVERY_CHANNEL_API);
|
||||
expect($gateway['channel_status']['broker']['state'])->toBe(edge_gateway_manager::STATUS_OFFLINE);
|
||||
expect($gateway['relay_health'][0]['execution_path'])->toBe('cloud');
|
||||
expect($gateway['relay_health'][0]['reason'])->toBe('device_stale');
|
||||
expect($gateway['relay_health'][0]['recommended_action'])->toBe('retry_discovery');
|
||||
expect($gateway['relay_health'][1]['execution_path'])->toBe('local');
|
||||
expect($gateway['relay_health'][1]['reason'])->toBe('device_missing');
|
||||
expect($gateway['fallback_summary']['cloud_relays'])->toBe(1);
|
||||
expect($gateway['fallback_summary']['local_only_relays'])->toBe(1);
|
||||
expect($gateway['transport_health']['status'])->toBe(edge_gateway_manager::STATUS_DEGRADED);
|
||||
expect($gateway['transport_health']['recommended_action'])->toBe('retry_discovery');
|
||||
expect($gateway['last_successful_discovery_at'])->toBe('2026-04-08 10:02:00');
|
||||
expect($gateway['last_successful_shell_at'])->toBe('2026-04-08 10:03:00');
|
||||
expect($gateway['backlog_depth'])->toBe([
|
||||
'commands' => 2,
|
||||
'shell_actions' => 1,
|
||||
'updates' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
|
||||
{
|
||||
$originalServer = $_SERVER;
|
||||
$originalPublicApiUrl = getenv('EDGE_PUBLIC_API_URL');
|
||||
$originalPublicBrokerUrl = getenv('EDGE_PUBLIC_BROKER_URL');
|
||||
|
||||
$_SERVER = $server;
|
||||
|
||||
@@ -27,6 +28,11 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
|
||||
} else {
|
||||
putenv('EDGE_PUBLIC_API_URL=' . $originalPublicApiUrl);
|
||||
}
|
||||
if ($originalPublicBrokerUrl === false) {
|
||||
putenv('EDGE_PUBLIC_BROKER_URL');
|
||||
} else {
|
||||
putenv('EDGE_PUBLIC_BROKER_URL=' . $originalPublicBrokerUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +58,7 @@ it('builds install script urls with forwarded https scheme when proxied', functi
|
||||
expect($script)->toContain('"commandPollTimeoutSeconds":20');
|
||||
expect($script)->toContain('"shellActionPollTimeoutSeconds":20');
|
||||
expect($script)->toContain('"updateVerificationTimeoutSeconds":45');
|
||||
expect($script)->not->toContain('"brokerUrl"');
|
||||
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"');
|
||||
expect($script)->not->toContain('Undefined variable $INSTALL_DIR');
|
||||
});
|
||||
});
|
||||
@@ -80,7 +86,7 @@ it('infers https for the staging api host when only the https port is present',
|
||||
expect($manager->getApiBaseUrl())->toBe('https://api.truckwash.io:4433');
|
||||
expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"');
|
||||
expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"');
|
||||
expect($script)->not->toContain('"brokerUrl"');
|
||||
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,11 +96,13 @@ it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void {
|
||||
'HTTP_X_FORWARDED_PROTO' => 'http',
|
||||
], function (): void {
|
||||
putenv('EDGE_PUBLIC_API_URL=https://edge.example.test/api');
|
||||
putenv('EDGE_PUBLIC_BROKER_URL=https://broker.edge.example.test');
|
||||
|
||||
$manager = new EdgeGatewayManagerUrlHarness();
|
||||
|
||||
expect($manager->getApiBaseUrl())->toBe('https://edge.example.test/api');
|
||||
expect($manager->buildInstallScriptUrl('token-1'))->toBe('https://edge.example.test/api/edge-agent/install.sh?token=token-1');
|
||||
expect($manager->buildInstallScript('token-1'))->toContain('"brokerUrl":"https://broker.edge.example.test"');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,12 +10,14 @@ it('registers the edge gateway management REST endpoints', function (): void {
|
||||
expect($route)->toContain("'/edge-gateways/{id}/discovery'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/bindings'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/update-jobs'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/uninstall'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/events'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/input'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/resize'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions/{sessionId}/close'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/rotate-credentials'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}'");
|
||||
expect($route)->toContain("'/departments/{id}/gateway-cutover'");
|
||||
});
|
||||
|
||||
@@ -34,3 +36,15 @@ it('registers public installer, claim, heartbeat, command polling, and shell pol
|
||||
expect($route)->toContain("'/edge-agent/gateways/{id}/shell-actions/{actionId}/result'");
|
||||
expect($route)->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
|
||||
});
|
||||
|
||||
it('registers authenticated internal broker validation and presence endpoints', function (): void {
|
||||
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
|
||||
|
||||
expect($route)->not->toBeFalse();
|
||||
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/validate'");
|
||||
expect($route)->toContain("'/edge-agent/internal/shell-sessions/validate'");
|
||||
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/presence'");
|
||||
expect($route)->toContain("'/edge-agent/internal/shell-sessions/close'");
|
||||
expect($route)->toContain('HTTP_X_EDGE_BROKER_SECRET');
|
||||
expect($route)->toContain('EDGE_BROKER_SHARED_SECRET');
|
||||
});
|
||||
|
||||
@@ -27,4 +27,6 @@ it('stores edge gateway heartbeats, bindings, shell transcripts, and shell polli
|
||||
expect($bootstrapContent)->toContain('payload_json JSON NULL');
|
||||
expect($bootstrapContent)->toContain('event_type VARCHAR(32) NOT NULL');
|
||||
expect($bootstrapContent)->toContain('context_json JSON NULL');
|
||||
expect(substr_count($bootstrapContent, 'delivery_json JSON NULL'))->toBeGreaterThanOrEqual(3);
|
||||
expect($bootstrapContent)->toContain('ADD COLUMN delivery_json JSON NULL');
|
||||
});
|
||||
|
||||
@@ -7,7 +7,10 @@ it('rewrites edge gateway shell transport to API polling queues', function (): v
|
||||
expect($managerSource)->not->toBeFalse();
|
||||
expect($routeSource)->not->toBeFalse();
|
||||
|
||||
expect($managerSource)->toContain("'transport' => 'API_POLLING'");
|
||||
expect($managerSource)->toContain("'transport' => self::DELIVERY_CHANNEL_API");
|
||||
expect($managerSource)->toContain("'transport_path' => self::DELIVERY_CHANNEL_API");
|
||||
expect($managerSource)->toContain("'reconnect_state' => 'PENDING'");
|
||||
expect($managerSource)->toContain("'preferred_channel' => self::DELIVERY_CHANNEL_API");
|
||||
expect($managerSource)->toContain('private function createShellActionJob');
|
||||
expect($managerSource)->toContain('private function claimNextShellActionJob');
|
||||
expect($managerSource)->toContain('private function appendShellEvent');
|
||||
@@ -25,6 +28,9 @@ it('rewrites edge gateway shell transport to API polling queues', function (): v
|
||||
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
|
||||
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-actions/{actionId}/result'");
|
||||
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
|
||||
expect($routeSource)->not->toContain("'/edge-agent/internal/agent/auth'");
|
||||
expect($routeSource)->not->toContain("'/edge-agent/internal/shell/auth'");
|
||||
expect($routeSource)->toContain("'/edge-agent/internal/gateways/{id}/validate'");
|
||||
expect($routeSource)->toContain("'/edge-agent/internal/shell-sessions/validate'");
|
||||
expect($routeSource)->toContain("'/edge-agent/internal/gateways/{id}/presence'");
|
||||
expect($routeSource)->toContain("'/edge-agent/internal/shell-sessions/close'");
|
||||
expect($routeSource)->toContain('requireBrokerSharedSecret');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user