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:
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user