Remove outdated edge gateway object classes, add new agent implementation

Transitioned from obsolete gateway object classes (`edge_gateway_shell_action_jobs_o`, `edge_gateway_shell_events_o`, `edge_gateway_shell_sessions_o`, `edge_gateway_update_jobs_o`) to the new agent implementation (`edge-gateway-agent/agent.php`).
This commit is contained in:
Jeppe Bundgaard
2026-04-21 14:13:17 +02:00
parent d30a006457
commit 7d450e285e
90 changed files with 11811 additions and 2760 deletions
+480
View File
@@ -0,0 +1,480 @@
import { execFile as execFileCallback, spawn as spawnCallback } from "node:child_process";
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";
const execFile = promisify(execFileCallback);
export const DEFAULT_CONTAINER_NAME = "truckwash-test-gateway";
export const DEFAULT_IMAGE_TAG = "truckwash-edge-agent:test-gateway";
export const DEFAULT_CONFIG_FILE_NAME = "test-gateway.json";
export const DEFAULT_HOST_API_URL = "http://localhost/api";
export const DEFAULT_CONTAINER_API_URL = "http://caddy";
export const DEFAULT_CONTAINER_BROKER_URL = "http://edge-broker:4300";
export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent";
export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15;
export const DEFAULT_INSTALLED_VERSION = "php-agent-v1";
const DEFAULT_COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "caddy"];
function printUsage() {
process.stdout.write(`Usage:
node scripts/test-gateway.mjs start [--install-token <token>] [--container-name <name>] [--hostname <hostname>]
node scripts/test-gateway.mjs stop [--container-name <name>]
node scripts/test-gateway.mjs logs [--container-name <name>] [--tail <lines>]
node scripts/test-gateway.mjs status [--container-name <name>]
Options:
--install-token <token> Claim a new edge gateway before starting the container.
--container-name <name> Docker container name. Default: ${DEFAULT_CONTAINER_NAME}
--hostname <hostname> Gateway hostname reported during claim and Docker run.
--host-api-url <url> Host-reachable API URL for claim/health checks. Default: ${DEFAULT_HOST_API_URL}
--api-url <url> Container-internal API URL. Default: ${DEFAULT_CONTAINER_API_URL}
--broker-url <url> Container-internal broker URL. Default: ${DEFAULT_CONTAINER_BROKER_URL}
--config-dir <dir> Directory for generated config. Default: backend-php/.tmp/test-gateway
--image-tag <tag> Docker image tag. Default: ${DEFAULT_IMAGE_TAG}
--heartbeat-seconds <n> Agent heartbeat interval. Default: ${DEFAULT_HEARTBEAT_INTERVAL_SECONDS}
--tail <lines> Log lines for the logs action. Default: 200
--skip-compose-up Do not start the local Docker Compose stack before start.
--skip-build Do not rebuild the test gateway image before start.
`);
}
export function resolveComposeProjectName(rootDir, env = process.env) {
const explicit = String(env.COMPOSE_PROJECT_NAME || "").trim();
if (explicit !== "") {
return explicit;
}
return path.basename(rootDir);
}
export function resolveComposeNetworkName(rootDir, env = process.env) {
return `${resolveComposeProjectName(rootDir, env)}_default`;
}
export function resolveConfigDirectory(rootDir, explicitDir = null) {
if (explicitDir) {
return path.resolve(rootDir, explicitDir);
}
return path.join(rootDir, ".tmp", "test-gateway");
}
export function shouldClaimGateway(existingConfig = {}, installToken = "") {
if (String(installToken || "").trim() !== "") {
return true;
}
return !(existingConfig.gatewayId && existingConfig.agentToken);
}
export function buildGatewayConfig({
existingConfig = {},
claim = null,
apiUrl = DEFAULT_CONTAINER_API_URL,
brokerUrl = DEFAULT_CONTAINER_BROKER_URL,
hostname = DEFAULT_CONTAINER_NAME,
heartbeatIntervalSeconds = DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
} = {}) {
const installedVersion = String(existingConfig.installedVersion || DEFAULT_INSTALLED_VERSION);
const targetVersion = String(existingConfig.targetVersion || installedVersion);
return {
apiUrl,
brokerUrl,
gatewayId: claim?.gateway?.id ?? existingConfig.gatewayId ?? null,
agentToken: claim?.agent_token ?? existingConfig.agentToken ?? null,
hostname,
releaseChannel: claim?.release_channel ?? existingConfig.releaseChannel ?? "stable",
installedVersion,
targetVersion,
installDir: DEFAULT_INSTALL_DIR,
agentPath: `${DEFAULT_INSTALL_DIR}/agent.php`,
serviceUnitPath: `${DEFAULT_INSTALL_DIR}/truckwash-edge-agent.service`,
serviceName: String(existingConfig.serviceName || hostname || DEFAULT_CONTAINER_NAME),
restartMode: "spawn",
heartbeatIntervalSeconds,
commandPollTimeoutSeconds: Number(existingConfig.commandPollTimeoutSeconds || 20),
shellActionPollTimeoutSeconds: Number(existingConfig.shellActionPollTimeoutSeconds || 20),
commandPollRetryDelayMs: Number(existingConfig.commandPollRetryDelayMs || 1000),
shellActionPollRetryDelayMs: Number(existingConfig.shellActionPollRetryDelayMs || 1000),
brokerReconnectDelayMs: Number(existingConfig.brokerReconnectDelayMs || 1500),
};
}
export function parseArgs(argv = process.argv.slice(2)) {
const knownActions = new Set(["start", "stop", "logs", "status"]);
const [first = "", ...remaining] = argv;
const action = knownActions.has(first) ? first : "start";
const rest = knownActions.has(first) ? remaining : argv;
const options = {
action,
containerName: DEFAULT_CONTAINER_NAME,
imageTag: DEFAULT_IMAGE_TAG,
hostApiUrl: DEFAULT_HOST_API_URL,
apiUrl: DEFAULT_CONTAINER_API_URL,
brokerUrl: DEFAULT_CONTAINER_BROKER_URL,
hostname: DEFAULT_CONTAINER_NAME,
configDir: null,
installToken: "",
heartbeatSeconds: DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
tail: "200",
skipComposeUp: false,
skipBuild: false,
};
for (let index = 0; index < rest.length; index += 1) {
const arg = rest[index];
const next = rest[index + 1];
switch (arg) {
case "--container-name":
options.containerName = String(next || "").trim() || DEFAULT_CONTAINER_NAME;
options.hostname = options.containerName;
index += 1;
break;
case "--hostname":
options.hostname = String(next || "").trim() || options.hostname;
index += 1;
break;
case "--image-tag":
options.imageTag = String(next || "").trim() || DEFAULT_IMAGE_TAG;
index += 1;
break;
case "--host-api-url":
options.hostApiUrl = String(next || "").trim() || DEFAULT_HOST_API_URL;
index += 1;
break;
case "--api-url":
options.apiUrl = String(next || "").trim() || DEFAULT_CONTAINER_API_URL;
index += 1;
break;
case "--broker-url":
options.brokerUrl = String(next || "").trim() || DEFAULT_CONTAINER_BROKER_URL;
index += 1;
break;
case "--config-dir":
options.configDir = String(next || "").trim() || null;
index += 1;
break;
case "--install-token":
options.installToken = String(next || "").trim();
index += 1;
break;
case "--heartbeat-seconds":
options.heartbeatSeconds = Number(next || DEFAULT_HEARTBEAT_INTERVAL_SECONDS);
index += 1;
break;
case "--tail":
options.tail = String(next || "200").trim() || "200";
index += 1;
break;
case "--skip-compose-up":
options.skipComposeUp = true;
break;
case "--skip-build":
options.skipBuild = true;
break;
case "--help":
case "-h":
options.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
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: "" };
}
try {
return await execFile(command, args, {
cwd,
windowsHide: true,
encoding: "utf8",
});
} catch (error) {
if (allowFailure) {
return {
stdout: error.stdout || "",
stderr: error.stderr || "",
code: error.code || 1,
};
}
throw error;
}
}
async function ensureComposeServices(rootDir, skipComposeUp) {
if (skipComposeUp) {
return;
}
await runCommand("docker", ["compose", "up", "-d", ...DEFAULT_COMPOSE_SERVICES], {
cwd: rootDir,
stdio: "inherit",
});
}
function normalizeBaseUrl(url) {
return String(url || "").replace(/\/+$/, "");
}
async function waitForApiReady(hostApiUrl, attempts = 60) {
const baseUrl = normalizeBaseUrl(hostApiUrl);
let lastError = null;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const response = await fetch(`${baseUrl}/ping`);
if (response.ok) {
return;
}
lastError = new Error(`HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error(`API did not become ready at ${baseUrl}/ping: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
async function claimGateway(hostApiUrl, installToken, hostname) {
const response = await fetch(`${normalizeBaseUrl(hostApiUrl)}/edge-agent/claim`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
token: installToken,
hostname,
installed_version: DEFAULT_INSTALLED_VERSION,
metadata: {
source: "docker-test-gateway-script",
},
}),
});
const json = await response.json();
if (!response.ok) {
throw new Error(json?.data?.message || json?.message || `HTTP ${response.status}`);
}
return json.data ?? json;
}
async function readConfig(configFilePath) {
try {
const raw = await fs.readFile(configFilePath, "utf8");
return JSON.parse(raw);
} catch (error) {
if (error?.code === "ENOENT") {
return {};
}
throw error;
}
}
async function writeConfig(configFilePath, config) {
await fs.mkdir(path.dirname(configFilePath), { recursive: true });
await fs.writeFile(configFilePath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
}
function toDockerMountPath(hostPath) {
return hostPath.replace(/\\/g, "/");
}
async function ensureImage(rootDir, imageTag, skipBuild) {
if (skipBuild) {
return;
}
await runCommand(
"docker",
["build", "-t", imageTag, "-f", "services/edge-agent/Dockerfile.test-gateway", "."],
{
cwd: rootDir,
stdio: "inherit",
}
);
}
async function removeContainer(containerName) {
await runCommand("docker", ["rm", "-f", containerName], { allowFailure: true });
}
async function startContainer({
rootDir,
imageTag,
containerName,
hostname,
configDir,
}) {
const networkName = resolveComposeNetworkName(rootDir);
const mountedConfigDir = toDockerMountPath(configDir);
await removeContainer(containerName);
await runCommand(
"docker",
[
"run",
"-d",
"--name",
containerName,
"--hostname",
hostname,
"--restart",
"unless-stopped",
"--network",
networkName,
"-v",
`${mountedConfigDir}:/config`,
imageTag,
"--config",
`/config/${DEFAULT_CONFIG_FILE_NAME}`,
],
{
cwd: rootDir,
stdio: "inherit",
}
);
}
async function printStatus({ imageTag, configDir, containerName }) {
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
const config = await readConfig(configFilePath);
const inspection = await runCommand("docker", ["inspect", containerName], {
allowFailure: true,
});
let container = null;
if (inspection.stdout && String(inspection.stdout).trim() !== "") {
try {
const decoded = JSON.parse(inspection.stdout);
container = Array.isArray(decoded) ? decoded[0] ?? null : null;
} catch {
container = null;
}
}
process.stdout.write(`${JSON.stringify({
containerName,
configFilePath,
gatewayId: config.gatewayId ?? null,
installDir: config.installDir ?? null,
agentPath: config.agentPath ?? null,
serviceUnitPath: config.serviceUnitPath ?? null,
containerStatus: container?.State?.Status ?? "missing",
running: Boolean(container?.State?.Running),
image: container?.Config?.Image ?? imageTag,
}, null, 2)}\n`);
}
async function main() {
const options = parseArgs();
if (options.help) {
printUsage();
return;
}
const scriptPath = fileURLToPath(import.meta.url);
const rootDir = path.resolve(path.dirname(scriptPath), "..");
const configDir = resolveConfigDirectory(rootDir, options.configDir);
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
switch (options.action) {
case "start": {
await ensureComposeServices(rootDir, options.skipComposeUp);
await waitForApiReady(options.hostApiUrl);
await ensureImage(rootDir, options.imageTag, options.skipBuild);
const existingConfig = await readConfig(configFilePath);
let claim = null;
if (shouldClaimGateway(existingConfig, options.installToken)) {
if (String(options.installToken || "").trim() === "") {
throw new Error(`An install token is required to create the first test gateway config at ${configFilePath}`);
}
claim = await claimGateway(options.hostApiUrl, options.installToken, options.hostname);
}
const config = buildGatewayConfig({
existingConfig,
claim,
apiUrl: options.apiUrl,
brokerUrl: options.brokerUrl,
hostname: options.hostname,
heartbeatIntervalSeconds: Number.isFinite(options.heartbeatSeconds) && options.heartbeatSeconds > 0
? Math.round(options.heartbeatSeconds)
: DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
});
await writeConfig(configFilePath, config);
await startContainer({
rootDir,
imageTag: options.imageTag,
containerName: options.containerName,
hostname: options.hostname,
configDir,
});
process.stdout.write(`Test gateway container started.
Container: ${options.containerName}
Config: ${configFilePath}
Gateway ID: ${config.gatewayId ?? "unclaimed"}
`);
return;
}
case "stop":
await removeContainer(options.containerName);
process.stdout.write(`Removed container ${options.containerName}\n`);
return;
case "logs":
await runCommand("docker", ["logs", "-f", "--tail", options.tail, options.containerName], {
stdio: "inherit",
});
return;
case "status":
await printStatus({
imageTag: options.imageTag,
configDir,
containerName: options.containerName,
});
return;
default:
throw new Error(`Unsupported action: ${options.action}`);
}
}
const currentFilePath = fileURLToPath(import.meta.url);
const invokedScript = process.argv[1] ? path.resolve(process.argv[1]) : "";
if (invokedScript === path.resolve(currentFilePath)) {
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
}