Remove deprecated Edge Gateway Agent classes and related services
This commit is contained in:
+397
-42
@@ -55,27 +55,81 @@ function resolveAuthMode(options = {}, managerUrl = "") {
|
||||
return managerUrl ? "manager" : "stub";
|
||||
}
|
||||
|
||||
function parseScopes(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.map((scope) => String(scope || "").trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function eventScopes(message) {
|
||||
switch (message?.type) {
|
||||
case "gateway.telemetry":
|
||||
case "presence.changed":
|
||||
return ["overview", "statistics"];
|
||||
case "task.updated":
|
||||
return ["tasks", "overview"];
|
||||
case "log.append":
|
||||
return ["logs"];
|
||||
case "stats.updated":
|
||||
return ["statistics"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function sessionAllowsScopes(sessionRecord, scopes) {
|
||||
const subscriptions = sessionRecord.subscriptions || new Set();
|
||||
if (subscriptions.has("*")) {
|
||||
return true;
|
||||
}
|
||||
if (!scopes || scopes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return scopes.some((scope) => subscriptions.has(scope));
|
||||
}
|
||||
|
||||
function sendJson(ws, payload) {
|
||||
if (!ws || ws.readyState !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify(payload));
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createBrokerServer(options = {}) {
|
||||
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 browserShellSessions = new Map();
|
||||
const browserStreamSessions = new Map();
|
||||
const gatewayStreamSessions = new Map();
|
||||
const inflightGatewaySyncs = new Map();
|
||||
|
||||
const managerRequest = async (path, body = {}) => {
|
||||
const managerRequest = async (path, body = {}, method = "POST") => {
|
||||
if (!managerUrl) {
|
||||
throw new Error("Edge manager URL is not configured");
|
||||
}
|
||||
|
||||
const response = await fetch(`${managerUrl}${path}`, {
|
||||
method: "POST",
|
||||
method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(sharedSecret ? { "x-edge-broker-secret": sharedSecret } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
body: method === "GET" ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const json = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
@@ -88,14 +142,23 @@ export function createBrokerServer(options = {}) {
|
||||
const validateAgent =
|
||||
options.validateAgent ||
|
||||
(authMode === "stub"
|
||||
? async ({ gatewayId }) => ({ id: gatewayId, gateway_id: gatewayId })
|
||||
? async ({ gatewayId }) => ({ id: gatewayId, gateway_id: gatewayId, label: `Gateway ${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 }) => ({ id: token, gateway_id: 1, reason: "stub", cols: 120, rows: 32 })
|
||||
: async ({ token }) => managerRequest("/edge-agent/internal/shell-sessions/validate", { token }));
|
||||
const markShellSessionOpened =
|
||||
options.markShellSessionOpened ||
|
||||
(authMode === "stub"
|
||||
? async () => ({})
|
||||
: async (token, connectionId) =>
|
||||
managerRequest("/edge-agent/internal/shell-sessions/opened", {
|
||||
token,
|
||||
connection_id: connectionId,
|
||||
}));
|
||||
const closeShellSession =
|
||||
options.closeShellSession ||
|
||||
(authMode === "stub"
|
||||
@@ -106,6 +169,15 @@ export function createBrokerServer(options = {}) {
|
||||
transcript,
|
||||
reason,
|
||||
}));
|
||||
const validateBrowserStream =
|
||||
options.validateBrowserStream ||
|
||||
(authMode === "stub"
|
||||
? async ({ token }) => ({
|
||||
id: token,
|
||||
gateway_id: 1,
|
||||
scopes: ["overview", "tasks", "logs", "statistics"],
|
||||
})
|
||||
: async ({ token }) => managerRequest("/edge-agent/internal/browser-streams/validate", { token }));
|
||||
const reportGatewayPresence =
|
||||
options.reportGatewayPresence ||
|
||||
(authMode === "stub"
|
||||
@@ -117,8 +189,58 @@ export function createBrokerServer(options = {}) {
|
||||
reason,
|
||||
metadata,
|
||||
}));
|
||||
const requestGatewayBacklog =
|
||||
options.requestGatewayBacklog ||
|
||||
(authMode === "stub"
|
||||
? async () => ({ gateway: {}, dispatch: [] })
|
||||
: async (gatewayId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/backlog`, payload));
|
||||
const ingestTelemetry =
|
||||
options.ingestTelemetry ||
|
||||
(authMode === "stub"
|
||||
? async (_gatewayId, payload = {}) => payload
|
||||
: async (gatewayId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/telemetry`, payload));
|
||||
const ingestTaskEvent =
|
||||
options.ingestTaskEvent ||
|
||||
(authMode === "stub"
|
||||
? async (_gatewayId, _operationId, payload = {}) => payload
|
||||
: async (gatewayId, operationId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/operations/${operationId}/events`, payload));
|
||||
const ingestTaskResult =
|
||||
options.ingestTaskResult ||
|
||||
(authMode === "stub"
|
||||
? async (_gatewayId, _operationId, payload = {}) => payload
|
||||
: async (gatewayId, operationId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/operations/${operationId}/complete`, payload));
|
||||
const ingestLogEntry =
|
||||
options.ingestLogEntry ||
|
||||
(authMode === "stub"
|
||||
? async (_gatewayId, payload = {}) => payload
|
||||
: async (gatewayId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, payload));
|
||||
|
||||
const closeBrowserSession = async (sessionRecord, reason) => {
|
||||
const broadcastGatewayEvent = (gatewayId, message) => {
|
||||
const sessionIds = gatewayStreamSessions.get(String(gatewayId));
|
||||
if (!sessionIds || sessionIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedScopes = eventScopes(message);
|
||||
for (const sessionId of sessionIds.values()) {
|
||||
const sessionRecord = browserStreamSessions.get(String(sessionId));
|
||||
if (!sessionRecord) {
|
||||
continue;
|
||||
}
|
||||
if (!sessionAllowsScopes(sessionRecord, allowedScopes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sendJson(sessionRecord.ws, message);
|
||||
}
|
||||
};
|
||||
|
||||
const closeBrowserShellSession = async (sessionRecord, reason) => {
|
||||
try {
|
||||
await closeShellSession(
|
||||
sessionRecord.session.id,
|
||||
@@ -131,8 +253,8 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const markBrowserSessionsClosed = (gatewayId, reason) => {
|
||||
for (const sessionRecord of browserSessions.values()) {
|
||||
const markGatewayShellSessionsClosed = (gatewayId, reason) => {
|
||||
for (const sessionRecord of browserShellSessions.values()) {
|
||||
if (String(sessionRecord.session.gateway_id) !== String(gatewayId)) {
|
||||
continue;
|
||||
}
|
||||
@@ -144,6 +266,59 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const registerGatewayStreamSession = (sessionRecord) => {
|
||||
const gatewayId = String(sessionRecord.session.gateway_id);
|
||||
if (!gatewayStreamSessions.has(gatewayId)) {
|
||||
gatewayStreamSessions.set(gatewayId, new Set());
|
||||
}
|
||||
gatewayStreamSessions.get(gatewayId).add(String(sessionRecord.session.id));
|
||||
browserStreamSessions.set(String(sessionRecord.session.id), sessionRecord);
|
||||
};
|
||||
|
||||
const removeGatewayStreamSession = (sessionRecord) => {
|
||||
browserStreamSessions.delete(String(sessionRecord.session.id));
|
||||
const gatewayId = String(sessionRecord.session.gateway_id);
|
||||
const sessionIds = gatewayStreamSessions.get(gatewayId);
|
||||
if (!sessionIds) {
|
||||
return;
|
||||
}
|
||||
sessionIds.delete(String(sessionRecord.session.id));
|
||||
if (sessionIds.size === 0) {
|
||||
gatewayStreamSessions.delete(gatewayId);
|
||||
}
|
||||
};
|
||||
|
||||
const syncGatewayBacklog = async (gatewayId, explicitAgent = null) => {
|
||||
const normalizedGatewayId = String(gatewayId);
|
||||
const agent = explicitAgent || agents.get(normalizedGatewayId);
|
||||
if (!agent || agent.readyState !== 1) {
|
||||
return { queued: false };
|
||||
}
|
||||
|
||||
if (inflightGatewaySyncs.has(normalizedGatewayId)) {
|
||||
return inflightGatewaySyncs.get(normalizedGatewayId);
|
||||
}
|
||||
|
||||
const syncPromise = (async () => {
|
||||
const backlog = await requestGatewayBacklog(normalizedGatewayId, {
|
||||
agent_instance_id: agent.agentInstanceId || null,
|
||||
});
|
||||
const dispatch = Array.isArray(backlog?.dispatch) ? backlog.dispatch : [];
|
||||
for (const instruction of dispatch) {
|
||||
sendJson(agent, instruction);
|
||||
}
|
||||
return {
|
||||
queued: dispatch.length > 0,
|
||||
dispatch,
|
||||
};
|
||||
})().finally(() => {
|
||||
inflightGatewaySyncs.delete(normalizedGatewayId);
|
||||
});
|
||||
|
||||
inflightGatewaySyncs.set(normalizedGatewayId, syncPromise);
|
||||
return syncPromise;
|
||||
};
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
@@ -174,13 +349,13 @@ export function createBrokerServer(options = {}) {
|
||||
});
|
||||
});
|
||||
|
||||
agent.send(JSON.stringify({
|
||||
sendJson(agent, {
|
||||
type: "COMMAND",
|
||||
commandId,
|
||||
commandType: body.commandType,
|
||||
payload: body.payload || {},
|
||||
jobId: body.jobId ?? null,
|
||||
}));
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await promise;
|
||||
@@ -191,6 +366,18 @@ export function createBrokerServer(options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && /^\/api\/gateways\/\d+\/sync$/.test(url.pathname)) {
|
||||
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
||||
jsonResponse(res, 403, { error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
const gatewayId = url.pathname.split("/")[3];
|
||||
const result = await syncGatewayBacklog(gatewayId);
|
||||
jsonResponse(res, 200, { ok: true, ...result });
|
||||
return;
|
||||
}
|
||||
|
||||
jsonResponse(res, 404, { error: "Not found" });
|
||||
} catch (error) {
|
||||
jsonResponse(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
||||
@@ -204,6 +391,7 @@ export function createBrokerServer(options = {}) {
|
||||
if (url.pathname === "/ws/agent") {
|
||||
const gatewayId = String(url.searchParams.get("gatewayId") || "");
|
||||
const token = String(url.searchParams.get("token") || "");
|
||||
const agentInstanceId = String(url.searchParams.get("agentInstanceId") || "");
|
||||
if (gatewayId === "" || token === "") {
|
||||
socket.destroy();
|
||||
return;
|
||||
@@ -218,6 +406,7 @@ export function createBrokerServer(options = {}) {
|
||||
|
||||
ws.gatewayId = gatewayId;
|
||||
ws.gatewayInfo = gatewayInfo;
|
||||
ws.agentInstanceId = agentInstanceId || null;
|
||||
ws.connectionId = randomUUID();
|
||||
agents.set(gatewayId, ws);
|
||||
reportGatewayPresence(gatewayId, {
|
||||
@@ -225,8 +414,16 @@ export function createBrokerServer(options = {}) {
|
||||
connectionId: ws.connectionId,
|
||||
metadata: {
|
||||
remote_address: req.socket.remoteAddress || null,
|
||||
agent_instance_id: ws.agentInstanceId,
|
||||
},
|
||||
}).catch(() => {});
|
||||
broadcastGatewayEvent(gatewayId, {
|
||||
type: "presence.changed",
|
||||
gatewayId,
|
||||
status: "connected",
|
||||
connectionId: ws.connectionId,
|
||||
});
|
||||
syncGatewayBacklog(gatewayId, ws).catch(() => {});
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
return;
|
||||
@@ -243,35 +440,64 @@ export function createBrokerServer(options = {}) {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ws.sessionToken = token;
|
||||
ws.sessionInfo = session;
|
||||
browserSessions.set(String(session.id), {
|
||||
const sessionRecord = {
|
||||
ws,
|
||||
session,
|
||||
transcript: "",
|
||||
closedReason: null,
|
||||
});
|
||||
};
|
||||
browserShellSessions.set(String(session.id), sessionRecord);
|
||||
|
||||
const agent = agents.get(String(session.gateway_id));
|
||||
if (agent && agent.readyState === 1) {
|
||||
agent.send(JSON.stringify({
|
||||
sendJson(agent, {
|
||||
type: "OPEN_ROOT_SHELL",
|
||||
payload: {
|
||||
sessionId: String(session.id),
|
||||
reason: session.reason,
|
||||
cols: session.metadata?.cols ?? null,
|
||||
rows: session.metadata?.rows ?? null,
|
||||
cols: session.cols ?? session.metadata?.cols ?? null,
|
||||
rows: session.rows ?? session.metadata?.rows ?? null,
|
||||
cwd: session.cwd ?? session.metadata?.cwd ?? null,
|
||||
shellCommand: session.shell_command ?? session.metadata?.shell_command ?? null,
|
||||
shellArgs: session.shell_args ?? session.metadata?.shell_args ?? [],
|
||||
},
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
const sessionRecord = browserSessions.get(String(session.id));
|
||||
if (sessionRecord) {
|
||||
sessionRecord.closedReason = "agent_offline";
|
||||
}
|
||||
sessionRecord.closedReason = "agent_offline";
|
||||
ws.close();
|
||||
}
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/ws/browser-gateway-stream") {
|
||||
const token = String(url.searchParams.get("token") || "");
|
||||
if (token === "") {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await validateBrowserStream({ token, headers: req.headers });
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ws.sessionToken = token;
|
||||
ws.streamSessionInfo = session;
|
||||
const sessionRecord = {
|
||||
ws,
|
||||
session,
|
||||
subscriptions: new Set(parseScopes(session.scopes || ["overview", "tasks", "logs", "statistics"])),
|
||||
};
|
||||
registerGatewayStreamSession(sessionRecord);
|
||||
sendJson(ws, {
|
||||
type: "gateway.stream.ready",
|
||||
gatewayId: String(session.gateway_id),
|
||||
subscriptions: Array.from(sessionRecord.subscriptions.values()),
|
||||
connected: Boolean(agents.get(String(session.gateway_id))?.readyState === 1),
|
||||
});
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
socket.destroy();
|
||||
return;
|
||||
@@ -282,7 +508,12 @@ export function createBrokerServer(options = {}) {
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
ws.on("message", async (raw) => {
|
||||
const message = JSON.parse(raw.toString());
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ws.gatewayId) {
|
||||
if (message.type === "COMMAND_RESULT") {
|
||||
@@ -300,22 +531,88 @@ export function createBrokerServer(options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "TELEMETRY") {
|
||||
const payload = message.payload || {};
|
||||
const ingested = await ingestTelemetry(String(ws.gatewayId), payload);
|
||||
broadcastGatewayEvent(String(ws.gatewayId), {
|
||||
type: "gateway.telemetry",
|
||||
gatewayId: String(ws.gatewayId),
|
||||
telemetry: payload,
|
||||
gateway: ingested?.gateway || ingested || null,
|
||||
});
|
||||
broadcastGatewayEvent(String(ws.gatewayId), {
|
||||
type: "stats.updated",
|
||||
gatewayId: String(ws.gatewayId),
|
||||
statistics: ingested?.statistics || ingested || null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "TASK_EVENT") {
|
||||
const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0);
|
||||
if (!Number.isFinite(operationId) || operationId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const operation = await ingestTaskEvent(String(ws.gatewayId), operationId, message.payload || {});
|
||||
broadcastGatewayEvent(String(ws.gatewayId), {
|
||||
type: "task.updated",
|
||||
gatewayId: String(ws.gatewayId),
|
||||
operationId,
|
||||
operation,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "TASK_RESULT") {
|
||||
const operationId = Number(message.operationId ?? message.payload?.operation_id ?? 0);
|
||||
if (!Number.isFinite(operationId) || operationId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const operation = await ingestTaskResult(String(ws.gatewayId), operationId, message.payload || {});
|
||||
broadcastGatewayEvent(String(ws.gatewayId), {
|
||||
type: "task.updated",
|
||||
gatewayId: String(ws.gatewayId),
|
||||
operationId,
|
||||
operation,
|
||||
});
|
||||
await syncGatewayBacklog(String(ws.gatewayId), ws).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "LOG_FRAME") {
|
||||
const logEntry = await ingestLogEntry(String(ws.gatewayId), message.payload || {});
|
||||
broadcastGatewayEvent(String(ws.gatewayId), {
|
||||
type: "log.append",
|
||||
gatewayId: String(ws.gatewayId),
|
||||
entry: logEntry,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
|
||||
const sessionRecord = browserSessions.get(String(message.sessionId));
|
||||
const sessionRecord = browserShellSessions.get(String(message.sessionId));
|
||||
if (!sessionRecord) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "SHELL_OUTPUT") {
|
||||
sessionRecord.transcript += String(message.data || "");
|
||||
sessionRecord.ws.send(JSON.stringify({ type: "output", data: String(message.data || "") }));
|
||||
sendJson(sessionRecord.ws, { type: "output", data: String(message.data || "") });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "SHELL_OPENED") {
|
||||
sessionRecord.ws.send(JSON.stringify({ type: "opened" }));
|
||||
await markShellSessionOpened(sessionRecord.ws.sessionToken, ws.connectionId || null).catch(() => {});
|
||||
sendJson(sessionRecord.ws, { type: "opened" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "SHELL_EXIT") {
|
||||
sessionRecord.ws.send(JSON.stringify({ type: "closed", code: message.code ?? 0 }));
|
||||
await closeBrowserSession(sessionRecord, "agent_exit");
|
||||
browserSessions.delete(String(message.sessionId));
|
||||
sendJson(sessionRecord.ws, { type: "closed", code: message.code ?? 0 });
|
||||
await closeBrowserShellSession(sessionRecord, "agent_exit");
|
||||
browserShellSessions.delete(String(message.sessionId));
|
||||
if (sessionRecord.ws.readyState < 2) {
|
||||
sessionRecord.ws.close();
|
||||
}
|
||||
@@ -331,29 +628,65 @@ export function createBrokerServer(options = {}) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "input") {
|
||||
agent.send(JSON.stringify({
|
||||
sendJson(agent, {
|
||||
type: "SHELL_INPUT",
|
||||
payload: {
|
||||
sessionId,
|
||||
data: String(message.data || ""),
|
||||
},
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type === "resize") {
|
||||
agent.send(JSON.stringify({
|
||||
sendJson(agent, {
|
||||
type: "RESIZE_ROOT_SHELL",
|
||||
payload: {
|
||||
sessionId,
|
||||
cols: Number(message.cols || 0),
|
||||
rows: Number(message.rows || 0),
|
||||
},
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type === "close") {
|
||||
agent.send(JSON.stringify({
|
||||
sendJson(agent, {
|
||||
type: "CLOSE_ROOT_SHELL",
|
||||
payload: { sessionId },
|
||||
}));
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ws.streamSessionInfo) {
|
||||
const sessionRecord = browserStreamSessions.get(String(ws.streamSessionInfo.id));
|
||||
if (!sessionRecord) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "SUBSCRIBE") {
|
||||
for (const scope of parseScopes(message.scopes || message.subscriptions || [])) {
|
||||
sessionRecord.subscriptions.add(scope);
|
||||
}
|
||||
sendJson(sessionRecord.ws, {
|
||||
type: "subscribed",
|
||||
subscriptions: Array.from(sessionRecord.subscriptions.values()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "UNSUBSCRIBE") {
|
||||
for (const scope of parseScopes(message.scopes || message.subscriptions || [])) {
|
||||
sessionRecord.subscriptions.delete(scope);
|
||||
}
|
||||
sendJson(sessionRecord.ws, {
|
||||
type: "unsubscribed",
|
||||
subscriptions: Array.from(sessionRecord.subscriptions.values()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "PING") {
|
||||
sendJson(sessionRecord.ws, { type: "PONG" });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -365,11 +698,20 @@ export function createBrokerServer(options = {}) {
|
||||
if (agents.get(String(ws.gatewayId)) === ws) {
|
||||
agents.delete(String(ws.gatewayId));
|
||||
}
|
||||
markBrowserSessionsClosed(String(ws.gatewayId), "agent_disconnected");
|
||||
markGatewayShellSessionsClosed(String(ws.gatewayId), "agent_disconnected");
|
||||
broadcastGatewayEvent(String(ws.gatewayId), {
|
||||
type: "presence.changed",
|
||||
gatewayId: String(ws.gatewayId),
|
||||
status: "disconnected",
|
||||
reason: closeReason || "agent_disconnected",
|
||||
});
|
||||
reportGatewayPresence(String(ws.gatewayId), {
|
||||
status: "disconnected",
|
||||
connectionId: ws.connectionId || null,
|
||||
reason: closeReason || "agent_disconnected",
|
||||
metadata: {
|
||||
agent_instance_id: ws.agentInstanceId || null,
|
||||
},
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
@@ -378,15 +720,23 @@ export function createBrokerServer(options = {}) {
|
||||
const sessionId = String(ws.sessionInfo.id);
|
||||
const agent = agents.get(String(ws.sessionInfo.gateway_id));
|
||||
if (agent && agent.readyState === 1) {
|
||||
agent.send(JSON.stringify({
|
||||
sendJson(agent, {
|
||||
type: "CLOSE_ROOT_SHELL",
|
||||
payload: { sessionId },
|
||||
}));
|
||||
});
|
||||
}
|
||||
const sessionRecord = browserSessions.get(sessionId);
|
||||
const sessionRecord = browserShellSessions.get(sessionId);
|
||||
if (sessionRecord) {
|
||||
await closeBrowserSession(sessionRecord, sessionRecord.closedReason || "browser_closed");
|
||||
browserSessions.delete(sessionId);
|
||||
await closeBrowserShellSession(sessionRecord, sessionRecord.closedReason || "browser_closed");
|
||||
browserShellSessions.delete(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ws.streamSessionInfo) {
|
||||
const sessionRecord = browserStreamSessions.get(String(ws.streamSessionInfo.id));
|
||||
if (sessionRecord) {
|
||||
removeGatewayStreamSession(sessionRecord);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -404,7 +754,10 @@ export function createBrokerServer(options = {}) {
|
||||
for (const agent of agents.values()) {
|
||||
agent.terminate();
|
||||
}
|
||||
for (const session of browserSessions.values()) {
|
||||
for (const session of browserShellSessions.values()) {
|
||||
session.ws.terminate();
|
||||
}
|
||||
for (const session of browserStreamSessions.values()) {
|
||||
session.ws.terminate();
|
||||
}
|
||||
for (const pending of pendingCommands.values()) {
|
||||
@@ -426,7 +779,9 @@ export function createBrokerServer(options = {}) {
|
||||
},
|
||||
state: {
|
||||
agents,
|
||||
browserSessions,
|
||||
browserShellSessions,
|
||||
browserStreamSessions,
|
||||
gatewayStreamSessions,
|
||||
pendingCommands,
|
||||
managerUrl,
|
||||
authMode,
|
||||
|
||||
@@ -215,3 +215,138 @@ test("broker closes browser shell sessions when the agent disconnects before she
|
||||
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker syncs queued gateway backlog on agent connect and manual sync", async () => {
|
||||
const backlogRequests = [];
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
sharedSecret: "secret",
|
||||
requestGatewayBacklog: async (gatewayId, payload) => {
|
||||
backlogRequests.push({ gatewayId, payload });
|
||||
return {
|
||||
dispatch: [
|
||||
{
|
||||
type: "TASK_DISPATCH",
|
||||
taskType: "OPERATION",
|
||||
operation: {
|
||||
id: 91,
|
||||
type: "DISCOVERY",
|
||||
request: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const agent = new WebSocket(
|
||||
`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token&agentInstanceId=instance-1`
|
||||
);
|
||||
const messages = collectMessages(agent);
|
||||
await new Promise((resolve) => agent.once("open", resolve));
|
||||
|
||||
await waitFor(
|
||||
() => messages.some((message) => message.type === "TASK_DISPATCH" && message.operation?.id === 91),
|
||||
{ description: "initial backlog dispatch" }
|
||||
);
|
||||
assert.equal(backlogRequests.length, 1);
|
||||
assert.equal(backlogRequests[0].gatewayId, "701");
|
||||
assert.equal(backlogRequests[0].payload.agent_instance_id, "instance-1");
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/gateways/701/sync`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-edge-broker-secret": "secret",
|
||||
},
|
||||
body: JSON.stringify({ gatewayId: 701 }),
|
||||
});
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(json.ok, true);
|
||||
await waitFor(() => backlogRequests.length >= 2, { description: "manual sync backlog request" });
|
||||
|
||||
agent.terminate();
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker fans out telemetry, task, log, and presence updates to browser gateway streams", async () => {
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
|
||||
validateBrowserStream: async () => ({
|
||||
id: "stream-1",
|
||||
gateway_id: "701",
|
||||
scopes: ["overview", "tasks", "logs", "statistics"],
|
||||
}),
|
||||
ingestTelemetry: async (_gatewayId, payload) => ({ gateway: { id: 701, metadata: payload.metadata || {} } }),
|
||||
ingestTaskEvent: async (_gatewayId, operationId, payload) => ({
|
||||
id: operationId,
|
||||
status: "IN_PROGRESS",
|
||||
latest_event: payload,
|
||||
}),
|
||||
ingestLogEntry: async (_gatewayId, payload) => ({
|
||||
id: 5001,
|
||||
...payload,
|
||||
}),
|
||||
});
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-gateway-stream?token=stream-token`);
|
||||
const browserMessages = collectMessages(browser);
|
||||
await new Promise((resolve) => browser.once("open", resolve));
|
||||
|
||||
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
||||
await new Promise((resolve) => agent.once("open", resolve));
|
||||
|
||||
agent.send(
|
||||
JSON.stringify({
|
||||
type: "TELEMETRY",
|
||||
payload: {
|
||||
status: "ONLINE",
|
||||
metadata: {
|
||||
broker_connected: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
agent.send(
|
||||
JSON.stringify({
|
||||
type: "TASK_EVENT",
|
||||
operationId: 41,
|
||||
payload: {
|
||||
level: "INFO",
|
||||
code: "DISCOVERY_RUNNING",
|
||||
message: "Discovery is running",
|
||||
},
|
||||
})
|
||||
);
|
||||
agent.send(
|
||||
JSON.stringify({
|
||||
type: "LOG_FRAME",
|
||||
payload: {
|
||||
level: "INFO",
|
||||
stream: "agent",
|
||||
source: "EDGE_AGENT",
|
||||
message: "Gateway heartbeat acknowledged",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => browserMessages.some((message) => message.type === "presence.changed" && message.status === "connected"),
|
||||
{ description: "presence update" }
|
||||
);
|
||||
assert.ok(browserMessages.some((message) => message.type === "gateway.telemetry"));
|
||||
assert.ok(browserMessages.some((message) => message.type === "stats.updated"));
|
||||
assert.ok(browserMessages.some((message) => message.type === "task.updated" && message.operationId === 41));
|
||||
assert.ok(browserMessages.some((message) => message.type === "log.append" && /heartbeat/.test(message.entry?.message)));
|
||||
|
||||
browser.terminate();
|
||||
agent.terminate();
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/interfaces/universal_module_i.php';
|
||||
require_once WD . '/modules/edgegateway/edgegateway_c.php';
|
||||
|
||||
use Exception;
|
||||
use interfaces\universal_module_i;
|
||||
use modules\edgegateway\edgegateway_c;
|
||||
|
||||
class edgegateway implements universal_module_i
|
||||
{
|
||||
public edgegateway_c $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = new edgegateway_c();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function requireModuleEnabled(): void
|
||||
{
|
||||
if (!$this->config->enabled->isTrue()) {
|
||||
throw new Exception('The edge gateway module is not enabled');
|
||||
}
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
try {
|
||||
return $this->config->enabled->isTrue();
|
||||
} catch (Exception $exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function defaultReleaseChannel(): string
|
||||
{
|
||||
$configured = trim((string)$this->config->default_release_channel->getVariableValue());
|
||||
return $configured !== '' ? $configured : 'stable';
|
||||
}
|
||||
|
||||
public function defaultUpdateWindow(): string
|
||||
{
|
||||
$configured = trim((string)$this->config->default_update_window->getVariableValue());
|
||||
return $configured !== '' ? $configured : '02:00-04:00';
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/interfaces/shelly_transport_i.php';
|
||||
require_once WD . '/classes/edge_gateway_manager.php';
|
||||
|
||||
use Exception;
|
||||
use interfaces\shelly_transport_i;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/interfaces/shelly_transport_i.php';
|
||||
require_once WD . '/classes/edge_gateway_manager.php';
|
||||
require_once WD . '/classes/cloud_shelly_transport.php';
|
||||
require_once WD . '/classes/gateway_shelly_transport.php';
|
||||
|
||||
|
||||
@@ -108,6 +108,9 @@ spl_autoload_register(function (string $class): void {
|
||||
// 1. Core folders: classes, interfaces, traits, objects, statistics
|
||||
$core_folders = ['classes', 'interfaces', 'traits', 'objects', 'statistics'];
|
||||
if (in_array($top, $core_folders)) {
|
||||
if ($top === 'classes' && str_starts_with(strtolower($relative), 'edge_gateway_')) {
|
||||
$candidates[] = $base . 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'classes' . DIRECTORY_SEPARATOR . $relative;
|
||||
}
|
||||
$candidates[] = $base . $top . DIRECTORY_SEPARATOR . $relative;
|
||||
}
|
||||
// 2. Modules folder: explicitly starting with 'modules'
|
||||
@@ -211,5 +214,47 @@ if ((preg_match('/\.(jpg|jpeg|png)$/', $_SERVER['REQUEST_URI']) || str_contains(
|
||||
exit;
|
||||
}
|
||||
|
||||
// Load enabled module routes before the global route scan.
|
||||
$load_enabled_module_routes = static function (): void {
|
||||
$modules_path = WD . DIRECTORY_SEPARATOR . 'modules';
|
||||
if (!is_dir($modules_path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$module_dirs = array_filter(scandir($modules_path), static function (string $item) use ($modules_path): bool {
|
||||
return $item !== '.' && $item !== '..' && is_dir($modules_path . DIRECTORY_SEPARATOR . $item);
|
||||
});
|
||||
|
||||
foreach ($module_dirs as $module_dir) {
|
||||
$routes_path = $modules_path . DIRECTORY_SEPARATOR . $module_dir . DIRECTORY_SEPARATOR . 'routes';
|
||||
if (!is_dir($routes_path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$module_class = 'classes\\' . $module_dir;
|
||||
if (!class_exists($module_class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$module = new $module_class();
|
||||
if (method_exists($module, 'isEnabled') && !$module->isEnabled()) {
|
||||
continue;
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (scandir($routes_path) as $file) {
|
||||
if ($file === '.' || $file === '..') {
|
||||
continue;
|
||||
}
|
||||
require_once $routes_path . DIRECTORY_SEPARATOR . $file;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$load_enabled_module_routes();
|
||||
|
||||
// Autoload all the routes
|
||||
$router->auto_load_routes(WD . '/routes');
|
||||
|
||||
+1035
-22
File diff suppressed because it is too large
Load Diff
+204
@@ -238,6 +238,7 @@ class edge_gateway_operation_service
|
||||
);
|
||||
|
||||
$this->refreshGatewayViewCache($gatewayId);
|
||||
$this->manager()->notifyBrokerGatewaySync($gatewayId);
|
||||
|
||||
return $this->serializeOperation((new edge_gateway_operations_o())->select($operationId), true);
|
||||
}
|
||||
@@ -306,6 +307,7 @@ class edge_gateway_operation_service
|
||||
}
|
||||
|
||||
$this->refreshGatewayViewCache($gatewayId);
|
||||
$this->manager()->notifyBrokerGatewaySync($gatewayId);
|
||||
|
||||
return $this->serializeOperation($operation, true);
|
||||
}
|
||||
@@ -351,6 +353,48 @@ class edge_gateway_operation_service
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function claimBrokerOperation(int $gatewayId, ?string $agentInstanceId = null): ?array
|
||||
{
|
||||
$this->requireGateway($gatewayId);
|
||||
$this->failTimedOutOperations($gatewayId);
|
||||
return $this->claimPendingOperation($gatewayId, $this->normalizeAgentInstanceId($agentInstanceId, $gatewayId));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function listBrokerCancellationRequests(int $gatewayId, ?string $agentInstanceId = null): array
|
||||
{
|
||||
$this->requireGateway($gatewayId);
|
||||
$rows = (new edge_gateway_operations_o())->getFieldsWhere([
|
||||
'gateway_id' => $gatewayId,
|
||||
'status' => self::STATUS_CANCEL_REQUESTED,
|
||||
'deleted_at' => null,
|
||||
], ['id']);
|
||||
|
||||
$requestedInstance = $this->normalizeAgentInstanceId($agentInstanceId, $gatewayId);
|
||||
$operations = [];
|
||||
foreach ($rows as $row) {
|
||||
$operation = (new edge_gateway_operations_o())->select((int)$row['id']);
|
||||
if (!$operation->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$claimedBy = trim((string)($operation->agent_instance_id->value() ?? ''));
|
||||
if ($claimedBy !== '' && $requestedInstance !== '' && $claimedBy !== $requestedInstance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$operations[] = $this->serializeOperation($operation, true);
|
||||
}
|
||||
|
||||
return $operations;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -407,6 +451,15 @@ class edge_gateway_operation_service
|
||||
return $this->serializeOperation($operation, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function appendBrokerOperationEvent(int $gatewayId, int $operationId, array $payload): array
|
||||
{
|
||||
$this->requireGateway($gatewayId);
|
||||
return $this->appendOperationEventWithoutAuthentication($gatewayId, $operationId, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -497,10 +550,20 @@ class edge_gateway_operation_service
|
||||
);
|
||||
}
|
||||
$this->refreshGatewayViewCache($gatewayId);
|
||||
$this->manager()->notifyBrokerGatewaySync($gatewayId);
|
||||
|
||||
return $this->serializeOperation($operation, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function completeBrokerOperation(int $gatewayId, int $operationId, array $payload): array
|
||||
{
|
||||
$this->requireGateway($gatewayId);
|
||||
return $this->completeOperationWithoutAuthentication($gatewayId, $operationId, $payload);
|
||||
}
|
||||
|
||||
private static function normalizeOperationType(string $type): string
|
||||
{
|
||||
$normalized = strtoupper(trim($type));
|
||||
@@ -668,6 +731,7 @@ class edge_gateway_operation_service
|
||||
]
|
||||
);
|
||||
$this->refreshGatewayViewCache($gatewayId);
|
||||
$this->manager()->notifyBrokerGatewaySync($gatewayId);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -698,6 +762,7 @@ class edge_gateway_operation_service
|
||||
);
|
||||
|
||||
$this->refreshGatewayViewCache($gatewayId);
|
||||
$this->manager()->notifyBrokerGatewaySync($gatewayId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -987,4 +1052,143 @@ class edge_gateway_operation_service
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function appendOperationEventWithoutAuthentication(int $gatewayId, int $operationId, array $payload): array
|
||||
{
|
||||
$operation = $this->requireOperation($gatewayId, $operationId);
|
||||
if (in_array((string)$operation->status->value(), [
|
||||
self::STATUS_COMPLETED,
|
||||
self::STATUS_FAILED,
|
||||
self::STATUS_CANCEL_REQUESTED,
|
||||
self::STATUS_CANCELLED,
|
||||
], true)) {
|
||||
return $this->serializeOperation($operation, true);
|
||||
}
|
||||
|
||||
$level = strtoupper(trim((string)($payload['level'] ?? self::LEVEL_INFO)));
|
||||
if (!in_array($level, [self::LEVEL_INFO, self::LEVEL_WARNING, self::LEVEL_ERROR], true)) {
|
||||
$level = self::LEVEL_INFO;
|
||||
}
|
||||
|
||||
$message = trim((string)($payload['message'] ?? 'Operation event received'));
|
||||
if ($message === '') {
|
||||
$message = 'Operation event received';
|
||||
}
|
||||
|
||||
$code = isset($payload['code']) ? trim((string)$payload['code']) : null;
|
||||
$context = isset($payload['context']) && is_array($payload['context']) ? (array)$payload['context'] : [];
|
||||
$this->appendEventRecord($gatewayId, $operationId, $level, $code, $message, $context);
|
||||
|
||||
$summary = (array)($operation->summary_json->value() ?? []);
|
||||
$summary['last_event_at'] = $this->now();
|
||||
$summary['last_event_message'] = $message;
|
||||
if (isset($context['progress'])) {
|
||||
$summary['progress'] = max(0, min(100, (int)$context['progress']));
|
||||
}
|
||||
if (isset($context['label']) && trim((string)$context['label']) !== '') {
|
||||
$summary['label'] = trim((string)$context['label']);
|
||||
}
|
||||
$operation->summary_json->set($summary);
|
||||
$this->refreshOperationLease($operation);
|
||||
|
||||
if ($level === self::LEVEL_ERROR) {
|
||||
$this->markOperationFailedFromEvent($gatewayId, $operation, $code, $message, $context);
|
||||
}
|
||||
|
||||
$this->refreshGatewayViewCache($gatewayId);
|
||||
|
||||
return $this->serializeOperation($operation, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function completeOperationWithoutAuthentication(int $gatewayId, int $operationId, array $payload): array
|
||||
{
|
||||
$operation = $this->requireOperation($gatewayId, $operationId);
|
||||
if (in_array((string)$operation->status->value(), [
|
||||
self::STATUS_COMPLETED,
|
||||
self::STATUS_FAILED,
|
||||
self::STATUS_CANCELLED,
|
||||
], true)) {
|
||||
return $this->serializeOperation($operation, true);
|
||||
}
|
||||
|
||||
$ok = (bool)($payload['ok'] ?? false);
|
||||
$result = isset($payload['result']) && is_array($payload['result']) ? (array)$payload['result'] : [];
|
||||
$errorMessage = trim((string)($payload['error_message'] ?? $payload['error'] ?? ''));
|
||||
$errorCode = trim((string)($payload['error_code'] ?? ''));
|
||||
$status = (string)$operation->status->value();
|
||||
if ($status === self::STATUS_CANCEL_REQUESTED && !$ok && $errorCode === '') {
|
||||
$errorCode = self::ERROR_CANCELLED;
|
||||
}
|
||||
if (!$ok && $errorCode === '') {
|
||||
$errorCode = $this->classifyCompletionError($gatewayId, $errorMessage);
|
||||
}
|
||||
if (!$ok && $errorMessage === '') {
|
||||
$errorMessage = $errorCode === self::ERROR_CANCELLED
|
||||
? 'Gateway operation cancelled'
|
||||
: 'Gateway operation failed';
|
||||
}
|
||||
|
||||
$finalStatus = $ok
|
||||
? self::STATUS_COMPLETED
|
||||
: (($status === self::STATUS_CANCEL_REQUESTED && $errorCode === self::ERROR_CANCELLED)
|
||||
? self::STATUS_CANCELLED
|
||||
: self::STATUS_FAILED);
|
||||
$operation->status->set($finalStatus);
|
||||
$operation->result_json->set($result);
|
||||
$operation->error_code->set($ok ? null : ($finalStatus === self::STATUS_CANCELLED ? self::ERROR_CANCELLED : $errorCode));
|
||||
$operation->error_message->set($ok ? null : $errorMessage);
|
||||
$operation->completed_at->set($this->now());
|
||||
$operation->lease_expires_at->set(null);
|
||||
$operation->last_progress_at->set($this->now());
|
||||
|
||||
$summary = (array)($operation->summary_json->value() ?? []);
|
||||
$summary['label'] = match ($finalStatus) {
|
||||
self::STATUS_COMPLETED => 'Completed',
|
||||
self::STATUS_CANCELLED => 'Cancelled',
|
||||
default => 'Failed',
|
||||
};
|
||||
$summary['progress'] = $finalStatus === self::STATUS_CANCELLED
|
||||
? max(0, min(100, (int)($summary['progress'] ?? 0)))
|
||||
: 100;
|
||||
$summary['retryable'] = $finalStatus === self::STATUS_CANCELLED
|
||||
? true
|
||||
: (!$ok && $errorCode !== self::ERROR_UNSUPPORTED_VERSION);
|
||||
$operation->summary_json->set($summary);
|
||||
|
||||
$this->appendEventRecord(
|
||||
$gatewayId,
|
||||
$operationId,
|
||||
$finalStatus === self::STATUS_COMPLETED
|
||||
? self::LEVEL_INFO
|
||||
: ($finalStatus === self::STATUS_CANCELLED ? self::LEVEL_WARNING : self::LEVEL_ERROR),
|
||||
$finalStatus === self::STATUS_COMPLETED
|
||||
? 'OPERATION_COMPLETED'
|
||||
: ($finalStatus === self::STATUS_CANCELLED ? 'OPERATION_CANCELLED' : $errorCode),
|
||||
$finalStatus === self::STATUS_COMPLETED
|
||||
? 'Operation completed successfully'
|
||||
: $errorMessage,
|
||||
$result
|
||||
);
|
||||
|
||||
if ($finalStatus !== self::STATUS_CANCELLED) {
|
||||
$this->applyCompletionSideEffects(
|
||||
$gatewayId,
|
||||
$operation,
|
||||
$ok,
|
||||
$result,
|
||||
$errorCode,
|
||||
$errorMessage
|
||||
);
|
||||
}
|
||||
$this->refreshGatewayViewCache($gatewayId);
|
||||
$this->manager()->notifyBrokerGatewaySync($gatewayId);
|
||||
|
||||
return $this->serializeOperation($operation, true);
|
||||
}
|
||||
}
|
||||
+16
@@ -24,6 +24,22 @@ class edge_gateway_registry_service
|
||||
return $this->manager()->verifyInstallToken($plainToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getInstallTokenStatus(int $claimTokenId): array
|
||||
{
|
||||
return $this->manager()->getInstallTokenStatus($claimTokenId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function reportInstallTokenStatus(string $plainToken, array $payload): array
|
||||
{
|
||||
return $this->manager()->reportInstallTokenStatus($plainToken, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
+72
@@ -181,6 +181,51 @@ class edge_gateway_schema_bootstrap
|
||||
INDEX idx_edge_gateway_audit_department (department_id),
|
||||
INDEX idx_edge_gateway_audit_action (action)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS edge_gateway_log_entries (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
gateway_id INT NOT NULL,
|
||||
department_id INT NULL,
|
||||
level VARCHAR(16) NOT NULL DEFAULT 'INFO',
|
||||
stream VARCHAR(32) NOT NULL DEFAULT 'agent',
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'BROKER',
|
||||
message TEXT NOT NULL,
|
||||
context_json JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_edge_gateway_log_entries_gateway (gateway_id),
|
||||
INDEX idx_edge_gateway_log_entries_department (department_id),
|
||||
INDEX idx_edge_gateway_log_entries_level (level),
|
||||
INDEX idx_edge_gateway_log_entries_stream (stream)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS edge_gateway_shell_sessions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
gateway_id INT NOT NULL,
|
||||
department_id INT NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
session_token_hash CHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
||||
reason VARCHAR(255) NULL,
|
||||
connection_id VARCHAR(128) NULL,
|
||||
cwd VARCHAR(255) NULL,
|
||||
shell_command VARCHAR(255) NULL,
|
||||
shell_args_json JSON NULL,
|
||||
cols INT NULL,
|
||||
rows INT NULL,
|
||||
transcript LONGTEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
expires_at DATETIME NULL,
|
||||
approved_at DATETIME NULL,
|
||||
opened_at DATETIME NULL,
|
||||
closed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
UNIQUE KEY uniq_edge_gateway_shell_session_token_hash (session_token_hash),
|
||||
INDEX idx_edge_gateway_shell_sessions_gateway (gateway_id),
|
||||
INDEX idx_edge_gateway_shell_sessions_status (status),
|
||||
INDEX idx_edge_gateway_shell_sessions_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $sql) {
|
||||
@@ -216,6 +261,33 @@ class edge_gateway_schema_bootstrap
|
||||
self::ensureColumn('edge_gateway_audit_logs', 'severity', "VARCHAR(16) NOT NULL DEFAULT 'INFO' AFTER actor_type");
|
||||
self::ensureColumn('edge_gateway_audit_logs', 'context_json', 'JSON NULL AFTER severity');
|
||||
|
||||
self::ensureColumn('edge_gateway_log_entries', 'department_id', 'INT NULL AFTER gateway_id');
|
||||
self::ensureColumn('edge_gateway_log_entries', 'level', "VARCHAR(16) NOT NULL DEFAULT 'INFO' AFTER department_id");
|
||||
self::ensureColumn('edge_gateway_log_entries', 'stream', "VARCHAR(32) NOT NULL DEFAULT 'agent' AFTER level");
|
||||
self::ensureColumn('edge_gateway_log_entries', 'source', "VARCHAR(64) NOT NULL DEFAULT 'BROKER' AFTER stream");
|
||||
self::ensureColumn('edge_gateway_log_entries', 'message', 'TEXT NOT NULL AFTER source');
|
||||
self::ensureColumn('edge_gateway_log_entries', 'context_json', 'JSON NULL AFTER message');
|
||||
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'department_id', 'INT NOT NULL AFTER gateway_id');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'actor_user_id', 'INT NULL AFTER department_id');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'session_token_hash', 'CHAR(64) NOT NULL AFTER actor_user_id');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'status', "VARCHAR(32) NOT NULL DEFAULT 'PENDING' AFTER session_token_hash");
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'reason', 'VARCHAR(255) NULL AFTER status');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'connection_id', 'VARCHAR(128) NULL AFTER reason');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'cwd', 'VARCHAR(255) NULL AFTER connection_id');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'shell_command', 'VARCHAR(255) NULL AFTER cwd');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'shell_args_json', 'JSON NULL AFTER shell_command');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'cols', 'INT NULL AFTER shell_args_json');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'rows', 'INT NULL AFTER cols');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'transcript', 'LONGTEXT NULL AFTER rows');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'metadata_json', 'JSON NULL AFTER transcript');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'expires_at', 'DATETIME NULL AFTER metadata_json');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'approved_at', 'DATETIME NULL AFTER expires_at');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'opened_at', 'DATETIME NULL AFTER approved_at');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'closed_at', 'DATETIME NULL AFTER opened_at');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'updated_at', 'TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
||||
self::ensureColumn('edge_gateway_shell_sessions', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER updated_at');
|
||||
|
||||
self::syncOperationTypeColumns();
|
||||
|
||||
self::$initialized = true;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace modules\edgegateway\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class edgegateway_default_release_channel_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'edgegateway',
|
||||
'default_release_channel',
|
||||
'string',
|
||||
true,
|
||||
['stable', 'canary'],
|
||||
'The default release channel assigned to newly claimed edge gateways.',
|
||||
'stable',
|
||||
false,
|
||||
'stable'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace modules\edgegateway\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class edgegateway_default_update_window_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'edgegateway',
|
||||
'default_update_window',
|
||||
'string',
|
||||
true,
|
||||
null,
|
||||
'The default maintenance window applied to newly claimed edge gateways.',
|
||||
'02:00-04:00',
|
||||
false,
|
||||
'02:00-04:00'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace modules\edgegateway\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class edgegateway_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'edgegateway',
|
||||
'enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether the edge gateway module is enabled.',
|
||||
'true',
|
||||
false,
|
||||
'true'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace modules\edgegateway;
|
||||
|
||||
require_once WD . '/modules/edgegateway/config/edgegateway_enabled_c.php';
|
||||
require_once WD . '/modules/edgegateway/config/edgegateway_default_release_channel_c.php';
|
||||
require_once WD . '/modules/edgegateway/config/edgegateway_default_update_window_c.php';
|
||||
|
||||
use modules\edgegateway\config\edgegateway_default_release_channel_c;
|
||||
use modules\edgegateway\config\edgegateway_default_update_window_c;
|
||||
use modules\edgegateway\config\edgegateway_enabled_c;
|
||||
use traits\module_config_t;
|
||||
|
||||
class edgegateway_c
|
||||
{
|
||||
use module_config_t;
|
||||
|
||||
public edgegateway_enabled_c $enabled;
|
||||
public edgegateway_default_release_channel_c $default_release_channel;
|
||||
public edgegateway_default_update_window_c $default_update_window;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfig('edgegateway');
|
||||
$this->allowUpdate([
|
||||
edgegateway_enabled_c::class,
|
||||
edgegateway_default_release_channel_c::class,
|
||||
edgegateway_default_update_window_c::class,
|
||||
]);
|
||||
$this->enabled = new edgegateway_enabled_c();
|
||||
$this->default_release_channel = new edgegateway_default_release_channel_c();
|
||||
$this->default_update_window = new edgegateway_default_update_window_c();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\edgegateway;
|
||||
use classes\response;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
class edgeGatewayConfigRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/edgegateway/config', fn() => $this->handleGetConfig(), [
|
||||
'modules_shelly_config' => 'Get edge gateway config',
|
||||
]);
|
||||
$this->post('/edgegateway/config', fn() => $this->handlePostConfig(), [
|
||||
'modules_shelly_config' => 'Update edge gateway config',
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleGetConfig(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
if (!$user) {
|
||||
(new logs_o())->add('edgegateway_config', 'global', 1, 0, 'EDGEGATEWAY_CONFIG', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully fetched edge gateway config');
|
||||
$response->success((new edgegateway())->config->getConfigRequest());
|
||||
}
|
||||
|
||||
private function handlePostConfig(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
if (!$user) {
|
||||
(new logs_o())->add('edgegateway_config', 'global', 1, 0, 'EDGEGATEWAY_CONFIG', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully updated edge gateway config');
|
||||
$response->success((new edgegateway())->config->postConfigRequest());
|
||||
}
|
||||
}
|
||||
+266
@@ -25,6 +25,21 @@ class edgeGatewaysRoute
|
||||
$this->get('/edge-gateways/{id}', fn() => $this->handleGatewayDetail(), [
|
||||
'modules_shelly_config' => 'View department edge gateway detail',
|
||||
]);
|
||||
$this->get('/edge-gateways/{id}/tasks', fn() => $this->handleGatewayTasksPage(), [
|
||||
'modules_shelly_config' => 'View edge gateway task timeline',
|
||||
]);
|
||||
$this->get('/edge-gateways/{id}/logs', fn() => $this->handleGatewayLogsPage(), [
|
||||
'modules_shelly_config' => 'View edge gateway logs',
|
||||
]);
|
||||
$this->get('/edge-gateways/{id}/statistics', fn() => $this->handleGatewayStatisticsPage(), [
|
||||
'modules_shelly_config' => 'View edge gateway statistics',
|
||||
]);
|
||||
$this->post('/edge-gateways/{id}/stream-session', fn() => $this->handleGatewayStreamSessionCreate(), [
|
||||
'modules_shelly_config' => 'Create an edge gateway live stream session',
|
||||
]);
|
||||
$this->post('/edge-gateways/{id}/shell-sessions', fn() => $this->handleGatewayShellSessionCreate(), [
|
||||
'modules_shelly_config' => 'Create an edge gateway shell session',
|
||||
]);
|
||||
$this->put('/edge-gateways/{id}', fn() => $this->handleGatewayUpdate(), [
|
||||
'modules_shelly_config' => 'Update edge gateway metadata and primary assignment',
|
||||
]);
|
||||
@@ -46,6 +61,9 @@ class edgeGatewaysRoute
|
||||
$this->post('/edge-gateways/install-token', fn() => $this->handleInstallTokenCreate(), [
|
||||
'modules_shelly_config' => 'Create a one-time Raspberry Pi edge gateway installer token',
|
||||
]);
|
||||
$this->get('/edge-gateways/install-token/{id}/status', fn() => $this->handleInstallTokenStatus(), [
|
||||
'modules_shelly_config' => 'View edge gateway installer session status',
|
||||
]);
|
||||
$this->post('/edge-gateways/{id}/discovery', fn() => $this->handleGatewayDiscovery(), [
|
||||
'modules_shelly_config' => 'Queue Shelly discovery through the local edge gateway',
|
||||
]);
|
||||
@@ -60,6 +78,7 @@ class edgeGatewaysRoute
|
||||
]);
|
||||
|
||||
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
|
||||
$this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus());
|
||||
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
|
||||
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
|
||||
$this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php'));
|
||||
@@ -79,6 +98,18 @@ class edgeGatewaysRoute
|
||||
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
|
||||
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
|
||||
$this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence());
|
||||
|
||||
$this->post('/edge-agent/internal/gateways/{id}/validate', fn() => $this->handleBrokerGatewayValidate());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/presence', fn() => $this->handleBrokerGatewayPresence());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/backlog', fn() => $this->handleBrokerGatewayBacklog());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/telemetry', fn() => $this->handleBrokerGatewayTelemetry());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/events', fn() => $this->handleBrokerOperationEvent());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleBrokerOperationComplete());
|
||||
$this->post('/edge-agent/internal/gateways/{id}/logs', fn() => $this->handleBrokerGatewayLogEntry());
|
||||
$this->post('/edge-agent/internal/browser-streams/validate', fn() => $this->handleBrokerBrowserStreamValidate());
|
||||
$this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellSessionValidate());
|
||||
$this->post('/edge-agent/internal/shell-sessions/opened', fn() => $this->handleBrokerShellSessionOpened());
|
||||
$this->post('/edge-agent/internal/shell-sessions/close', fn() => $this->handleBrokerShellSessionClose());
|
||||
}
|
||||
|
||||
private function handleListGateways(): void
|
||||
@@ -104,6 +135,58 @@ class edgeGatewaysRoute
|
||||
$response->success($this->requireGatewayAccess((int)$this->fromRoute('id')));
|
||||
}
|
||||
|
||||
private function handleGatewayTasksPage(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$response->success($this->manager()->buildGatewayTasksPage($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayLogsPage(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$response->success($this->manager()->buildGatewayLogsPage($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayStatisticsPage(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$response->success($this->manager()->buildGatewayStatisticsPage($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayStreamSessionCreate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$payload = self::getParametersAsArray();
|
||||
$scopes = isset($payload['scopes']) && is_array($payload['scopes']) ? (array)$payload['scopes'] : [];
|
||||
$response->success($this->manager()->createBrowserStreamSession($gatewayId, $this->actorUserId(), $scopes), 201);
|
||||
}
|
||||
|
||||
private function handleGatewayShellSessionCreate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$payload = self::getParametersAsArray();
|
||||
$reason = isset($payload['reason']) ? (string)$payload['reason'] : '';
|
||||
$cwd = isset($payload['cwd']) ? (string)$payload['cwd'] : null;
|
||||
$cols = isset($payload['cols']) ? (int)$payload['cols'] : null;
|
||||
$rows = isset($payload['rows']) ? (int)$payload['rows'] : null;
|
||||
$response->success($this->manager()->createShellSession($gatewayId, $this->actorUserId(), $reason, $cols, $rows, $cwd), 201);
|
||||
}
|
||||
|
||||
private function handleGatewayUpdate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
@@ -223,6 +306,20 @@ class edgeGatewaysRoute
|
||||
);
|
||||
}
|
||||
|
||||
private function handleInstallTokenStatus(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
|
||||
$claimTokenId = (int)$this->fromRoute('id');
|
||||
self::requireParameterIntPositive($claimTokenId, 'id');
|
||||
|
||||
$status = $this->registry()->getInstallTokenStatus($claimTokenId);
|
||||
$this->requireDepartmentAccess((int)$status['department_id']);
|
||||
unset($status['department_id']);
|
||||
$response->success($status);
|
||||
}
|
||||
|
||||
private function handleGatewayDiscovery(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
@@ -293,6 +390,25 @@ class edgeGatewaysRoute
|
||||
$response->success($this->install()->verifyInstallToken($token));
|
||||
}
|
||||
|
||||
private function handleAgentInstallTokenStatus(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
self::requireParameters(['token', 'status']);
|
||||
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->registry()->reportInstallTokenStatus(
|
||||
(string)$payload['token'],
|
||||
[
|
||||
'status' => (string)$payload['status'],
|
||||
'step' => isset($payload['step']) ? (string)$payload['step'] : null,
|
||||
'message' => isset($payload['message']) ? (string)$payload['message'] : null,
|
||||
'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [],
|
||||
'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null,
|
||||
'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null,
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
private function renderArtifact(string $fileName): void
|
||||
{
|
||||
try {
|
||||
@@ -445,6 +561,147 @@ class edgeGatewaysRoute
|
||||
));
|
||||
}
|
||||
|
||||
private function handleBrokerGatewayValidate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
self::requireParameters(['token']);
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$response->success($this->manager()->validateGatewayAgentForBroker($gatewayId, (string)self::getParameter('token')));
|
||||
}
|
||||
|
||||
private function handleBrokerGatewayPresence(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->manager()->recordBrokerPresence(
|
||||
$gatewayId,
|
||||
isset($payload['status']) ? (string)$payload['status'] : 'disconnected',
|
||||
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 handleBrokerGatewayBacklog(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->manager()->buildBrokerBacklog(
|
||||
$gatewayId,
|
||||
isset($payload['agent_instance_id']) ? (string)$payload['agent_instance_id'] : null
|
||||
));
|
||||
}
|
||||
|
||||
private function handleBrokerGatewayTelemetry(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->manager()->recordTelemetryFromBroker($gatewayId, $payload));
|
||||
}
|
||||
|
||||
private function handleBrokerOperationEvent(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$operationId = (int)$this->fromRoute('operationId');
|
||||
self::requireParameterIntPositive($operationId, 'operationId');
|
||||
$payload = self::getParametersAsArray();
|
||||
|
||||
try {
|
||||
$response->success($this->operations()->appendBrokerOperationEvent($gatewayId, $operationId, $payload));
|
||||
} catch (edge_gateway_operation_exception $exception) {
|
||||
$response->error([
|
||||
'message' => $exception->getMessage(),
|
||||
'error_code' => $exception->errorCode,
|
||||
], $exception->status);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleBrokerOperationComplete(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$operationId = (int)$this->fromRoute('operationId');
|
||||
self::requireParameterIntPositive($operationId, 'operationId');
|
||||
$payload = self::getParametersAsArray();
|
||||
|
||||
try {
|
||||
$response->success($this->operations()->completeBrokerOperation($gatewayId, $operationId, $payload));
|
||||
} catch (edge_gateway_operation_exception $exception) {
|
||||
$response->error([
|
||||
'message' => $exception->getMessage(),
|
||||
'error_code' => $exception->errorCode,
|
||||
], $exception->status);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleBrokerGatewayLogEntry(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
self::requireParameters(['message']);
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->manager()->appendGatewayLogEntry(
|
||||
$gatewayId,
|
||||
(string)self::getParameter('message'),
|
||||
isset($payload['level']) ? (string)$payload['level'] : 'INFO',
|
||||
isset($payload['stream']) ? (string)$payload['stream'] : 'agent',
|
||||
isset($payload['source']) ? (string)$payload['source'] : 'BROKER',
|
||||
isset($payload['context']) && is_array($payload['context']) ? (array)$payload['context'] : []
|
||||
));
|
||||
}
|
||||
|
||||
private function handleBrokerBrowserStreamValidate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
self::requireParameters(['token']);
|
||||
$response->success($this->manager()->validateBrowserStreamToken((string)self::getParameter('token')));
|
||||
}
|
||||
|
||||
private function handleBrokerShellSessionValidate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
self::requireParameters(['token']);
|
||||
$response->success($this->manager()->validateShellSessionToken((string)self::getParameter('token')));
|
||||
}
|
||||
|
||||
private function handleBrokerShellSessionOpened(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
self::requireParameters(['token']);
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->manager()->markShellSessionOpened(
|
||||
(string)self::getParameter('token'),
|
||||
isset($payload['connection_id']) ? (string)$payload['connection_id'] : null
|
||||
));
|
||||
}
|
||||
|
||||
private function handleBrokerShellSessionClose(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requireBrokerSecret();
|
||||
self::requireParameters(['token']);
|
||||
$payload = self::getParametersAsArray();
|
||||
$response->success($this->manager()->closeShellSessionByToken(
|
||||
(string)self::getParameter('token'),
|
||||
isset($payload['transcript']) ? (string)$payload['transcript'] : '',
|
||||
isset($payload['reason']) ? (string)$payload['reason'] : null
|
||||
));
|
||||
}
|
||||
|
||||
private function requireGatewayAccess(int $gatewayId): array
|
||||
{
|
||||
self::requireParameterIntPositive($gatewayId, 'id');
|
||||
@@ -464,6 +721,15 @@ class edgeGatewaysRoute
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function requireBrokerSecret(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$provided = trim((string)($_SERVER['HTTP_X_EDGE_BROKER_SECRET'] ?? ''));
|
||||
if (!$this->manager()->validateBrokerSharedSecret($provided)) {
|
||||
$response->error('Invalid edge broker secret', 403);
|
||||
}
|
||||
}
|
||||
|
||||
private function actorUserId(): ?int
|
||||
{
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\edge_gateway_manager;
|
||||
use classes\edge_gateway_operation_exception;
|
||||
use classes\edge_gateway_operation_service;
|
||||
use classes\edge_gateway_registry_service;
|
||||
use classes\edge_gateway_view_service;
|
||||
use classes\edgegateway;
|
||||
use classes\response;
|
||||
use Exception;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
class moduleEdgeGatewayRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/modules/edge-gateways', fn() => $this->handleListGateways(), [
|
||||
'modules_shelly_config' => 'List edge gateway module fleet',
|
||||
]);
|
||||
$this->get('/modules/edge-gateways/{id}', fn() => $this->handleGatewayDetail(), [
|
||||
'modules_shelly_config' => 'View edge gateway module detail',
|
||||
]);
|
||||
$this->get('/modules/edge-gateways/{id}/tasks', fn() => $this->handleGatewayTasksPage(), [
|
||||
'modules_shelly_config' => 'View edge gateway module tasks',
|
||||
]);
|
||||
$this->get('/modules/edge-gateways/{id}/logs', fn() => $this->handleGatewayLogsPage(), [
|
||||
'modules_shelly_config' => 'View edge gateway module logs',
|
||||
]);
|
||||
$this->get('/modules/edge-gateways/{id}/statistics', fn() => $this->handleGatewayStatisticsPage(), [
|
||||
'modules_shelly_config' => 'View edge gateway module statistics',
|
||||
]);
|
||||
$this->post('/modules/edge-gateways/{id}/stream-session', fn() => $this->handleGatewayStreamSessionCreate(), [
|
||||
'modules_shelly_config' => 'Create an edge gateway module live stream session',
|
||||
]);
|
||||
$this->put('/modules/edge-gateways/{id}', fn() => $this->handleGatewayUpdate(), [
|
||||
'modules_shelly_config' => 'Update edge gateway module metadata',
|
||||
]);
|
||||
$this->get('/modules/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationsList(), [
|
||||
'modules_shelly_config' => 'List edge gateway module operations',
|
||||
]);
|
||||
$this->post('/modules/edge-gateways/{id}/operations', fn() => $this->handleGatewayOperationCreate(), [
|
||||
'modules_shelly_config' => 'Queue an edge gateway module operation',
|
||||
]);
|
||||
$this->post('/modules/edge-gateways/{id}/operations/{operationId}/cancel', fn() => $this->handleGatewayOperationCancel(), [
|
||||
'modules_shelly_config' => 'Cancel an edge gateway module operation',
|
||||
]);
|
||||
$this->get('/modules/edge-gateways/{id}/operations/{operationId}/events', fn() => $this->handleGatewayOperationEvents(), [
|
||||
'modules_shelly_config' => 'List edge gateway module operation events',
|
||||
]);
|
||||
$this->post('/modules/edge-gateways/{id}/rotate-credentials', fn() => $this->handleGatewayCredentialRotate(), [
|
||||
'modules_shelly_config' => 'Rotate edge gateway module credentials',
|
||||
]);
|
||||
$this->post('/modules/edge-gateways/install-token', fn() => $this->handleInstallTokenCreate(), [
|
||||
'modules_shelly_config' => 'Create an edge gateway module install token',
|
||||
]);
|
||||
$this->get('/modules/edge-gateways/install-token/{id}/status', fn() => $this->handleInstallTokenStatus(), [
|
||||
'modules_shelly_config' => 'View edge gateway module installer status',
|
||||
]);
|
||||
$this->post('/modules/edge-gateways/{id}/discovery', fn() => $this->handleGatewayDiscovery(), [
|
||||
'modules_shelly_config' => 'Queue discovery through the edge gateway module',
|
||||
]);
|
||||
$this->put('/modules/edge-gateways/{id}/bindings', fn() => $this->handleBindingsUpdate(), [
|
||||
'modules_shelly_config' => 'Update edge gateway module relay bindings',
|
||||
]);
|
||||
$this->delete('/modules/edge-gateways/{id}', fn() => $this->handleGatewayDelete(), [
|
||||
'modules_shelly_config' => 'Delete an edge gateway module registration',
|
||||
]);
|
||||
$this->post('/modules/edge-gateways/departments/{id}/cutover', fn() => $this->handleDepartmentCutover(), [
|
||||
'modules_shelly_config' => 'Update department cutover through the edge gateway module',
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleListGateways(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
|
||||
$departmentId = self::isParametersSet(['department_id']) ? (int)self::getParameter('department_id') : null;
|
||||
$view = trim((string)$this->fromQuery('view'));
|
||||
if ($departmentId !== null && $departmentId > 0) {
|
||||
$this->requireDepartmentAccess((string)$departmentId);
|
||||
}
|
||||
|
||||
$payload = $this->views()->listGatewaysWithFleetUsage($departmentId, $view !== 'summary');
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_LIST', 'Listed edge gateways');
|
||||
$response->add_meta('fleet_usage', $payload['fleet_usage']);
|
||||
$response->success($payload['gateways']);
|
||||
}
|
||||
|
||||
private function handleGatewayDetail(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gateway = $this->requireGatewayAccess((int)$this->fromRoute('id'));
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_GET', 'Fetched edge gateway detail');
|
||||
$response->success($gateway);
|
||||
}
|
||||
|
||||
private function handleGatewayTasksPage(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_TASKS_GET', 'Fetched edge gateway task timeline');
|
||||
$response->success($this->manager()->buildGatewayTasksPage($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayLogsPage(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_LOGS_GET', 'Fetched edge gateway logs');
|
||||
$response->success($this->manager()->buildGatewayLogsPage($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayStatisticsPage(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_STATISTICS_GET', 'Fetched edge gateway statistics');
|
||||
$response->success($this->manager()->buildGatewayStatisticsPage($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayStreamSessionCreate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$payload = self::getParametersAsArray();
|
||||
$scopes = isset($payload['scopes']) && is_array($payload['scopes']) ? (array)$payload['scopes'] : [];
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_STREAM_SESSION_CREATE', 'Created edge gateway stream session');
|
||||
$response->success($this->manager()->createBrowserStreamSession($gatewayId, (int)$user->id, $scopes), 201);
|
||||
}
|
||||
|
||||
private function handleGatewayUpdate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
self::requireParameters(['label', 'is_primary']);
|
||||
self::requireType(self::getParameter('label'), self::TYPE_STRING());
|
||||
self::requireType(self::getParameter('is_primary'), self::TYPE_BOOL());
|
||||
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$result = $this->registry()->updateGatewayMetadata($gatewayId, [
|
||||
'label' => (string)self::getParameter('label'),
|
||||
'is_primary' => (bool)self::getParameter('is_primary'),
|
||||
], (int)$user->id);
|
||||
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_UPDATE', 'Updated edge gateway metadata');
|
||||
$response->success($result);
|
||||
}
|
||||
|
||||
private function handleGatewayOperationsList(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_OPERATIONS_LIST', 'Listed edge gateway operations');
|
||||
$response->success($this->operations()->listOperations($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayOperationCreate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
self::requireParameters(['type', 'request']);
|
||||
self::requireType(self::getParameter('type'), self::TYPE_STRING());
|
||||
self::requireType(self::getParameter('request'), self::TYPE_ARRAY());
|
||||
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
|
||||
try {
|
||||
$operation = $this->operations()->queueOperation(
|
||||
$gatewayId,
|
||||
(string)self::getParameter('type'),
|
||||
(array)self::getParameter('request'),
|
||||
(int)$user->id
|
||||
);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_OPERATION_QUEUE', 'Queued edge gateway operation');
|
||||
$response->success([
|
||||
'operation' => $operation,
|
||||
'gateway' => $this->views()->getGateway($gatewayId),
|
||||
], 201);
|
||||
} catch (edge_gateway_operation_exception $exception) {
|
||||
$response->error([
|
||||
'message' => $exception->getMessage(),
|
||||
'error_code' => $exception->errorCode,
|
||||
], $exception->status);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleGatewayOperationCancel(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$operationId = (int)$this->fromRoute('operationId');
|
||||
self::requireParameterIntPositive($operationId, 'operationId');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
|
||||
try {
|
||||
$operation = $this->operations()->cancelOperation($gatewayId, $operationId, (int)$user->id);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_OPERATION_CANCEL', 'Cancelled edge gateway operation');
|
||||
$response->success([
|
||||
'operation' => $operation,
|
||||
'gateway' => $this->views()->getGateway($gatewayId),
|
||||
]);
|
||||
} catch (edge_gateway_operation_exception $exception) {
|
||||
$response->error([
|
||||
'message' => $exception->getMessage(),
|
||||
'error_code' => $exception->errorCode,
|
||||
], $exception->status);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleGatewayOperationEvents(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$operationId = (int)$this->fromRoute('operationId');
|
||||
self::requireParameterIntPositive($operationId, 'operationId');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_EVENTS_LIST', 'Listed edge gateway operation events');
|
||||
$response->success($this->operations()->listOperationEvents($gatewayId, $operationId));
|
||||
}
|
||||
|
||||
private function handleGatewayCredentialRotate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_CREDENTIALS_ROTATE', 'Rotated edge gateway credentials');
|
||||
$response->success($this->operations()->rotateCredentials($gatewayId, (int)$user->id));
|
||||
}
|
||||
|
||||
private function handleInstallTokenCreate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
self::requireParameters(['department_id']);
|
||||
|
||||
$departmentId = (int)self::getParameter('department_id');
|
||||
self::requireParameterIntPositive($departmentId, 'department_id');
|
||||
$this->requireDepartmentAccess((string)$departmentId);
|
||||
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_INSTALL_TOKEN_CREATE', 'Created edge gateway install token');
|
||||
$response->success(
|
||||
$this->registry()->createInstallToken(
|
||||
$departmentId,
|
||||
self::isParametersSet(['label']) ? (string)self::getParameter('label') : null,
|
||||
(int)$user->id
|
||||
),
|
||||
201
|
||||
);
|
||||
}
|
||||
|
||||
private function handleInstallTokenStatus(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$claimTokenId = (int)$this->fromRoute('id');
|
||||
self::requireParameterIntPositive($claimTokenId, 'id');
|
||||
|
||||
$status = $this->registry()->getInstallTokenStatus($claimTokenId);
|
||||
$departmentId = (int)($status['department_id'] ?? 0);
|
||||
self::requireParameterIntPositive($departmentId, 'department_id');
|
||||
$this->requireDepartmentAccess((string)$departmentId);
|
||||
|
||||
(new logs_o())->add(
|
||||
'modules_edgegateway',
|
||||
'global',
|
||||
1,
|
||||
$user->id,
|
||||
'MODULES_EDGEGATEWAY_INSTALL_TOKEN_STATUS',
|
||||
'Viewed edge gateway installer status'
|
||||
);
|
||||
|
||||
unset($status['department_id']);
|
||||
$response->success($status);
|
||||
}
|
||||
|
||||
private function handleGatewayDiscovery(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$this->operations()->queueDiscoveryOperation($gatewayId, (int)$user->id);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_DISCOVERY_QUEUE', 'Queued edge gateway discovery');
|
||||
$response->success($this->views()->getGateway($gatewayId));
|
||||
}
|
||||
|
||||
private function handleBindingsUpdate(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
self::requireParameters(['bindings']);
|
||||
self::requireType(self::getParameter('bindings'), self::TYPE_ARRAY());
|
||||
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
$this->registry()->setRelayBindings($gatewayId, (array)self::getParameter('bindings'), (int)$user->id);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_BINDINGS_UPDATE', 'Updated edge gateway bindings');
|
||||
$response->success($this->views()->getGateway($gatewayId));
|
||||
}
|
||||
|
||||
private function handleGatewayDelete(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
$gatewayId = (int)$this->fromRoute('id');
|
||||
$this->requireGatewayAccess($gatewayId);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_DELETE', 'Deleted edge gateway');
|
||||
$response->success($this->registry()->deleteGateway($gatewayId, (int)$user->id));
|
||||
}
|
||||
|
||||
private function handleDepartmentCutover(): void
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$user = $this->requireModuleOperator();
|
||||
self::requireParameters(['transport_mode']);
|
||||
$departmentId = (int)$this->fromRoute('id');
|
||||
self::requireParameterIntPositive($departmentId, 'id');
|
||||
$this->requireDepartmentAccess((string)$departmentId);
|
||||
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_DEPARTMENT_CUTOVER', 'Updated department gateway cutover');
|
||||
$response->success($this->registry()->setDepartmentTransportMode($departmentId, (string)self::getParameter('transport_mode'), (int)$user->id));
|
||||
}
|
||||
|
||||
private function requireModuleOperator(): object
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
|
||||
try {
|
||||
(new edgegateway())->requireModuleEnabled();
|
||||
} catch (Exception $exception) {
|
||||
$response->error($exception->getMessage(), 409);
|
||||
}
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function requireGatewayAccess(int $gatewayId): array
|
||||
{
|
||||
global /** @var response $response */ $response;
|
||||
$gateway = $this->views()->getGateway($gatewayId);
|
||||
if (!isset($gateway['id'])) {
|
||||
$response->error('Edge gateway not found', 404);
|
||||
}
|
||||
|
||||
$departmentId = (int)($gateway['department_id'] ?? 0);
|
||||
self::requireParameterIntPositive($departmentId, 'department_id');
|
||||
$this->requireDepartmentAccess((string)$departmentId);
|
||||
return $gateway;
|
||||
}
|
||||
|
||||
private function views(): edge_gateway_view_service
|
||||
{
|
||||
return new edge_gateway_view_service();
|
||||
}
|
||||
|
||||
private function registry(): edge_gateway_registry_service
|
||||
{
|
||||
return new edge_gateway_registry_service();
|
||||
}
|
||||
|
||||
private function operations(): edge_gateway_operation_service
|
||||
{
|
||||
return new edge_gateway_operation_service();
|
||||
}
|
||||
|
||||
private function manager(): edge_gateway_manager
|
||||
{
|
||||
return new edge_gateway_manager();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\edge_gateway_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use traits\db_object_t;
|
||||
|
||||
class edge_gateway_log_entries_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $gateway_id;
|
||||
public object_property $department_id;
|
||||
public object_property $level;
|
||||
public object_property $stream;
|
||||
public object_property $source;
|
||||
public object_property $message;
|
||||
public object_property $context_json;
|
||||
public object_property $created_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
edge_gateway_schema_bootstrap::ensureTables();
|
||||
$this->setTable('edge_gateway_log_entries');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||
$this->level = new object_property($this->table, $this->id, 'level', 'string', false);
|
||||
$this->stream = new object_property($this->table, $this->id, 'stream', 'string', false);
|
||||
$this->source = new object_property($this->table, $this->id, 'source', 'string', false);
|
||||
$this->message = new object_property($this->table, $this->id, 'message', 'text', false);
|
||||
$this->context_json = new object_property($this->table, $this->id, 'context_json', 'json', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'gateway_id' => (int)$this->gateway_id->value(),
|
||||
'department_id' => $this->department_id->value() === null ? null : (int)$this->department_id->value(),
|
||||
'level' => (string)$this->level->value(),
|
||||
'stream' => (string)$this->stream->value(),
|
||||
'source' => (string)$this->source->value(),
|
||||
'message' => (string)$this->message->value(),
|
||||
'context' => (array)($this->context_json->value() ?? []),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\edge_gateway_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use traits\db_object_t;
|
||||
|
||||
class edge_gateway_shell_sessions_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public object_property $gateway_id;
|
||||
public object_property $department_id;
|
||||
public object_property $actor_user_id;
|
||||
public object_property $session_token_hash;
|
||||
public object_property $status;
|
||||
public object_property $reason;
|
||||
public object_property $connection_id;
|
||||
public object_property $cwd;
|
||||
public object_property $shell_command;
|
||||
public object_property $shell_args_json;
|
||||
public object_property $cols;
|
||||
public object_property $rows;
|
||||
public object_property $transcript;
|
||||
public object_property $metadata_json;
|
||||
public object_property $expires_at;
|
||||
public object_property $approved_at;
|
||||
public object_property $opened_at;
|
||||
public object_property $closed_at;
|
||||
public object_property $created_at;
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
edge_gateway_schema_bootstrap::ensureTables();
|
||||
$this->setTable('edge_gateway_shell_sessions');
|
||||
}
|
||||
|
||||
public function getObjectProperties(): void
|
||||
{
|
||||
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||
$this->actor_user_id = new object_property($this->table, $this->id, 'actor_user_id', 'int', false);
|
||||
$this->session_token_hash = new object_property($this->table, $this->id, 'session_token_hash', 'string', false);
|
||||
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
|
||||
$this->reason = new object_property($this->table, $this->id, 'reason', 'string', false);
|
||||
$this->connection_id = new object_property($this->table, $this->id, 'connection_id', 'string', false);
|
||||
$this->cwd = new object_property($this->table, $this->id, 'cwd', 'string', false);
|
||||
$this->shell_command = new object_property($this->table, $this->id, 'shell_command', 'string', false);
|
||||
$this->shell_args_json = new object_property($this->table, $this->id, 'shell_args_json', 'json', false);
|
||||
$this->cols = new object_property($this->table, $this->id, 'cols', 'int', false);
|
||||
$this->rows = new object_property($this->table, $this->id, 'rows', 'int', false);
|
||||
$this->transcript = new object_property($this->table, $this->id, 'transcript', 'text', false);
|
||||
$this->metadata_json = new object_property($this->table, $this->id, 'metadata_json', 'json', false);
|
||||
$this->expires_at = new object_property($this->table, $this->id, 'expires_at', 'string', false);
|
||||
$this->approved_at = new object_property($this->table, $this->id, 'approved_at', 'string', false);
|
||||
$this->opened_at = new object_property($this->table, $this->id, 'opened_at', 'string', false);
|
||||
$this->closed_at = new object_property($this->table, $this->id, 'closed_at', 'string', 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);
|
||||
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false);
|
||||
}
|
||||
|
||||
public function objectChanged(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$this->requireSelected();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'gateway_id' => (int)$this->gateway_id->value(),
|
||||
'department_id' => (int)$this->department_id->value(),
|
||||
'actor_user_id' => $this->actor_user_id->value() === null ? null : (int)$this->actor_user_id->value(),
|
||||
'status' => (string)$this->status->value(),
|
||||
'reason' => $this->reason->value() === null ? null : (string)$this->reason->value(),
|
||||
'connection_id' => $this->connection_id->value() === null ? null : (string)$this->connection_id->value(),
|
||||
'cwd' => $this->cwd->value() === null ? null : (string)$this->cwd->value(),
|
||||
'shell_command' => $this->shell_command->value() === null ? null : (string)$this->shell_command->value(),
|
||||
'shell_args' => (array)($this->shell_args_json->value() ?? []),
|
||||
'cols' => $this->cols->value() === null ? null : (int)$this->cols->value(),
|
||||
'rows' => $this->rows->value() === null ? null : (int)$this->rows->value(),
|
||||
'transcript' => $this->transcript->value() === null ? null : (string)$this->transcript->value(),
|
||||
'metadata' => (array)($this->metadata_json->value() ?? []),
|
||||
'expires_at' => $this->expires_at->value() === null ? null : (string)$this->expires_at->value(),
|
||||
'approved_at' => $this->approved_at->value() === null ? null : (string)$this->approved_at->value(),
|
||||
'opened_at' => $this->opened_at->value() === null ? null : (string)$this->opened_at->value(),
|
||||
'closed_at' => $this->closed_at->value() === null ? null : (string)$this->closed_at->value(),
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => $this->updated_at->value() === null ? null : (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,11 +25,6 @@ services:
|
||||
MARIADB_ROOT_PASSWORD: truckwash_edge_root
|
||||
volumes:
|
||||
- ./runtime/mariadb:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -ptruckwash_edge_root --silent"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
|
||||
minio:
|
||||
image: ${MINIO_BASE_IMAGE:-minio/minio:latest}
|
||||
@@ -41,11 +36,6 @@ services:
|
||||
MINIO_ROOT_PASSWORD: truckwash_edge_storage
|
||||
volumes:
|
||||
- ./runtime/minio:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9000/minio/health/live"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
lan-worker:
|
||||
build:
|
||||
@@ -61,13 +51,19 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
condition: service_started
|
||||
minio:
|
||||
condition: service_healthy
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./runtime:/opt/truckwash-edge-agent/runtime
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8090/health"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"php",
|
||||
"-r",
|
||||
"$$json=@file_get_contents('http://127.0.0.1:8090/health'); if ($$json===false) exit(1); $$data=json_decode($$json,true); exit((($$data['status'] ?? '') === 'healthy') ? 0 : 1);",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -84,16 +80,16 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
condition: service_started
|
||||
minio:
|
||||
condition: service_healthy
|
||||
condition: service_started
|
||||
lan-worker:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./config.json:/config/config.json
|
||||
- ./runtime:/opt/truckwash-edge-agent/runtime
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "test -f /opt/truckwash-edge-agent/runtime/last-heartbeat-ok.txt"]
|
||||
test: ["CMD-SHELL", "kill -0 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -118,7 +114,7 @@ services:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"php -r '$path=\"/opt/truckwash-edge-agent/runtime/auto-updater-heartbeat.json\"; if (!is_file($path)) { exit(1); } exit((time() - filemtime($path)) <= 90 ? 0 : 1);'",
|
||||
"php -r '$$path=\"/opt/truckwash-edge-agent/runtime/auto-updater-heartbeat.json\"; if (!is_file($$path)) { exit(1); } exit((time() - filemtime($$path)) <= 90 ? 0 : 1);'",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
@@ -8,7 +8,7 @@ COMPOSE_FILE="$INSTALL_DIR/docker-compose.gateway.yml"
|
||||
RUNTIME_DIR="$INSTALL_DIR/runtime"
|
||||
ROLLBACK_STATUS_PATH="$RUNTIME_DIR/rollback-status.json"
|
||||
STAGED_UPDATE_PATH="$RUNTIME_DIR/staged-update.json"
|
||||
STACK_HEALTHCHECK_TIMEOUT_SECONDS="${STACK_HEALTHCHECK_TIMEOUT_SECONDS:-120}"
|
||||
STACK_HEALTHCHECK_TIMEOUT_SECONDS="${STACK_HEALTHCHECK_TIMEOUT_SECONDS:-300}"
|
||||
|
||||
log() {
|
||||
printf '[gateway-launcher] %s\n' "$1"
|
||||
@@ -110,13 +110,16 @@ apply_stack() {
|
||||
local redis_base_image
|
||||
local mariadb_base_image
|
||||
local minio_base_image
|
||||
local compose_project_name
|
||||
edge_base_image="$(config_value edgeAgentBaseImage 'php:8.2-cli-bookworm')"
|
||||
worker_base_image="$(config_value lanWorkerBaseImage 'php:8.2-cli-bookworm')"
|
||||
auto_updater_base_image="$(config_value autoUpdaterBaseImage 'php:8.2-cli-bookworm')"
|
||||
redis_base_image="$(config_value redisBaseImage 'redis:7-alpine')"
|
||||
mariadb_base_image="$(config_value mariadbBaseImage 'mariadb:11')"
|
||||
minio_base_image="$(config_value minioBaseImage 'minio/minio:latest')"
|
||||
compose_project_name="$(config_value composeProjectName 'truckwash-edge-gateway')"
|
||||
cd "$INSTALL_DIR"
|
||||
COMPOSE_PROJECT_NAME="$compose_project_name" \
|
||||
EDGE_AGENT_BASE_IMAGE="$edge_base_image" \
|
||||
LAN_WORKER_BASE_IMAGE="$worker_base_image" \
|
||||
AUTO_UPDATER_BASE_IMAGE="$auto_updater_base_image" \
|
||||
@@ -144,7 +147,10 @@ rollback_stack() {
|
||||
mv -f "$INSTALL_DIR/$file.bak" "$INSTALL_DIR/$file"
|
||||
fi
|
||||
done
|
||||
apply_stack
|
||||
if ! apply_stack; then
|
||||
write_rollback_status "FAILED" "rollback_apply_failed" "$installed_version"
|
||||
return 1
|
||||
fi
|
||||
write_rollback_status "ROLLED_BACK" "healthcheck_failed" "$installed_version"
|
||||
if [ -f "$STAGED_UPDATE_PATH" ]; then
|
||||
php -r '
|
||||
@@ -193,9 +199,16 @@ wait_for_stack_health() {
|
||||
}
|
||||
|
||||
reconcile_stack() {
|
||||
local installed_version
|
||||
installed_version="$(config_value installedVersion '')"
|
||||
ensure_dirs
|
||||
log "Reconciling compose stack"
|
||||
apply_stack
|
||||
if ! apply_stack; then
|
||||
log "Compose rollout failed during build/startup"
|
||||
print_compose_diagnostics
|
||||
write_rollback_status "FAILED" "compose_up_failed" "$installed_version"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! wait_for_stack_health "$STACK_HEALTHCHECK_TIMEOUT_SECONDS"; then
|
||||
log "Healthcheck failed after compose rollout; reverting to previous artifacts"
|
||||
|
||||
@@ -10,7 +10,19 @@ function app_path(string $relative = ''): string
|
||||
return WD;
|
||||
}
|
||||
|
||||
return WD . DIRECTORY_SEPARATOR . ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative), DIRECTORY_SEPARATOR);
|
||||
$normalized = ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative), DIRECTORY_SEPARATOR);
|
||||
|
||||
if (str_starts_with($normalized, 'classes' . DIRECTORY_SEPARATOR . 'edge_gateway_')) {
|
||||
$normalized = 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'classes' . DIRECTORY_SEPARATOR . basename($normalized);
|
||||
} elseif ($normalized === 'routes' . DIRECTORY_SEPARATOR . 'edgeGatewaysRoute.php') {
|
||||
$normalized = 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'edgeGatewaysRoute.php';
|
||||
} elseif ($normalized === 'routes' . DIRECTORY_SEPARATOR . 'moduleEdgeGatewayRoute.php') {
|
||||
$normalized = 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'moduleEdgeGatewayRoute.php';
|
||||
} elseif ($normalized === 'routes' . DIRECTORY_SEPARATOR . 'edgeGatewayConfigRoute.php') {
|
||||
$normalized = 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'edgeGatewayConfigRoute.php';
|
||||
}
|
||||
|
||||
return WD . DIRECTORY_SEPARATOR . $normalized;
|
||||
}
|
||||
|
||||
function app_require(string $relative): void
|
||||
@@ -36,6 +48,12 @@ spl_autoload_register(function (string $class): void {
|
||||
$candidates = [];
|
||||
|
||||
if (in_array($top, ['classes', 'interfaces', 'traits', 'objects', 'routes', 'statistics'], true)) {
|
||||
if ($top === 'classes' && str_starts_with(strtolower($relative), 'edge_gateway_')) {
|
||||
$candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'classes' . DIRECTORY_SEPARATOR . $relative;
|
||||
}
|
||||
if ($top === 'routes' && in_array($relative, ['edgeGatewaysRoute', 'moduleEdgeGatewayRoute', 'edgeGatewayConfigRoute'], true)) {
|
||||
$candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'edgegateway' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . $relative;
|
||||
}
|
||||
$candidates[] = $base . DIRECTORY_SEPARATOR . $top . DIRECTORY_SEPARATOR . $relative;
|
||||
} elseif ($top === 'modules') {
|
||||
$candidates[] = $base . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . $relative;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/edge_gateway_manager.php');
|
||||
|
||||
use classes\edge_gateway_manager;
|
||||
|
||||
it('caps install-session diagnostics and events while preserving the first start timestamp', function (): void {
|
||||
$session = [];
|
||||
|
||||
for ($index = 1; $index <= 14; $index += 1) {
|
||||
$session = edge_gateway_manager::mergeInstallSessionUpdate($session, [
|
||||
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_RUNNING,
|
||||
'step' => 'STEP_' . $index,
|
||||
'message' => 'Installer phase ' . $index,
|
||||
], strtotime('2026-04-08 10:00:' . str_pad((string)$index, 2, '0', STR_PAD_LEFT)));
|
||||
}
|
||||
|
||||
$failed = edge_gateway_manager::mergeInstallSessionUpdate($session, [
|
||||
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED,
|
||||
'step' => 'START_STACK',
|
||||
'message' => 'Compose rollout failed during startup.',
|
||||
'diagnostics' => array_map(
|
||||
static fn(int $index): array => [
|
||||
'name' => 'Diagnostic ' . $index,
|
||||
'output' => 'Output ' . $index,
|
||||
],
|
||||
range(1, 8)
|
||||
),
|
||||
], strtotime('2026-04-08 10:01:30'));
|
||||
|
||||
expect($failed['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED);
|
||||
expect($failed['step'])->toBe('START_STACK');
|
||||
expect($failed['message'])->toBe('Compose rollout failed during startup.');
|
||||
expect($failed['started_at'])->toBe('2026-04-08 10:00:01');
|
||||
expect($failed['updated_at'])->toBe('2026-04-08 10:01:30');
|
||||
expect($failed['last_error'])->toBe('Compose rollout failed during startup.');
|
||||
expect($failed['diagnostics'])->toHaveCount(6);
|
||||
expect($failed['diagnostics'][0]['name'])->toBe('Diagnostic 3');
|
||||
expect($failed['diagnostics'][5]['name'])->toBe('Diagnostic 8');
|
||||
expect($failed['events'])->toHaveCount(12);
|
||||
expect($failed['events'][11]['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED);
|
||||
expect($failed['events'][11]['step'])->toBe('START_STACK');
|
||||
});
|
||||
|
||||
it('clears terminal failure details after a successful claim update', function (): void {
|
||||
$claimed = edge_gateway_manager::mergeInstallSessionUpdate([
|
||||
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED,
|
||||
'step' => 'START_STACK',
|
||||
'message' => 'Compose rollout failed during startup.',
|
||||
'started_at' => '2026-04-08 10:00:01',
|
||||
'updated_at' => '2026-04-08 10:01:30',
|
||||
'last_error' => 'Compose rollout failed during startup.',
|
||||
'diagnostics' => [
|
||||
['name' => 'systemctl status', 'output' => 'failed'],
|
||||
],
|
||||
'events' => [
|
||||
[
|
||||
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_FAILED,
|
||||
'step' => 'START_STACK',
|
||||
'message' => 'Compose rollout failed during startup.',
|
||||
'at' => '2026-04-08 10:01:30',
|
||||
],
|
||||
],
|
||||
], [
|
||||
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_CLAIMED,
|
||||
'step' => edge_gateway_manager::INSTALL_SESSION_STATUS_CLAIMED,
|
||||
'message' => 'Gateway claimed successfully.',
|
||||
'gateway_id' => 703,
|
||||
], strtotime('2026-04-08 10:02:00'));
|
||||
|
||||
expect($claimed['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_CLAIMED);
|
||||
expect($claimed['gateway_id'])->toBe(703);
|
||||
expect($claimed['last_error'])->toBeNull();
|
||||
expect($claimed['diagnostics'])->toBe([]);
|
||||
expect($claimed['events'])->toHaveCount(2);
|
||||
expect($claimed['events'][1]['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_CLAIMED);
|
||||
});
|
||||
|
||||
it('marks expired non-terminal install sessions as terminal when read back', function (): void {
|
||||
$normalized = edge_gateway_manager::normalizeInstallSessionRecord([
|
||||
'status' => edge_gateway_manager::INSTALL_SESSION_STATUS_RUNNING,
|
||||
'step' => 'WAIT_FOR_CLAIM',
|
||||
'message' => 'Installer is waiting for the gateway heartbeat and claim.',
|
||||
'started_at' => '2026-04-08 10:00:01',
|
||||
'updated_at' => '2026-04-08 10:01:30',
|
||||
], '2026-04-08 10:01:00', strtotime('2026-04-08 10:02:00'));
|
||||
|
||||
expect($normalized['status'])->toBe(edge_gateway_manager::INSTALL_SESSION_STATUS_EXPIRED);
|
||||
expect($normalized['step'])->toBe('WAIT_FOR_CLAIM');
|
||||
expect($normalized['updated_at'])->toBe('2026-04-08 10:01:30');
|
||||
expect($normalized['message'])->toBe('Installer is waiting for the gateway heartbeat and claim.');
|
||||
expect($normalized['last_error'])->toBe('Install token expired.');
|
||||
expect($normalized['terminal'])->toBeTrue();
|
||||
});
|
||||
@@ -43,21 +43,29 @@ it('builds install script urls with the compose edge gateway artifacts and forwa
|
||||
expect($manager->buildInstallScriptUrl('abc123'))->toBe('https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123');
|
||||
expect($manager->buildInstallCommand('abc123'))->toContain("https://api.truckwash.io:4433/edge-agent/install.sh?token=abc123");
|
||||
expect($script)->toContain('fetch_http "Verify install token" "https://api.truckwash.io:4433/edge-agent/install-token/verify?token=abc123"');
|
||||
expect($script)->toContain('INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"');
|
||||
expect($script)->toContain('fetch_http "Download PHP edge agent" "https://api.truckwash.io:4433/edge-agent/artifacts/agent.php" "$INSTALL_DIR/agent.php"');
|
||||
expect($script)->toContain('fetch_http "Download LAN worker" "https://api.truckwash.io:4433/edge-agent/artifacts/lan-worker.php" "$INSTALL_DIR/lan-worker.php"');
|
||||
expect($script)->toContain('fetch_http "Download compose stack" "https://api.truckwash.io:4433/edge-agent/artifacts/docker-compose.gateway.yml" "$INSTALL_DIR/docker-compose.gateway.yml"');
|
||||
expect($script)->toContain('fetch_http "Download compose stack service unit" "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-gateway-stack.service" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"');
|
||||
expect($script)->toContain('Installer failed during step: ${CURRENT_STEP:-unknown}');
|
||||
expect($script)->toContain('report_install_status() {');
|
||||
expect($script)->toContain('begin_install_phase "VERIFY_TOKEN" "Verifying install token"');
|
||||
expect($script)->toContain('begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"');
|
||||
expect($script)->toContain('report_install_status "FAILED" "FAILED" "$failure_message" "$diagnostics_json" "$gateway_id"');
|
||||
expect($script)->toContain('report_install_status "CLAIMED" "CLAIMED"');
|
||||
expect($script)->toContain('Installer failed during step ${CURRENT_STEP_CODE:-FAILED}: ${CURRENT_STEP:-unknown}');
|
||||
expect($script)->toContain('Last request: ${CURRENT_METHOD} ${CURRENT_URL}');
|
||||
expect($script)->toContain('Response body preview (first 400 bytes):');
|
||||
expect($script)->toContain('wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
|
||||
expect($script)->toContain('wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
|
||||
expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"');
|
||||
expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"');
|
||||
expect($script)->toContain('"stackServiceName":"truckwash-edge-gateway-stack.service"');
|
||||
expect($script)->toContain('"composeFileName":"docker-compose.gateway.yml"');
|
||||
expect($script)->toContain('"stateDatabasePath":"/opt/truckwash-edge-agent/runtime/gateway-state.sqlite"');
|
||||
expect($script)->toContain('"operationPollTimeoutSeconds":20');
|
||||
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"');
|
||||
expect($script)->not->toContain('"shellActionPollTimeoutSeconds"');
|
||||
expect($script)->not->toContain('"brokerUrl"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,6 +94,6 @@ it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void {
|
||||
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($script)->toContain('"apiUrl":"https://edge.example.test/api"');
|
||||
expect($script)->not->toContain('"brokerUrl"');
|
||||
expect($script)->toContain('"brokerUrl":"https://edge.example.test:4300"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
it('registers module-scoped edge gateway operator routes', function (): void {
|
||||
$route = file_get_contents(app_path('modules/edgegateway/routes/moduleEdgeGatewayRoute.php'));
|
||||
|
||||
expect($route)->not->toBeFalse();
|
||||
expect($route)->toContain("'/modules/edge-gateways'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/{id}'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/install-token'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/install-token/{id}/status'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/{id}/discovery'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/{id}/bindings'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/{id}/operations'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/{id}/operations/{operationId}/cancel'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/{id}/operations/{operationId}/events'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/{id}/rotate-credentials'");
|
||||
expect($route)->toContain("'/modules/edge-gateways/departments/{id}/cutover'");
|
||||
expect($route)->toContain('requireModuleEnabled()');
|
||||
expect($route)->not->toContain("'/modules/edge-gateways/{id}/shell-sessions'");
|
||||
});
|
||||
|
||||
it('registers edge gateway config endpoints from the module route directory', function (): void {
|
||||
$route = file_get_contents(app_path('modules/edgegateway/routes/edgeGatewayConfigRoute.php'));
|
||||
$legacyRoute = file_get_contents(app_path('routes/moduleConfigRoute.php'));
|
||||
|
||||
expect($route)->not->toBeFalse();
|
||||
expect($route)->toContain("'/edgegateway/config'");
|
||||
expect($route)->toContain('new edgegateway()');
|
||||
expect($legacyRoute)->not->toContain("'/edgegateway/config'");
|
||||
});
|
||||
|
||||
it('keeps only the module facade in the global classes directory and conditionally loads module routes', function (): void {
|
||||
$classes = glob(app_path('classes/*.php')) ?: [];
|
||||
$edgeGatewayClasses = array_values(array_filter($classes, static function (string $path): bool {
|
||||
$name = basename($path);
|
||||
return str_contains($name, 'edgegateway') || str_contains($name, 'edge_gateway');
|
||||
}));
|
||||
$index = file_get_contents(app_path('index.php'));
|
||||
|
||||
expect($edgeGatewayClasses)->toEqual([app_path('classes/edgegateway.php')]);
|
||||
expect($index)->toContain("\$routes_path = \$modules_path . DIRECTORY_SEPARATOR . \$module_dir . DIRECTORY_SEPARATOR . 'routes'");
|
||||
expect($index)->toContain("method_exists(\$module, 'isEnabled') && !\$module->isEnabled()");
|
||||
});
|
||||
@@ -7,16 +7,21 @@ it('registers the v2 operator-facing edge gateway routes', function (): void {
|
||||
expect($route)->toContain("'/edge-gateways'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}'");
|
||||
expect($route)->toContain("'/edge-gateways/install-token'");
|
||||
expect($route)->toContain("'/edge-gateways/install-token/{id}/status'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/discovery'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/bindings'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/operations'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/operations/{operationId}/cancel'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/operations/{operationId}/events'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/tasks'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/logs'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/statistics'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/stream-session'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/shell-sessions'");
|
||||
expect($route)->toContain("'/edge-gateways/{id}/rotate-credentials'");
|
||||
expect($route)->toContain("'/departments/{id}/gateway-cutover'");
|
||||
expect($route)->toContain("add_meta('fleet_usage'");
|
||||
expect($route)->toContain('listGatewaysWithFleetUsage(');
|
||||
expect($route)->not->toContain("'/edge-gateways/{id}/shell-sessions'");
|
||||
expect($route)->not->toContain('private function requirePermission');
|
||||
expect($route)->not->toContain('private function requireDepartmentAccess');
|
||||
});
|
||||
@@ -25,6 +30,7 @@ it('registers PHP edge agent routes for operations and legacy relay command poll
|
||||
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
|
||||
|
||||
expect($route)->toContain("'/edge-agent/install-token/verify'");
|
||||
expect($route)->toContain("'/edge-agent/install-token/status'");
|
||||
expect($route)->toContain("'/edge-agent/install.sh'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/agent.php'");
|
||||
expect($route)->toContain("'/edge-agent/artifacts/lan-worker.php'");
|
||||
@@ -44,6 +50,12 @@ it('registers PHP edge agent routes for operations and legacy relay command poll
|
||||
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/poll'");
|
||||
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/{jobId}/result'");
|
||||
expect($route)->toContain("'/edge-agent/gateways/{id}/presence'");
|
||||
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/validate'");
|
||||
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/backlog'");
|
||||
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/telemetry'");
|
||||
expect($route)->toContain("'/edge-agent/internal/gateways/{id}/logs'");
|
||||
expect($route)->toContain("'/edge-agent/internal/browser-streams/validate'");
|
||||
expect($route)->toContain("'/edge-agent/internal/shell-sessions/opened'");
|
||||
expect($route)->toContain('echo $exception->getMessage()');
|
||||
expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
|
||||
expect($route)->not->toContain("'/edge-agent/gateways/{id}/shell-sessions/{sessionId}/events'");
|
||||
|
||||
@@ -12,7 +12,8 @@ it('defines the v2 edge gateway schema bootstrap tables', function (): void {
|
||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operations');
|
||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_operation_events');
|
||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_audit_logs');
|
||||
expect($bootstrapContent)->not->toContain('edge_gateway_shell_sessions');
|
||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_log_entries');
|
||||
expect($bootstrapContent)->toContain('CREATE TABLE IF NOT EXISTS edge_gateway_shell_sessions');
|
||||
expect($bootstrapContent)->not->toContain('edge_gateway_shell_action_jobs');
|
||||
expect($bootstrapContent)->not->toContain('edge_gateway_shell_events');
|
||||
});
|
||||
@@ -40,7 +41,8 @@ it('stores operation metadata and event timelines for management workflows', fun
|
||||
expect($bootstrapContent)->toContain('SET type = operation_type');
|
||||
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_operation_events', 'context_json', 'JSON NULL AFTER message')");
|
||||
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_audit_logs', 'context_json', 'JSON NULL AFTER severity')");
|
||||
expect($bootstrapContent)->toContain("ensureColumn('edge_gateway_log_entries', 'context_json', 'JSON NULL AFTER message')");
|
||||
expect($bootstrapContent)->toContain('summary_json JSON NULL');
|
||||
expect($bootstrapContent)->toContain('context_json JSON NULL');
|
||||
expect($bootstrapContent)->not->toContain('session_token_hash CHAR(64) NOT NULL');
|
||||
expect($bootstrapContent)->toContain('session_token_hash CHAR(64) NOT NULL');
|
||||
});
|
||||
|
||||
@@ -34,26 +34,44 @@ it('builds the installer around the compose stack artifacts and management polli
|
||||
expect($managerSource)->toContain('apt-get install -y docker-compose');
|
||||
expect($managerSource)->toContain('Unable to install Docker Compose using docker-compose-plugin or docker-compose.');
|
||||
expect($managerSource)->toContain('Existing claimed gateway detected; reinstall will reuse saved gateway credentials.');
|
||||
expect($managerSource)->toContain('report_install_status() {');
|
||||
expect($managerSource)->toContain('begin_install_phase "START_STACK" "Starting edge gateway stack"');
|
||||
expect($managerSource)->toContain('report_install_status "RUNNING" "$CURRENT_STEP_CODE" "$CURRENT_STEP"');
|
||||
expect($managerSource)->toContain('report_install_status "FAILED" "FAILED" "$failure_message" "$diagnostics_json" "$gateway_id"');
|
||||
expect($managerSource)->toContain('run_step "Writing agent config" merge_agent_config "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH"');
|
||||
expect($managerSource)->toContain('chmod 0755 "$INSTALL_DIR/agent.php" "$INSTALL_DIR/lan-worker.php" "$INSTALL_DIR/auto-updater.php" "$INSTALL_DIR/gateway-launcher.sh"');
|
||||
expect($managerSource)->toContain('systemctl enable truckwash-edge-gateway-stack.service');
|
||||
expect($managerSource)->toContain('systemctl restart truckwash-edge-gateway-stack.service');
|
||||
expect($managerSource)->toContain('systemctl is-active --quiet truckwash-edge-gateway-stack.service');
|
||||
expect($managerSource)->toContain('run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30');
|
||||
expect($managerSource)->toContain('run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 30');
|
||||
expect($managerSource)->toContain('run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
|
||||
expect($managerSource)->toContain('run_step "Waiting for post-reinstall heartbeat" wait_for_post_restart_heartbeat "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180');
|
||||
expect($managerSource)->toContain('journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager || true');
|
||||
expect($managerSource)->not->toContain('agent.mjs');
|
||||
expect($managerSource)->not->toContain('"brokerUrl"');
|
||||
expect($managerSource)->toContain("'brokerUrl' => \$this->buildBrokerPublicUrl()");
|
||||
expect($serviceSource)->toContain('ExecStart=/usr/bin/php /opt/truckwash-edge-agent/agent.php --config /opt/truckwash-edge-agent/config.json');
|
||||
expect($stackServiceSource)->toContain('ExecStart=/opt/truckwash-edge-agent/gateway-launcher.sh up');
|
||||
expect($stackServiceSource)->toContain('TimeoutStartSec=900');
|
||||
expect($launcherSource)->toContain('STACK_HEALTHCHECK_TIMEOUT_SECONDS="${STACK_HEALTHCHECK_TIMEOUT_SECONDS:-120}"');
|
||||
expect($launcherSource)->toContain('STACK_HEALTHCHECK_TIMEOUT_SECONDS="${STACK_HEALTHCHECK_TIMEOUT_SECONDS:-300}"');
|
||||
expect($launcherSource)->toContain('wait_for_stack_health');
|
||||
expect($launcherSource)->toContain('container_is_healthy truckwash-auto-updater');
|
||||
expect($launcherSource)->toContain('compose_project_name="$(config_value composeProjectName \'truckwash-edge-gateway\')"');
|
||||
expect($launcherSource)->toContain('COMPOSE_PROJECT_NAME="$compose_project_name"');
|
||||
expect($launcherSource)->toContain('AUTO_UPDATER_BASE_IMAGE="$auto_updater_base_image"');
|
||||
expect($launcherSource)->toContain('compose_cmd -f "$COMPOSE_FILE" logs --tail=80 || true');
|
||||
expect($launcherSource)->toContain('log "Compose rollout failed during build/startup"');
|
||||
expect($launcherSource)->toContain('write_rollback_status "FAILED" "compose_up_failed" "$installed_version"');
|
||||
expect($launcherSource)->toContain('write_rollback_status "FAILED" "rollback_apply_failed" "$installed_version"');
|
||||
expect($launcherSource)->toContain('write_rollback_status "ROLLED_BACK" "healthcheck_failed" "$installed_version"');
|
||||
expect($composeSource)->toContain('version: "2.4"');
|
||||
expect($composeSource)->toContain('condition: service_healthy');
|
||||
expect($composeSource)->toContain("minio:\n condition: service_started");
|
||||
expect($composeSource)->toContain("mariadb:\n condition: service_started");
|
||||
expect($composeSource)->toContain("test: [\"CMD-SHELL\", \"kill -0 1\"]");
|
||||
expect($composeSource)->toContain("http://127.0.0.1:8090/health");
|
||||
expect($composeSource)->toContain('$$json=@file_get_contents(\'http://127.0.0.1:8090/health\');');
|
||||
expect($composeSource)->toContain('$$path=\"/opt/truckwash-edge-agent/runtime/auto-updater-heartbeat.json\"');
|
||||
expect($composeSource)->not->toContain("curl\", \"-fsS\", \"http://127.0.0.1:9000/minio/health/live");
|
||||
expect($composeSource)->not->toContain('mysqladmin ping -h 127.0.0.1 -uroot -ptruckwash_edge_root --silent');
|
||||
expect($composeSource)->toContain('container_name: truckwash-redis');
|
||||
expect($composeSource)->toContain('container_name: truckwash-mariadb');
|
||||
expect($composeSource)->toContain('container_name: truckwash-minio');
|
||||
@@ -79,20 +97,38 @@ it('exposes update payload, credential rotation, cancel endpoints, and operation
|
||||
expect($managerSource)->toContain('public function rotateGatewayCredentials');
|
||||
expect($managerSource)->toContain("'autoUpdaterArtifactUrl' => \$this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_ARTIFACT)");
|
||||
expect($managerSource)->toContain("'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE");
|
||||
expect($managerSource)->toContain("'brokerUrl' => \$this->buildBrokerPublicUrl()");
|
||||
expect($routeSource)->toContain("'/edge-gateways/{id}/rotate-credentials'");
|
||||
expect($routeSource)->toContain("'/edge-gateways/{id}/operations/{operationId}/cancel'");
|
||||
expect($routeSource)->toContain("'/edge-agent/gateways/{id}/operations/next'");
|
||||
expect($routeSource)->toContain("'/edge-gateways/{id}/shell-sessions'");
|
||||
expect($agentSource)->toContain("/operations/next");
|
||||
expect($agentSource)->toContain("/operations/' . \$operationId . '/complete");
|
||||
expect($agentSource)->toContain('final class OperationAbortException extends RuntimeException');
|
||||
expect($agentSource)->toContain('private BrokerWebSocketClient $brokerClient;');
|
||||
expect($agentSource)->toContain('private AgentShellBridge $shellBridge;');
|
||||
expect($agentSource)->toContain('private function dispatchBrokerControlPlaneEvent(string $endpoint, array $payload): ?bool');
|
||||
expect($agentSource)->toContain("'type' => 'TELEMETRY'");
|
||||
expect($agentSource)->toContain("'type' => 'TASK_EVENT'");
|
||||
expect($agentSource)->toContain("'type' => 'TASK_RESULT'");
|
||||
expect($agentSource)->toContain("'type' => 'LOG_FRAME'");
|
||||
expect($agentSource)->toContain('private function requestControlPlaneEvent(string $endpoint, array $payload, string $type, int $timeoutSeconds = 20): ?array');
|
||||
expect($agentSource)->toContain('throw new OperationAbortException(\'Operation cancelled by operator\', true);');
|
||||
expect($agentSource)->toContain('private const OPERATION_COMPLETE_TIMEOUT_SECONDS = 120;');
|
||||
expect($agentSource)->toContain('if ($this->resumePendingOperationCompletion()) {');
|
||||
expect($agentSource)->toContain('$this->finalizeOperationCompletion(');
|
||||
expect($agentSource)->toContain('private function finalizeOperationCompletion(');
|
||||
expect($agentSource)->toContain('private function resumePendingOperationCompletion(): bool');
|
||||
expect($agentSource)->toContain('private function dispatchOperationCompletion(array $completion, bool $queueOnFailure): bool');
|
||||
expect($agentSource)->toContain("\$state['status'] = 'COMPLETION_PENDING';");
|
||||
expect($agentSource)->toContain("\$state['stage'] = 'awaiting_completion_ack';");
|
||||
expect($agentSource)->toContain("? self::OPERATION_COMPLETE_TIMEOUT_SECONDS");
|
||||
expect($agentSource)->toContain("unset(\$state['completion']);");
|
||||
expect($agentSource)->toContain('$services[] = $this->probeTcpService(\'redis\', \'redis\', 6379);');
|
||||
expect($agentSource)->toContain('final class HttpRequestTimeoutException extends RuntimeException');
|
||||
expect($agentSource)->toContain('], $this->pollRequestTimeoutSeconds($waitSeconds));');
|
||||
expect($agentSource)->toContain('} catch (HttpRequestTimeoutException) {');
|
||||
expect($agentSource)->toContain('private function pollRequestTimeoutSeconds(int $waitSeconds): int');
|
||||
expect($agentSource)->toContain('last-heartbeat-ok.txt');
|
||||
expect($routeSource)->not->toContain("'/edge-gateways/{id}/shell-sessions'");
|
||||
expect($routeSource)->not->toContain("'/edge-agent/gateways/{id}/shell-actions/poll'");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user