diff --git a/services/edge-agent/dist/agent.mjs b/services/edge-agent/dist/agent.mjs index daa5b129..690b7a71 100644 --- a/services/edge-agent/dist/agent.mjs +++ b/services/edge-agent/dist/agent.mjs @@ -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 }, - { - 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); + const result = await handleAgentCommand( + { commandType, payload }, + { + ...deps, + fetchImpl, + config, + configPath: payload.configPath || config.configPath || null, + liveConfig: config, + } + ); - if (commandType === "RUN_UPDATE" && followUp?.type === "RUN_UPDATE") { - try { - await startDetachedUpdateVerifier(config.configPath || payload.configPath || null, config); - await restartAgentAfterUpdate(followUp.restartPlan, {}); - } catch (followUpError) { - await rollbackPendingUpdate(config.configPath || payload.configPath || null, { - config, - liveConfig: config, - reason: createUpdateErrorMessage(followUpError, "Edge agent restart failed after update"), - }).catch(() => {}); - } + 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 || 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 || execution.payload?.configPath || null, { + config, + liveConfig: config, + reason: createUpdateErrorMessage(followUpError, followUpErrorMessage), + }).catch(() => {}); + } + return; + } + + 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; } - return { ok: true, payload: responsePayload }; + 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, diff --git a/services/edge-agent/test/agent.test.mjs b/services/edge-agent/test/agent.test.mjs index da949a33..4e8dd7bc 100644 --- a/services/edge-agent/test/agent.test.mjs +++ b/services/edge-agent/test/agent.test.mjs @@ -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,70 +672,74 @@ test("startAgent reports API polling metadata, executes polled commands, and upl }, }); - const agent = await startAgent({ - configPath, - fetchImpl: fakeFetch, - collectMetricsImpl, - createShellBridgeImpl, - }); + let agent = null; + try { + agent = await startAgent({ + configPath, + fetchImpl: fakeFetch, + collectMetricsImpl, + createShellBridgeImpl, + }); - assert.equal(heartbeats[0].body.status, "ONLINE"); - assert.equal(heartbeats[0].body.metadata.command_transport, "API_POLLING"); - assert.equal(heartbeats[0].body.metadata.shell_transport, "API_POLLING"); - assert.equal(heartbeats[0].body.metadata.system_metrics.cpu_usage_pct, 27.5); - 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("discovery_status" in heartbeats[0].body, false); + assert.equal(heartbeats[0].body.status, "ONLINE"); + assert.equal(heartbeats[0].body.metadata.command_transport, "API_POLLING"); + assert.equal(heartbeats[0].body.metadata.shell_transport, "API_POLLING"); + assert.equal(heartbeats[0].body.metadata.system_metrics.cpu_usage_pct, 27.5); + 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(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( - () => - commandResultPosts.length === 1 && - shellActionResults.length === 2 && - shellEventPosts.length > 0 && + await waitFor( + () => + commandResultPosts.length === 1 && + shellActionResults.length === 2 && + shellEventPosts.length > 0 && + heartbeats.some( + (heartbeat) => + heartbeat.body.metadata.system_metrics && + typeof heartbeat.body.metadata.system_metrics.latency_ms === "number" + ), + { timeoutMs: 1000, description: "agent polling activity and follow-up heartbeat" } + ); + + assert.equal(commandResultPosts.length, 1); + assert.equal(commandResultPosts[0].body.ok, true); + assert.equal(Array.isArray(commandResultPosts[0].body.payload.inventory), true); + assert.equal(commandResultPosts[0].body.payload.inventory[0].device_id, "AA:BB:CC:DD:EE:FF"); + + assert.ok(shellCalls.some((call) => call.type === "open")); + assert.ok(shellCalls.some((call) => call.type === "close")); + assert.equal(shellActionResults.length, 2); + assert.ok(shellActionResults.every((result) => result.body.ok === true)); + assert.ok( + shellEventPosts.some((request) => + request.body.events.some((event) => event.type === "OPENED") + ) + ); + assert.ok( + shellEventPosts.some((request) => + request.body.events.some((event) => event.type === "OUTPUT") + ) + ); + assert.ok( + shellEventPosts.some((request) => + request.body.events.some((event) => event.type === "CLOSED") + ) + ); + assert.ok( heartbeats.some( (heartbeat) => heartbeat.body.metadata.system_metrics && typeof heartbeat.body.metadata.system_metrics.latency_ms === "number" - ), - { timeoutMs: 1000, description: "agent polling activity and follow-up heartbeat" } - ); - - assert.equal(commandResultPosts.length, 1); - assert.equal(commandResultPosts[0].body.ok, true); - assert.equal(Array.isArray(commandResultPosts[0].body.payload.inventory), true); - assert.equal(commandResultPosts[0].body.payload.inventory[0].device_id, "AA:BB:CC:DD:EE:FF"); - - assert.ok(shellCalls.some((call) => call.type === "open")); - assert.ok(shellCalls.some((call) => call.type === "close")); - assert.equal(shellActionResults.length, 2); - assert.ok(shellActionResults.every((result) => result.body.ok === true)); - assert.ok( - shellEventPosts.some((request) => - request.body.events.some((event) => event.type === "OPENED") - ) - ); - assert.ok( - shellEventPosts.some((request) => - request.body.events.some((event) => event.type === "OUTPUT") - ) - ); - assert.ok( - shellEventPosts.some((request) => - request.body.events.some((event) => event.type === "CLOSED") - ) - ); - assert.ok( - heartbeats.some( - (heartbeat) => - heartbeat.body.metadata.system_metrics && - typeof heartbeat.body.metadata.system_metrics.latency_ms === "number" - ) - ); - - await agent.stop(); - await rm(tempDir, { recursive: true, force: true }); + ) + ); + } 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 }); diff --git a/services/edge-broker/server.mjs b/services/edge-broker/server.mjs index 70eed202..083a20d1 100644 --- a/services/edge-broker/server.mjs +++ b/services/edge-broker/server.mjs @@ -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) { - agents.delete(String(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; - }); -} diff --git a/services/nginx/app/classes/edge_gateway_manager.php b/services/nginx/app/classes/edge_gateway_manager.php index 46f6968a..a9edf9a3 100644 --- a/services/nginx/app/classes/edge_gateway_manager.php +++ b/services/nginx/app/classes/edge_gateway_manager.php @@ -21,6 +21,12 @@ class edge_gateway_manager public const DEPARTMENT_VARIABLE_TRANSPORT_MODE = 'shelly_transport_mode'; public const TRANSPORT_MODE_CLOUD = 'cloud'; public const TRANSPORT_MODE_GATEWAY = 'gateway'; + public const DELIVERY_CHANNEL_BROKER = 'BROKER_FAST_PATH'; + public const DELIVERY_CHANNEL_API = 'API_POLLING'; + public const DELIVERY_CHANNEL_CLOUD = 'CLOUD'; + public const RELAY_FALLBACK_PREFER_LOCAL = 'PREFER_LOCAL'; + public const RELAY_FALLBACK_LOCAL_ONLY = 'LOCAL_ONLY'; + public const RELAY_FALLBACK_CLOUD_ONLY = 'CLOUD_ONLY'; public const STATUS_PENDING = 'PENDING'; public const STATUS_ONLINE = 'ONLINE'; public const STATUS_DEGRADED = 'DEGRADED'; @@ -35,10 +41,16 @@ class edge_gateway_manager public const COMMAND_POLL_TIMEOUT_SECONDS = 20; public const COMMAND_POLL_INTERVAL_MICROSECONDS = 250000; public const COMMAND_DISPATCH_STALE_AFTER_SECONDS = 30; + public const COMMAND_EXPIRES_AFTER_SECONDS = 90; public const SHELL_ACTION_POLL_TIMEOUT_SECONDS = 20; public const SHELL_ACTION_STALE_AFTER_SECONDS = 30; + public const SHELL_ACTION_EXPIRES_AFTER_SECONDS = 90; public const SHELL_EVENT_POLL_TIMEOUT_SECONDS = 5; public const SHELL_EVENT_POLL_LIMIT = 200; + public const BROKER_PRESENCE_TTL_SECONDS = 90; + public const BROKER_CONNECTIVITY_DEGRADED_AFTER_SECONDS = 45; + public const BROKER_HTTP_TIMEOUT_SECONDS = 5; + public const DEVICE_FRESHNESS_DEGRADED_AFTER_SECONDS = 180; public function __construct() { @@ -127,6 +139,7 @@ class edge_gateway_manager 'agent_token' => $agentToken, 'heartbeat_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/heartbeat', 'release_channel' => (string)$gateway->release_channel->value(), + 'broker_url' => $this->buildBrokerPublicUrl(), ]; } @@ -217,6 +230,7 @@ class edge_gateway_manager { $gateway = $this->requireGateway($gatewayId); $data = $gateway->asArray(); + $data['metadata']['broker_presence'] = $this->readBrokerPresence((int)$gateway->id); $data['inventory'] = $this->listInventory($gatewayId); $data['bindings'] = $this->listBindings($gatewayId); $data['recent_commands'] = $this->listRecentObjects(new edge_gateway_command_jobs_o(), ['gateway_id' => $gatewayId, 'deleted_at' => null]); @@ -224,6 +238,7 @@ class edge_gateway_manager $data['recent_shell_sessions'] = $this->listRecentObjects(new edge_gateway_shell_sessions_o(), ['gateway_id' => $gatewayId, 'deleted_at' => null]); $data['audit_logs'] = $this->listRecentObjects(new edge_gateway_audit_logs_o(), ['gateway_id' => $gatewayId]); $data['department_transport_mode'] = $this->getDepartmentTransportMode((int)$gateway->department_id->value()); + $data['operational_snapshot'] = $this->buildGatewayOperationalSnapshot((int)$gateway->id); return self::deriveGatewayRuntimeState($data); } @@ -279,6 +294,8 @@ class edge_gateway_manager throw new Exception('Each relay binding must contain relay_id and device_id'); } + $bindingMetadata = $this->normalizeRelayBindingMetadata((array)($binding['metadata'] ?? []), $binding); + $incomingRelayIds[] = $relayId; $existing = (new edge_gateway_relay_bindings_o())->getFieldsWhere([ 'gateway_id' => $gatewayId, @@ -294,7 +311,7 @@ class edge_gateway_manager $bindingObject->binding_source->set((string)($binding['binding_source'] ?? 'MANUAL')); $bindingObject->approved_by->set($userId); $bindingObject->approved_at->set($this->now()); - $bindingObject->metadata_json->set((array)($binding['metadata'] ?? [])); + $bindingObject->metadata_json->set($bindingMetadata); continue; } @@ -308,7 +325,7 @@ class edge_gateway_manager 'binding_source' => (string)($binding['binding_source'] ?? 'MANUAL'), 'approved_by' => $userId, 'approved_at' => $this->now(), - 'metadata_json' => (array)($binding['metadata'] ?? []), + 'metadata_json' => $bindingMetadata, ]); } @@ -336,9 +353,11 @@ class edge_gateway_manager */ public function queueDiscovery(int $gatewayId, ?int $userId = null): array { - $gateway = $this->requireDispatchableGateway($gatewayId); + $gateway = $this->requireGateway($gatewayId); $gateway->discovery_status->set('PENDING'); - $this->createCommandJob($gatewayId, 'DISCOVER_SHELLY', [], $userId); + $this->createCommandJob($gatewayId, 'DISCOVER_SHELLY', [], $userId, [ + 'preferred_channel' => $this->resolveGatewayPreferredCommandChannel($gateway), + ]); return $this->getGateway($gatewayId); } @@ -348,7 +367,7 @@ class edge_gateway_manager */ public function queueUpdate(int $gatewayId, string $targetVersion, string $releaseChannel, ?int $userId = null): array { - $gateway = $this->requireDispatchableGateway($gatewayId); + $gateway = $this->requireGateway($gatewayId); $gateway->target_version->set($targetVersion); $jobObject = new edge_gateway_update_jobs_o(); @@ -360,6 +379,9 @@ class edge_gateway_manager 'status' => 'PENDING', 'requested_by' => $userId, 'requested_at' => $this->now(), + 'delivery_json' => $this->buildDeliveryMetadata([ + 'preferred_channel' => $this->resolveGatewayPreferredCommandChannel($gateway), + ], self::COMMAND_EXPIRES_AFTER_SECONDS), 'result_json' => [], ]); $jobObject->select($jobId); @@ -368,7 +390,10 @@ class edge_gateway_manager $gatewayId, 'RUN_UPDATE', $this->buildUpdateCommandPayload($targetVersion, $releaseChannel), - $userId + $userId, + [ + 'preferred_channel' => $this->resolveGatewayPreferredCommandChannel($gateway), + ] ); $jobObject->command_job_id->set((int)$command->id); @@ -383,6 +408,72 @@ class edge_gateway_manager return $jobObject->asArray(); } + /** + * @throws Exception + */ + public function queueUninstall(int $gatewayId, ?int $userId = null): array + { + $gateway = $this->requireDispatchableGateway($gatewayId); + $metadata = (array)($gateway->metadata_json->value() ?? []); + $metadata['pending_uninstall'] = [ + 'requested_at' => $this->now(), + 'requested_by' => $userId, + ]; + $gateway->metadata_json->set($metadata); + + $job = $this->createCommandJob( + $gatewayId, + 'UNINSTALL_AGENT', + [ + 'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME, + ], + $userId, + [ + 'preferred_channel' => $this->resolveGatewayPreferredCommandChannel($gateway), + ] + ); + + $this->writeAudit( + $gatewayId, + (int)$gateway->department_id->value(), + 'GATEWAY_UNINSTALL_QUEUED', + $userId, + ['command_job_id' => (int)$job->id] + ); + + return [ + 'gateway' => $this->getGateway($gatewayId), + 'job' => $job->asArray(), + ]; + } + + /** + * @throws Exception + */ + public function deleteGateway(int $gatewayId, ?int $userId = null): array + { + $gateway = $this->requireGateway($gatewayId); + $departmentId = (int)$gateway->department_id->value(); + $label = (string)$gateway->label->value(); + + $this->softDeleteGatewayRelations($gatewayId); + $gateway->deleted_at->set($this->now()); + + $this->writeAudit( + $gatewayId, + $departmentId, + 'GATEWAY_DELETED', + $userId, + ['label' => $label] + ); + + return [ + 'deleted' => true, + 'gateway_id' => $gatewayId, + 'department_id' => $departmentId, + ]; + } + /** * @throws Exception */ @@ -461,11 +552,19 @@ class edge_gateway_manager */ public function createShellSessionRequest(int $gatewayId, string $reason, array $options = [], ?int $userId = null): array { - $gateway = $this->requireDispatchableGateway($gatewayId); + $gateway = $this->requireGateway($gatewayId); + $gatewayStatus = self::resolveGatewayStatus( + $gateway->status->value() === null ? null : (string)$gateway->status->value(), + $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value() + ); $sessionToken = bin2hex(random_bytes(32)); $metadata = [ 'ttl_seconds' => self::SHELL_SESSION_TTL_SECONDS, - 'transport' => 'API_POLLING', + 'transport' => self::DELIVERY_CHANNEL_API, + 'transport_path' => self::DELIVERY_CHANNEL_API, + 'reconnect_state' => 'PENDING', + 'degraded' => $gatewayStatus !== self::STATUS_ONLINE, + 'gateway_status' => $gatewayStatus, ]; $cols = isset($options['cols']) ? (int)$options['cols'] : null; @@ -493,7 +592,10 @@ class edge_gateway_manager 'cols' => $cols, 'rows' => $rows, ], static fn(mixed $value): bool => $value !== null && $value !== ''), - $userId + $userId, + [ + 'preferred_channel' => self::DELIVERY_CHANNEL_API, + ] ); $this->writeAudit( @@ -507,7 +609,7 @@ class edge_gateway_manager return [ 'session' => $sessionObject->asArray(), 'session_token' => $sessionToken, - 'transport' => 'API_POLLING', + 'transport' => self::DELIVERY_CHANNEL_API, ]; } @@ -548,6 +650,8 @@ class edge_gateway_manager $session->transcript_text->set($transcript); $metadata = (array)($session->metadata_json->value() ?? []); $metadata['closed_reason'] = $closedReason; + $metadata['reconnect_state'] = 'CLOSED'; + $metadata['transport_state'] = 'DEGRADED'; $session->metadata_json->set($metadata); $this->writeAudit( @@ -617,7 +721,8 @@ class edge_gateway_manager $gatewayId, 'INPUT', ['data' => $data], - $userId + $userId, + ['preferred_channel' => self::DELIVERY_CHANNEL_API] ); return $session->asArray(); @@ -637,7 +742,8 @@ class edge_gateway_manager 'cols' => max(1, $cols), 'rows' => max(1, $rows), ], - $userId + $userId, + ['preferred_channel' => self::DELIVERY_CHANNEL_API] ); return $session->asArray(); @@ -651,6 +757,7 @@ class edge_gateway_manager $session = $this->requireShellSessionForGateway($gatewayId, $sessionId); $metadata = (array)($session->metadata_json->value() ?? []); $metadata['close_requested_by'] = $userId; + $metadata['reconnect_state'] = 'CLOSING'; $session->metadata_json->set($metadata); $wasClosed = $session->closed_at->value() !== null; @@ -672,7 +779,8 @@ class edge_gateway_manager $gatewayId, 'CLOSE', ['reason' => 'browser_requested'], - $userId + $userId, + ['preferred_channel' => self::DELIVERY_CHANNEL_API] ); $this->appendShellEvent( (int)$session->gateway_id->value(), @@ -738,9 +846,28 @@ class edge_gateway_manager ]; } + $resolvedError = $ok ? null : (trim((string)$error) !== '' ? trim((string)$error) : 'Edge gateway shell action failed'); $job->status->set($ok ? 'COMPLETED' : 'FAILED'); $job->completed_at->set($this->now()); - $job->error_message->set($ok ? null : (trim((string)$error) !== '' ? trim((string)$error) : 'Edge gateway shell action failed')); + $job->error_message->set($resolvedError); + $job->delivery_json->set($this->buildDeliveryMetadata( + array_merge( + (array)($job->delivery_json->value() ?? []), + [ + 'delivery_channel' => ((array)($job->delivery_json->value() ?? []))['delivery_channel'] ?? self::DELIVERY_CHANNEL_API, + 'last_dispatch_error' => $resolvedError, + ] + ), + self::SHELL_ACTION_EXPIRES_AFTER_SECONDS + )); + + if ((string)$job->action_type->value() === 'OPEN') { + $session = $this->requireShellSessionForGateway((int)$gateway->id, (int)$job->session_id->value()); + $metadata = (array)($session->metadata_json->value() ?? []); + $metadata['reconnect_state'] = $ok ? 'CONNECTED' : 'FAILED'; + $metadata['last_dispatch_error'] = $resolvedError; + $session->metadata_json->set($metadata); + } return [ 'acknowledged' => true, @@ -785,7 +912,7 @@ class edge_gateway_manager */ public function resolveRelayBinding(int $departmentId, string $logicalRelayId): array { - $gateway = $this->getPrimaryGatewayForDepartment($departmentId); + $gateway = $this->getPrimaryGatewayForDepartment($departmentId, false); $rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere([ 'department_id' => $departmentId, 'gateway_id' => (int)$gateway->id, @@ -806,15 +933,36 @@ class edge_gateway_manager public function dispatchRelayStatus(int $departmentId, string $logicalRelayId): array { $binding = $this->resolveRelayBinding($departmentId, $logicalRelayId); - $gateway = $this->requireDispatchableGateway((int)$binding['gateway_id']); + $gateway = $this->requireGateway((int)$binding['gateway_id']); + $resolution = $this->resolveRelayExecutionPlan($gateway, $binding, $logicalRelayId); + + if (($resolution['execution_path'] ?? 'local') === 'cloud') { + return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, null, $binding, $resolution); + } + $job = $this->createCommandJob((int)$gateway->id, 'GET_RELAY_STATUS', [ 'relayId' => $logicalRelayId, 'deviceId' => $binding['device_id'], 'localIp' => $binding['local_ip'], 'channel' => (int)$binding['channel'], - ], null); + ], null, [ + 'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), + 'fallback_reason' => $resolution['reason'] ?? null, + ]); - return $this->waitForCommandResult((int)$job->id); + try { + $result = $this->dispatchGatewayCommand($gateway, $job); + return $this->finalizeRelayDispatch($binding, $resolution, $result); + } catch (Exception $exception) { + return $this->handleRelayDispatchFailure( + $departmentId, + $logicalRelayId, + $binding, + $resolution, + null, + $exception + ); + } } /** @@ -823,16 +971,37 @@ class edge_gateway_manager public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array { $binding = $this->resolveRelayBinding($departmentId, $logicalRelayId); - $gateway = $this->requireDispatchableGateway((int)$binding['gateway_id']); + $gateway = $this->requireGateway((int)$binding['gateway_id']); + $resolution = $this->resolveRelayExecutionPlan($gateway, $binding, $logicalRelayId); + + if (($resolution['execution_path'] ?? 'local') === 'cloud') { + return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, $on, $binding, $resolution); + } + $job = $this->createCommandJob((int)$gateway->id, 'SET_RELAY_STATE', [ 'relayId' => $logicalRelayId, 'deviceId' => $binding['device_id'], 'localIp' => $binding['local_ip'], 'channel' => (int)$binding['channel'], 'on' => $on, - ], null); + ], null, [ + 'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), + 'fallback_reason' => $resolution['reason'] ?? null, + ]); - return $this->waitForCommandResult((int)$job->id); + try { + $result = $this->dispatchGatewayCommand($gateway, $job); + return $this->finalizeRelayDispatch($binding, $resolution, $result); + } catch (Exception $exception) { + return $this->handleRelayDispatchFailure( + $departmentId, + $logicalRelayId, + $binding, + $resolution, + $on, + $exception + ); + } } /** @@ -876,6 +1045,7 @@ class edge_gateway_manager { $configJson = json_encode([ 'apiUrl' => $this->getApiBaseUrl(), + 'brokerUrl' => $this->buildBrokerPublicUrl(), 'installToken' => $plainToken, 'gatewayId' => null, 'agentToken' => null, @@ -1088,13 +1258,41 @@ BASH; private function requireGateway(int $gatewayId): edge_gateways_o { $gateway = (new edge_gateways_o())->select($gatewayId); - if (!$gateway->exists()) { + if (!$gateway->exists() || $gateway->deleted_at->value() !== null) { throw new Exception('Edge gateway not found'); } return $gateway; } + private function softDeleteGatewayRelations(int $gatewayId): void + { + $tables = [ + 'edge_gateway_device_inventory', + 'edge_gateway_relay_bindings', + 'edge_gateway_command_jobs', + 'edge_gateway_update_jobs', + 'edge_gateway_shell_sessions', + 'edge_gateway_shell_action_jobs', + 'edge_gateway_shell_events', + ]; + $pdo = db::getPDO(); + $deletedAt = $this->now(); + + foreach ($tables as $table) { + $statement = $pdo->prepare( + "UPDATE {$table} + SET deleted_at = :deleted_at + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL" + ); + $statement->execute([ + ':deleted_at' => $deletedAt, + ':gateway_id' => $gatewayId, + ]); + } + } + /** * @throws Exception */ @@ -1136,7 +1334,7 @@ BASH; /** * @throws Exception */ - private function getPrimaryGatewayForDepartment(int $departmentId): edge_gateways_o + private function getPrimaryGatewayForDepartment(int $departmentId, bool $requireDispatchable = true): edge_gateways_o { $rows = (new edge_gateways_o())->getFieldsWhere([ 'department_id' => $departmentId, @@ -1171,15 +1369,22 @@ BASH; $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value() ); - if (!in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) { + if ($requireDispatchable && !in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) { throw new Exception('Department edge gateway is offline'); } return $gateway; } - private function createCommandJob(int $gatewayId, string $commandType, array $request, ?int $userId): edge_gateway_command_jobs_o + private function createCommandJob( + int $gatewayId, + string $commandType, + array $request, + ?int $userId, + array $delivery = [] + ): edge_gateway_command_jobs_o { + $deliveryMetadata = $this->buildDeliveryMetadata($delivery, self::COMMAND_EXPIRES_AFTER_SECONDS); $jobObject = new edge_gateway_command_jobs_o(); $jobId = $jobObject->add_object([ 'gateway_id' => $gatewayId, @@ -1187,6 +1392,7 @@ BASH; 'status' => 'PENDING', 'request_json' => $request, 'response_json' => [], + 'delivery_json' => $deliveryMetadata, 'correlation_id' => bin2hex(random_bytes(16)), 'requested_by' => $userId, 'requested_at' => $this->now(), @@ -1200,8 +1406,10 @@ BASH; int $gatewayId, string $actionType, array $payload, - ?int $userId + ?int $userId, + array $delivery = [] ): edge_gateway_shell_action_jobs_o { + $deliveryMetadata = $this->buildDeliveryMetadata($delivery, self::SHELL_ACTION_EXPIRES_AFTER_SECONDS); $jobObject = new edge_gateway_shell_action_jobs_o(); $jobId = $jobObject->add_object([ 'gateway_id' => $gatewayId, @@ -1209,6 +1417,7 @@ BASH; 'action_type' => strtoupper($actionType), 'status' => 'PENDING', 'payload_json' => $payload, + 'delivery_json' => $deliveryMetadata, 'requested_by' => $userId, 'requested_at' => $this->now(), ]); @@ -1255,12 +1464,19 @@ BASH; $update = $pdo->prepare( 'UPDATE edge_gateway_shell_action_jobs SET status = :status, + delivery_json = JSON_SET( + COALESCE(delivery_json, JSON_OBJECT()), + \'$.delivery_channel\', :delivery_channel, + \'$.attempt_count\', COALESCE(JSON_EXTRACT(COALESCE(delivery_json, JSON_OBJECT()), \'$.attempt_count\'), 0) + 1, + \'$.last_dispatch_error\', CAST(NULL AS JSON) + ), error_message = NULL, completed_at = NULL WHERE id = :id' ); $update->execute([ ':status' => 'DISPATCHING', + ':delivery_channel' => json_encode(self::DELIVERY_CHANNEL_API, JSON_UNESCAPED_UNICODE), ':id' => (int)$row['id'], ]); @@ -1317,6 +1533,11 @@ BASH; $session->opened_at->set($this->now()); } $session->approval_status->set('OPEN'); + $metadata = (array)($session->metadata_json->value() ?? []); + $metadata['reconnect_state'] = 'CONNECTED'; + $metadata['transport_state'] = 'LIVE'; + $metadata['last_dispatch_error'] = null; + $session->metadata_json->set($metadata); return $this->appendShellEvent((int)$session->gateway_id->value(), (int)$session->id, $type, $payload); } @@ -1339,6 +1560,8 @@ BASH; $metadata = (array)($session->metadata_json->value() ?? []); $closedReason = trim((string)($payload['reason'] ?? 'agent_closed')); $metadata['closed_reason'] = $closedReason !== '' ? $closedReason : 'agent_closed'; + $metadata['reconnect_state'] = 'CLOSED'; + $metadata['transport_state'] = 'DEGRADED'; if (array_key_exists('code', $payload)) { $metadata['exit_code'] = (int)$payload['code']; } @@ -1484,6 +1707,17 @@ BASH; usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS); } while (true); + $job = (new edge_gateway_command_jobs_o())->select($jobId); + if ($job->exists()) { + $job->delivery_json->set($this->buildDeliveryMetadata( + array_merge( + (array)($job->delivery_json->value() ?? []), + ['last_dispatch_error' => 'Edge gateway command timed out'] + ), + self::COMMAND_EXPIRES_AFTER_SECONDS + )); + } + throw new Exception('Edge gateway command timed out'); } @@ -1527,6 +1761,12 @@ BASH; 'UPDATE edge_gateway_command_jobs SET status = :status, response_json = :response_json, + delivery_json = JSON_SET( + COALESCE(delivery_json, JSON_OBJECT()), + \'$.delivery_channel\', :delivery_channel, + \'$.attempt_count\', COALESCE(JSON_EXTRACT(COALESCE(delivery_json, JSON_OBJECT()), \'$.attempt_count\'), 0) + 1, + \'$.last_dispatch_error\', CAST(NULL AS JSON) + ), error_message = NULL, completed_at = NULL WHERE id = :id' @@ -1534,6 +1774,7 @@ BASH; $update->execute([ ':status' => 'DISPATCHING', ':response_json' => json_encode([], JSON_UNESCAPED_UNICODE), + ':delivery_channel' => json_encode(self::DELIVERY_CHANNEL_API, JSON_UNESCAPED_UNICODE), ':id' => (int)$row['id'], ]); @@ -1591,6 +1832,16 @@ BASH; } $job->response_json->set($response); + $job->delivery_json->set($this->buildDeliveryMetadata( + array_merge( + (array)($job->delivery_json->value() ?? []), + [ + 'delivery_channel' => ((array)($job->delivery_json->value() ?? []))['delivery_channel'] ?? self::DELIVERY_CHANNEL_API, + 'last_dispatch_error' => $ok ? null : $errorMessage, + ] + ), + self::COMMAND_EXPIRES_AFTER_SECONDS + )); $job->completed_at->set($this->now()); $job->error_message->set($ok ? null : $errorMessage); $job->status->set($ok ? 'COMPLETED' : 'FAILED'); @@ -1624,6 +1875,29 @@ BASH; } else { $this->finalizeLinkedUpdateJob((int)$job->id, $ok, $payload, $errorMessage); } + return; + } + + if ($commandType === 'UNINSTALL_AGENT') { + $metadata = (array)($gateway->metadata_json->value() ?? []); + $metadata['pending_uninstall'] = null; + $metadata['last_uninstall'] = [ + 'ok' => $ok, + 'completed_at' => $this->now(), + 'error' => $ok ? null : $errorMessage, + ]; + $gateway->metadata_json->set($metadata); + + $this->writeAudit( + (int)$gateway->id, + (int)$gateway->department_id->value(), + $ok ? 'GATEWAY_UNINSTALL_COMPLETED' : 'GATEWAY_UNINSTALL_FAILED', + null, + [ + 'command_job_id' => (int)$job->id, + 'error' => $ok ? null : $errorMessage, + ] + ); } } @@ -1829,6 +2103,637 @@ BASH; return $result; } + /** + * @throws Exception + */ + public function validateBrokerAgentConnection(int $gatewayId, string $plainToken): array + { + $gateway = $this->authenticateGateway($gatewayId, $plainToken); + + return [ + 'id' => (int)$gateway->id, + 'gateway_id' => (int)$gateway->id, + 'department_id' => (int)$gateway->department_id->value(), + 'label' => (string)$gateway->label->value(), + 'hostname' => $gateway->hostname->value() === null ? null : (string)$gateway->hostname->value(), + ]; + } + + /** + * @throws Exception + */ + public function recordBrokerPresence( + int $gatewayId, + string $status, + ?string $connectionId = null, + ?string $reason = null, + array $metadata = [] + ): array { + $gateway = $this->requireGateway($gatewayId); + $normalizedStatus = trim(strtolower($status)); + $connected = $normalizedStatus === 'connected'; + $presence = array_filter([ + 'gateway_id' => $gatewayId, + 'connected' => $connected, + 'connection_id' => $connectionId, + 'last_seen_at' => $this->now(), + 'disconnect_reason' => $connected ? null : $reason, + 'last_error' => !$connected && $reason !== null && trim($reason) !== '' ? trim($reason) : null, + 'metadata' => $metadata, + ], static fn(mixed $value): bool => $value !== null); + + $this->writeBrokerPresence($gatewayId, $presence); + + $gatewayMetadata = (array)($gateway->metadata_json->value() ?? []); + $gatewayMetadata['broker_presence'] = array_merge( + (array)($gatewayMetadata['broker_presence'] ?? []), + $presence + ); + $gatewayMetadata['broker_connected'] = $connected; + if ($connected) { + $gatewayMetadata['broker_connected_at'] = $this->now(); + $gatewayMetadata['broker_last_error'] = null; + } else { + $gatewayMetadata['broker_disconnected_at'] = $this->now(); + $gatewayMetadata['broker_last_error'] = $reason; + } + $gateway->metadata_json->set($gatewayMetadata); + + return $presence; + } + + private function readBrokerPresence(int $gatewayId): array + { + $redisPresence = $this->withRedis( + static fn(redis $redis): ?string => $redis->get(edge_gateway_manager::brokerPresenceKey($gatewayId)), + null + ); + if (is_string($redisPresence) && trim($redisPresence) !== '') { + $decoded = json_decode($redisPresence, true); + if (is_array($decoded)) { + return $decoded; + } + } + + $gateway = (new edge_gateways_o())->select($gatewayId); + if ($gateway->exists()) { + $metadata = (array)($gateway->metadata_json->value() ?? []); + if (isset($metadata['broker_presence']) && is_array($metadata['broker_presence'])) { + return (array)$metadata['broker_presence']; + } + } + + return []; + } + + private function writeBrokerPresence(int $gatewayId, array $presence): void + { + $encoded = json_encode($presence, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($encoded === false) { + return; + } + + $this->withRedis( + static function (redis $redis) use ($gatewayId, $encoded): void { + $redis->setEx(edge_gateway_manager::brokerPresenceKey($gatewayId), $encoded, edge_gateway_manager::BROKER_PRESENCE_TTL_SECONDS); + } + ); + } + + private static function brokerPresenceKey(int $gatewayId): string + { + return 'edge_gateway_broker_presence_' . $gatewayId; + } + + private function withRedis(callable $callback, mixed $fallback = null): mixed + { + try { + return $callback(new redis()); + } catch (\Throwable) { + return $fallback; + } + } + + private function buildGatewayOperationalSnapshot(int $gatewayId): array + { + $pdo = db::getPDO(); + + $counts = [ + 'command_backlog' => 0, + 'shell_backlog' => 0, + 'update_backlog' => 0, + 'last_successful_command_at' => null, + 'last_successful_discovery_at' => null, + 'last_successful_shell_at' => null, + ]; + + $countQueries = [ + 'command_backlog' => "SELECT COUNT(*) AS c + FROM edge_gateway_command_jobs + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND status IN ('PENDING', 'DISPATCHING')", + 'shell_backlog' => "SELECT COUNT(*) AS c + FROM edge_gateway_shell_action_jobs + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND status IN ('PENDING', 'DISPATCHING')", + 'update_backlog' => "SELECT COUNT(*) AS c + FROM edge_gateway_update_jobs + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND status IN ('PENDING', 'DISPATCHING', 'VERIFYING')", + ]; + + foreach ($countQueries as $key => $sql) { + $statement = $pdo->prepare($sql); + $statement->execute([':gateway_id' => $gatewayId]); + $row = $statement->fetch(); + $counts[$key] = isset($row['c']) ? (int)$row['c'] : 0; + } + + $timestampQueries = [ + 'last_successful_command_at' => "SELECT completed_at AS ts + FROM edge_gateway_command_jobs + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND status = 'COMPLETED' + ORDER BY completed_at DESC, id DESC + LIMIT 1", + 'last_successful_discovery_at' => "SELECT completed_at AS ts + FROM edge_gateway_command_jobs + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND status = 'COMPLETED' + AND command_type = 'DISCOVER_SHELLY' + ORDER BY completed_at DESC, id DESC + LIMIT 1", + 'last_successful_shell_at' => "SELECT COALESCE(opened_at, approved_at, created_at) AS ts + FROM edge_gateway_shell_sessions + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + ORDER BY COALESCE(opened_at, approved_at, created_at) DESC, id DESC + LIMIT 1", + ]; + + foreach ($timestampQueries as $key => $sql) { + $statement = $pdo->prepare($sql); + $statement->execute([':gateway_id' => $gatewayId]); + $row = $statement->fetch(); + $counts[$key] = isset($row['ts']) ? (string)$row['ts'] : null; + } + + return $counts; + } + + private function buildBrokerPublicUrl(): ?string + { + $configured = trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: '')); + if ($configured !== '') { + return rtrim($configured, '/'); + } + + $apiBaseUrl = $this->getApiBaseUrl(); + $parsed = parse_url($apiBaseUrl); + $host = $parsed['host'] ?? null; + if (!is_string($host) || trim($host) === '') { + return null; + } + + $scheme = strtolower((string)($parsed['scheme'] ?? 'http')) === 'https' ? 'https' : 'http'; + $port = (int)(getenv('EDGE_PUBLIC_BROKER_PORT') ?: 4300); + if ($port <= 0) { + $port = 4300; + } + + $hostWithPort = $host; + if (!(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) { + $hostWithPort .= ':' . $port; + } + + return $scheme . '://' . $hostWithPort; + } + + private function buildBrokerInternalUrl(): ?string + { + $configured = trim((string)(getenv('EDGE_BROKER_URL') ?: '')); + return $configured !== '' ? rtrim($configured, '/') : null; + } + + private function resolveGatewayPreferredCommandChannel(edge_gateways_o|array $gateway): string + { + $gatewayId = is_array($gateway) ? (int)($gateway['id'] ?? 0) : (int)$gateway->id; + if ($gatewayId <= 0 || $this->buildBrokerInternalUrl() === null) { + return self::DELIVERY_CHANNEL_API; + } + + $presence = $this->readBrokerPresence($gatewayId); + return !empty($presence['connected']) ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API; + } + + private function normalizeRelayBindingMetadata(array $metadata = [], array $binding = []): array + { + $fallbackMode = strtoupper(trim((string)($binding['fallback_mode'] ?? $metadata['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL))); + if (!in_array($fallbackMode, [ + self::RELAY_FALLBACK_PREFER_LOCAL, + self::RELAY_FALLBACK_LOCAL_ONLY, + self::RELAY_FALLBACK_CLOUD_ONLY, + ], true)) { + $fallbackMode = self::RELAY_FALLBACK_PREFER_LOCAL; + } + + $metadata['fallback_mode'] = $fallbackMode; + + if (isset($binding['last_resolution']) && is_array($binding['last_resolution'])) { + $metadata['last_resolution'] = (array)$binding['last_resolution']; + } + if (array_key_exists('last_success_at', $binding)) { + $metadata['last_success_at'] = $binding['last_success_at']; + } + if (array_key_exists('last_error', $binding)) { + $metadata['last_error'] = $binding['last_error']; + } + + return $metadata; + } + + private function buildDeliveryMetadata(array $overrides = [], int $ttlSeconds = self::COMMAND_EXPIRES_AFTER_SECONDS): array + { + $preferredChannel = strtoupper(trim((string)($overrides['preferred_channel'] ?? self::DELIVERY_CHANNEL_API))); + if (!in_array($preferredChannel, [ + self::DELIVERY_CHANNEL_BROKER, + self::DELIVERY_CHANNEL_API, + self::DELIVERY_CHANNEL_CLOUD, + ], true)) { + $preferredChannel = self::DELIVERY_CHANNEL_API; + } + + return array_merge([ + 'preferred_channel' => $preferredChannel, + 'delivery_channel' => $overrides['delivery_channel'] ?? null, + 'attempt_count' => isset($overrides['attempt_count']) ? (int)$overrides['attempt_count'] : 0, + 'expires_at' => $overrides['expires_at'] ?? $this->formatDateTime(time() + max(30, $ttlSeconds)), + 'fallback_reason' => $overrides['fallback_reason'] ?? null, + 'last_dispatch_error' => $overrides['last_dispatch_error'] ?? null, + ], $overrides); + } + + private function resolveRelayExecutionPlan(edge_gateways_o $gateway, array $binding, string $logicalRelayId): array + { + $gatewayData = $gateway->asArray(); + $gatewayData['metadata']['broker_presence'] = $this->readBrokerPresence((int)$gateway->id); + $gatewayData['department_transport_mode'] = $this->getDepartmentTransportMode((int)$gateway->department_id->value()); + $gatewayData['inventory'] = $this->listInventory((int)$gateway->id); + $gatewayData['bindings'] = [$binding]; + + $runtime = self::deriveGatewayRuntimeState($gatewayData); + $relayHealth = (array)($runtime['relay_health'][0] ?? []); + $executionPath = (string)($relayHealth['execution_path'] ?? 'local'); + + return array_merge($relayHealth, [ + 'relay_id' => $logicalRelayId, + 'execution_path' => $executionPath, + 'preferred_channel' => $executionPath === 'local' + ? $this->resolveGatewayPreferredCommandChannel($gateway) + : self::DELIVERY_CHANNEL_CLOUD, + ]); + } + + /** + * @throws Exception + */ + private function dispatchGatewayCommand(edge_gateways_o $gateway, edge_gateway_command_jobs_o $job): array + { + $effectiveStatus = self::resolveGatewayStatus( + $gateway->status->value() === null ? null : (string)$gateway->status->value(), + $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value() + ); + if (!in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) { + throw new Exception('Gateway agent is offline'); + } + + $delivery = (array)($job->delivery_json->value() ?? []); + $preferredChannel = (string)($delivery['preferred_channel'] ?? self::DELIVERY_CHANNEL_API); + + if ($preferredChannel === self::DELIVERY_CHANNEL_BROKER && $this->buildBrokerInternalUrl() !== null) { + try { + $this->markCommandJobDispatching($job, self::DELIVERY_CHANNEL_BROKER); + $payload = $this->dispatchBrokerCommand($gateway, $job); + $this->finalizeCommandJob($job, true, $payload, null, $gateway); + return $payload; + } catch (Exception $exception) { + $this->markCommandDeliveryFailure($job, self::DELIVERY_CHANNEL_BROKER, $exception->getMessage(), 'broker_dispatch_failed'); + } + } + + return $this->waitForCommandResult((int)$job->id); + } + + private function markCommandJobDispatching( + edge_gateway_command_jobs_o $job, + string $channel, + ?string $fallbackReason = null + ): void { + $delivery = $this->buildDeliveryMetadata( + array_merge( + (array)($job->delivery_json->value() ?? []), + [ + 'delivery_channel' => $channel, + 'attempt_count' => (int)((array)($job->delivery_json->value() ?? [])['attempt_count'] ?? 0) + 1, + 'fallback_reason' => $fallbackReason, + 'last_dispatch_error' => null, + ] + ), + self::COMMAND_EXPIRES_AFTER_SECONDS + ); + + $job->status->set('DISPATCHING'); + $job->response_json->set([]); + $job->completed_at->set(null); + $job->error_message->set(null); + $job->delivery_json->set($delivery); + } + + private function markCommandDeliveryFailure( + edge_gateway_command_jobs_o $job, + string $channel, + string $errorMessage, + ?string $fallbackReason = null + ): void { + $delivery = $this->buildDeliveryMetadata( + array_merge( + (array)($job->delivery_json->value() ?? []), + [ + 'delivery_channel' => $channel, + 'fallback_reason' => $fallbackReason, + 'last_dispatch_error' => $errorMessage, + ] + ), + self::COMMAND_EXPIRES_AFTER_SECONDS + ); + $job->delivery_json->set($delivery); + } + + /** + * @throws Exception + */ + private function dispatchBrokerCommand(edge_gateways_o $gateway, edge_gateway_command_jobs_o $job): array + { + $brokerUrl = $this->buildBrokerInternalUrl(); + if ($brokerUrl === null) { + throw new Exception('Edge broker is not configured'); + } + + $presence = $this->readBrokerPresence((int)$gateway->id); + if (empty($presence['connected'])) { + throw new Exception('Edge broker fast path is unavailable'); + } + + $result = $this->httpJsonRequest( + $brokerUrl . '/api/gateways/' . (int)$gateway->id . '/commands', + [ + 'commandType' => (string)$job->command_type->value(), + 'payload' => $this->buildCommandExecutionPayload($job, $gateway), + ], + [ + 'x-edge-broker-secret: ' . trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: '')), + ], + self::BROKER_HTTP_TIMEOUT_SECONDS + ); + + if (!is_array($result) || empty($result['ok'])) { + throw new Exception(trim((string)($result['error'] ?? 'Edge broker dispatch failed')) ?: 'Edge broker dispatch failed'); + } + + return isset($result['payload']) && is_array($result['payload']) ? (array)$result['payload'] : []; + } + + /** + * @throws Exception + */ + private function dispatchRelayThroughCloud( + int $departmentId, + string $logicalRelayId, + ?bool $on, + array $binding, + array $resolution + ): array { + $transport = new cloud_shelly_transport(); + $response = $on === null + ? $transport->sendPostRequest('/v2/devices/api/get', ['ids' => [$logicalRelayId]], $departmentId) + : $transport->sendPostRequest('/v2/devices/api/set/switch', ['id' => $logicalRelayId, 'on' => $on], $departmentId); + + $normalized = is_array($response) ? (array)($response[0] ?? []) : (array)$response; + $result = [ + 'online' => (bool)($normalized['online'] ?? true), + 'on' => (bool)($normalized['on'] + ?? $normalized['output'] + ?? $normalized['status']['switch:0']['output'] + ?? $on + ?? false), + 'raw' => (array)($normalized['raw'] ?? $normalized), + ]; + + return $this->finalizeRelayDispatch( + $binding, + array_merge($resolution, [ + 'execution_path' => 'cloud', + 'delivery_channel' => self::DELIVERY_CHANNEL_CLOUD, + ]), + $result + ); + } + + /** + * @throws Exception + */ + private function handleRelayDispatchFailure( + int $departmentId, + string $logicalRelayId, + array $binding, + array $resolution, + ?bool $on, + Exception $exception + ): array { + $recommendedAction = $this->mapRelayFailureToRecommendedAction($exception->getMessage()); + $this->recordRelayBindingResolution( + $binding, + array_merge($resolution, [ + 'execution_path' => 'local', + 'delivery_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), + 'reason' => 'local_dispatch_failed', + 'recommended_action' => $recommendedAction, + 'recovery_actions' => [$recommendedAction], + ]), + false, + $exception->getMessage() + ); + + if ((string)($resolution['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL) !== self::RELAY_FALLBACK_PREFER_LOCAL) { + throw $exception; + } + + try { + return $this->dispatchRelayThroughCloud( + $departmentId, + $logicalRelayId, + $on, + $binding, + array_merge($resolution, [ + 'execution_path' => 'cloud', + 'reason' => 'local_dispatch_failed', + 'fallback_reason' => $exception->getMessage(), + 'recommended_action' => $recommendedAction, + 'recovery_actions' => [$recommendedAction, 'force_cloud'], + ]) + ); + } catch (Exception $cloudException) { + $this->recordRelayBindingResolution( + $binding, + array_merge($resolution, [ + 'execution_path' => 'cloud', + 'delivery_channel' => self::DELIVERY_CHANNEL_CLOUD, + 'reason' => 'cloud_fallback_failed', + 'recommended_action' => $recommendedAction, + ]), + false, + $cloudException->getMessage() + ); + throw $cloudException; + } + } + + private function finalizeRelayDispatch(array $binding, array $resolution, array $result): array + { + $executionPath = (string)($resolution['execution_path'] ?? 'local'); + $deliveryChannel = $executionPath === 'cloud' + ? self::DELIVERY_CHANNEL_CLOUD + : (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API); + + $resolutionPayload = array_merge($resolution, [ + 'delivery_channel' => $deliveryChannel, + 'execution_path' => $executionPath, + ]); + $this->recordRelayBindingResolution($binding, $resolutionPayload, true, null); + + return array_merge($result, [ + 'binding' => $this->reloadRelayBinding((int)$binding['id']), + 'execution' => [ + 'path' => $executionPath, + 'channel' => $deliveryChannel, + 'reason' => $resolutionPayload['reason'] ?? null, + 'fallback_mode' => $resolutionPayload['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL, + 'recommended_action' => $resolutionPayload['recommended_action'] ?? null, + ], + 'raw' => (array)($result['raw'] ?? []), + ]); + } + + private function reloadRelayBinding(int $bindingId): array + { + $binding = (new edge_gateway_relay_bindings_o())->select($bindingId); + return $binding->exists() ? $binding->asArray() : []; + } + + private function recordRelayBindingResolution( + array $binding, + array $resolution, + bool $success, + ?string $errorMessage + ): void { + $bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$binding['id']); + if (!$bindingObject->exists()) { + return; + } + + $metadata = $this->normalizeRelayBindingMetadata((array)($bindingObject->metadata_json->value() ?? []), $binding); + $metadata['last_resolution'] = [ + 'at' => $this->now(), + 'execution_path' => $resolution['execution_path'] ?? 'local', + 'delivery_channel' => $resolution['delivery_channel'] ?? ($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API), + 'reason' => $resolution['reason'] ?? null, + 'fallback_mode' => $metadata['fallback_mode'], + 'recommended_action' => $resolution['recommended_action'] ?? null, + 'recovery_actions' => array_values(array_filter((array)($resolution['recovery_actions'] ?? []))), + 'gateway_status' => $resolution['gateway_status'] ?? null, + 'device_online' => $resolution['device_online'] ?? null, + 'device_freshness_seconds' => $resolution['device_freshness_seconds'] ?? null, + 'device_freshness_state' => $resolution['device_freshness_state'] ?? null, + ]; + + if ($success) { + $metadata['last_success_at'] = $this->now(); + $metadata['last_error'] = null; + } else { + $metadata['last_error'] = $errorMessage; + } + + $bindingObject->metadata_json->set($metadata); + } + + private function mapRelayFailureToRecommendedAction(string $errorMessage): string + { + $normalized = strtolower(trim($errorMessage)); + if ($normalized === '') { + return 'retry_local_command'; + } + if (str_contains($normalized, 'credential') || str_contains($normalized, 'token')) { + return 'rotate_credentials'; + } + if (str_contains($normalized, 'discovery') || str_contains($normalized, 'device')) { + return 'retry_discovery'; + } + if (str_contains($normalized, 'update')) { + return 'retry_update'; + } + if (str_contains($normalized, 'offline') || str_contains($normalized, 'timeout') || str_contains($normalized, 'broker')) { + return 'restart_agent'; + } + + return 'retry_local_command'; + } + + /** + * @throws Exception + */ + private function httpJsonRequest(string $url, array $payload, array $headers = [], int $timeoutSeconds = 5): array + { + $defaultHeaders = [ + 'Content-Type: application/json', + 'Accept: application/json', + ]; + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", array_filter(array_merge($defaultHeaders, $headers))), + 'content' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'timeout' => max(1, $timeoutSeconds), + 'ignore_errors' => true, + ], + ]); + + $response = @file_get_contents($url, false, $context); + if ($response === false) { + throw new Exception('Unable to reach edge broker'); + } + + $decoded = json_decode($response, true); + if (!is_array($decoded)) { + throw new Exception('Edge broker returned an invalid response'); + } + + $statusLine = is_array($http_response_header ?? null) ? (string)($http_response_header[0] ?? '') : ''; + if ($statusLine !== '' && preg_match('/\s(\d{3})\s/', $statusLine, $matches) === 1) { + $statusCode = (int)$matches[1]; + if ($statusCode >= 400) { + throw new Exception(trim((string)($decoded['error'] ?? $decoded['message'] ?? 'Edge broker request failed')) ?: 'Edge broker request failed'); + } + } + + return $decoded; + } + public static function deriveGatewayRuntimeState(array $gateway, ?int $now = null): array { $effectiveStatus = self::resolveGatewayStatus( @@ -1844,10 +2749,293 @@ BASH; isset($gateway['discovery_status']) ? (string)$gateway['discovery_status'] : null, $effectiveStatus ); + $gateway['metadata'] = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : []; + $gateway['operational_snapshot'] = isset($gateway['operational_snapshot']) && is_array($gateway['operational_snapshot']) + ? (array)$gateway['operational_snapshot'] + : []; + + $channelStatus = self::deriveChannelStatus($gateway, $effectiveStatus, $now); + $relayHealth = self::buildRelayHealth($gateway, $effectiveStatus, $now); + $fallbackSummary = self::buildFallbackSummary($relayHealth); + + $gateway['channel_status'] = $channelStatus; + $gateway['relay_health'] = $relayHealth; + $gateway['fallback_summary'] = $fallbackSummary; + $gateway['transport_health'] = self::deriveTransportHealth($effectiveStatus, $channelStatus, $fallbackSummary); + $gateway['last_successful_command_at'] = $gateway['operational_snapshot']['last_successful_command_at'] + ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null); + $gateway['last_successful_discovery_at'] = $gateway['operational_snapshot']['last_successful_discovery_at'] + ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), 'DISCOVER_SHELLY'); + $gateway['last_successful_shell_at'] = $gateway['operational_snapshot']['last_successful_shell_at'] + ?? self::findLatestShellTimestamp((array)($gateway['recent_shell_sessions'] ?? [])); + $gateway['backlog_depth'] = [ + 'commands' => (int)($gateway['operational_snapshot']['command_backlog'] ?? 0), + 'shell_actions' => (int)($gateway['operational_snapshot']['shell_backlog'] ?? 0), + 'updates' => (int)($gateway['operational_snapshot']['update_backlog'] ?? 0), + ]; return $gateway; } + private static function deriveChannelStatus(array $gateway, string $effectiveStatus, ?int $now = null): array + { + $metadata = (array)($gateway['metadata'] ?? []); + $operational = (array)($gateway['operational_snapshot'] ?? []); + $brokerPresence = isset($metadata['broker_presence']) && is_array($metadata['broker_presence']) + ? (array)$metadata['broker_presence'] + : []; + $brokerConnected = !empty($brokerPresence['connected']); + $brokerLastSeenAt = isset($brokerPresence['last_seen_at']) ? (string)$brokerPresence['last_seen_at'] : null; + $brokerAgeSeconds = self::heartbeatAgeSeconds($brokerLastSeenAt, $now); + $brokerHealthy = $brokerConnected + && $brokerAgeSeconds !== null + && $brokerAgeSeconds < self::BROKER_CONNECTIVITY_DEGRADED_AFTER_SECONDS; + $commandPreferred = $brokerConnected ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API; + + return [ + 'command' => [ + 'preferred' => $commandPreferred, + 'active' => $brokerHealthy ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API, + 'state' => $effectiveStatus === self::STATUS_OFFLINE + ? self::STATUS_OFFLINE + : ($brokerConnected ? ($brokerHealthy ? self::STATUS_ONLINE : self::STATUS_DEGRADED) : self::STATUS_DEGRADED), + 'backlog_depth' => (int)($operational['command_backlog'] ?? 0), + 'last_success_at' => $operational['last_successful_command_at'] + ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null), + ], + 'shell' => [ + 'preferred' => self::DELIVERY_CHANNEL_API, + 'active' => self::DELIVERY_CHANNEL_API, + 'state' => $effectiveStatus, + 'backlog_depth' => (int)($operational['shell_backlog'] ?? 0), + 'last_success_at' => $operational['last_successful_shell_at'] + ?? self::findLatestShellTimestamp((array)($gateway['recent_shell_sessions'] ?? [])), + ], + 'broker' => [ + 'connected' => $brokerConnected, + 'state' => !$brokerConnected + ? self::STATUS_OFFLINE + : ($brokerHealthy ? self::STATUS_ONLINE : self::STATUS_DEGRADED), + 'last_seen_at' => $brokerLastSeenAt, + 'disconnect_reason' => isset($brokerPresence['disconnect_reason']) ? (string)$brokerPresence['disconnect_reason'] : null, + 'last_error' => isset($brokerPresence['last_error']) ? (string)$brokerPresence['last_error'] : null, + ], + ]; + } + + private static function buildRelayHealth(array $gateway, string $effectiveStatus, ?int $now = null): array + { + $bindings = is_array($gateway['bindings'] ?? null) ? (array)$gateway['bindings'] : []; + $inventory = is_array($gateway['inventory'] ?? null) ? (array)$gateway['inventory'] : []; + $inventoryByDeviceId = []; + foreach ($inventory as $device) { + if (!is_array($device)) { + continue; + } + $deviceId = trim((string)($device['device_id'] ?? '')); + if ($deviceId !== '') { + $inventoryByDeviceId[$deviceId] = $device; + } + } + + $departmentTransportMode = (string)($gateway['department_transport_mode'] ?? self::TRANSPORT_MODE_CLOUD); + $relayHealth = []; + foreach ($bindings as $binding) { + if (!is_array($binding)) { + continue; + } + + $bindingMetadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : []; + $fallbackMode = self::normalizeFallbackMode((string)($binding['fallback_mode'] ?? $bindingMetadata['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL)); + $device = isset($inventoryByDeviceId[(string)($binding['device_id'] ?? '')]) + ? (array)$inventoryByDeviceId[(string)$binding['device_id']] + : null; + $deviceLastSeenAt = is_array($device) && isset($device['last_seen_at']) ? (string)$device['last_seen_at'] : null; + $deviceFreshnessSeconds = self::heartbeatAgeSeconds($deviceLastSeenAt, $now); + $deviceOnline = is_array($device) && array_key_exists('online', $device) ? (bool)$device['online'] : null; + $deviceFresh = $device !== null + && $deviceOnline !== false + && $deviceFreshnessSeconds !== null + && $deviceFreshnessSeconds < self::DEVICE_FRESHNESS_DEGRADED_AFTER_SECONDS; + + $executionPath = 'local'; + $reason = null; + if ($departmentTransportMode === self::TRANSPORT_MODE_CLOUD) { + $executionPath = 'cloud'; + $reason = 'department_cutover'; + } elseif ($fallbackMode === self::RELAY_FALLBACK_CLOUD_ONLY) { + $executionPath = 'cloud'; + $reason = 'binding_cloud_only'; + } elseif ($effectiveStatus === self::STATUS_OFFLINE) { + $executionPath = $fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY ? 'local' : 'cloud'; + $reason = 'gateway_offline'; + } elseif ($device === null) { + $executionPath = $fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY ? 'local' : 'cloud'; + $reason = 'device_missing'; + } elseif (!$deviceFresh) { + $executionPath = $fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY ? 'local' : 'cloud'; + $reason = $deviceOnline === false ? 'device_offline' : 'device_stale'; + } + + $recommendedAction = match ($reason) { + 'department_cutover' => 'review_department_cutover', + 'binding_cloud_only' => 'review_binding_override', + 'gateway_offline' => 'restart_agent', + 'device_missing', 'device_stale', 'device_offline' => 'retry_discovery', + default => null, + }; + + $relayHealth[] = [ + 'binding_id' => isset($binding['id']) ? (int)$binding['id'] : null, + 'relay_id' => isset($binding['relay_id']) ? (string)$binding['relay_id'] : null, + 'device_id' => isset($binding['device_id']) ? (string)$binding['device_id'] : null, + 'fallback_mode' => $fallbackMode, + 'execution_path' => $executionPath, + 'reason' => $reason, + 'recommended_action' => $recommendedAction, + 'recovery_actions' => array_values(array_filter([$recommendedAction])), + 'device_online' => $deviceOnline, + 'device_freshness_seconds' => $deviceFreshnessSeconds, + 'device_freshness_state' => self::resolveDeviceFreshnessState($device, $deviceFreshnessSeconds), + 'gateway_status' => $effectiveStatus, + 'last_resolution' => isset($binding['last_resolution']) && is_array($binding['last_resolution']) + ? (array)$binding['last_resolution'] + : (isset($bindingMetadata['last_resolution']) && is_array($bindingMetadata['last_resolution']) + ? (array)$bindingMetadata['last_resolution'] + : null), + 'last_success_at' => $binding['last_success_at'] ?? $bindingMetadata['last_success_at'] ?? null, + 'last_error' => $binding['last_error'] ?? $bindingMetadata['last_error'] ?? null, + ]; + } + + return $relayHealth; + } + + private static function buildFallbackSummary(array $relayHealth): array + { + $summary = [ + 'local_relays' => 0, + 'cloud_relays' => 0, + 'local_only_relays' => 0, + 'cloud_only_relays' => 0, + 'affected_relays' => [], + 'recommended_action' => null, + ]; + + foreach ($relayHealth as $relay) { + $executionPath = (string)($relay['execution_path'] ?? 'local'); + if ($executionPath === 'cloud') { + $summary['cloud_relays'] += 1; + if (!empty($relay['relay_id'])) { + $summary['affected_relays'][] = (string)$relay['relay_id']; + } + if ($summary['recommended_action'] === null && !empty($relay['recommended_action'])) { + $summary['recommended_action'] = (string)$relay['recommended_action']; + } + } else { + $summary['local_relays'] += 1; + } + + $fallbackMode = (string)($relay['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL); + if ($fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY) { + $summary['local_only_relays'] += 1; + } + if ($fallbackMode === self::RELAY_FALLBACK_CLOUD_ONLY) { + $summary['cloud_only_relays'] += 1; + } + } + + return $summary; + } + + private static function deriveTransportHealth(string $effectiveStatus, array $channelStatus, array $fallbackSummary): array + { + $brokerState = (string)($channelStatus['broker']['state'] ?? self::STATUS_OFFLINE); + $affectedRelayCount = count((array)($fallbackSummary['affected_relays'] ?? [])); + $transportState = $effectiveStatus; + if ($effectiveStatus !== self::STATUS_OFFLINE && ($brokerState === self::STATUS_DEGRADED || $affectedRelayCount > 0)) { + $transportState = self::STATUS_DEGRADED; + } + + return [ + 'status' => $transportState, + 'broker_connected' => !empty($channelStatus['broker']['connected']), + 'affected_relay_count' => $affectedRelayCount, + 'summary' => $affectedRelayCount > 0 + ? $affectedRelayCount . ' relæ(er) kører via cloud fallback' + : (!empty($channelStatus['broker']['connected']) + ? 'Broker fast path er aktiv med API polling som fallback' + : 'API polling er aktiv som primær kontrolkanal'), + 'recommended_action' => $fallbackSummary['recommended_action'] ?? ($channelStatus['broker']['last_error'] ?? null), + ]; + } + + private static function findLatestCompletionTimestamp(array $jobs, ?string $commandType = null): ?string + { + foreach ($jobs as $job) { + if (!is_array($job)) { + continue; + } + if (($job['status'] ?? null) !== 'COMPLETED') { + continue; + } + if ($commandType !== null && ($job['command_type'] ?? null) !== $commandType) { + continue; + } + return isset($job['completed_at']) ? (string)$job['completed_at'] : null; + } + + return null; + } + + private static function findLatestShellTimestamp(array $sessions): ?string + { + foreach ($sessions as $session) { + if (!is_array($session)) { + continue; + } + foreach (['opened_at', 'approved_at', 'created_at'] as $field) { + if (!empty($session[$field])) { + return (string)$session[$field]; + } + } + } + + return null; + } + + private static function normalizeFallbackMode(?string $fallbackMode): string + { + $normalized = strtoupper(trim((string)$fallbackMode)); + if (in_array($normalized, [ + self::RELAY_FALLBACK_PREFER_LOCAL, + self::RELAY_FALLBACK_LOCAL_ONLY, + self::RELAY_FALLBACK_CLOUD_ONLY, + ], true)) { + return $normalized; + } + + return self::RELAY_FALLBACK_PREFER_LOCAL; + } + + private static function resolveDeviceFreshnessState(?array $device, ?int $ageSeconds): string + { + if ($device === null) { + return 'MISSING'; + } + if (isset($device['online']) && $device['online'] === false) { + return 'OFFLINE'; + } + if ($ageSeconds === null) { + return 'UNKNOWN'; + } + if ($ageSeconds >= self::DEVICE_FRESHNESS_DEGRADED_AFTER_SECONDS) { + return 'STALE'; + } + + return 'READY'; + } + public static function resolveGatewayStatus(?string $reportedStatus, ?string $lastHeartbeatAt, ?int $now = null): string { $normalizedStatus = self::normalizeGatewayStatus($reportedStatus); diff --git a/services/nginx/app/classes/edge_gateway_schema_bootstrap.php b/services/nginx/app/classes/edge_gateway_schema_bootstrap.php index 843fc66c..5f4d580e 100644 --- a/services/nginx/app/classes/edge_gateway_schema_bootstrap.php +++ b/services/nginx/app/classes/edge_gateway_schema_bootstrap.php @@ -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; } diff --git a/services/nginx/app/objects/edge_gateway_command_jobs_o.php b/services/nginx/app/objects/edge_gateway_command_jobs_o.php index 314c1c02..5f7db50e 100644 --- a/services/nginx/app/objects/edge_gateway_command_jobs_o.php +++ b/services/nginx/app/objects/edge_gateway_command_jobs_o.php @@ -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(), diff --git a/services/nginx/app/objects/edge_gateway_relay_bindings_o.php b/services/nginx/app/objects/edge_gateway_relay_bindings_o.php index e2363b09..5dee84e7 100644 --- a/services/nginx/app/objects/edge_gateway_relay_bindings_o.php +++ b/services/nginx/app/objects/edge_gateway_relay_bindings_o.php @@ -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(), ]; diff --git a/services/nginx/app/objects/edge_gateway_shell_action_jobs_o.php b/services/nginx/app/objects/edge_gateway_shell_action_jobs_o.php index f339291a..0f6ef8c5 100644 --- a/services/nginx/app/objects/edge_gateway_shell_action_jobs_o.php +++ b/services/nginx/app/objects/edge_gateway_shell_action_jobs_o.php @@ -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(), diff --git a/services/nginx/app/objects/edge_gateway_update_jobs_o.php b/services/nginx/app/objects/edge_gateway_update_jobs_o.php index c7e2df1b..eb99ea9a 100644 --- a/services/nginx/app/objects/edge_gateway_update_jobs_o.php +++ b/services/nginx/app/objects/edge_gateway_update_jobs_o.php @@ -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(), diff --git a/services/nginx/app/routes/edgeGatewaysRoute.php b/services/nginx/app/routes/edgeGatewaysRoute.php index 5c1cc0f6..4d405b6a 100644 --- a/services/nginx/app/routes/edgeGatewaysRoute.php +++ b/services/nginx/app/routes/edgeGatewaysRoute.php @@ -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); + } + } } diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php index 8be17018..16b98dee 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php @@ -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(); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerHeartbeatStatusTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerHeartbeatStatusTest.php index 1de231e2..5c1dd5ee 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerHeartbeatStatusTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerHeartbeatStatusTest.php @@ -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, + ]); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php index 7ba75d03..bc098449 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php @@ -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"'); }); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php index c823fa87..5fc80e32 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayRouteWiringTest.php @@ -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'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewaySchemaBootstrapTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewaySchemaBootstrapTest.php index cf277e76..a6634a23 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewaySchemaBootstrapTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewaySchemaBootstrapTest.php @@ -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'); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayShellPollingTransportTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayShellPollingTransportTest.php index 5316e0a1..0ff713c8 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayShellPollingTransportTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayShellPollingTransportTest.php @@ -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'); });