Add Edge Gateway tests for unit, API, and integration scenarios
This commit is contained in:
@@ -120,6 +120,52 @@ jobs:
|
||||
working-directory: services/edge-broker
|
||||
run: npm test
|
||||
|
||||
edge-gateway-backend:
|
||||
name: Edge Gateway Backend (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Materialize compose env files
|
||||
env:
|
||||
COMPOSE_ENV: ${{ secrets.COMPOSE_ENV }}
|
||||
COMPOSE_ENV_STAGING: ${{ secrets.COMPOSE_ENV_STAGING }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${COMPOSE_ENV}" ]; then
|
||||
echo "Required GitHub secret COMPOSE_ENV is not configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${COMPOSE_ENV_STAGING}" ]; then
|
||||
echo "Required GitHub secret COMPOSE_ENV_STAGING is not configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$COMPOSE_ENV" > .env
|
||||
printf '%s\n' "$COMPOSE_ENV_STAGING" > .env.staging
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Boot local stack
|
||||
run: docker compose up -d traefik redis mysql-debug edge-broker php1 caddy
|
||||
|
||||
- name: Run edge gateway API tests
|
||||
run: docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:api:edge"
|
||||
|
||||
- name: Run edge gateway integration tests
|
||||
run: docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:integration:edge"
|
||||
|
||||
- name: Run edge gateway E2E smoke
|
||||
run: node scripts/edge-gateway-e2e.mjs
|
||||
|
||||
- name: Tear down local stack
|
||||
if: always()
|
||||
run: docker compose down -v
|
||||
|
||||
integration:
|
||||
name: Integration (advisory)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
.env
|
||||
/services/caddy/logs*
|
||||
/.tmp/
|
||||
/.env.staging
|
||||
|
||||
@@ -91,6 +91,8 @@ For local Docker development, run the PHP suites inside `php1`:
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer test:unit"
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer test:integration"
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer test:api"
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer test:api:edge"
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer test:integration:edge"
|
||||
```
|
||||
|
||||
Integration tests are opt-in and should be run with required services available:
|
||||
@@ -100,6 +102,28 @@ $env:RUN_INTEGRATION_TESTS='1'
|
||||
composer test:integration
|
||||
```
|
||||
|
||||
### Edge Gateway Regression Coverage
|
||||
The dedicated backend regression lane for the PHP edge gateway stack is split into:
|
||||
|
||||
- API contract tests for operator, agent, and broker-facing routes
|
||||
- DB-backed integration tests for install sessions, heartbeats, tasks, logs, statistics, and shell persistence
|
||||
- a local dockerized smoke that runs the real PHP edge agent against the local backend and broker
|
||||
|
||||
Run the targeted PHP suites inside `php1`:
|
||||
|
||||
```powershell
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer test:api:edge"
|
||||
docker exec php1 sh -lc "cd /var/www/html && composer test:integration:edge"
|
||||
```
|
||||
|
||||
Run the full local smoke from `backend-php` on the host:
|
||||
|
||||
```powershell
|
||||
node .\scripts\edge-gateway-e2e.mjs
|
||||
```
|
||||
|
||||
The E2E smoke expects the local compose stack and Docker daemon to be available. It boots a disposable gateway container, waits for a real heartbeat, validates live operations and telemetry, and verifies browser shell transcript persistence.
|
||||
|
||||
### Public Staging Edge-Gateway Smoke
|
||||
`api.truckwash.io:4433` is the public staging ingress. For Edge Gateways v2, the router must serve the canonical artifacts from `services/nginx/app/resources/edge-gateway-agent`, not from the legacy `dist/agent.mjs` output or a separate runtime mount.
|
||||
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile as execFileCallback, spawn as spawnCallback } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.mjs";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "caddy"];
|
||||
|
||||
async function runCommand(command, args, { cwd, allowFailure = false, stdio = "pipe" } = {}) {
|
||||
if (stdio === "inherit") {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawnCallback(command, args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0 || allowFailure) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`${command} ${args.join(" ")} failed with exit code ${code}`));
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await execFile(command, args, {
|
||||
cwd,
|
||||
windowsHide: true,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
return { stdout: result.stdout, stderr: result.stderr, code: 0 };
|
||||
} catch (error) {
|
||||
if (!allowFailure) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
stdout: error.stdout || "",
|
||||
stderr: error.stderr || "",
|
||||
code: typeof error.code === "number" ? error.code : 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url) {
|
||||
return String(url || "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function waitForCondition(predicate, { timeoutMs = 30_000, intervalMs = 500, message = "Timed out" } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async function ensureComposeServices(rootDir) {
|
||||
await runCommand("docker", ["compose", "up", "-d", ...COMPOSE_SERVICES], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForApiReady(baseUrl, attempts = 60) {
|
||||
const root = normalizeBaseUrl(baseUrl);
|
||||
let lastError = "API never responded";
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(`${root}/ping`);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastError = `Unexpected ping status ${response.status}`;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
throw new Error(`API did not become ready at ${root}/ping: ${lastError}`);
|
||||
}
|
||||
|
||||
function parseLastJsonLine(output) {
|
||||
const lines = String(output || "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
try {
|
||||
return JSON.parse(lines[index]);
|
||||
} catch {
|
||||
// Continue scanning backwards for the JSON payload.
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to parse JSON from command output:\n${output}`);
|
||||
}
|
||||
|
||||
async function runPhpFixture(rootDir, action, payload = null) {
|
||||
const encodedPayload = payload === null
|
||||
? ""
|
||||
: ` ${Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")}`;
|
||||
const command = `cd /var/www/html && CONFIG_DB_TARGET=debug php tests/Support/EdgeGatewayE2eFixture.php ${action}${encodedPayload}`;
|
||||
|
||||
const result = await runCommand("docker", [
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"php1",
|
||||
"sh",
|
||||
"-lc",
|
||||
command,
|
||||
], {
|
||||
cwd: rootDir,
|
||||
});
|
||||
|
||||
return parseLastJsonLine(result.stdout);
|
||||
}
|
||||
|
||||
async function apiRequest(baseUrl, method, endpoint, { token = null, body = null, headers = {} } = {}) {
|
||||
const response = await fetch(`${normalizeBaseUrl(baseUrl)}${endpoint}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(body === null ? {} : { "content-type": "application/json" }),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body === null ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
let json = null;
|
||||
|
||||
if (rawBody !== "") {
|
||||
try {
|
||||
json = JSON.parse(rawBody);
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
json?.data?.message ||
|
||||
json?.error ||
|
||||
rawBody ||
|
||||
`HTTP ${response.status}`;
|
||||
throw new Error(`${method} ${endpoint} failed: ${message}`);
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
async function loadWebSocketImplementation() {
|
||||
if (typeof WebSocket !== "undefined") {
|
||||
return WebSocket;
|
||||
}
|
||||
|
||||
const module = await import("ws");
|
||||
return module.default;
|
||||
}
|
||||
|
||||
function onSocket(socket, eventName, handler) {
|
||||
if (typeof socket.addEventListener === "function") {
|
||||
socket.addEventListener(eventName, (event) => {
|
||||
if (eventName === "message") {
|
||||
handler(event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
handler(event);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.on(eventName, handler);
|
||||
}
|
||||
|
||||
function collectSocketMessages(socket) {
|
||||
const messages = [];
|
||||
|
||||
onSocket(socket, "message", (payload) => {
|
||||
const text = typeof payload === "string"
|
||||
? payload
|
||||
: Buffer.isBuffer(payload)
|
||||
? payload.toString("utf8")
|
||||
: typeof payload?.toString === "function"
|
||||
? payload.toString()
|
||||
: "";
|
||||
|
||||
if (text === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
messages.push(JSON.parse(text));
|
||||
} catch {
|
||||
// Ignore non-JSON frames.
|
||||
}
|
||||
});
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
async function waitForSocketOpen(socket) {
|
||||
if (socket.readyState === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("Timed out waiting for websocket open.")), 10_000);
|
||||
|
||||
const onOpen = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
|
||||
onSocket(socket, "open", onOpen);
|
||||
onSocket(socket, "error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForSocketMessage(messages, predicate, options) {
|
||||
await waitForCondition(() => messages.some(predicate), options);
|
||||
}
|
||||
|
||||
function buildSocketUrl(wsUrl, token) {
|
||||
const url = new URL(String(wsUrl));
|
||||
url.searchParams.set("token", token);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function closeSocket(socket) {
|
||||
if (!socket || typeof socket.close !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const readyState = typeof socket.readyState === "number" ? socket.readyState : null;
|
||||
if (readyState !== null && readyState >= 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.close();
|
||||
}
|
||||
|
||||
function collectMessages(rows) {
|
||||
return Array.isArray(rows)
|
||||
? rows
|
||||
.map((row) => (row && typeof row === "object" ? row.message : null))
|
||||
.filter((value) => typeof value === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const rootDir = path.resolve(path.dirname(scriptPath), "..");
|
||||
const runId = randomUUID().slice(0, 8);
|
||||
const containerName = `truckwash-edge-e2e-${runId}`;
|
||||
const configDir = path.join(rootDir, ".tmp", "edge-gateway-e2e", runId);
|
||||
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
|
||||
const baseUrl = process.env.EDGE_GATEWAY_E2E_BASE_URL || DEFAULT_HOST_API_URL;
|
||||
|
||||
let fixture = null;
|
||||
let gatewayId = null;
|
||||
let streamSocket = null;
|
||||
let shellSocket = null;
|
||||
|
||||
try {
|
||||
await ensureComposeServices(rootDir);
|
||||
await waitForApiReady(baseUrl);
|
||||
|
||||
fixture = await runPhpFixture(rootDir, "create");
|
||||
const authToken = String(fixture.auth_token || "");
|
||||
const departmentId = Number(fixture.department_id || 0);
|
||||
|
||||
assert.ok(authToken !== "", "Fixture helper did not return an auth token.");
|
||||
assert.ok(departmentId > 0, "Fixture helper did not return a department id.");
|
||||
|
||||
const installTokenResponse = await apiRequest(baseUrl, "POST", "/edge-gateways/install-token", {
|
||||
token: authToken,
|
||||
body: {
|
||||
department_id: departmentId,
|
||||
label: `Edge Gateway E2E ${runId}`,
|
||||
},
|
||||
});
|
||||
const installToken = String(installTokenResponse?.data?.token || "");
|
||||
assert.ok(installToken !== "", "Install token creation did not return a token.");
|
||||
|
||||
await runCommand(process.execPath, [
|
||||
"scripts/test-gateway.mjs",
|
||||
"start",
|
||||
"--install-token",
|
||||
installToken,
|
||||
"--container-name",
|
||||
containerName,
|
||||
"--config-dir",
|
||||
configDir,
|
||||
"--heartbeat-seconds",
|
||||
"3",
|
||||
"--skip-compose-up",
|
||||
], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
try {
|
||||
await fs.access(configFilePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: `Gateway config file was not created at ${configFilePath}` }
|
||||
);
|
||||
|
||||
const config = JSON.parse(await fs.readFile(configFilePath, "utf8"));
|
||||
gatewayId = Number(config.gatewayId || 0);
|
||||
assert.ok(gatewayId > 0, "Gateway config did not include a gateway id.");
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const result = await runCommand("docker", [
|
||||
"exec",
|
||||
containerName,
|
||||
"test",
|
||||
"-f",
|
||||
"/opt/truckwash-edge-agent/runtime/last-heartbeat-ok.txt",
|
||||
], {
|
||||
allowFailure: true,
|
||||
});
|
||||
|
||||
return result.code === 0;
|
||||
},
|
||||
{
|
||||
timeoutMs: 60_000,
|
||||
message: "Gateway never wrote the successful heartbeat marker.",
|
||||
}
|
||||
);
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
return detail?.data?.status === "ONLINE"
|
||||
&& Object.keys(detail?.data?.metadata?.system_metrics || {}).length > 0;
|
||||
},
|
||||
{
|
||||
timeoutMs: 60_000,
|
||||
message: "Gateway detail never transitioned to ONLINE with fresh system metrics after install.",
|
||||
}
|
||||
);
|
||||
|
||||
const WebSocketImpl = await loadWebSocketImplementation();
|
||||
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
|
||||
token: authToken,
|
||||
body: {
|
||||
scopes: ["overview", "tasks", "logs", "statistics"],
|
||||
},
|
||||
});
|
||||
const streamWsUrl = buildSocketUrl(String(streamSession?.data?.ws_url || ""), String(streamSession?.data?.token || ""));
|
||||
streamSocket = new WebSocketImpl(streamWsUrl);
|
||||
const streamMessages = collectSocketMessages(streamSocket);
|
||||
|
||||
await waitForSocketOpen(streamSocket);
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => message?.type === "gateway.stream.ready",
|
||||
{ timeoutMs: 15_000, message: "Gateway stream never became ready." }
|
||||
);
|
||||
|
||||
const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, {
|
||||
token: authToken,
|
||||
body: {
|
||||
type: "DISCOVERY",
|
||||
request: {
|
||||
inventory: [{
|
||||
device_id: `edge-e2e-${runId}`,
|
||||
local_ip: "10.70.80.90",
|
||||
model: "TruckWash Edge E2E",
|
||||
channel_count: 1,
|
||||
online: true,
|
||||
capabilities: {
|
||||
gateway_management_v2: true,
|
||||
},
|
||||
metadata: {
|
||||
hostname: `edge-e2e-${runId}`,
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
const operationId = Number(operationResponse?.data?.operation?.id || 0);
|
||||
assert.ok(operationId > 0, "Operation creation did not return an operation id.");
|
||||
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => message?.type === "task.updated" && Number(message?.operationId || 0) === operationId,
|
||||
{ timeoutMs: 30_000, message: "Live gateway stream never emitted task.updated for the queued operation." }
|
||||
);
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
const operation = Array.isArray(operations?.data)
|
||||
? operations.data.find((item) => Number(item?.id || 0) === operationId)
|
||||
: null;
|
||||
|
||||
return operation?.status === "COMPLETED";
|
||||
},
|
||||
{ timeoutMs: 30_000, message: "Gateway operation never completed through the live agent." }
|
||||
);
|
||||
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated",
|
||||
{ timeoutMs: 15_000, message: "Live gateway stream never emitted telemetry or statistics updates." }
|
||||
);
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const logs = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
return Array.isArray(logs?.data?.log_entries) && logs.data.log_entries.length > 0;
|
||||
},
|
||||
{ timeoutMs: 20_000, message: "Gateway logs page never received live log entries from the running agent." }
|
||||
);
|
||||
|
||||
const statistics = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/statistics`, {
|
||||
token: authToken,
|
||||
});
|
||||
assert.ok(
|
||||
Object.keys(statistics?.data?.system_metrics || {}).length > 0,
|
||||
"Gateway statistics page did not expose system metrics after live telemetry."
|
||||
);
|
||||
|
||||
const shellSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/shell-sessions`, {
|
||||
token: authToken,
|
||||
body: {
|
||||
reason: "Edge gateway E2E shell validation",
|
||||
cwd: "/opt/truckwash-edge-agent",
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
},
|
||||
});
|
||||
const shellWsUrl = buildSocketUrl(String(shellSession?.data?.ws_url || ""), String(shellSession?.data?.token || ""));
|
||||
shellSocket = new WebSocketImpl(shellWsUrl);
|
||||
const shellMessages = collectSocketMessages(shellSocket);
|
||||
|
||||
await waitForSocketOpen(shellSocket);
|
||||
await waitForSocketMessage(
|
||||
shellMessages,
|
||||
(message) => message?.type === "opened",
|
||||
{ timeoutMs: 20_000, message: "Browser shell never opened against the live gateway." }
|
||||
);
|
||||
|
||||
shellSocket.send(JSON.stringify({
|
||||
type: "input",
|
||||
data: "printf 'edge-e2e-shell\\n'; exit\r",
|
||||
}));
|
||||
|
||||
await waitForSocketMessage(
|
||||
shellMessages,
|
||||
(message) => message?.type === "output" && String(message?.data || "").includes("edge-e2e-shell"),
|
||||
{ timeoutMs: 20_000, message: "Browser shell never returned the expected command output." }
|
||||
);
|
||||
|
||||
await waitForSocketMessage(
|
||||
shellMessages,
|
||||
(message) => message?.type === "closed",
|
||||
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
|
||||
);
|
||||
|
||||
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
||||
token: authToken,
|
||||
});
|
||||
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
|
||||
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
|
||||
: [];
|
||||
|
||||
assert.ok(
|
||||
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
|
||||
"Gateway logs page did not persist the shell transcript."
|
||||
);
|
||||
|
||||
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
|
||||
assert.ok(
|
||||
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"),
|
||||
"Gateway logs page did not include the shell close audit event."
|
||||
);
|
||||
|
||||
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
|
||||
} finally {
|
||||
closeSocket(shellSocket);
|
||||
closeSocket(streamSocket);
|
||||
|
||||
if (gatewayId !== null && fixture?.auth_token) {
|
||||
await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, {
|
||||
token: String(fixture.auth_token),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
await runCommand(process.execPath, [
|
||||
"scripts/test-gateway.mjs",
|
||||
"stop",
|
||||
"--container-name",
|
||||
containerName,
|
||||
], {
|
||||
cwd: rootDir,
|
||||
allowFailure: true,
|
||||
}).catch(() => {});
|
||||
|
||||
if (fixture !== null) {
|
||||
await runPhpFixture(rootDir, "cleanup", fixture).catch(() => {});
|
||||
}
|
||||
|
||||
await fs.rm(configDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -9,6 +9,8 @@ class shelly_relay_inventory
|
||||
private ?shelly $client;
|
||||
/** @var callable|null */
|
||||
private $inventory_fetcher = null;
|
||||
/** @var callable|null */
|
||||
private $device_list_fetcher = null;
|
||||
|
||||
public function __construct(?shelly $client = null)
|
||||
{
|
||||
@@ -21,6 +23,12 @@ class shelly_relay_inventory
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setDeviceListFetcher(callable $fetcher): self
|
||||
{
|
||||
$this->device_list_fetcher = $fetcher;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
* @throws Exception
|
||||
@@ -28,10 +36,21 @@ class shelly_relay_inventory
|
||||
public function listRelayOptions(): array
|
||||
{
|
||||
$devices_status = $this->fetchOwnedDevicesStatus();
|
||||
$device_catalog = $this->fetchOwnedDeviceCatalog();
|
||||
$options_by_id = [];
|
||||
|
||||
foreach ($devices_status as $device) {
|
||||
$option = $this->buildRelayOption($this->normalizeToArray($device));
|
||||
$normalized_device = $this->normalizeToArray($device);
|
||||
$device_id = $this->extractFirstString([
|
||||
$normalized_device['_dev_info']['id'] ?? null,
|
||||
$normalized_device['id'] ?? null,
|
||||
]);
|
||||
$catalog_entry = $device_id !== '' ? ($device_catalog[$device_id] ?? null) : null;
|
||||
|
||||
$option = $this->buildRelayOption(
|
||||
$normalized_device,
|
||||
is_array($catalog_entry) ? $catalog_entry : null
|
||||
);
|
||||
if ($option === null) {
|
||||
continue;
|
||||
}
|
||||
@@ -41,9 +60,10 @@ class shelly_relay_inventory
|
||||
|
||||
$options = array_values($options_by_id);
|
||||
usort($options, static function (array $left, array $right): int {
|
||||
$online_compare = ((int)!($left['online'] ?? false)) <=> ((int)!($right['online'] ?? false));
|
||||
if ($online_compare !== 0) {
|
||||
return $online_compare;
|
||||
$status_compare = self::statusSortWeight((string)($left['status_color'] ?? ''))
|
||||
<=> self::statusSortWeight((string)($right['status_color'] ?? ''));
|
||||
if ($status_compare !== 0) {
|
||||
return $status_compare;
|
||||
}
|
||||
|
||||
$name_compare = strcasecmp((string)($left['name'] ?? ''), (string)($right['name'] ?? ''));
|
||||
@@ -57,6 +77,48 @@ class shelly_relay_inventory
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,array<string,mixed>>
|
||||
*/
|
||||
private function fetchOwnedDeviceCatalog(): array
|
||||
{
|
||||
try {
|
||||
$payload = is_callable($this->device_list_fetcher)
|
||||
? ($this->device_list_fetcher)()
|
||||
: $this->getClient()->sendGetRequest('/interface/device/list', [
|
||||
'no_shared' => 'true',
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeToArray($payload);
|
||||
if (($normalized['isok'] ?? true) === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$devices = $normalized['data']['devices'] ?? null;
|
||||
if (!is_array($devices)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$catalog_by_id = [];
|
||||
foreach ($devices as $device_key => $device) {
|
||||
$normalized_device = $this->normalizeToArray($device);
|
||||
$device_id = $this->extractFirstString([
|
||||
$normalized_device['id'] ?? null,
|
||||
is_string($device_key) ? $device_key : null,
|
||||
]);
|
||||
if ($device_id === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$catalog_by_id[$device_id] = $normalized_device;
|
||||
}
|
||||
|
||||
return $catalog_by_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
* @throws Exception
|
||||
@@ -98,7 +160,7 @@ class shelly_relay_inventory
|
||||
* @param array<string,mixed> $device
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function buildRelayOption(array $device): ?array
|
||||
private function buildRelayOption(array $device, ?array $catalog_entry = null): ?array
|
||||
{
|
||||
if ($device === [] || !$this->isRelayCapableDevice($device)) {
|
||||
return null;
|
||||
@@ -112,7 +174,10 @@ class shelly_relay_inventory
|
||||
return null;
|
||||
}
|
||||
|
||||
$device_name = $this->extractFirstString([
|
||||
$cloud_name = $this->extractFirstString([
|
||||
$catalog_entry['name'] ?? null,
|
||||
]);
|
||||
$local_device_name = $this->extractFirstString([
|
||||
$device['name'] ?? null,
|
||||
$device['_dev_info']['name'] ?? null,
|
||||
$device['settings']['name'] ?? null,
|
||||
@@ -120,18 +185,38 @@ class shelly_relay_inventory
|
||||
$device['status']['name'] ?? null,
|
||||
$device['status']['sys']['device']['name'] ?? null,
|
||||
]);
|
||||
$device_name = $cloud_name !== '' ? $cloud_name : $local_device_name;
|
||||
$device_code = $this->extractFirstString([
|
||||
$device['_dev_info']['code'] ?? null,
|
||||
$device['code'] ?? null,
|
||||
]);
|
||||
$device_type = $this->extractDeviceType($device, $device_code, $catalog_entry);
|
||||
$control_type = $this->extractControlType($device);
|
||||
$control_name = $this->extractControlName($device);
|
||||
$online = $this->extractOnlineState($device);
|
||||
if ($online === null) {
|
||||
$online = $this->normalizeBoolean($catalog_entry['cloud_online'] ?? null);
|
||||
}
|
||||
$status_color = $this->extractStatusColor($online);
|
||||
|
||||
return [
|
||||
'id' => $device_id,
|
||||
'name' => $this->buildRelayLabel($device_id, $device_name, $device_code),
|
||||
'name' => $this->buildRelayLabel(
|
||||
$device_type,
|
||||
$cloud_name,
|
||||
$local_device_name,
|
||||
$control_name,
|
||||
$device_id
|
||||
),
|
||||
'device_id' => $device_id,
|
||||
'device_name' => $device_name !== '' ? $device_name : null,
|
||||
'cloud_name' => $cloud_name !== '' ? $cloud_name : null,
|
||||
'device_type' => $device_type,
|
||||
'code' => $device_code !== '' ? $device_code : null,
|
||||
'online' => $this->extractOnlineState($device),
|
||||
'control_type' => $control_type,
|
||||
'control_name' => $control_name !== '' ? $control_name : null,
|
||||
'status_color' => $status_color,
|
||||
'online' => $online,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -176,7 +261,7 @@ class shelly_relay_inventory
|
||||
/**
|
||||
* @param array<string,mixed> $device
|
||||
*/
|
||||
private function extractOnlineState(array $device): bool
|
||||
private function extractOnlineState(array $device): ?bool
|
||||
{
|
||||
$online = $device['_dev_info']['online'] ?? $device['online'] ?? null;
|
||||
if (is_bool($online)) {
|
||||
@@ -187,21 +272,106 @@ class shelly_relay_inventory
|
||||
return (int)$online === 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
private function buildRelayLabel(string $device_id, string $device_name, string $device_code): string
|
||||
private function extractStatusColor(?bool $online): string
|
||||
{
|
||||
$label = $device_id;
|
||||
if ($device_name !== '' && strcasecmp($device_name, $device_id) !== 0) {
|
||||
$label = $device_name . ' (' . $device_id . ')';
|
||||
if ($online === true) {
|
||||
return 'Green';
|
||||
}
|
||||
|
||||
if ($device_code !== '') {
|
||||
return $label . ' · ' . $device_code;
|
||||
if ($online === false) {
|
||||
return 'Red';
|
||||
}
|
||||
|
||||
return $label;
|
||||
return 'Yellow';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $device
|
||||
*/
|
||||
private function extractDeviceType(array $device, string $device_code, ?array $catalog_entry = null): ?string
|
||||
{
|
||||
$device_type = $this->extractFirstString([
|
||||
$device['_dev_info']['model'] ?? null,
|
||||
$device['_dev_info']['type'] ?? null,
|
||||
$device['model'] ?? null,
|
||||
$device['type'] ?? null,
|
||||
$device['settings']['device']['type'] ?? null,
|
||||
$catalog_entry['type'] ?? null,
|
||||
$device_code,
|
||||
]);
|
||||
|
||||
return $device_type !== '' ? $device_type : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $device
|
||||
*/
|
||||
private function extractControlType(array $device): string
|
||||
{
|
||||
$status = $this->normalizeToArray($device['status'] ?? null);
|
||||
$settings = $this->normalizeToArray($device['settings'] ?? null);
|
||||
|
||||
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
|
||||
if (array_key_exists($switch_key, $device) || array_key_exists($switch_key, $status)) {
|
||||
return 'Switch';
|
||||
}
|
||||
}
|
||||
|
||||
if ((isset($device['switches']) && is_array($device['switches']) && $device['switches'] !== [])
|
||||
|| (isset($settings['switches']) && is_array($settings['switches']) && $settings['switches'] !== [])) {
|
||||
return 'Switch';
|
||||
}
|
||||
|
||||
if ((isset($device['relays']) && is_array($device['relays']) && $device['relays'] !== [])
|
||||
|| (isset($settings['relays']) && is_array($settings['relays']) && $settings['relays'] !== [])) {
|
||||
return 'Relay';
|
||||
}
|
||||
|
||||
return 'Device';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $device
|
||||
*/
|
||||
private function extractControlName(array $device): string
|
||||
{
|
||||
$status = $this->normalizeToArray($device['status'] ?? null);
|
||||
$settings = $this->normalizeToArray($device['settings'] ?? null);
|
||||
|
||||
return $this->extractFirstString([
|
||||
$status['switch:0']['name'] ?? null,
|
||||
$status['switch_0']['name'] ?? null,
|
||||
$status['switch0']['name'] ?? null,
|
||||
$device['switches'][0]['name'] ?? null,
|
||||
$settings['switches'][0]['name'] ?? null,
|
||||
$device['relays'][0]['name'] ?? null,
|
||||
$settings['relays'][0]['name'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildRelayLabel(
|
||||
?string $device_type,
|
||||
string $cloud_name,
|
||||
string $local_device_name,
|
||||
string $control_name,
|
||||
string $device_id
|
||||
): string {
|
||||
if ($cloud_name !== '') {
|
||||
$display_name = $cloud_name;
|
||||
} elseif ($local_device_name !== '' && $control_name !== '' && strcasecmp($local_device_name, $control_name) !== 0) {
|
||||
$display_name = $local_device_name . ' / ' . $control_name;
|
||||
} else {
|
||||
$display_name = $control_name !== '' ? $control_name : ($local_device_name !== '' ? $local_device_name : $device_id);
|
||||
}
|
||||
|
||||
if ($device_type !== null && $device_type !== '') {
|
||||
return $display_name . ' (' . $device_type . ')';
|
||||
}
|
||||
|
||||
return $display_name;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,6 +389,29 @@ class shelly_relay_inventory
|
||||
return '';
|
||||
}
|
||||
|
||||
private function normalizeBoolean(mixed $value): ?bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int)$value === 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function statusSortWeight(string $status_color): int
|
||||
{
|
||||
return match (strtolower($status_color)) {
|
||||
'green' => 0,
|
||||
'yellow' => 1,
|
||||
'red' => 2,
|
||||
default => 3,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php -r \"putenv('RUN_API_TESTS=1'); passthru('vendor/bin/pest --testsuite=Api --colors=always', $exitCode); exit($exitCode);\""
|
||||
],
|
||||
"test:api:edge": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php -r \"putenv('RUN_API_TESTS=1'); putenv('API_TEST_BOOTSTRAP_SCHEMA=1'); putenv('CONFIG_DB_TARGET=debug'); passthru('vendor/bin/pest tests/Api/EdgeGateway*ApiTest.php --colors=always', $exitCode); exit($exitCode);\""
|
||||
],
|
||||
"test:integration:edge": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php -r \"putenv('RUN_INTEGRATION_TESTS=1'); putenv('CONFIG_DB_TARGET=debug'); passthru('vendor/bin/pest tests/Integration/EdgeGateway --colors=always', $exitCode); exit($exitCode);\""
|
||||
],
|
||||
"test:coverage": [
|
||||
"@php -r \"is_dir('build/logs') || mkdir('build/logs', 0777, true);\"",
|
||||
"@php -r \"if (extension_loaded('pcov')) { passthru('php -d pcov.enabled=1 vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always', $exitCode); exit($exitCode); } if (extension_loaded('xdebug')) { passthru('php -d xdebug.mode=coverage vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always', $exitCode); exit($exitCode); } fwrite(STDERR, 'No coverage driver available. Enable pcov or xdebug for test:coverage.' . PHP_EOL); exit(1);\""
|
||||
|
||||
@@ -3959,11 +3959,19 @@ BASH;
|
||||
$channelStatus = self::deriveChannelStatus($gateway, $effectiveStatus, $now);
|
||||
$relayHealth = self::buildRelayHealth($gateway, $effectiveStatus, $now);
|
||||
$fallbackSummary = self::buildFallbackSummary($relayHealth);
|
||||
$gateway['outbox_status'] = self::buildOutboxStatusSummary($gateway, $now);
|
||||
$lastSyncAt = self::resolveLastSyncAt($gateway);
|
||||
|
||||
$gateway['channel_status'] = $channelStatus;
|
||||
$gateway['relay_health'] = $relayHealth;
|
||||
$gateway['fallback_summary'] = $fallbackSummary;
|
||||
$gateway['transport_health'] = self::deriveTransportHealth($effectiveStatus, $channelStatus, $fallbackSummary);
|
||||
$gateway['transport_health'] = self::deriveTransportHealth(
|
||||
$gateway,
|
||||
$effectiveStatus,
|
||||
$channelStatus,
|
||||
$fallbackSummary,
|
||||
$lastSyncAt
|
||||
);
|
||||
$gateway['last_successful_command_at'] = $gateway['operational_snapshot']['last_successful_command_at']
|
||||
?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null);
|
||||
$gateway['last_successful_discovery_at'] = $gateway['operational_snapshot']['last_successful_discovery_at']
|
||||
@@ -3976,8 +3984,7 @@ BASH;
|
||||
$gateway['version_drift'] = self::buildVersionDriftSummary($gateway);
|
||||
$gateway['credential_freshness'] = self::buildCredentialFreshnessSummary($gateway, $now);
|
||||
$gateway['container_health'] = self::buildContainerHealthSummary($gateway, $effectiveStatus);
|
||||
$gateway['outbox_status'] = self::buildOutboxStatusSummary($gateway, $now);
|
||||
$gateway['last_sync_at'] = self::resolveLastSyncAt($gateway);
|
||||
$gateway['last_sync_at'] = $lastSyncAt;
|
||||
$gateway['update_window'] = self::buildUpdateWindowSummary($gateway);
|
||||
$gateway['staged_version'] = self::buildStagedVersionSummary($gateway);
|
||||
$gateway['rollback_status'] = self::buildRollbackStatusSummary($gateway);
|
||||
@@ -4150,8 +4157,18 @@ BASH;
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private static function deriveTransportHealth(string $effectiveStatus, array $channelStatus, array $fallbackSummary): array
|
||||
private static function deriveTransportHealth(
|
||||
array $gateway,
|
||||
string $effectiveStatus,
|
||||
array $channelStatus,
|
||||
array $fallbackSummary,
|
||||
?string $lastSyncAt
|
||||
): array
|
||||
{
|
||||
$metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
|
||||
$controlPlaneStatus = isset($metadata['control_plane_status']) && is_array($metadata['control_plane_status'])
|
||||
? (array)$metadata['control_plane_status']
|
||||
: [];
|
||||
$brokerState = (string)($channelStatus['broker']['state'] ?? self::STATUS_OFFLINE);
|
||||
$affectedRelayCount = count((array)($fallbackSummary['affected_relays'] ?? []));
|
||||
$transportState = $effectiveStatus;
|
||||
@@ -4169,6 +4186,13 @@ BASH;
|
||||
? 'Broker fast path er aktiv med API polling som fallback'
|
||||
: 'API polling er aktiv som primær kontrolkanal'),
|
||||
'recommended_action' => $fallbackSummary['recommended_action'] ?? ($channelStatus['broker']['last_error'] ?? null),
|
||||
'last_successful_sync_at' => $lastSyncAt,
|
||||
'last_transport_failure_at' => isset($controlPlaneStatus['last_transport_failure_at'])
|
||||
? (string)$controlPlaneStatus['last_transport_failure_at']
|
||||
: null,
|
||||
'last_transport_error' => isset($controlPlaneStatus['last_transport_error'])
|
||||
? (string)$controlPlaneStatus['last_transport_error']
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4320,17 +4344,23 @@ BASH;
|
||||
private static function resolveLastSyncAt(array $gateway): ?string
|
||||
{
|
||||
$metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
|
||||
if (!empty($metadata['last_sync_at'])) {
|
||||
return (string)$metadata['last_sync_at'];
|
||||
}
|
||||
|
||||
$controlPlaneStatus = isset($metadata['control_plane_status']) && is_array($metadata['control_plane_status'])
|
||||
? (array)$metadata['control_plane_status']
|
||||
: [];
|
||||
$outbox = isset($gateway['outbox_status']) && is_array($gateway['outbox_status'])
|
||||
? (array)$gateway['outbox_status']
|
||||
: [];
|
||||
$outboxMetadata = isset($metadata['outbox_status']) && is_array($metadata['outbox_status'])
|
||||
? (array)$metadata['outbox_status']
|
||||
: [];
|
||||
|
||||
return isset($outbox['last_replayed_at']) && $outbox['last_replayed_at'] !== null
|
||||
? (string)$outbox['last_replayed_at']
|
||||
: null;
|
||||
return self::latestTimestamp([
|
||||
$controlPlaneStatus['last_successful_sync_at'] ?? null,
|
||||
$metadata['last_sync_at'] ?? null,
|
||||
$outbox['last_replayed_at'] ?? null,
|
||||
$outboxMetadata['last_replayed_at'] ?? null,
|
||||
$gateway['last_heartbeat_at'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function buildUpdateWindowSummary(array $gateway): array
|
||||
@@ -4551,6 +4581,33 @@ BASH;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $timestamps
|
||||
*/
|
||||
private static function latestTimestamp(array $timestamps): ?string
|
||||
{
|
||||
$latestValue = null;
|
||||
$latestEpoch = 0;
|
||||
|
||||
foreach ($timestamps as $timestamp) {
|
||||
if (!is_string($timestamp) || trim($timestamp) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$epoch = strtotime($timestamp);
|
||||
if ($epoch === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($latestValue === null || $epoch >= $latestEpoch) {
|
||||
$latestValue = $timestamp;
|
||||
$latestEpoch = $epoch;
|
||||
}
|
||||
}
|
||||
|
||||
return $latestValue;
|
||||
}
|
||||
|
||||
private static function normalizeFallbackMode(?string $fallbackMode): string
|
||||
{
|
||||
$normalized = strtoupper(trim((string)$fallbackMode));
|
||||
|
||||
@@ -998,6 +998,8 @@ final class TruckwashEdgeAgent
|
||||
private const DEFAULT_UPDATE_WINDOW = '02:00-04:00';
|
||||
private const OPERATION_COMPLETE_TIMEOUT_SECONDS = 120;
|
||||
private const BROKER_MESSAGE_PUMP_LIMIT = 12;
|
||||
private const LOOP_STALE_AFTER_SECONDS = 30;
|
||||
private const CONTROL_PLANE_SYNC_STALE_AFTER_SECONDS = 90;
|
||||
|
||||
private AgentConfig $config;
|
||||
private HttpJsonClient $http;
|
||||
@@ -1011,6 +1013,7 @@ final class TruckwashEdgeAgent
|
||||
private string $statePath;
|
||||
private string $lastOperationSnapshotPath;
|
||||
private string $lastHeartbeatMarkerPath;
|
||||
private string $controlPlaneStatusPath;
|
||||
private string $stagedUpdatePath;
|
||||
private int $lastHeartbeatAt = 0;
|
||||
private string $agentInstanceId;
|
||||
@@ -1031,12 +1034,14 @@ final class TruckwashEdgeAgent
|
||||
$this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json';
|
||||
$this->lastOperationSnapshotPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-operation.json';
|
||||
$this->lastHeartbeatMarkerPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'last-heartbeat-ok.txt';
|
||||
$this->controlPlaneStatusPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'control-plane-status.json';
|
||||
$this->stagedUpdatePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'staged-update.json';
|
||||
$this->agentInstanceId = $this->ensureAgentInstanceId();
|
||||
$this->brokerClient = new BrokerWebSocketClient($this->logger);
|
||||
$this->shellBridge = new AgentShellBridge($this->installDir);
|
||||
$this->logger->setSink(fn(string $level, string $message): bool => $this->emitLogFrame($level, $message));
|
||||
$this->configureBrokerClient();
|
||||
$this->initializeControlPlaneStatus();
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
@@ -1046,6 +1051,7 @@ final class TruckwashEdgeAgent
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
$this->touchLoopHeartbeat();
|
||||
$this->reloadConfigFromDisk();
|
||||
$this->ensureClaimed();
|
||||
$this->configureBrokerClient();
|
||||
@@ -1081,23 +1087,29 @@ final class TruckwashEdgeAgent
|
||||
}
|
||||
|
||||
$installedVersion = (string)$this->config->get('installedVersion', 'compose-php-agent-v2');
|
||||
$response = $this->http->post('/edge-agent/claim', [
|
||||
'token' => (string)$this->config->get('installToken'),
|
||||
'hostname' => gethostname() ?: 'truckwash-edge',
|
||||
'installed_version' => $installedVersion,
|
||||
'metadata' => [
|
||||
'runtime' => 'compose-php',
|
||||
'runtime_mode' => 'compose',
|
||||
'php_version' => PHP_VERSION,
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW),
|
||||
'container_health' => $this->buildContainerHealth(),
|
||||
'outbox_status' => $this->buildOutboxStatus(),
|
||||
'last_sync_at' => $this->stateStore->getJson('last_sync_at'),
|
||||
'rollback_status' => $this->readRollbackStatus(),
|
||||
'staged_version' => $this->currentStagedUpdate(),
|
||||
],
|
||||
]);
|
||||
try {
|
||||
$response = $this->http->post('/edge-agent/claim', [
|
||||
'token' => (string)$this->config->get('installToken'),
|
||||
'hostname' => gethostname() ?: 'truckwash-edge',
|
||||
'installed_version' => $installedVersion,
|
||||
'metadata' => [
|
||||
'runtime' => 'compose-php',
|
||||
'runtime_mode' => 'compose',
|
||||
'php_version' => PHP_VERSION,
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW),
|
||||
'container_health' => $this->buildContainerHealth(),
|
||||
'outbox_status' => $this->buildOutboxStatus(),
|
||||
'last_sync_at' => $this->currentLastSuccessfulSyncAt(),
|
||||
'control_plane_status' => $this->buildControlPlaneStatusPayload(),
|
||||
'rollback_status' => $this->readRollbackStatus(),
|
||||
'staged_version' => $this->currentStagedUpdate(),
|
||||
],
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
$this->recordTransportFailure('Gateway claim failed', $throwable);
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$payload = $response['data'] ?? [];
|
||||
$gateway = is_array($payload['gateway'] ?? null) ? (array)$payload['gateway'] : [];
|
||||
@@ -1110,7 +1122,7 @@ final class TruckwashEdgeAgent
|
||||
$this->config->set('targetVersion', (string)($gateway['target_version'] ?? $installedVersion));
|
||||
$this->config->set('agentInstanceId', $this->agentInstanceId);
|
||||
$this->config->save();
|
||||
$this->stateStore->setJson('last_sync_at', date('c'));
|
||||
$this->recordSuccessfulSync();
|
||||
$this->logger->info('Claimed gateway ' . (string)($gateway['id'] ?? 'unknown') . '.');
|
||||
}
|
||||
|
||||
@@ -1145,7 +1157,7 @@ final class TruckwashEdgeAgent
|
||||
{
|
||||
$sent = $this->brokerClient->send($message);
|
||||
if ($sent) {
|
||||
$this->stateStore->setJson('last_sync_at', date('c'));
|
||||
$this->recordSuccessfulSync();
|
||||
}
|
||||
return $sent;
|
||||
}
|
||||
@@ -1224,6 +1236,7 @@ final class TruckwashEdgeAgent
|
||||
return;
|
||||
}
|
||||
|
||||
$this->recordHeartbeatAttempt();
|
||||
$operationState = $this->readOperationState();
|
||||
$payload = [
|
||||
'agent_token' => (string)$this->config->get('agentToken'),
|
||||
@@ -1242,7 +1255,8 @@ final class TruckwashEdgeAgent
|
||||
'system_metrics' => $this->buildSystemMetrics(),
|
||||
'container_health' => $this->buildContainerHealth(),
|
||||
'outbox_status' => $this->buildOutboxStatus(),
|
||||
'last_sync_at' => $this->stateStore->getJson('last_sync_at'),
|
||||
'last_sync_at' => $this->currentLastSuccessfulSyncAt(),
|
||||
'control_plane_status' => $this->buildControlPlaneStatusPayload(),
|
||||
'update_window' => (string)$this->config->get('updateWindow', self::DEFAULT_UPDATE_WINDOW),
|
||||
'staged_version' => $this->currentStagedUpdate(),
|
||||
'rollback_status' => $this->readRollbackStatus(),
|
||||
@@ -1265,10 +1279,14 @@ final class TruckwashEdgeAgent
|
||||
return;
|
||||
}
|
||||
|
||||
$heartbeatSucceededAt = date('c');
|
||||
$this->lastHeartbeatAt = time();
|
||||
$this->recordSuccessfulSync($heartbeatSucceededAt, [
|
||||
'last_heartbeat_success_at' => $heartbeatSucceededAt,
|
||||
]);
|
||||
file_put_contents($this->lastHeartbeatMarkerPath, json_encode([
|
||||
'gateway_id' => $gatewayId,
|
||||
'at' => date('c'),
|
||||
'at' => $heartbeatSucceededAt,
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
], JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
}
|
||||
@@ -1991,6 +2009,143 @@ final class TruckwashEdgeAgent
|
||||
];
|
||||
}
|
||||
|
||||
private function initializeControlPlaneStatus(): void
|
||||
{
|
||||
$this->writeControlPlaneStatus();
|
||||
}
|
||||
|
||||
private function touchLoopHeartbeat(): void
|
||||
{
|
||||
$this->writeControlPlaneStatus([
|
||||
'last_loop_at' => date('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function currentLastSuccessfulSyncAt(): ?string
|
||||
{
|
||||
$current = $this->readControlPlaneStatus()['last_successful_sync_at'] ?? $this->stateStore->getJson('last_sync_at');
|
||||
return $this->normalizeControlPlaneStatusTimestamp($current);
|
||||
}
|
||||
|
||||
private function recordHeartbeatAttempt(): string
|
||||
{
|
||||
$attemptedAt = date('c');
|
||||
$this->writeControlPlaneStatus([
|
||||
'last_heartbeat_attempt_at' => $attemptedAt,
|
||||
]);
|
||||
return $attemptedAt;
|
||||
}
|
||||
|
||||
private function recordSuccessfulSync(?string $at = null, array $statusOverrides = []): void
|
||||
{
|
||||
$syncedAt = $this->normalizeControlPlaneStatusTimestamp($at ?? date('c')) ?? date('c');
|
||||
$this->stateStore->setJson('last_sync_at', $syncedAt);
|
||||
$statusOverrides['last_successful_sync_at'] = $syncedAt;
|
||||
$this->writeControlPlaneStatus($statusOverrides);
|
||||
}
|
||||
|
||||
private function recordTransportFailure(string $context, Throwable $throwable): void
|
||||
{
|
||||
$message = $this->normalizeControlPlaneStatusString($throwable->getMessage()) ?? $throwable::class;
|
||||
$this->writeControlPlaneStatus([
|
||||
'last_transport_failure_at' => date('c'),
|
||||
'last_transport_error' => $message,
|
||||
]);
|
||||
$this->logger->warning($context . ': ' . $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function buildControlPlaneStatusPayload(): array
|
||||
{
|
||||
return $this->writeControlPlaneStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function readControlPlaneStatus(): array
|
||||
{
|
||||
if (!is_file($this->controlPlaneStatusPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)file_get_contents($this->controlPlaneStatusPath), true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $overrides
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function writeControlPlaneStatus(array $overrides = []): array
|
||||
{
|
||||
$current = $this->readControlPlaneStatus();
|
||||
$outboxSummary = $this->stateStore->outboxSummary();
|
||||
$lastSuccessfulSyncAt = array_key_exists('last_successful_sync_at', $overrides)
|
||||
? $overrides['last_successful_sync_at']
|
||||
: ($current['last_successful_sync_at'] ?? $this->stateStore->getJson('last_sync_at'));
|
||||
$lastTransportError = array_key_exists('last_transport_error', $overrides)
|
||||
? $overrides['last_transport_error']
|
||||
: ($current['last_transport_error'] ?? null);
|
||||
$lastTransportFailureAt = array_key_exists('last_transport_failure_at', $overrides)
|
||||
? $overrides['last_transport_failure_at']
|
||||
: ($current['last_transport_failure_at'] ?? null);
|
||||
$lastHeartbeatAttemptAt = array_key_exists('last_heartbeat_attempt_at', $overrides)
|
||||
? $overrides['last_heartbeat_attempt_at']
|
||||
: ($current['last_heartbeat_attempt_at'] ?? null);
|
||||
$lastHeartbeatSuccessAt = array_key_exists('last_heartbeat_success_at', $overrides)
|
||||
? $overrides['last_heartbeat_success_at']
|
||||
: ($current['last_heartbeat_success_at'] ?? null);
|
||||
$lastLoopAt = array_key_exists('last_loop_at', $overrides)
|
||||
? $overrides['last_loop_at']
|
||||
: ($current['last_loop_at'] ?? null);
|
||||
|
||||
$status = [
|
||||
'started_at' => $this->normalizeControlPlaneStatusTimestamp($current['started_at'] ?? null) ?? date('c'),
|
||||
'agent_instance_id' => $this->agentInstanceId,
|
||||
'last_loop_at' => $this->normalizeControlPlaneStatusTimestamp($lastLoopAt),
|
||||
'last_heartbeat_attempt_at' => $this->normalizeControlPlaneStatusTimestamp($lastHeartbeatAttemptAt),
|
||||
'last_heartbeat_success_at' => $this->normalizeControlPlaneStatusTimestamp($lastHeartbeatSuccessAt),
|
||||
'last_successful_sync_at' => $this->normalizeControlPlaneStatusTimestamp($lastSuccessfulSyncAt),
|
||||
'outbox_queued' => array_key_exists('outbox_queued', $overrides)
|
||||
? max(0, (int)$overrides['outbox_queued'])
|
||||
: max(0, (int)($outboxSummary['queued'] ?? 0)),
|
||||
'broker_connected' => array_key_exists('broker_connected', $overrides)
|
||||
? (bool)$overrides['broker_connected']
|
||||
: $this->isBrokerConnected(),
|
||||
'last_transport_error' => $this->normalizeControlPlaneStatusString($lastTransportError),
|
||||
'last_transport_failure_at' => $this->normalizeControlPlaneStatusTimestamp($lastTransportFailureAt),
|
||||
];
|
||||
|
||||
file_put_contents(
|
||||
$this->controlPlaneStatusPath,
|
||||
json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL
|
||||
);
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
private function normalizeControlPlaneStatusTimestamp(mixed $value): ?string
|
||||
{
|
||||
return is_string($value) && trim($value) !== '' ? trim($value) : null;
|
||||
}
|
||||
|
||||
private function normalizeControlPlaneStatusString(mixed $value): ?string
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$normalized = trim($value);
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
if (is_scalar($value)) {
|
||||
$normalized = trim((string)$value);
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function flushOutbox(): void
|
||||
{
|
||||
$items = $this->stateStore->queuedItems(25);
|
||||
@@ -2006,9 +2161,12 @@ final class TruckwashEdgeAgent
|
||||
$this->http->post($endpoint, $payload, $timeoutSeconds);
|
||||
}
|
||||
$this->stateStore->removeOutboxItem((int)$item['id']);
|
||||
$this->stateStore->setJson('last_sync_at', date('c'));
|
||||
$this->recordSuccessfulSync();
|
||||
} catch (Throwable $throwable) {
|
||||
$this->logger->warning('Outbox replay blocked on ' . (string)$item['type'] . ': ' . $throwable->getMessage());
|
||||
$this->recordTransportFailure(
|
||||
'Outbox replay blocked on ' . (string)$item['type'] . ' for ' . (string)$item['endpoint'],
|
||||
$throwable
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -2106,17 +2264,19 @@ final class TruckwashEdgeAgent
|
||||
{
|
||||
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
|
||||
if ($brokerDispatch === true) {
|
||||
$this->stateStore->setJson('last_sync_at', date('c'));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->http->post($endpoint, $payload, 20);
|
||||
$this->stateStore->setJson('last_sync_at', date('c'));
|
||||
$this->recordSuccessfulSync();
|
||||
return true;
|
||||
} catch (Throwable $throwable) {
|
||||
$this->stateStore->enqueue($type, $endpoint, $payload);
|
||||
$this->logger->warning('Queued ' . $type . ' to local outbox after transport failure: ' . $throwable->getMessage());
|
||||
$this->recordTransportFailure(
|
||||
'Queued ' . $type . ' to local outbox after transport failure on ' . $endpoint,
|
||||
$throwable
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2125,17 +2285,19 @@ final class TruckwashEdgeAgent
|
||||
{
|
||||
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
|
||||
if ($brokerDispatch === true) {
|
||||
$this->stateStore->setJson('last_sync_at', date('c'));
|
||||
return ['data' => ['status' => 'ACKNOWLEDGED']];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->http->post($endpoint, $payload, $timeoutSeconds);
|
||||
$this->stateStore->setJson('last_sync_at', date('c'));
|
||||
$this->recordSuccessfulSync();
|
||||
return is_array($response) ? $response : null;
|
||||
} catch (Throwable $throwable) {
|
||||
$this->stateStore->enqueue($type, $endpoint, $payload);
|
||||
$this->logger->warning('Queued ' . $type . ' to local outbox after transport failure: ' . $throwable->getMessage());
|
||||
$this->recordTransportFailure(
|
||||
'Queued ' . $type . ' to local outbox after transport failure on ' . $endpoint,
|
||||
$throwable
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2356,22 +2518,27 @@ final class TruckwashEdgeAgent
|
||||
|
||||
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
|
||||
if ($brokerDispatch === true) {
|
||||
$this->stateStore->setJson('last_sync_at', date('c'));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->http->post($endpoint, $payload, self::OPERATION_COMPLETE_TIMEOUT_SECONDS);
|
||||
$this->stateStore->setJson('last_sync_at', date('c'));
|
||||
$this->recordSuccessfulSync();
|
||||
return true;
|
||||
} catch (Throwable $throwable) {
|
||||
if ($queueOnFailure) {
|
||||
$this->stateStore->enqueue('operation_complete', $endpoint, $payload);
|
||||
$this->logger->warning('Queued operation_complete to local outbox after completion acknowledgement failure: ' . $throwable->getMessage());
|
||||
$this->recordTransportFailure(
|
||||
'Queued operation_complete to local outbox after completion acknowledgement failure on ' . $endpoint,
|
||||
$throwable
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->logger->warning('Retrying backend completion acknowledgement later: ' . $throwable->getMessage());
|
||||
$this->recordTransportFailure(
|
||||
'Retrying backend completion acknowledgement later for ' . $endpoint,
|
||||
$throwable
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,11 @@ services:
|
||||
- ./config.json:/config/config.json
|
||||
- ./runtime:/opt/truckwash-edge-agent/runtime
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "kill -0 1"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"php -r '$$path=\"/opt/truckwash-edge-agent/runtime/control-plane-status.json\"; if (!is_file($$path)) { exit(1); } $$data=json_decode((string)file_get_contents($$path), true); if (!is_array($$data)) { exit(1); } $$loopAt=strtotime((string)($$data[\"last_loop_at\"] ?? \"\")); $$syncAt=strtotime((string)($$data[\"last_successful_sync_at\"] ?? $$data[\"started_at\"] ?? \"\")); if ($$loopAt === false || $$syncAt === false) { exit(1); } $$now=time(); exit((($$now - $$loopAt) <= 30 && ($$now - $$syncAt) <= 90) ? 0 : 1);'",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -302,11 +302,11 @@ class departmentLanesRoute
|
||||
// Get the request data
|
||||
$name = $response->getRequestParameter('name') ?? null;
|
||||
$department = $response->getRequestParameter('department') ?? null;
|
||||
$relay_in_id = $response->getRequestParameter('relay_in_id') ?? null;
|
||||
$relay_out_id = $response->getRequestParameter('relay_out_id') ?? null;
|
||||
$relay_machine_id = $response->getRequestParameter('relay_machine_id') ?? null;
|
||||
$relay_machine_program_picker_id = $response->getRequestParameter('relay_machine_program_picker_id') ?? null;
|
||||
$relay_machine_cleaner_id = $response->getRequestParameter('relay_machine_cleaner_id') ?? null;
|
||||
$relay_in_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_in_id') ?? null);
|
||||
$relay_out_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_out_id') ?? null);
|
||||
$relay_machine_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_id') ?? null);
|
||||
$relay_machine_program_picker_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_program_picker_id') ?? null);
|
||||
$relay_machine_cleaner_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_cleaner_id') ?? null);
|
||||
|
||||
$dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null;
|
||||
$machine_type_id = $response->getRequestParameter('machine_type_id') ?? null;
|
||||
@@ -323,7 +323,6 @@ class departmentLanesRoute
|
||||
} else {
|
||||
$machine_type_id = null;
|
||||
}
|
||||
// Remove spaces from the relay_in_id and relay_out_id
|
||||
// Check if the required fields are set
|
||||
if ($name && $department) {
|
||||
// Add the department lane
|
||||
@@ -362,11 +361,11 @@ class departmentLanesRoute
|
||||
$id = $response->getRequestParameter('id') ?? null;
|
||||
$name = $response->getRequestParameter('name') ?? null;
|
||||
$department = $response->getRequestParameter('department') ?? null;
|
||||
$relay_in_id = $response->getRequestParameter('relay_in_id') ?? null;
|
||||
$relay_out_id = $response->getRequestParameter('relay_out_id') ?? null;
|
||||
$relay_machine_id = $response->getRequestParameter('relay_machine_id') ?? null;
|
||||
$relay_machine_program_picker_id = $response->getRequestParameter('relay_machine_program_picker_id') ?? null;
|
||||
$relay_machine_cleaner_id = $response->getRequestParameter('relay_machine_cleaner_id') ?? null;
|
||||
$relay_in_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_in_id') ?? null);
|
||||
$relay_out_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_out_id') ?? null);
|
||||
$relay_machine_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_id') ?? null);
|
||||
$relay_machine_program_picker_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_program_picker_id') ?? null);
|
||||
$relay_machine_cleaner_id = self::normalizeRelayRequestParameter($response->getRequestParameter('relay_machine_cleaner_id') ?? null);
|
||||
$dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null;
|
||||
$machine_type_id = $response->getRequestParameter('machine_type_id') ?? null;
|
||||
|
||||
@@ -387,19 +386,19 @@ class departmentLanesRoute
|
||||
$department_lane->department->set((int)$department);
|
||||
}
|
||||
if (self::isParametersSet(['relay_in_id'])) {
|
||||
$department_lane->relay_in_id->set((string)$relay_in_id);
|
||||
self::syncDepartmentLaneRelayValue($department_lane->relay_in_id, $relay_in_id);
|
||||
}
|
||||
if (self::isParametersSet(['relay_out_id'])) {
|
||||
$department_lane->relay_out_id->set((string)$relay_out_id);
|
||||
self::syncDepartmentLaneRelayValue($department_lane->relay_out_id, $relay_out_id);
|
||||
}
|
||||
if (self::isParametersSet(['relay_machine_id'])) {
|
||||
$department_lane->relay_machine_id->set((string)$relay_machine_id);
|
||||
self::syncDepartmentLaneRelayValue($department_lane->relay_machine_id, $relay_machine_id);
|
||||
}
|
||||
if (self::isParametersSet(['relay_machine_program_picker_id'])) {
|
||||
$department_lane->relay_machine_program_picker_id->set((string)$relay_machine_program_picker_id);
|
||||
self::syncDepartmentLaneRelayValue($department_lane->relay_machine_program_picker_id, $relay_machine_program_picker_id);
|
||||
}
|
||||
if (self::isParametersSet(['relay_machine_cleaner_id'])) {
|
||||
$department_lane->relay_machine_cleaner_id->set((string)$relay_machine_cleaner_id);
|
||||
self::syncDepartmentLaneRelayValue($department_lane->relay_machine_cleaner_id, $relay_machine_cleaner_id);
|
||||
}
|
||||
if (self::isParametersSet(['dynamic_image_id'])) {
|
||||
$param = $response->getRequestParameter('dynamic_image_id');
|
||||
@@ -437,4 +436,25 @@ class departmentLanesRoute
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private static function normalizeRelayRequestParameter(mixed $value): ?string
|
||||
{
|
||||
$normalized = trim((string)($value ?? ''));
|
||||
if ($normalized === '' || strtolower($normalized) === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private static function syncDepartmentLaneRelayValue(mixed $field, mixed $value): void
|
||||
{
|
||||
$normalized = self::normalizeRelayRequestParameter($value);
|
||||
if ($normalized === null) {
|
||||
$field->nullify();
|
||||
return;
|
||||
}
|
||||
|
||||
$field->set($normalized);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use classes\edge_gateway_manager;
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
it('serves installer artifacts and recovers gateway runtime status after fresh heartbeats', function (): void {
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Edge Agent Department',
|
||||
]);
|
||||
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
|
||||
$installTokenResponse = api_client()->post('/edge-gateways/install-token', [
|
||||
'department_id' => (int)$department['id'],
|
||||
'label' => 'Edge Agent Install',
|
||||
], $session['headers']);
|
||||
|
||||
$installTokenResponse
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$installToken = $installTokenResponse->data();
|
||||
|
||||
$installScript = api_client()->get('/edge-agent/install.sh?token=' . urlencode((string)$installToken['token']));
|
||||
|
||||
expect($installScript->status)->toBe(200)
|
||||
->and($installScript->body)
|
||||
->toContain('/edge-agent/install-token/status')
|
||||
->toContain('agent.php')
|
||||
->toContain((string)$installToken['token']);
|
||||
|
||||
$artifact = api_client()->get('/edge-agent/artifacts/agent.php');
|
||||
expect($artifact->status)->toBe(200)
|
||||
->and($artifact->body)
|
||||
->toContain('<?php');
|
||||
|
||||
$claimResponse = api_client()->post('/edge-agent/claim', [
|
||||
'token' => (string)$installToken['token'],
|
||||
'hostname' => 'edge-agent-api',
|
||||
'installed_version' => 'php-agent-v1',
|
||||
]);
|
||||
|
||||
$claimResponse
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$gatewayId = (int)($claimResponse->data()['gateway']['id'] ?? 0);
|
||||
$agentToken = (string)($claimResponse->data()['agent_token'] ?? '');
|
||||
|
||||
expect($gatewayId)->toBeGreaterThan(0)
|
||||
->and($agentToken)->not->toBe('');
|
||||
|
||||
edge_agent_test_set_heartbeat_age($gatewayId, edge_gateway_manager::HEARTBEAT_DEGRADED_AFTER_SECONDS + 1);
|
||||
|
||||
$degradedDetail = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']);
|
||||
|
||||
$degradedDetail
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($degradedDetail->data())
|
||||
->toHaveKey('status', 'DEGRADED');
|
||||
|
||||
edge_agent_test_set_heartbeat_age($gatewayId, edge_gateway_manager::HEARTBEAT_OFFLINE_AFTER_SECONDS + 1);
|
||||
|
||||
$offlineDetail = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']);
|
||||
|
||||
$offlineDetail
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($offlineDetail->data())
|
||||
->toHaveKey('status', 'OFFLINE');
|
||||
|
||||
$heartbeatResponse = api_client()->post('/edge-agent/gateways/' . $gatewayId . '/heartbeat', [
|
||||
'agent_token' => $agentToken,
|
||||
'status' => 'ONLINE',
|
||||
'hostname' => 'edge-agent-api',
|
||||
'metadata' => [
|
||||
'system_metrics' => [
|
||||
'cpu_percent' => 21,
|
||||
'memory_mb' => 128,
|
||||
],
|
||||
],
|
||||
'inventory' => edge_agent_test_inventory('heartbeat'),
|
||||
]);
|
||||
|
||||
$heartbeatResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($heartbeatResponse->data())
|
||||
->toHaveKey('status', 'ONLINE');
|
||||
|
||||
$recoveredDetail = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']);
|
||||
|
||||
$recoveredDetail
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($recoveredDetail->data())
|
||||
->toHaveKey('status', 'ONLINE')
|
||||
->and($recoveredDetail->data()['metadata']['system_metrics']['cpu_percent'] ?? null)
|
||||
->toBe(21);
|
||||
});
|
||||
|
||||
it('polls operations and commands, submits results, and records broker presence for task pages', function (): void {
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Edge Agent Operations Department',
|
||||
]);
|
||||
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
|
||||
$gateway = api_fixtures()->createClaimedEdgeGateway([
|
||||
'department_id' => (int)$department['id'],
|
||||
'label' => 'Edge Agent Runtime',
|
||||
]);
|
||||
|
||||
$operationResponse = api_client()->post('/edge-gateways/' . (int)$gateway['id'] . '/operations', [
|
||||
'type' => 'DISCOVERY',
|
||||
'request' => [
|
||||
'inventory' => edge_agent_test_inventory('operation'),
|
||||
],
|
||||
], $session['headers']);
|
||||
|
||||
$operationResponse
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$operationId = (int)($operationResponse->data()['operation']['id'] ?? 0);
|
||||
expect($operationId)->toBeGreaterThan(0);
|
||||
|
||||
$operationLease = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/operations/next', [
|
||||
'agent_token' => (string)$gateway['agent_token'],
|
||||
'wait_seconds' => 0,
|
||||
'agent_instance_id' => 'edge-agent-api-test',
|
||||
]);
|
||||
|
||||
$operationLease
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($operationLease->data())
|
||||
->toHaveKey('id', $operationId)
|
||||
->toHaveKey('status', 'IN_PROGRESS');
|
||||
|
||||
$eventResponse = api_client()->post(
|
||||
'/edge-agent/gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/events',
|
||||
[
|
||||
'agent_token' => (string)$gateway['agent_token'],
|
||||
'level' => 'INFO',
|
||||
'code' => 'DISCOVERY_RUNNING',
|
||||
'message' => 'Discovery is running through the agent API.',
|
||||
'context' => [
|
||||
'progress' => 55,
|
||||
'label' => 'Discovery running',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$eventResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$completeResponse = api_client()->post(
|
||||
'/edge-agent/gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/complete',
|
||||
[
|
||||
'agent_token' => (string)$gateway['agent_token'],
|
||||
'ok' => true,
|
||||
'result' => [
|
||||
'inventory' => edge_agent_test_inventory('completed'),
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$completeResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($completeResponse->data())
|
||||
->toHaveKey('status', 'COMPLETED');
|
||||
|
||||
$job = api_fixtures()->createEdgeCommandJob([
|
||||
'gateway_id' => (int)$gateway['id'],
|
||||
'command_type' => 'GET_RELAY_STATUS',
|
||||
'request' => [
|
||||
'relayId' => 'relay-main',
|
||||
'localIp' => '10.0.0.18',
|
||||
],
|
||||
]);
|
||||
|
||||
$commandPoll = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/commands/poll', [
|
||||
'agent_token' => (string)$gateway['agent_token'],
|
||||
'wait_seconds' => 0,
|
||||
]);
|
||||
|
||||
$commandPoll
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($commandPoll->data())
|
||||
->toHaveKey('id', (int)$job['id'])
|
||||
->toHaveKey('command_type', 'GET_RELAY_STATUS');
|
||||
|
||||
$commandResult = api_client()->post(
|
||||
'/edge-agent/gateways/' . (int)$gateway['id'] . '/commands/' . (int)$job['id'] . '/result',
|
||||
[
|
||||
'agent_token' => (string)$gateway['agent_token'],
|
||||
'ok' => true,
|
||||
'result' => [
|
||||
'relayId' => 'relay-main',
|
||||
'online' => true,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$commandResult
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($commandResult->data())
|
||||
->toHaveKey('acknowledged', true)
|
||||
->and($commandResult->data()['job']['status'] ?? null)
|
||||
->toBe('COMPLETED');
|
||||
|
||||
$presenceResponse = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/presence', [
|
||||
'agent_token' => (string)$gateway['agent_token'],
|
||||
'status' => 'connected',
|
||||
'connection_id' => 'broker-connection-1',
|
||||
'metadata' => [
|
||||
'transport' => 'ws',
|
||||
],
|
||||
]);
|
||||
|
||||
$presenceResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($presenceResponse->data())
|
||||
->toHaveKey('connected', true);
|
||||
|
||||
$tasksPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/tasks', $session['headers']);
|
||||
|
||||
$tasksPage
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($tasksPage->data()['operations'] ?? [])
|
||||
->toBeArray()
|
||||
->not->toBeEmpty()
|
||||
->and($tasksPage->data()['recent_commands'] ?? [])
|
||||
->toBeArray()
|
||||
->not->toBeEmpty();
|
||||
|
||||
expect($tasksPage->data()['recent_operations_summary']['completed'] ?? 0)
|
||||
->toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('rejects missing and invalid edge agent tokens', function (): void {
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Edge Agent Auth Department',
|
||||
]);
|
||||
$gateway = api_fixtures()->createClaimedEdgeGateway([
|
||||
'department_id' => (int)$department['id'],
|
||||
]);
|
||||
|
||||
$missingToken = api_client()->get('/edge-agent/install-token/verify');
|
||||
|
||||
$missingToken
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Missing token');
|
||||
|
||||
$missingAgentToken = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/heartbeat', [
|
||||
'status' => 'ONLINE',
|
||||
]);
|
||||
|
||||
$missingAgentToken
|
||||
->assertStatus(401)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Missing edge gateway agent token');
|
||||
|
||||
$invalidAgentToken = api_client()->post('/edge-agent/gateways/' . (int)$gateway['id'] . '/operations/next', [
|
||||
'agent_token' => 'invalid-token',
|
||||
'wait_seconds' => 0,
|
||||
]);
|
||||
|
||||
$invalidAgentToken
|
||||
->assertStatus(401)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Invalid edge gateway token');
|
||||
});
|
||||
|
||||
function edge_agent_test_set_heartbeat_age(int $gatewayId, int $secondsAgo): void
|
||||
{
|
||||
$db = api_test_runtime()->db();
|
||||
$timestamp = date('Y-m-d H:i:s', time() - max(0, $secondsAgo));
|
||||
$escapedTimestamp = $db->real_escape_string($timestamp);
|
||||
$db->query("UPDATE edge_gateways SET last_heartbeat_at = '{$escapedTimestamp}', status = 'ONLINE' WHERE id = " . (int)$gatewayId);
|
||||
api_fixtures()->clearEdgeGatewayViewCache();
|
||||
}
|
||||
|
||||
function edge_agent_test_inventory(string $suffix): array
|
||||
{
|
||||
return [[
|
||||
'device_id' => 'agent-' . $suffix,
|
||||
'local_ip' => '10.30.40.50',
|
||||
'model' => 'TruckWash Edge Agent',
|
||||
'channel_count' => 1,
|
||||
'online' => true,
|
||||
'capabilities' => [
|
||||
'gateway_management_v2' => true,
|
||||
],
|
||||
'metadata' => [
|
||||
'hostname' => 'edge-' . $suffix,
|
||||
],
|
||||
]];
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
it('validates broker sessions and ingests presence, telemetry, logs, and shell lifecycle data', function (): void {
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Edge Broker Department',
|
||||
]);
|
||||
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
|
||||
$gateway = api_fixtures()->createClaimedEdgeGateway([
|
||||
'department_id' => (int)$department['id'],
|
||||
'label' => 'Broker Gateway',
|
||||
]);
|
||||
|
||||
$validateGateway = api_client()->post(
|
||||
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/validate',
|
||||
['token' => (string)$gateway['agent_token']],
|
||||
edge_test_broker_headers()
|
||||
);
|
||||
|
||||
$validateGateway
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($validateGateway->data())
|
||||
->toHaveKey('gateway_id', (int)$gateway['id'])
|
||||
->toHaveKey('department_id', (int)$department['id']);
|
||||
|
||||
$presence = api_client()->post(
|
||||
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/presence',
|
||||
[
|
||||
'status' => 'connected',
|
||||
'connection_id' => 'broker-presence-1',
|
||||
'metadata' => [
|
||||
'transport' => 'ws',
|
||||
],
|
||||
],
|
||||
edge_test_broker_headers()
|
||||
);
|
||||
|
||||
$presence
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($presence->data())
|
||||
->toHaveKey('connected', true)
|
||||
->toHaveKey('connection_id', 'broker-presence-1');
|
||||
|
||||
$telemetry = api_client()->post(
|
||||
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/telemetry',
|
||||
[
|
||||
'status' => 'ONLINE',
|
||||
'metadata' => [
|
||||
'system_metrics' => [
|
||||
'cpu_load' => 0.42,
|
||||
'memory_mb' => 512,
|
||||
],
|
||||
],
|
||||
'inventory' => edge_broker_test_inventory('telemetry'),
|
||||
],
|
||||
edge_test_broker_headers()
|
||||
);
|
||||
|
||||
$telemetry
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($telemetry->data()['metadata']['system_metrics']['cpu_load'] ?? null)
|
||||
->toBe(0.42);
|
||||
|
||||
$logEntry = api_client()->post(
|
||||
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/logs',
|
||||
[
|
||||
'level' => 'INFO',
|
||||
'stream' => 'agent',
|
||||
'source' => 'BROKER',
|
||||
'message' => 'Broker forwarded a live gateway log.',
|
||||
'context' => [
|
||||
'source' => 'broker-test',
|
||||
],
|
||||
],
|
||||
edge_test_broker_headers()
|
||||
);
|
||||
|
||||
$logEntry
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($logEntry->data())
|
||||
->toHaveKey('message', 'Broker forwarded a live gateway log.');
|
||||
|
||||
$streamSession = api_client()->post(
|
||||
'/edge-gateways/' . (int)$gateway['id'] . '/stream-session',
|
||||
['scopes' => ['logs', 'statistics', 'tasks']],
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$streamSession
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$streamValidation = api_client()->post(
|
||||
'/edge-agent/internal/browser-streams/validate',
|
||||
['token' => (string)$streamSession->data()['token']],
|
||||
edge_test_broker_headers()
|
||||
);
|
||||
|
||||
$streamValidation
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($streamValidation->data())
|
||||
->toHaveKey('session_type', 'gateway-stream')
|
||||
->toHaveKey('gateway_id', (int)$gateway['id']);
|
||||
|
||||
$shellSession = api_client()->post(
|
||||
'/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions',
|
||||
['reason' => 'Broker shell validation'],
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$shellSession
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$shellToken = (string)$shellSession->data()['token'];
|
||||
|
||||
$validateShell = api_client()->post(
|
||||
'/edge-agent/internal/shell-sessions/validate',
|
||||
['token' => $shellToken],
|
||||
edge_test_broker_headers()
|
||||
);
|
||||
|
||||
$validateShell
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($validateShell->data())
|
||||
->toHaveKey('status', 'PENDING');
|
||||
|
||||
$openedShell = api_client()->post(
|
||||
'/edge-agent/internal/shell-sessions/opened',
|
||||
[
|
||||
'token' => $shellToken,
|
||||
'connection_id' => 'shell-connection-1',
|
||||
],
|
||||
edge_test_broker_headers()
|
||||
);
|
||||
|
||||
$openedShell
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($openedShell->data())
|
||||
->toHaveKey('status', 'OPEN')
|
||||
->toHaveKey('connection_id', 'shell-connection-1');
|
||||
|
||||
$closedShell = api_client()->post(
|
||||
'/edge-agent/internal/shell-sessions/close',
|
||||
[
|
||||
'token' => $shellToken,
|
||||
'transcript' => "edge-broker-shell\n",
|
||||
'reason' => 'agent_exit',
|
||||
],
|
||||
edge_test_broker_headers()
|
||||
);
|
||||
|
||||
$closedShell
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($closedShell->data())
|
||||
->toHaveKey('status', 'COMPLETED')
|
||||
->and($closedShell->data()['transcript'] ?? null)
|
||||
->toBe("edge-broker-shell\n");
|
||||
|
||||
$logsPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/logs', $session['headers']);
|
||||
|
||||
$logsPage
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect(collect_gateway_messages($logsPage->data()['log_entries'] ?? []))
|
||||
->toContain('Broker forwarded a live gateway log.');
|
||||
expect(collect_gateway_messages($logsPage->data()['timeline'] ?? []))
|
||||
->toContain('GATEWAY_SHELL_SESSION_OPENED')
|
||||
->toContain('GATEWAY_SHELL_SESSION_CLOSED');
|
||||
expect($logsPage->data()['shell_sessions'][0]['transcript'] ?? null)
|
||||
->toBe("edge-broker-shell\n");
|
||||
|
||||
$statisticsPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/statistics', $session['headers']);
|
||||
|
||||
$statisticsPage
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($statisticsPage->data())
|
||||
->toHaveKey('system_metrics')
|
||||
->and($statisticsPage->data()['system_metrics']['cpu_load'] ?? null)
|
||||
->toBe(0.42);
|
||||
});
|
||||
|
||||
it('builds broker backlog and completes gateway operations through broker endpoints', function (): void {
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Edge Broker Backlog Department',
|
||||
]);
|
||||
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
|
||||
$gateway = api_fixtures()->createClaimedEdgeGateway([
|
||||
'department_id' => (int)$department['id'],
|
||||
'label' => 'Broker Backlog Gateway',
|
||||
]);
|
||||
|
||||
$queuedOperation = api_client()->post('/edge-gateways/' . (int)$gateway['id'] . '/operations', [
|
||||
'type' => 'DISCOVERY',
|
||||
'request' => [
|
||||
'inventory' => edge_broker_test_inventory('backlog'),
|
||||
],
|
||||
], $session['headers']);
|
||||
|
||||
$queuedOperation
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$operationId = (int)($queuedOperation->data()['operation']['id'] ?? 0);
|
||||
expect($operationId)->toBeGreaterThan(0);
|
||||
|
||||
$backlog = api_client()->post(
|
||||
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/backlog',
|
||||
['agent_instance_id' => 'broker-agent-1'],
|
||||
edge_test_broker_headers()
|
||||
);
|
||||
|
||||
$backlog
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($backlog->data()['dispatch'] ?? [])
|
||||
->toBeArray()
|
||||
->not->toBeEmpty()
|
||||
->and($backlog->data()['dispatch'][0]['type'] ?? null)
|
||||
->toBe('TASK_DISPATCH')
|
||||
->and((int)($backlog->data()['dispatch'][0]['operation']['id'] ?? 0))
|
||||
->toBe($operationId);
|
||||
|
||||
$event = api_client()->post(
|
||||
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/events',
|
||||
[
|
||||
'level' => 'INFO',
|
||||
'code' => 'BROKER_EXECUTING',
|
||||
'message' => 'Broker is executing the operation.',
|
||||
'context' => [
|
||||
'progress' => 50,
|
||||
],
|
||||
],
|
||||
edge_test_broker_headers()
|
||||
);
|
||||
|
||||
$event
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$complete = api_client()->post(
|
||||
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/complete',
|
||||
[
|
||||
'ok' => true,
|
||||
'result' => [
|
||||
'inventory' => edge_broker_test_inventory('completed'),
|
||||
],
|
||||
],
|
||||
edge_test_broker_headers()
|
||||
);
|
||||
|
||||
$complete
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($complete->data())
|
||||
->toHaveKey('status', 'COMPLETED');
|
||||
|
||||
$operations = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/operations', $session['headers']);
|
||||
|
||||
$operations
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect(($operations->data()[0]['status'] ?? null))
|
||||
->toBe('COMPLETED');
|
||||
|
||||
$events = api_client()->get(
|
||||
'/edge-gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/events',
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$events
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect(collect_gateway_messages($events->data()))
|
||||
->toContain('Broker is executing the operation.')
|
||||
->toContain('Operation completed successfully');
|
||||
|
||||
$tasksPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/tasks', $session['headers']);
|
||||
|
||||
$tasksPage
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($tasksPage->data()['recent_operations_summary']['completed'] ?? 0)
|
||||
->toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('rejects invalid edge broker shared secrets', function (): void {
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Edge Broker Forbidden Department',
|
||||
]);
|
||||
$gateway = api_fixtures()->createClaimedEdgeGateway([
|
||||
'department_id' => (int)$department['id'],
|
||||
]);
|
||||
|
||||
$response = api_client()->post(
|
||||
'/edge-agent/internal/gateways/' . (int)$gateway['id'] . '/presence',
|
||||
['status' => 'connected'],
|
||||
['X-Edge-Broker-Secret' => 'wrong-secret']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Invalid edge broker secret');
|
||||
});
|
||||
|
||||
function edge_broker_test_inventory(string $suffix): array
|
||||
{
|
||||
return [[
|
||||
'device_id' => 'broker-' . $suffix,
|
||||
'local_ip' => '10.40.50.60',
|
||||
'model' => 'TruckWash Edge Broker',
|
||||
'channel_count' => 1,
|
||||
'online' => true,
|
||||
'capabilities' => [
|
||||
'relay_commands' => true,
|
||||
],
|
||||
'metadata' => [
|
||||
'hostname' => 'broker-' . $suffix,
|
||||
],
|
||||
]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $items
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function collect_gateway_messages(mixed $items): array
|
||||
{
|
||||
if (!is_array($items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$messages = [];
|
||||
foreach ($items as $item) {
|
||||
if (is_array($item) && isset($item['message']) && is_string($item['message'])) {
|
||||
$messages[] = $item['message'];
|
||||
}
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
it('creates install tokens, tracks installer status, and exposes claimed gateway detail to authorized operators', function (): void {
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Edge Operator Department',
|
||||
]);
|
||||
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
|
||||
|
||||
$createResponse = api_client()->post('/edge-gateways/install-token', [
|
||||
'department_id' => (int)$department['id'],
|
||||
'label' => 'Dock 7 Gateway',
|
||||
], $session['headers']);
|
||||
|
||||
$createResponse
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$installToken = $createResponse->data();
|
||||
expect($installToken)
|
||||
->toBeArray()
|
||||
->toHaveKeys(['claim_token_id', 'token', 'install_command']);
|
||||
|
||||
$statusResponse = api_client()->get(
|
||||
'/edge-gateways/install-token/' . (int)$installToken['claim_token_id'] . '/status',
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$statusResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($statusResponse->data())
|
||||
->toHaveKey('status', 'PENDING')
|
||||
->toHaveKey('gateway_id', null);
|
||||
|
||||
$verifyResponse = api_client()->get('/edge-agent/install-token/verify?token=' . urlencode((string)$installToken['token']));
|
||||
|
||||
$verifyResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($verifyResponse->data())
|
||||
->toHaveKey('valid', true)
|
||||
->toHaveKey('claim_token_id', (int)$installToken['claim_token_id']);
|
||||
|
||||
$runningStatus = api_client()->post('/edge-agent/install-token/status', [
|
||||
'token' => (string)$installToken['token'],
|
||||
'status' => 'RUNNING',
|
||||
'step' => 'BOOTSTRAP',
|
||||
'message' => 'Installer is downloading gateway artifacts.',
|
||||
'diagnostics' => [
|
||||
'download-1',
|
||||
'download-2',
|
||||
'download-3',
|
||||
'download-4',
|
||||
'download-5',
|
||||
'download-6',
|
||||
'download-7',
|
||||
],
|
||||
]);
|
||||
|
||||
$runningStatus
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$runningStatusDetail = api_client()->get(
|
||||
'/edge-gateways/install-token/' . (int)$installToken['claim_token_id'] . '/status',
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$runningStatusDetail
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($runningStatusDetail->data())
|
||||
->toHaveKey('status', 'RUNNING')
|
||||
->toHaveKey('step', 'BOOTSTRAP')
|
||||
->and($runningStatusDetail->data()['diagnostics'] ?? [])
|
||||
->toBeArray()
|
||||
->toHaveCount(6);
|
||||
|
||||
$claimResponse = api_client()->post('/edge-agent/claim', [
|
||||
'token' => (string)$installToken['token'],
|
||||
'hostname' => 'edge-operator-api',
|
||||
'installed_version' => 'php-agent-v1',
|
||||
'metadata' => [
|
||||
'agent_runtime' => 'compose-php',
|
||||
],
|
||||
]);
|
||||
|
||||
$claimResponse
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$claimedPayload = $claimResponse->data();
|
||||
$gatewayId = (int)($claimedPayload['gateway']['id'] ?? 0);
|
||||
|
||||
expect($claimedPayload)
|
||||
->toHaveKey('agent_token')
|
||||
->and($gatewayId)
|
||||
->toBeGreaterThan(0)
|
||||
->and($claimedPayload['gateway']['status'] ?? null)
|
||||
->toBe('ONLINE');
|
||||
|
||||
$claimedStatus = api_client()->get(
|
||||
'/edge-gateways/install-token/' . (int)$installToken['claim_token_id'] . '/status',
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$claimedStatus
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($claimedStatus->data())
|
||||
->toHaveKey('status', 'CLAIMED')
|
||||
->toHaveKey('gateway_id', $gatewayId)
|
||||
->toHaveKey('last_error', null);
|
||||
|
||||
$listResponse = api_client()->get('/edge-gateways?department_id=' . (int)$department['id'], $session['headers']);
|
||||
|
||||
$listResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect(collect_gateway_ids_from_api_response($listResponse->data()))
|
||||
->toContain($gatewayId);
|
||||
|
||||
$detailResponse = api_client()->get('/edge-gateways/' . $gatewayId, $session['headers']);
|
||||
|
||||
$detailResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($detailResponse->data())
|
||||
->toHaveKey('id', $gatewayId)
|
||||
->toHaveKey('department_id', (int)$department['id'])
|
||||
->toHaveKey('status', 'ONLINE');
|
||||
});
|
||||
|
||||
it('manages edge gateway metadata, bindings, operations, sessions, rotation, cutover, and deletion', function (): void {
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Edge Operator Control Department',
|
||||
]);
|
||||
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
|
||||
$gateway = api_fixtures()->createClaimedEdgeGateway([
|
||||
'department_id' => (int)$department['id'],
|
||||
'label' => 'Original Gateway Label',
|
||||
]);
|
||||
api_fixtures()->createClaimedEdgeGateway([
|
||||
'department_id' => (int)$department['id'],
|
||||
'label' => 'Alternate Gateway Label',
|
||||
'is_primary' => 0,
|
||||
]);
|
||||
|
||||
$updateResponse = api_client()->put('/edge-gateways/' . (int)$gateway['id'], [
|
||||
'label' => 'Renamed Gateway',
|
||||
'is_primary' => false,
|
||||
], $session['headers']);
|
||||
|
||||
$updateResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($updateResponse->data())
|
||||
->toHaveKey('label', 'Renamed Gateway')
|
||||
->toHaveKey('is_primary', false);
|
||||
|
||||
$bindingsResponse = api_client()->put('/edge-gateways/' . (int)$gateway['id'] . '/bindings', [
|
||||
'bindings' => [[
|
||||
'relay_id' => 'relay-main',
|
||||
'device_id' => 'device-main',
|
||||
'local_ip' => '10.0.0.18',
|
||||
'channel' => 0,
|
||||
'binding_source' => 'MANUAL',
|
||||
]],
|
||||
], $session['headers']);
|
||||
|
||||
$bindingsResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($bindingsResponse->data()['bindings'] ?? [])
|
||||
->toBeArray()
|
||||
->toHaveCount(1)
|
||||
->and(($bindingsResponse->data()['bindings'][0]['relay_id'] ?? null))
|
||||
->toBe('relay-main');
|
||||
|
||||
$operationResponse = api_client()->post('/edge-gateways/' . (int)$gateway['id'] . '/operations', [
|
||||
'type' => 'DISCOVERY',
|
||||
'request' => [
|
||||
'inventory' => edge_operator_test_inventory('operator'),
|
||||
],
|
||||
], $session['headers']);
|
||||
|
||||
$operationResponse
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$operationId = (int)($operationResponse->data()['operation']['id'] ?? 0);
|
||||
expect($operationId)->toBeGreaterThan(0);
|
||||
|
||||
$operationsList = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/operations', $session['headers']);
|
||||
|
||||
$operationsList
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect(collect_gateway_ids_from_api_response($operationsList->data(), 'id'))
|
||||
->toContain($operationId);
|
||||
|
||||
$eventsResponse = api_client()->get(
|
||||
'/edge-gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/events',
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$eventsResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($eventsResponse->data())
|
||||
->toBeArray()
|
||||
->not->toBeEmpty()
|
||||
->and($eventsResponse->data()[0]['code'] ?? null)
|
||||
->toBe('OPERATION_QUEUED');
|
||||
|
||||
$cancelResponse = api_client()->post(
|
||||
'/edge-gateways/' . (int)$gateway['id'] . '/operations/' . $operationId . '/cancel',
|
||||
[],
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$cancelResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($cancelResponse->data()['operation']['status'] ?? null)
|
||||
->toBe('CANCELLED');
|
||||
|
||||
$streamSession = api_client()->post(
|
||||
'/edge-gateways/' . (int)$gateway['id'] . '/stream-session',
|
||||
['scopes' => ['logs', 'tasks']],
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$streamSession
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($streamSession->data())
|
||||
->toHaveKey('token')
|
||||
->toHaveKey('ws_url');
|
||||
|
||||
$shellSession = api_client()->post(
|
||||
'/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions',
|
||||
[
|
||||
'reason' => 'Operator smoke session',
|
||||
'cwd' => '/opt/truckwash-edge-agent',
|
||||
'cols' => 120,
|
||||
'rows' => 40,
|
||||
],
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$shellSession
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($shellSession->data())
|
||||
->toHaveKey('token')
|
||||
->toHaveKey('session')
|
||||
->and($shellSession->data()['session']['reason'] ?? null)
|
||||
->toBe('Operator smoke session');
|
||||
|
||||
$rotateResponse = api_client()->post(
|
||||
'/edge-gateways/' . (int)$gateway['id'] . '/rotate-credentials',
|
||||
[],
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$rotateResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($rotateResponse->data())
|
||||
->toHaveKey('gateway_id', (int)$gateway['id'])
|
||||
->toHaveKey('agent_token')
|
||||
->toHaveKey('config_json');
|
||||
|
||||
$cutoverResponse = api_client()->post(
|
||||
'/departments/' . (int)$department['id'] . '/gateway-cutover',
|
||||
['transport_mode' => 'gateway'],
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$cutoverResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($cutoverResponse->data())
|
||||
->toHaveKey('department_id', (int)$department['id'])
|
||||
->toHaveKey('transport_mode', 'gateway');
|
||||
|
||||
$deleteResponse = api_client()->delete('/edge-gateways/' . (int)$gateway['id'], null, $session['headers']);
|
||||
|
||||
$deleteResponse
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($deleteResponse->data())
|
||||
->toHaveKey('deleted', true)
|
||||
->toHaveKey('gateway_id', (int)$gateway['id']);
|
||||
});
|
||||
|
||||
it('rejects operator edge routes when module permission or department access is missing', function (): void {
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Edge Operator Access Department',
|
||||
]);
|
||||
$allowed = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
|
||||
$token = api_client()->post('/edge-gateways/install-token', [
|
||||
'department_id' => (int)$department['id'],
|
||||
'label' => 'Restricted Gateway',
|
||||
], $allowed['headers']);
|
||||
|
||||
$token
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$missingModule = api_fixtures()->createUserSession([
|
||||
'department_access_' . (int)$department['id'],
|
||||
]);
|
||||
|
||||
$missingModuleResponse = api_client()->get(
|
||||
'/edge-gateways?department_id=' . (int)$department['id'],
|
||||
$missingModule['headers']
|
||||
);
|
||||
|
||||
$missingModuleResponse
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['modules_shelly_config']);
|
||||
|
||||
$missingDepartment = api_fixtures()->createUserSession(['modules_shelly_config']);
|
||||
|
||||
$missingDepartmentResponse = api_client()->get(
|
||||
'/edge-gateways/install-token/' . (int)$token->data()['claim_token_id'] . '/status',
|
||||
$missingDepartment['headers']
|
||||
);
|
||||
|
||||
$missingDepartmentResponse
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['department_access_' . (int)$department['id']]);
|
||||
});
|
||||
|
||||
function edge_operator_test_inventory(string $suffix): array
|
||||
{
|
||||
return [[
|
||||
'device_id' => 'gateway-' . $suffix,
|
||||
'local_ip' => '10.20.30.40',
|
||||
'model' => 'TruckWash Edge Test',
|
||||
'channel_count' => 2,
|
||||
'online' => true,
|
||||
'capabilities' => [
|
||||
'local_discovery' => true,
|
||||
'relay_commands' => true,
|
||||
],
|
||||
'metadata' => [
|
||||
'hostname' => 'edge-' . $suffix,
|
||||
],
|
||||
]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $items
|
||||
* @return array<int, int>
|
||||
*/
|
||||
function collect_gateway_ids_from_api_response(mixed $items, string $key = 'id'): array
|
||||
{
|
||||
if (!is_array($items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($items as $item) {
|
||||
if (is_array($item) && isset($item[$key]) && is_numeric($item[$key])) {
|
||||
$ids[] = (int)$item[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
+421
@@ -0,0 +1,421 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use classes\db;
|
||||
use classes\edge_gateway_manager;
|
||||
use classes\edge_gateway_operation_service;
|
||||
use Predis\Client as PredisClient;
|
||||
use Tests\Support\Api\ApiCleanup;
|
||||
use Tests\Support\Api\ApiFixtures;
|
||||
use Tests\Support\Api\ApiSchemaBootstrap;
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/Support/Api/ApiCleanup.php';
|
||||
require_once dirname(__DIR__, 2) . '/Support/Api/ApiFixtures.php';
|
||||
require_once dirname(__DIR__, 2) . '/Support/Api/ApiSchemaBootstrap.php';
|
||||
|
||||
app_require('classes/db.php');
|
||||
app_require('classes/edge_gateway_manager.php');
|
||||
app_require('classes/edge_gateway_operation_service.php');
|
||||
|
||||
it('persists install-session updates and derives gateway runtime status from heartbeats', function (): void {
|
||||
$context = edge_gateway_integration_context();
|
||||
|
||||
try {
|
||||
$department = $context['fixtures']->createDepartment([
|
||||
'name' => 'Edge Integration Install Department',
|
||||
]);
|
||||
|
||||
$token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Install Token');
|
||||
$claimTokenId = (int)($token['claim_token_id'] ?? 0);
|
||||
|
||||
$context['manager']->reportInstallTokenStatus((string)$token['token'], [
|
||||
'status' => 'FAILED',
|
||||
'step' => 'DOWNLOAD_FAILED',
|
||||
'message' => 'The installer could not download the runtime bundle.',
|
||||
'diagnostics' => [
|
||||
'diag-1',
|
||||
'diag-2',
|
||||
'diag-3',
|
||||
'diag-4',
|
||||
'diag-5',
|
||||
'diag-6',
|
||||
'diag-7',
|
||||
'diag-8',
|
||||
],
|
||||
]);
|
||||
|
||||
$failedStatus = $context['manager']->getInstallTokenStatus($claimTokenId);
|
||||
|
||||
expect($failedStatus)
|
||||
->toHaveKey('status', 'FAILED')
|
||||
->toHaveKey('step', 'DOWNLOAD_FAILED')
|
||||
->toHaveKey('last_error', 'The installer could not download the runtime bundle.')
|
||||
->and($failedStatus['diagnostics'] ?? [])
|
||||
->toBeArray()
|
||||
->toHaveCount(6);
|
||||
|
||||
$claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-integration-host', 'php-agent-v1', [
|
||||
'source' => 'integration-test',
|
||||
]);
|
||||
|
||||
$gatewayId = (int)($claimed['gateway']['id'] ?? 0);
|
||||
$agentToken = (string)($claimed['agent_token'] ?? '');
|
||||
expect($gatewayId)->toBeGreaterThan(0)
|
||||
->and($agentToken)->not->toBe('');
|
||||
|
||||
$claimedStatus = $context['manager']->getInstallTokenStatus($claimTokenId);
|
||||
expect($claimedStatus)
|
||||
->toHaveKey('status', 'CLAIMED')
|
||||
->toHaveKey('gateway_id', $gatewayId)
|
||||
->toHaveKey('last_error', null);
|
||||
|
||||
edge_gateway_integration_set_heartbeat_age($context['mysqli'], $gatewayId, edge_gateway_manager::HEARTBEAT_DEGRADED_AFTER_SECONDS + 1);
|
||||
$degraded = $context['manager']->getGateway($gatewayId);
|
||||
expect($degraded)->toHaveKey('status', 'DEGRADED');
|
||||
|
||||
edge_gateway_integration_set_heartbeat_age($context['mysqli'], $gatewayId, edge_gateway_manager::HEARTBEAT_OFFLINE_AFTER_SECONDS + 1);
|
||||
$offline = $context['manager']->getGateway($gatewayId);
|
||||
expect($offline)->toHaveKey('status', 'OFFLINE');
|
||||
|
||||
$context['manager']->recordHeartbeat($gatewayId, $agentToken, [
|
||||
'status' => 'ONLINE',
|
||||
'hostname' => 'edge-integration-host',
|
||||
'metadata' => [
|
||||
'system_metrics' => [
|
||||
'cpu_percent' => 44,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$online = $context['manager']->getGateway($gatewayId);
|
||||
expect($online)
|
||||
->toHaveKey('status', 'ONLINE')
|
||||
->and($online['metadata']['system_metrics']['cpu_percent'] ?? null)
|
||||
->toBe(44);
|
||||
} finally {
|
||||
$context['cleanup']->run();
|
||||
}
|
||||
});
|
||||
|
||||
it('assembles tasks, logs, statistics, operations, commands, and shell lifecycle state from persisted records', function (): void {
|
||||
$context = edge_gateway_integration_context();
|
||||
|
||||
try {
|
||||
$department = $context['fixtures']->createDepartment([
|
||||
'name' => 'Edge Integration Runtime Department',
|
||||
]);
|
||||
$user = $context['fixtures']->createUser([
|
||||
'display_name' => 'Edge Integration User',
|
||||
]);
|
||||
|
||||
$token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Runtime Token', (int)$user['id']);
|
||||
$claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-runtime-host', 'php-agent-v1');
|
||||
$gatewayId = (int)($claimed['gateway']['id'] ?? 0);
|
||||
$agentToken = (string)($claimed['agent_token'] ?? '');
|
||||
|
||||
$operation = $context['operations']->queueOperation(
|
||||
$gatewayId,
|
||||
edge_gateway_operation_service::TYPE_DISCOVERY,
|
||||
['inventory' => edge_gateway_integration_inventory('runtime')],
|
||||
(int)$user['id']
|
||||
);
|
||||
$operationId = (int)($operation['id'] ?? 0);
|
||||
expect($operationId)->toBeGreaterThan(0);
|
||||
|
||||
$claimedOperation = $context['operations']->claimNextOperation($gatewayId, $agentToken, 0, 'integration-agent-1');
|
||||
expect($claimedOperation)
|
||||
->toBeArray()
|
||||
->toHaveKey('status', 'IN_PROGRESS');
|
||||
|
||||
$context['operations']->appendAgentOperationEvent($gatewayId, $operationId, $agentToken, [
|
||||
'level' => 'INFO',
|
||||
'code' => 'DISCOVERY_RUNNING',
|
||||
'message' => 'Integration discovery is executing.',
|
||||
'context' => [
|
||||
'progress' => 70,
|
||||
],
|
||||
]);
|
||||
|
||||
$context['operations']->completeAgentOperation($gatewayId, $operationId, $agentToken, [
|
||||
'ok' => true,
|
||||
'result' => [
|
||||
'inventory' => edge_gateway_integration_inventory('completed'),
|
||||
],
|
||||
]);
|
||||
|
||||
$job = $context['fixtures']->createEdgeCommandJob([
|
||||
'gateway_id' => $gatewayId,
|
||||
'command_type' => 'GET_RELAY_STATUS',
|
||||
'request' => [
|
||||
'relayId' => 'relay-main',
|
||||
],
|
||||
'requested_by' => (int)$user['id'],
|
||||
]);
|
||||
$jobId = (int)($job['id'] ?? 0);
|
||||
|
||||
$polledCommand = $context['manager']->pollCommand($gatewayId, $agentToken, 0);
|
||||
expect($polledCommand)
|
||||
->toBeArray()
|
||||
->toHaveKey('id', $jobId)
|
||||
->toHaveKey('command_type', 'GET_RELAY_STATUS');
|
||||
|
||||
$commandResult = $context['manager']->submitCommandResult($gatewayId, $jobId, $agentToken, true, [
|
||||
'relayId' => 'relay-main',
|
||||
'online' => true,
|
||||
]);
|
||||
expect($commandResult)
|
||||
->toHaveKey('acknowledged', true)
|
||||
->and($commandResult['job']['status'] ?? null)
|
||||
->toBe('COMPLETED');
|
||||
|
||||
$shellSession = $context['manager']->createShellSession(
|
||||
$gatewayId,
|
||||
(int)$user['id'],
|
||||
'Integration shell session',
|
||||
120,
|
||||
40,
|
||||
'/opt/truckwash-edge-agent'
|
||||
);
|
||||
$shellToken = (string)($shellSession['token'] ?? '');
|
||||
expect($shellToken)->not->toBe('');
|
||||
|
||||
$validatedShell = $context['manager']->validateShellSessionToken($shellToken);
|
||||
expect($validatedShell)->toHaveKey('status', 'PENDING');
|
||||
|
||||
$openedShell = $context['manager']->markShellSessionOpened($shellToken, 'shell-connection-1');
|
||||
expect($openedShell)->toHaveKey('status', 'OPEN');
|
||||
|
||||
$closedShell = $context['manager']->closeShellSessionByToken(
|
||||
$shellToken,
|
||||
"edge-shell-output\n",
|
||||
'agent_exit'
|
||||
);
|
||||
expect($closedShell)
|
||||
->toHaveKey('status', 'COMPLETED')
|
||||
->and($closedShell['transcript'] ?? null)
|
||||
->toBe("edge-shell-output\n");
|
||||
|
||||
$context['manager']->appendGatewayLogEntry(
|
||||
$gatewayId,
|
||||
'Integration log line',
|
||||
'INFO',
|
||||
'agent',
|
||||
'BROKER',
|
||||
['source' => 'integration']
|
||||
);
|
||||
|
||||
$context['manager']->recordBrokerPresence(
|
||||
$gatewayId,
|
||||
'connected',
|
||||
'broker-connection-1',
|
||||
null,
|
||||
['transport' => 'ws']
|
||||
);
|
||||
|
||||
$context['manager']->recordTelemetryFromBroker($gatewayId, [
|
||||
'status' => 'ONLINE',
|
||||
'metadata' => [
|
||||
'system_metrics' => [
|
||||
'cpu_percent' => 17,
|
||||
'memory_mb' => 256,
|
||||
],
|
||||
],
|
||||
'inventory' => edge_gateway_integration_inventory('telemetry'),
|
||||
]);
|
||||
|
||||
$tasks = $context['manager']->buildGatewayTasksPage($gatewayId);
|
||||
$logs = $context['manager']->buildGatewayLogsPage($gatewayId);
|
||||
$statistics = $context['manager']->buildGatewayStatisticsPage($gatewayId);
|
||||
|
||||
expect($tasks['operations'] ?? [])
|
||||
->toBeArray()
|
||||
->not->toBeEmpty()
|
||||
->and(($tasks['operations'][0]['status'] ?? null))
|
||||
->toBe('COMPLETED');
|
||||
expect($tasks['recent_commands'] ?? [])
|
||||
->toBeArray()
|
||||
->not->toBeEmpty()
|
||||
->and(($tasks['recent_commands'][0]['status'] ?? null))
|
||||
->toBe('COMPLETED');
|
||||
|
||||
expect(edge_gateway_integration_messages($logs['log_entries'] ?? []))
|
||||
->toContain('Integration log line');
|
||||
expect(edge_gateway_integration_messages($logs['timeline'] ?? []))
|
||||
->toContain('Integration discovery is executing.');
|
||||
expect($logs['shell_sessions'] ?? [])
|
||||
->toBeArray()
|
||||
->not->toBeEmpty()
|
||||
->and(($logs['shell_sessions'][0]['transcript'] ?? null))
|
||||
->toBe("edge-shell-output\n");
|
||||
|
||||
expect($statistics['system_metrics']['cpu_percent'] ?? null)
|
||||
->toBe(17);
|
||||
expect($statistics['gateway']['inventory'] ?? [])
|
||||
->toBeArray()
|
||||
->not->toBeEmpty();
|
||||
} finally {
|
||||
$context['cleanup']->run();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{cleanup:ApiCleanup,fixtures:ApiFixtures,manager:edge_gateway_manager,operations:edge_gateway_operation_service,mysqli:mysqli}
|
||||
*/
|
||||
function edge_gateway_integration_context(): array
|
||||
{
|
||||
if (!integration_enabled()) {
|
||||
test()->markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run edge gateway integration tests.');
|
||||
}
|
||||
|
||||
static $bootstrapped = null;
|
||||
|
||||
if ($bootstrapped === null) {
|
||||
$dbConfig = edge_gateway_integration_db_config();
|
||||
$GLOBALS['CONFIG_DB'] = $dbConfig;
|
||||
$GLOBALS['response'] = new class {
|
||||
public function internal_server_error(string $message): void
|
||||
{
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
};
|
||||
|
||||
$db = new db($dbConfig);
|
||||
$db->connect();
|
||||
$GLOBALS['db'] = $db;
|
||||
|
||||
$mysqli = $db->conn();
|
||||
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
|
||||
$mysqli->set_charset('utf8mb4');
|
||||
|
||||
(new ApiSchemaBootstrap($mysqli))->ensureSchema();
|
||||
|
||||
$bootstrapped = [
|
||||
'mysqli' => $mysqli,
|
||||
'redis' => edge_gateway_integration_redis_client(),
|
||||
];
|
||||
}
|
||||
|
||||
$cleanup = new ApiCleanup();
|
||||
|
||||
return [
|
||||
'cleanup' => $cleanup,
|
||||
'fixtures' => new ApiFixtures($bootstrapped['mysqli'], $bootstrapped['redis'], $cleanup),
|
||||
'manager' => new edge_gateway_manager(),
|
||||
'operations' => new edge_gateway_operation_service(),
|
||||
'mysqli' => $bootstrapped['mysqli'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{host:string,user:string,password:string,database:string,port:int}
|
||||
*/
|
||||
function edge_gateway_integration_db_config(): array
|
||||
{
|
||||
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
|
||||
if ($target !== 'debug') {
|
||||
$target = 'live';
|
||||
}
|
||||
|
||||
$host = edge_gateway_integration_config_value('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target);
|
||||
$user = edge_gateway_integration_config_value('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target);
|
||||
$password = edge_gateway_integration_config_value('CONFIG_DB_PASSWORD', 'CONFIG_DB_DEBUG_PASSWORD', $target);
|
||||
$database = edge_gateway_integration_config_value('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target);
|
||||
$port = (int)(edge_gateway_integration_config_value('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306');
|
||||
|
||||
if ($host === '' || $user === '' || $database === '') {
|
||||
test()->markTestSkipped('Edge gateway integration tests require configured database environment variables.');
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => $host,
|
||||
'user' => $user,
|
||||
'password' => $password,
|
||||
'database' => $database,
|
||||
'port' => $port > 0 ? $port : 3306,
|
||||
];
|
||||
}
|
||||
|
||||
function edge_gateway_integration_redis_client(): ?PredisClient
|
||||
{
|
||||
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
|
||||
if ($target !== 'debug') {
|
||||
$target = 'live';
|
||||
}
|
||||
|
||||
$host = edge_gateway_integration_config_value('REDIS_CONFIG_HOST', 'REDIS_CONFIG_DEBUG_HOST', $target);
|
||||
if ($host === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parameters = [
|
||||
'scheme' => 'tcp',
|
||||
'host' => $host,
|
||||
'port' => (int)(edge_gateway_integration_config_value('REDIS_CONFIG_PORT', 'REDIS_CONFIG_DEBUG_PORT', $target) ?: '6379'),
|
||||
'database' => (int)(edge_gateway_integration_config_value('REDIS_CONFIG_DATABASE', 'REDIS_CONFIG_DEBUG_DATABASE', $target) ?: '0'),
|
||||
'password' => edge_gateway_integration_config_value('REDIS_CONFIG_PASSWORD', 'REDIS_CONFIG_DEBUG_PASSWORD', $target),
|
||||
];
|
||||
|
||||
$user = edge_gateway_integration_config_value('REDIS_CONFIG_USER', 'REDIS_CONFIG_DEBUG_USER', $target);
|
||||
if ($user !== '') {
|
||||
$parameters['username'] = $user;
|
||||
}
|
||||
|
||||
return new PredisClient($parameters);
|
||||
}
|
||||
|
||||
function edge_gateway_integration_config_value(string $liveKey, string $debugKey, string $target): string
|
||||
{
|
||||
$liveValue = trim((string)(getenv($liveKey) ?: ''));
|
||||
$debugValue = trim((string)(getenv($debugKey) ?: ''));
|
||||
|
||||
if ($target === 'debug' && $debugValue !== '') {
|
||||
return $debugValue;
|
||||
}
|
||||
|
||||
return $liveValue;
|
||||
}
|
||||
|
||||
function edge_gateway_integration_set_heartbeat_age(mysqli $mysqli, int $gatewayId, int $secondsAgo): void
|
||||
{
|
||||
$timestamp = date('Y-m-d H:i:s', time() - max(0, $secondsAgo));
|
||||
$escaped = $mysqli->real_escape_string($timestamp);
|
||||
$mysqli->query("UPDATE edge_gateways SET last_heartbeat_at = '{$escaped}', status = 'ONLINE' WHERE id = " . (int)$gatewayId);
|
||||
}
|
||||
|
||||
function edge_gateway_integration_inventory(string $suffix): array
|
||||
{
|
||||
return [[
|
||||
'device_id' => 'integration-' . $suffix,
|
||||
'local_ip' => '10.50.60.70',
|
||||
'model' => 'TruckWash Integration Gateway',
|
||||
'channel_count' => 1,
|
||||
'online' => true,
|
||||
'capabilities' => [
|
||||
'gateway_management_v2' => true,
|
||||
],
|
||||
'metadata' => [
|
||||
'hostname' => 'integration-' . $suffix,
|
||||
],
|
||||
]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $rows
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function edge_gateway_integration_messages(mixed $rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$messages = [];
|
||||
foreach ($rows as $row) {
|
||||
if (is_array($row) && isset($row['message']) && is_string($row['message'])) {
|
||||
$messages[] = $row['message'];
|
||||
}
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
@@ -445,6 +445,25 @@ final class ApiFixtures
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
* @param array<string, mixed> $userAttributes
|
||||
* @return array{user:array<string,mixed>,token:string,headers:array<string,string>}
|
||||
*/
|
||||
public function createEdgeOperatorSession(int $departmentId, array $permissions = [], array $userAttributes = []): array
|
||||
{
|
||||
if ($departmentId <= 0) {
|
||||
throw new RuntimeException('Edge operator sessions require a positive department id.');
|
||||
}
|
||||
|
||||
$permissions = array_values(array_unique(array_merge(
|
||||
['modules_shelly_config', 'department_access_' . $departmentId],
|
||||
$permissions
|
||||
)));
|
||||
|
||||
return $this->createUserSession($permissions, $userAttributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
* @return array{user:array<string,mixed>,subuser:array<string,mixed>,token:string,headers:array<string,string>}
|
||||
@@ -593,6 +612,11 @@ final class ApiFixtures
|
||||
], $extraHeaders);
|
||||
}
|
||||
|
||||
public function clearEdgeGatewayViewCache(): void
|
||||
{
|
||||
$this->deleteRedisPattern('edge_gateway:view:v1:*');
|
||||
}
|
||||
|
||||
public function fetchRowById(string $table, int $id): ?array
|
||||
{
|
||||
$table = $this->sanitizeIdentifier($table);
|
||||
@@ -600,6 +624,395 @@ final class ApiFixtures
|
||||
return $this->queryOneBySql("SELECT * FROM `{$table}` WHERE id = {$id} LIMIT 1");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createEdgeInstallToken(array $attributes): array
|
||||
{
|
||||
$departmentId = (int)($attributes['department_id'] ?? 0);
|
||||
if ($departmentId <= 0) {
|
||||
throw new RuntimeException('Edge install tokens require department_id.');
|
||||
}
|
||||
|
||||
$token = (string)($attributes['token'] ?? (bin2hex(random_bytes(18)) . $this->uniqueSuffix()));
|
||||
$createdAt = (string)($attributes['created_at'] ?? $this->now());
|
||||
$expiresAt = (string)($attributes['expires_at'] ?? date('Y-m-d H:i:s', strtotime($createdAt) + 1800));
|
||||
$installSession = [
|
||||
'status' => (string)($attributes['status'] ?? 'PENDING'),
|
||||
'step' => (string)($attributes['step'] ?? 'PENDING'),
|
||||
'message' => (string)($attributes['message'] ?? 'Installer command generated. Run it on the gateway host.'),
|
||||
'started_at' => $createdAt,
|
||||
'updated_at' => $createdAt,
|
||||
'terminal' => false,
|
||||
'gateway_id' => $attributes['gateway_id'] ?? null,
|
||||
'last_error' => $attributes['last_error'] ?? null,
|
||||
'diagnostics' => isset($attributes['diagnostics']) && is_array($attributes['diagnostics'])
|
||||
? (array)$attributes['diagnostics']
|
||||
: [],
|
||||
'events' => isset($attributes['events']) && is_array($attributes['events'])
|
||||
? (array)$attributes['events']
|
||||
: [
|
||||
[
|
||||
'status' => (string)($attributes['status'] ?? 'PENDING'),
|
||||
'step' => (string)($attributes['step'] ?? 'PENDING'),
|
||||
'message' => (string)($attributes['message'] ?? 'Installer command generated. Run it on the gateway host.'),
|
||||
'at' => $createdAt,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$claimTokenId = $this->insertRow('edge_gateway_claim_tokens', [
|
||||
'department_id' => $departmentId,
|
||||
'label' => $attributes['label'] ?? ('Edge Install ' . $this->uniqueSuffix()),
|
||||
'token_hash' => hash('sha256', $token),
|
||||
'created_by' => $attributes['created_by'] ?? null,
|
||||
'expires_at' => $expiresAt,
|
||||
'used_at' => $attributes['used_at'] ?? null,
|
||||
'metadata_json' => array_merge(
|
||||
isset($attributes['metadata']) && is_array($attributes['metadata']) ? (array)$attributes['metadata'] : [],
|
||||
['install_session' => $installSession]
|
||||
),
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $attributes['updated_at'] ?? $createdAt,
|
||||
'deleted_at' => $attributes['deleted_at'] ?? null,
|
||||
]);
|
||||
|
||||
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_claim_tokens', $claimTokenId));
|
||||
|
||||
return [
|
||||
'claim_token_id' => $claimTokenId,
|
||||
'department_id' => $departmentId,
|
||||
'label' => $attributes['label'] ?? null,
|
||||
'token' => $token,
|
||||
'expires_at' => $expiresAt,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createClaimedEdgeGateway(array $attributes): array
|
||||
{
|
||||
$departmentId = (int)($attributes['department_id'] ?? 0);
|
||||
if ($departmentId <= 0) {
|
||||
throw new RuntimeException('Claimed edge gateways require department_id.');
|
||||
}
|
||||
|
||||
$agentToken = (string)($attributes['agent_token'] ?? (bin2hex(random_bytes(24)) . $this->uniqueSuffix()));
|
||||
$createdAt = (string)($attributes['created_at'] ?? $this->now());
|
||||
$metadata = array_merge([
|
||||
'credentials_rotated_at' => $createdAt,
|
||||
'agent_runtime' => 'compose-php',
|
||||
'runtime_mode' => 'compose',
|
||||
'update_window' => '02:00-04:00',
|
||||
'container_health' => [
|
||||
'overall_status' => 'PENDING',
|
||||
'services' => [],
|
||||
],
|
||||
'outbox_status' => [
|
||||
'depth' => 0,
|
||||
'oldest_age_seconds' => 0,
|
||||
'last_flushed_at' => null,
|
||||
'pending_types' => [],
|
||||
],
|
||||
'rollback_status' => [
|
||||
'state' => 'NONE',
|
||||
'reason' => null,
|
||||
'at' => null,
|
||||
],
|
||||
'last_sync_at' => null,
|
||||
], isset($attributes['metadata']) && is_array($attributes['metadata']) ? (array)$attributes['metadata'] : []);
|
||||
|
||||
$gatewayId = $this->insertRow('edge_gateways', [
|
||||
'department_id' => $departmentId,
|
||||
'label' => (string)($attributes['label'] ?? ('Edge Gateway ' . $this->uniqueSuffix())),
|
||||
'hostname' => $attributes['hostname'] ?? ('edge-' . $this->uniqueSuffix()),
|
||||
'agent_token_hash' => hash('sha256', $agentToken),
|
||||
'status' => (string)($attributes['status'] ?? 'ONLINE'),
|
||||
'transport_mode' => (string)($attributes['transport_mode'] ?? 'gateway'),
|
||||
'release_channel' => (string)($attributes['release_channel'] ?? 'stable'),
|
||||
'installed_version' => $attributes['installed_version'] ?? 'php-agent-v1',
|
||||
'target_version' => $attributes['target_version'] ?? ($attributes['installed_version'] ?? 'php-agent-v1'),
|
||||
'last_heartbeat_at' => $attributes['last_heartbeat_at'] ?? $createdAt,
|
||||
'last_seen_ip' => $attributes['last_seen_ip'] ?? '127.0.0.1',
|
||||
'discovery_status' => (string)($attributes['discovery_status'] ?? 'PENDING'),
|
||||
'is_primary' => $attributes['is_primary'] ?? 1,
|
||||
'metadata_json' => $metadata,
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $attributes['updated_at'] ?? $createdAt,
|
||||
'deleted_at' => $attributes['deleted_at'] ?? null,
|
||||
]);
|
||||
|
||||
$this->cleanup->add(function () use ($gatewayId): void {
|
||||
$this->deleteWhere('edge_gateway_operation_events', ['gateway_id' => $gatewayId]);
|
||||
$this->deleteWhere('edge_gateway_operations', ['gateway_id' => $gatewayId]);
|
||||
$this->deleteWhere('edge_gateway_command_jobs', ['gateway_id' => $gatewayId]);
|
||||
$this->deleteWhere('edge_gateway_device_inventory', ['gateway_id' => $gatewayId]);
|
||||
$this->deleteWhere('edge_gateway_relay_bindings', ['gateway_id' => $gatewayId]);
|
||||
$this->deleteWhere('edge_gateway_log_entries', ['gateway_id' => $gatewayId]);
|
||||
$this->deleteWhere('edge_gateway_shell_sessions', ['gateway_id' => $gatewayId]);
|
||||
$this->deleteWhere('edge_gateway_audit_logs', ['gateway_id' => $gatewayId]);
|
||||
$this->deleteById('edge_gateways', $gatewayId);
|
||||
$this->clearEdgeGatewayViewCache();
|
||||
});
|
||||
|
||||
return [
|
||||
'id' => $gatewayId,
|
||||
'department_id' => $departmentId,
|
||||
'label' => (string)($attributes['label'] ?? ''),
|
||||
'agent_token' => $agentToken,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createEdgeCommandJob(array $attributes): array
|
||||
{
|
||||
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
|
||||
if ($gatewayId <= 0) {
|
||||
throw new RuntimeException('Edge command jobs require gateway_id.');
|
||||
}
|
||||
|
||||
$jobId = $this->insertRow('edge_gateway_command_jobs', [
|
||||
'gateway_id' => $gatewayId,
|
||||
'command_type' => (string)($attributes['command_type'] ?? 'DISCOVER_SHELLY'),
|
||||
'status' => (string)($attributes['status'] ?? 'PENDING'),
|
||||
'request_json' => isset($attributes['request']) && is_array($attributes['request']) ? (array)$attributes['request'] : [],
|
||||
'response_json' => isset($attributes['response']) && is_array($attributes['response']) ? (array)$attributes['response'] : [],
|
||||
'delivery_json' => isset($attributes['delivery']) && is_array($attributes['delivery']) ? (array)$attributes['delivery'] : [],
|
||||
'correlation_id' => (string)($attributes['correlation_id'] ?? ('edge-command-' . $this->uniqueSuffix())),
|
||||
'requested_by' => $attributes['requested_by'] ?? null,
|
||||
'requested_at' => $attributes['requested_at'] ?? $this->now(),
|
||||
'completed_at' => $attributes['completed_at'] ?? null,
|
||||
'error_message' => $attributes['error_message'] ?? null,
|
||||
'created_at' => $attributes['created_at'] ?? $this->now(),
|
||||
'updated_at' => $attributes['updated_at'] ?? $this->now(),
|
||||
'deleted_at' => $attributes['deleted_at'] ?? null,
|
||||
]);
|
||||
|
||||
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_command_jobs', $jobId));
|
||||
|
||||
return [
|
||||
'id' => $jobId,
|
||||
'gateway_id' => $gatewayId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createEdgeOperation(array $attributes): array
|
||||
{
|
||||
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
|
||||
if ($gatewayId <= 0) {
|
||||
throw new RuntimeException('Edge operations require gateway_id.');
|
||||
}
|
||||
|
||||
$operationId = $this->insertRow('edge_gateway_operations', [
|
||||
'gateway_id' => $gatewayId,
|
||||
'type' => (string)($attributes['type'] ?? 'DISCOVERY'),
|
||||
'operation_type' => (string)($attributes['operation_type'] ?? ($attributes['type'] ?? 'DISCOVERY')),
|
||||
'status' => (string)($attributes['status'] ?? 'PENDING'),
|
||||
'request_json' => isset($attributes['request']) && is_array($attributes['request']) ? (array)$attributes['request'] : [],
|
||||
'summary_json' => isset($attributes['summary']) && is_array($attributes['summary']) ? (array)$attributes['summary'] : [
|
||||
'label' => 'Queued',
|
||||
'progress' => 0,
|
||||
'retryable' => true,
|
||||
],
|
||||
'result_json' => isset($attributes['result']) && is_array($attributes['result']) ? (array)$attributes['result'] : [],
|
||||
'error_code' => $attributes['error_code'] ?? null,
|
||||
'error_message' => $attributes['error_message'] ?? null,
|
||||
'correlation_id' => (string)($attributes['correlation_id'] ?? ('edge-operation-' . $this->uniqueSuffix())),
|
||||
'agent_instance_id' => $attributes['agent_instance_id'] ?? null,
|
||||
'lease_expires_at' => $attributes['lease_expires_at'] ?? null,
|
||||
'last_progress_at' => $attributes['last_progress_at'] ?? null,
|
||||
'attempt_count' => $attributes['attempt_count'] ?? 0,
|
||||
'requested_by' => $attributes['requested_by'] ?? null,
|
||||
'requested_at' => $attributes['requested_at'] ?? $this->now(),
|
||||
'started_at' => $attributes['started_at'] ?? null,
|
||||
'completed_at' => $attributes['completed_at'] ?? null,
|
||||
'created_at' => $attributes['created_at'] ?? $this->now(),
|
||||
'updated_at' => $attributes['updated_at'] ?? $this->now(),
|
||||
'deleted_at' => $attributes['deleted_at'] ?? null,
|
||||
]);
|
||||
|
||||
$this->cleanup->add(function () use ($gatewayId, $operationId): void {
|
||||
$this->deleteWhere('edge_gateway_operation_events', [
|
||||
'gateway_id' => $gatewayId,
|
||||
'operation_id' => $operationId,
|
||||
]);
|
||||
$this->deleteById('edge_gateway_operations', $operationId);
|
||||
});
|
||||
|
||||
return [
|
||||
'id' => $operationId,
|
||||
'gateway_id' => $gatewayId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createEdgeOperationEvent(array $attributes): array
|
||||
{
|
||||
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
|
||||
$operationId = (int)($attributes['operation_id'] ?? 0);
|
||||
if ($gatewayId <= 0 || $operationId <= 0) {
|
||||
throw new RuntimeException('Edge operation events require gateway_id and operation_id.');
|
||||
}
|
||||
|
||||
$eventId = $this->insertRow('edge_gateway_operation_events', [
|
||||
'gateway_id' => $gatewayId,
|
||||
'operation_id' => $operationId,
|
||||
'stage' => (string)($attributes['stage'] ?? 'RECORDED'),
|
||||
'level' => (string)($attributes['level'] ?? 'INFO'),
|
||||
'code' => $attributes['code'] ?? null,
|
||||
'message' => (string)($attributes['message'] ?? 'Edge operation event'),
|
||||
'context_json' => isset($attributes['context']) && is_array($attributes['context']) ? (array)$attributes['context'] : [],
|
||||
'counts_json' => isset($attributes['counts']) && is_array($attributes['counts']) ? (array)$attributes['counts'] : [],
|
||||
'payload_json' => isset($attributes['payload']) && is_array($attributes['payload']) ? (array)$attributes['payload'] : [],
|
||||
'created_at' => $attributes['created_at'] ?? $this->now(),
|
||||
'deleted_at' => $attributes['deleted_at'] ?? null,
|
||||
]);
|
||||
|
||||
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_operation_events', $eventId));
|
||||
|
||||
return [
|
||||
'id' => $eventId,
|
||||
'gateway_id' => $gatewayId,
|
||||
'operation_id' => $operationId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createEdgeAuditLog(array $attributes): array
|
||||
{
|
||||
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
|
||||
$departmentId = (int)($attributes['department_id'] ?? 0);
|
||||
if ($gatewayId <= 0 || $departmentId <= 0) {
|
||||
throw new RuntimeException('Edge audit logs require gateway_id and department_id.');
|
||||
}
|
||||
|
||||
$auditLogId = $this->insertRow('edge_gateway_audit_logs', [
|
||||
'gateway_id' => $gatewayId,
|
||||
'department_id' => $departmentId,
|
||||
'action' => (string)($attributes['action'] ?? 'EDGE_AUDIT'),
|
||||
'actor_user_id' => $attributes['actor_user_id'] ?? null,
|
||||
'actor_type' => (string)($attributes['actor_type'] ?? 'USER'),
|
||||
'severity' => (string)($attributes['severity'] ?? 'INFO'),
|
||||
'context_json' => isset($attributes['context']) && is_array($attributes['context']) ? (array)$attributes['context'] : [],
|
||||
'created_at' => $attributes['created_at'] ?? $this->now(),
|
||||
'updated_at' => $attributes['updated_at'] ?? $this->now(),
|
||||
]);
|
||||
|
||||
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_audit_logs', $auditLogId));
|
||||
|
||||
return [
|
||||
'id' => $auditLogId,
|
||||
'gateway_id' => $gatewayId,
|
||||
'department_id' => $departmentId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createEdgeLogEntry(array $attributes): array
|
||||
{
|
||||
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
|
||||
if ($gatewayId <= 0) {
|
||||
throw new RuntimeException('Edge log entries require gateway_id.');
|
||||
}
|
||||
|
||||
$gatewayRow = $this->fetchRowById('edge_gateways', $gatewayId);
|
||||
$departmentId = (int)($attributes['department_id'] ?? ($gatewayRow['department_id'] ?? 0));
|
||||
|
||||
$logEntryId = $this->insertRow('edge_gateway_log_entries', [
|
||||
'gateway_id' => $gatewayId,
|
||||
'department_id' => $departmentId > 0 ? $departmentId : null,
|
||||
'level' => (string)($attributes['level'] ?? 'INFO'),
|
||||
'stream' => (string)($attributes['stream'] ?? 'agent'),
|
||||
'source' => (string)($attributes['source'] ?? 'BROKER'),
|
||||
'message' => (string)($attributes['message'] ?? 'Edge gateway log entry'),
|
||||
'context_json' => isset($attributes['context']) && is_array($attributes['context']) ? (array)$attributes['context'] : [],
|
||||
'created_at' => $attributes['created_at'] ?? $this->now(),
|
||||
'updated_at' => $attributes['updated_at'] ?? $this->now(),
|
||||
]);
|
||||
|
||||
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_log_entries', $logEntryId));
|
||||
|
||||
return [
|
||||
'id' => $logEntryId,
|
||||
'gateway_id' => $gatewayId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createEdgeShellSession(array $attributes): array
|
||||
{
|
||||
$gatewayId = (int)($attributes['gateway_id'] ?? 0);
|
||||
if ($gatewayId <= 0) {
|
||||
throw new RuntimeException('Edge shell sessions require gateway_id.');
|
||||
}
|
||||
|
||||
$gatewayRow = $this->fetchRowById('edge_gateways', $gatewayId);
|
||||
$departmentId = (int)($attributes['department_id'] ?? ($gatewayRow['department_id'] ?? 0));
|
||||
if ($departmentId <= 0) {
|
||||
throw new RuntimeException('Edge shell sessions require department_id or a valid gateway row.');
|
||||
}
|
||||
|
||||
$sessionToken = (string)($attributes['token'] ?? bin2hex(random_bytes(24)));
|
||||
$createdAt = (string)($attributes['created_at'] ?? $this->now());
|
||||
$expiresAt = (string)($attributes['expires_at'] ?? date('Y-m-d H:i:s', strtotime($createdAt) + 900));
|
||||
|
||||
$sessionId = $this->insertRow('edge_gateway_shell_sessions', [
|
||||
'gateway_id' => $gatewayId,
|
||||
'department_id' => $departmentId,
|
||||
'actor_user_id' => $attributes['actor_user_id'] ?? null,
|
||||
'session_token_hash' => hash('sha256', $sessionToken),
|
||||
'status' => (string)($attributes['status'] ?? 'PENDING'),
|
||||
'reason' => (string)($attributes['reason'] ?? 'Diagnostic shell session'),
|
||||
'connection_id' => $attributes['connection_id'] ?? null,
|
||||
'cwd' => $attributes['cwd'] ?? '/opt/truckwash-edge-agent',
|
||||
'shell_command' => $attributes['shell_command'] ?? null,
|
||||
'shell_args_json' => isset($attributes['shell_args']) && is_array($attributes['shell_args']) ? (array)$attributes['shell_args'] : [],
|
||||
'cols' => $attributes['cols'] ?? 120,
|
||||
'terminal_rows' => $attributes['rows'] ?? 32,
|
||||
'transcript' => $attributes['transcript'] ?? null,
|
||||
'metadata_json' => isset($attributes['metadata']) && is_array($attributes['metadata']) ? (array)$attributes['metadata'] : [],
|
||||
'expires_at' => $expiresAt,
|
||||
'approved_at' => $attributes['approved_at'] ?? $createdAt,
|
||||
'opened_at' => $attributes['opened_at'] ?? null,
|
||||
'closed_at' => $attributes['closed_at'] ?? null,
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $attributes['updated_at'] ?? $createdAt,
|
||||
'deleted_at' => $attributes['deleted_at'] ?? null,
|
||||
]);
|
||||
|
||||
$this->cleanup->add(fn() => $this->deleteById('edge_gateway_shell_sessions', $sessionId));
|
||||
|
||||
return [
|
||||
'id' => $sessionId,
|
||||
'gateway_id' => $gatewayId,
|
||||
'department_id' => $departmentId,
|
||||
'token' => $sessionToken,
|
||||
'expires_at' => $expiresAt,
|
||||
];
|
||||
}
|
||||
|
||||
public function cleanupDeleteById(string $table, int $id): void
|
||||
{
|
||||
$this->cleanup->add(fn() => $this->deleteById($table, $id));
|
||||
|
||||
@@ -49,3 +49,21 @@ function assert_api_envelope(ApiResponse $response): ApiResponse
|
||||
{
|
||||
return $response->assertEnvelope();
|
||||
}
|
||||
|
||||
function edge_test_broker_secret(): string
|
||||
{
|
||||
$secret = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
|
||||
|
||||
return $secret !== '' ? $secret : 'truckwash-edge-test-secret';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $extraHeaders
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function edge_test_broker_headers(array $extraHeaders = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'X-Edge-Broker-Secret' => edge_test_broker_secret(),
|
||||
], $extraHeaders);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use classes\db;
|
||||
use Tests\Support\Api\ApiCleanup;
|
||||
use Tests\Support\Api\ApiFixtures;
|
||||
use Tests\Support\Api\ApiSchemaBootstrap;
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once __DIR__ . '/Api/ApiCleanup.php';
|
||||
require_once __DIR__ . '/Api/ApiFixtures.php';
|
||||
require_once __DIR__ . '/Api/ApiSchemaBootstrap.php';
|
||||
|
||||
app_require('classes/db.php');
|
||||
app_require('classes/edge_gateway_manager.php');
|
||||
|
||||
$action = strtolower(trim((string)($argv[1] ?? '')));
|
||||
if ($action === '') {
|
||||
edge_gateway_e2e_fixture_fail('Missing action. Expected create or cleanup.');
|
||||
}
|
||||
|
||||
try {
|
||||
if ($action === 'create') {
|
||||
$context = edge_gateway_e2e_fixture_context();
|
||||
$fixtures = $context['fixtures'];
|
||||
$department = $fixtures->createDepartment([
|
||||
'name' => 'Edge Gateway E2E Department',
|
||||
]);
|
||||
$session = $fixtures->createEdgeOperatorSession((int)$department['id'], [], [
|
||||
'display_name' => 'Edge Gateway E2E Operator',
|
||||
]);
|
||||
|
||||
edge_gateway_e2e_fixture_output([
|
||||
'department_id' => (int)$department['id'],
|
||||
'user_id' => (int)$session['user']['id'],
|
||||
'group_id' => (int)$session['user']['group_id'],
|
||||
'customer_number' => (int)$session['user']['customer_number'],
|
||||
'auth_token' => (string)$session['token'],
|
||||
'broker_secret' => edge_test_broker_secret(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($action === 'cleanup') {
|
||||
$encoded = trim((string)($argv[2] ?? ''));
|
||||
if ($encoded === '') {
|
||||
edge_gateway_e2e_fixture_fail('Cleanup requires a base64url payload argument.');
|
||||
}
|
||||
|
||||
$payload = json_decode(base64_decode(strtr($encoded, '-_', '+/')) ?: '', true);
|
||||
if (!is_array($payload)) {
|
||||
edge_gateway_e2e_fixture_fail('Invalid cleanup payload.');
|
||||
}
|
||||
|
||||
$context = edge_gateway_e2e_fixture_context();
|
||||
$db = $context['mysqli'];
|
||||
$departmentId = (int)($payload['department_id'] ?? 0);
|
||||
$userId = (int)($payload['user_id'] ?? 0);
|
||||
$groupId = (int)($payload['group_id'] ?? 0);
|
||||
|
||||
if ($departmentId > 0) {
|
||||
$gatewayIds = edge_gateway_e2e_gateway_ids($db, $departmentId);
|
||||
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_operation_events', $gatewayIds);
|
||||
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_operations', $gatewayIds);
|
||||
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_command_jobs', $gatewayIds);
|
||||
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_log_entries', $gatewayIds);
|
||||
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_shell_sessions', $gatewayIds);
|
||||
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_device_inventory', $gatewayIds);
|
||||
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_relay_bindings', $gatewayIds);
|
||||
edge_gateway_e2e_delete_by_gateway_ids($db, 'edge_gateway_audit_logs', $gatewayIds);
|
||||
|
||||
$db->query('DELETE FROM edge_gateway_claim_tokens WHERE department_id = ' . $departmentId);
|
||||
$db->query('DELETE FROM edge_gateways WHERE department_id = ' . $departmentId);
|
||||
$db->query('DELETE FROM department_variables WHERE department_id = ' . $departmentId);
|
||||
}
|
||||
|
||||
if ($userId > 0) {
|
||||
$db->query('DELETE FROM tokens WHERE user_id = ' . $userId);
|
||||
$db->query('DELETE FROM users WHERE id = ' . $userId);
|
||||
}
|
||||
|
||||
if ($groupId > 0) {
|
||||
$db->query('DELETE FROM groups_permissions WHERE group_id = ' . $groupId);
|
||||
$db->query('DELETE FROM groups WHERE id = ' . $groupId);
|
||||
}
|
||||
|
||||
if ($departmentId > 0) {
|
||||
$db->query('DELETE FROM departments WHERE id = ' . $departmentId);
|
||||
}
|
||||
|
||||
edge_gateway_e2e_fixture_output(['ok' => true]);
|
||||
}
|
||||
|
||||
edge_gateway_e2e_fixture_fail('Unsupported action: ' . $action);
|
||||
} catch (Throwable $throwable) {
|
||||
edge_gateway_e2e_fixture_fail($throwable->getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{mysqli:mysqli,fixtures:ApiFixtures}
|
||||
*/
|
||||
function edge_gateway_e2e_fixture_context(): array
|
||||
{
|
||||
static $context = null;
|
||||
|
||||
if ($context !== null) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
$dbConfig = edge_gateway_e2e_db_config();
|
||||
$GLOBALS['CONFIG_DB'] = $dbConfig;
|
||||
$GLOBALS['response'] = new class {
|
||||
public function internal_server_error(string $message): void
|
||||
{
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
};
|
||||
|
||||
$db = new db($dbConfig);
|
||||
$db->connect();
|
||||
$GLOBALS['db'] = $db;
|
||||
|
||||
$mysqli = $db->conn();
|
||||
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
|
||||
$mysqli->set_charset('utf8mb4');
|
||||
|
||||
(new ApiSchemaBootstrap($mysqli))->ensureSchema();
|
||||
|
||||
$context = [
|
||||
'mysqli' => $mysqli,
|
||||
'fixtures' => new ApiFixtures($mysqli, null, new ApiCleanup()),
|
||||
];
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{host:string,user:string,password:string,database:string,port:int}
|
||||
*/
|
||||
function edge_gateway_e2e_db_config(): array
|
||||
{
|
||||
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
|
||||
if ($target !== 'debug') {
|
||||
$target = 'live';
|
||||
}
|
||||
|
||||
$host = edge_gateway_e2e_config_value('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target);
|
||||
$user = edge_gateway_e2e_config_value('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target);
|
||||
$password = edge_gateway_e2e_config_value('CONFIG_DB_PASSWORD', 'CONFIG_DB_DEBUG_PASSWORD', $target);
|
||||
$database = edge_gateway_e2e_config_value('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target);
|
||||
$port = (int)(edge_gateway_e2e_config_value('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306');
|
||||
|
||||
if ($host === '' || $user === '' || $database === '') {
|
||||
throw new RuntimeException('Missing database configuration for edge gateway E2E fixtures.');
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => $host,
|
||||
'user' => $user,
|
||||
'password' => $password,
|
||||
'database' => $database,
|
||||
'port' => $port > 0 ? $port : 3306,
|
||||
];
|
||||
}
|
||||
|
||||
function edge_gateway_e2e_config_value(string $liveKey, string $debugKey, string $target): string
|
||||
{
|
||||
$liveValue = trim((string)(getenv($liveKey) ?: ''));
|
||||
$debugValue = trim((string)(getenv($debugKey) ?: ''));
|
||||
|
||||
if ($target === 'debug' && $debugValue !== '') {
|
||||
return $debugValue;
|
||||
}
|
||||
|
||||
return $liveValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
function edge_gateway_e2e_gateway_ids(mysqli $db, int $departmentId): array
|
||||
{
|
||||
$ids = [];
|
||||
$result = $db->query('SELECT id FROM edge_gateways WHERE department_id = ' . $departmentId);
|
||||
if ($result === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if (isset($row['id']) && is_numeric($row['id'])) {
|
||||
$ids[] = (int)$row['id'];
|
||||
}
|
||||
}
|
||||
|
||||
$result->free();
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $gatewayIds
|
||||
*/
|
||||
function edge_gateway_e2e_delete_by_gateway_ids(mysqli $db, string $table, array $gatewayIds): void
|
||||
{
|
||||
$gatewayIds = array_values(array_unique(array_filter(array_map('intval', $gatewayIds), static fn(int $id): bool => $id > 0)));
|
||||
if ($gatewayIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query('DELETE FROM `' . $table . '` WHERE gateway_id IN (' . implode(', ', $gatewayIds) . ')');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
function edge_gateway_e2e_fixture_output(array $payload): void
|
||||
{
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
|
||||
exit(0);
|
||||
}
|
||||
|
||||
function edge_gateway_e2e_fixture_fail(string $message): never
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
@@ -95,6 +95,14 @@ function integration_enabled(): bool
|
||||
return getenv('RUN_INTEGRATION_TESTS') === '1';
|
||||
}
|
||||
|
||||
$edgeBrokerSharedSecret = trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
|
||||
if ($edgeBrokerSharedSecret === '') {
|
||||
$edgeBrokerSharedSecret = 'truckwash-edge-test-secret';
|
||||
putenv('EDGE_BROKER_SHARED_SECRET=' . $edgeBrokerSharedSecret);
|
||||
$_ENV['EDGE_BROKER_SHARED_SECRET'] = $edgeBrokerSharedSecret;
|
||||
$_SERVER['EDGE_BROKER_SHARED_SECRET'] = $edgeBrokerSharedSecret;
|
||||
}
|
||||
|
||||
function run_legacy_script(string $relativeScriptPath): array
|
||||
{
|
||||
$script = app_path($relativeScriptPath);
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
it('nullifies department lane relay fields when blank select values are submitted', function (): void {
|
||||
$route = file_get_contents(app_path('routes/departmentLanesRoute.php'));
|
||||
|
||||
expect($route)->not->toBeFalse();
|
||||
expect($route)->toContain('private static function normalizeRelayRequestParameter');
|
||||
expect($route)->toContain('private static function syncDepartmentLaneRelayValue');
|
||||
expect($route)->toContain('$field->nullify();');
|
||||
expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_in_id, $relay_in_id);');
|
||||
expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_out_id, $relay_out_id);');
|
||||
expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_machine_id, $relay_machine_id);');
|
||||
expect($route)->toContain(
|
||||
'self::syncDepartmentLaneRelayValue($department_lane->relay_machine_program_picker_id, $relay_machine_program_picker_id);'
|
||||
);
|
||||
expect($route)->toContain('self::syncDepartmentLaneRelayValue($department_lane->relay_machine_cleaner_id, $relay_machine_cleaner_id);');
|
||||
});
|
||||
@@ -74,11 +74,17 @@ it('derives relay fallback and transport health details without shell or update
|
||||
'last_heartbeat_at' => '2026-04-08 10:04:30',
|
||||
'department_transport_mode' => edge_gateway_manager::TRANSPORT_MODE_GATEWAY,
|
||||
'metadata' => [
|
||||
'last_sync_at' => '2026-04-08 10:04:20',
|
||||
'broker_presence' => [
|
||||
'connected' => false,
|
||||
'last_seen_at' => '2026-04-08 10:03:00',
|
||||
'last_error' => 'broker timeout',
|
||||
],
|
||||
'control_plane_status' => [
|
||||
'last_successful_sync_at' => '2026-04-08 10:04:10',
|
||||
'last_transport_failure_at' => '2026-04-08 10:04:12',
|
||||
'last_transport_error' => 'POST https://api.truckwash.io/edge-agent/gateways/17/heartbeat returned HTTP 502',
|
||||
],
|
||||
],
|
||||
'operational_snapshot' => [
|
||||
'command_backlog' => 2,
|
||||
@@ -132,6 +138,10 @@ it('derives relay fallback and transport health details without shell or update
|
||||
expect($gateway['fallback_summary']['local_only_relays'])->toBe(1);
|
||||
expect($gateway['transport_health']['status'])->toBe(edge_gateway_manager::STATUS_DEGRADED);
|
||||
expect($gateway['transport_health']['recommended_action'])->toBe('retry_discovery');
|
||||
expect($gateway['transport_health']['last_successful_sync_at'])->toBe('2026-04-08 10:04:30');
|
||||
expect($gateway['transport_health']['last_transport_failure_at'])->toBe('2026-04-08 10:04:12');
|
||||
expect($gateway['transport_health']['last_transport_error'])->toContain('returned HTTP 502');
|
||||
expect($gateway['last_sync_at'])->toBe('2026-04-08 10:04:30');
|
||||
expect($gateway['last_successful_discovery_at'])->toBe('2026-04-08 10:02:00');
|
||||
expect($gateway['diagnostics'])->not->toBeEmpty();
|
||||
expect($gateway['error_state']['code'])->toBe('EDGE_GATEWAY_OPERATION_TIMEOUT');
|
||||
|
||||
@@ -6,6 +6,7 @@ it('builds the installer around the compose stack artifacts and management polli
|
||||
$stackServiceSource = file_get_contents(app_path('resources/edge-gateway-agent/truckwash-edge-gateway-stack.service'));
|
||||
$launcherSource = file_get_contents(app_path('resources/edge-gateway-agent/gateway-launcher.sh'));
|
||||
$composeSource = file_get_contents(app_path('resources/edge-gateway-agent/docker-compose.gateway.yml'));
|
||||
$agentSource = file_get_contents(app_path('resources/edge-gateway-agent/agent.php'));
|
||||
$edgeDockerfileSource = file_get_contents(app_path('resources/edge-gateway-agent/Dockerfile.edge-agent'));
|
||||
$workerDockerfileSource = file_get_contents(app_path('resources/edge-gateway-agent/Dockerfile.lan-worker'));
|
||||
$autoUpdaterSource = file_get_contents(app_path('resources/edge-gateway-agent/auto-updater.php'));
|
||||
@@ -14,6 +15,7 @@ it('builds the installer around the compose stack artifacts and management polli
|
||||
expect($managerSource)->not->toBeFalse();
|
||||
expect($launcherSource)->not->toBeFalse();
|
||||
expect($composeSource)->not->toBeFalse();
|
||||
expect($agentSource)->not->toBeFalse();
|
||||
expect($edgeDockerfileSource)->not->toBeFalse();
|
||||
expect($workerDockerfileSource)->not->toBeFalse();
|
||||
expect($autoUpdaterSource)->not->toBeFalse();
|
||||
@@ -66,7 +68,11 @@ it('builds the installer around the compose stack artifacts and management polli
|
||||
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('/opt/truckwash-edge-agent/runtime/control-plane-status.json');
|
||||
expect($composeSource)->toContain('$$data[\\"last_loop_at\\"]');
|
||||
expect($composeSource)->toContain('$$data[\\"last_successful_sync_at\\"]');
|
||||
expect($composeSource)->toContain('<= 30');
|
||||
expect($composeSource)->toContain('<= 90');
|
||||
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\"');
|
||||
@@ -76,6 +82,15 @@ it('builds the installer around the compose stack artifacts and management polli
|
||||
expect($composeSource)->toContain('container_name: truckwash-mariadb');
|
||||
expect($composeSource)->toContain('container_name: truckwash-minio');
|
||||
expect($composeSource)->toContain('container_name: truckwash-auto-updater');
|
||||
expect($agentSource)->toContain('private string $controlPlaneStatusPath;');
|
||||
expect($agentSource)->toContain('control-plane-status.json');
|
||||
expect($agentSource)->toContain("'control_plane_status' => \$this->buildControlPlaneStatusPayload()");
|
||||
expect($agentSource)->toContain("'last_heartbeat_attempt_at'");
|
||||
expect($agentSource)->toContain("'last_heartbeat_success_at'");
|
||||
expect($agentSource)->toContain("'last_successful_sync_at'");
|
||||
expect($agentSource)->toContain("'last_transport_failure_at'");
|
||||
expect($agentSource)->toContain("'last_transport_error'");
|
||||
expect($agentSource)->toContain('private function recordTransportFailure(string $context, Throwable $throwable): void');
|
||||
expect($edgeDockerfileSource)->toContain('FROM ${BASE_IMAGE}');
|
||||
expect($edgeDockerfileSource)->toContain('COPY agent.php /opt/truckwash-edge-agent/agent.php');
|
||||
expect($edgeDockerfileSource)->not->toContain('docker-php-ext-install');
|
||||
|
||||
@@ -5,69 +5,181 @@ app_require('classes/shelly_relay_inventory.php');
|
||||
use classes\shelly_relay_inventory;
|
||||
|
||||
it('normalizes owned Shelly devices into relay select options', function (): void {
|
||||
$inventory = (new shelly_relay_inventory())->setInventoryFetcher(static function (): array {
|
||||
return [
|
||||
'isok' => true,
|
||||
'data' => [
|
||||
'devices_status' => [
|
||||
'device-key-1' => [
|
||||
'_dev_info' => [
|
||||
'id' => 'shelly-plus-01',
|
||||
'code' => 'SPSW-001PE16EU',
|
||||
'online' => 1,
|
||||
$inventory = (new shelly_relay_inventory())
|
||||
->setInventoryFetcher(static function (): array {
|
||||
return [
|
||||
'isok' => true,
|
||||
'data' => [
|
||||
'devices_status' => [
|
||||
'device-key-1' => [
|
||||
'_dev_info' => [
|
||||
'id' => 'shelly-plus-01',
|
||||
'code' => 'SPSW-001PE16EU',
|
||||
'model' => 'Shelly Plus 1PM',
|
||||
'online' => 1,
|
||||
],
|
||||
'name' => 'Entry Gate',
|
||||
'status' => [
|
||||
'switch:0' => [
|
||||
'output' => false,
|
||||
'name' => 'Entrance Relay',
|
||||
],
|
||||
],
|
||||
],
|
||||
'name' => 'Entry Gate',
|
||||
'status' => [
|
||||
'switch:0' => [
|
||||
'output' => false,
|
||||
'device-key-2' => [
|
||||
'_dev_info' => [
|
||||
'id' => 'shelly-pro-03',
|
||||
'code' => 'SPSW-201XE16EU',
|
||||
'model' => 'Shelly Pro 2PM',
|
||||
],
|
||||
'name' => 'Machine Cabinet',
|
||||
'relays' => [
|
||||
['ison' => false, 'name' => 'Program Picker'],
|
||||
],
|
||||
],
|
||||
'device-key-3' => [
|
||||
'_dev_info' => [
|
||||
'id' => 'shelly-legacy-02',
|
||||
'code' => 'SHSW-1',
|
||||
'model' => 'Shelly 1',
|
||||
'online' => 0,
|
||||
],
|
||||
'relays' => [
|
||||
['ison' => false],
|
||||
],
|
||||
],
|
||||
'device-key-4' => [
|
||||
'_dev_info' => [
|
||||
'id' => 'shelly-sensor-01',
|
||||
'code' => 'SHHT-1',
|
||||
'model' => 'Shelly H&T',
|
||||
'online' => 1,
|
||||
],
|
||||
'sensor' => [
|
||||
'temperature' => 21.5,
|
||||
],
|
||||
],
|
||||
],
|
||||
'device-key-2' => [
|
||||
'_dev_info' => [
|
||||
'id' => 'shelly-legacy-02',
|
||||
'code' => 'SHSW-1',
|
||||
'online' => 0,
|
||||
],
|
||||
];
|
||||
})
|
||||
->setDeviceListFetcher(static function (): array {
|
||||
return [
|
||||
'isok' => true,
|
||||
'data' => [
|
||||
'devices' => [
|
||||
'shelly-plus-01' => [
|
||||
'id' => 'shelly-plus-01',
|
||||
'name' => 'Gate From Shelly Cloud',
|
||||
'type' => 'SPSW-001PE16EU',
|
||||
'cloud_online' => true,
|
||||
],
|
||||
'relays' => [
|
||||
['ison' => false],
|
||||
],
|
||||
],
|
||||
'device-key-3' => [
|
||||
'_dev_info' => [
|
||||
'id' => 'shelly-sensor-01',
|
||||
'code' => 'SHHT-1',
|
||||
'online' => 1,
|
||||
],
|
||||
'sensor' => [
|
||||
'temperature' => 21.5,
|
||||
'shelly-pro-03' => [
|
||||
'id' => 'shelly-pro-03',
|
||||
'name' => 'Program Picker From Shelly Cloud',
|
||||
'type' => 'SPSW-201XE16EU',
|
||||
'cloud_online' => false,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
});
|
||||
];
|
||||
});
|
||||
|
||||
expect($inventory->listRelayOptions())->toBe([
|
||||
[
|
||||
'id' => 'shelly-plus-01',
|
||||
'name' => 'Entry Gate (shelly-plus-01) · SPSW-001PE16EU',
|
||||
'name' => 'Gate From Shelly Cloud (Shelly Plus 1PM)',
|
||||
'device_id' => 'shelly-plus-01',
|
||||
'device_name' => 'Entry Gate',
|
||||
'device_name' => 'Gate From Shelly Cloud',
|
||||
'cloud_name' => 'Gate From Shelly Cloud',
|
||||
'device_type' => 'Shelly Plus 1PM',
|
||||
'code' => 'SPSW-001PE16EU',
|
||||
'control_type' => 'Switch',
|
||||
'control_name' => 'Entrance Relay',
|
||||
'status_color' => 'Green',
|
||||
'online' => true,
|
||||
],
|
||||
[
|
||||
'id' => 'shelly-pro-03',
|
||||
'name' => 'Program Picker From Shelly Cloud (Shelly Pro 2PM)',
|
||||
'device_id' => 'shelly-pro-03',
|
||||
'device_name' => 'Program Picker From Shelly Cloud',
|
||||
'cloud_name' => 'Program Picker From Shelly Cloud',
|
||||
'device_type' => 'Shelly Pro 2PM',
|
||||
'code' => 'SPSW-201XE16EU',
|
||||
'control_type' => 'Relay',
|
||||
'control_name' => 'Program Picker',
|
||||
'status_color' => 'Red',
|
||||
'online' => false,
|
||||
],
|
||||
[
|
||||
'id' => 'shelly-legacy-02',
|
||||
'name' => 'shelly-legacy-02 · SHSW-1',
|
||||
'name' => 'shelly-legacy-02 (Shelly 1)',
|
||||
'device_id' => 'shelly-legacy-02',
|
||||
'device_name' => null,
|
||||
'cloud_name' => null,
|
||||
'device_type' => 'Shelly 1',
|
||||
'code' => 'SHSW-1',
|
||||
'control_type' => 'Relay',
|
||||
'control_name' => null,
|
||||
'status_color' => 'Red',
|
||||
'online' => false,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to local device and control names when Shelly cloud list metadata is unavailable', function (): void {
|
||||
$inventory = (new shelly_relay_inventory())
|
||||
->setInventoryFetcher(static function (): array {
|
||||
return [
|
||||
'isok' => true,
|
||||
'data' => [
|
||||
'devices_status' => [
|
||||
'device-key-1' => [
|
||||
'_dev_info' => [
|
||||
'id' => 'shelly-plus-01',
|
||||
'code' => 'SPSW-001PE16EU',
|
||||
'model' => 'Shelly Plus 1PM',
|
||||
'online' => 1,
|
||||
],
|
||||
'name' => 'Entry Gate',
|
||||
'status' => [
|
||||
'switch:0' => [
|
||||
'output' => false,
|
||||
'name' => 'Entrance Relay',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
})
|
||||
->setDeviceListFetcher(static function (): array {
|
||||
return [
|
||||
'isok' => false,
|
||||
'errors' => [
|
||||
'404' => 'Requested method was not found',
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
expect($inventory->listRelayOptions())->toBe([
|
||||
[
|
||||
'id' => 'shelly-plus-01',
|
||||
'name' => 'Entry Gate / Entrance Relay (Shelly Plus 1PM)',
|
||||
'device_id' => 'shelly-plus-01',
|
||||
'device_name' => 'Entry Gate',
|
||||
'cloud_name' => null,
|
||||
'device_type' => 'Shelly Plus 1PM',
|
||||
'code' => 'SPSW-001PE16EU',
|
||||
'control_type' => 'Switch',
|
||||
'control_name' => 'Entrance Relay',
|
||||
'status_color' => 'Green',
|
||||
'online' => true,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails fast when Shelly inventory does not include owned devices status', function (): void {
|
||||
$inventory = (new shelly_relay_inventory())->setInventoryFetcher(static function (): array {
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user