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_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`; export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`; export const DEFAULT_STACK_SERVICE_NAME = "truckwash-edge-gateway-stack.service"; 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 composeArgs(projectName, args) { return ["compose", "-p", projectName, ...args]; } async function resolveRootDir(scriptPath) { const cwd = process.cwd(); try { await fs.access(path.join(cwd, "docker-compose.yml")); return cwd; } catch { return path.resolve(path.dirname(scriptPath), ".."); } } function printUsage() { process.stdout.write(`Usage: node scripts/test-gateway.mjs start [--install-token ] [--container-name ] [--hostname ] node scripts/test-gateway.mjs stop [--container-name ] node scripts/test-gateway.mjs logs [--container-name ] [--tail ] node scripts/test-gateway.mjs status [--container-name ] Options: --install-token Claim a new edge gateway before starting the container. --container-name Docker container name. Default: ${DEFAULT_CONTAINER_NAME} --hostname Gateway hostname reported during claim and Docker run. --host-api-url Host-reachable API URL for claim/health checks. Default: ${DEFAULT_HOST_API_URL} --api-url Container-internal API URL. Default: ${DEFAULT_CONTAINER_API_URL} --broker-url Container-internal broker URL. Default: ${DEFAULT_CONTAINER_BROKER_URL} --config-dir Directory for generated config. Default: backend-php/.tmp/test-gateway --image-tag Docker image tag. Default: ${DEFAULT_IMAGE_TAG} --heartbeat-seconds Agent heartbeat interval. Default: ${DEFAULT_HEARTBEAT_INTERVAL_SECONDS} --tail 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, runtimeDir: DEFAULT_RUNTIME_DIR, stateDatabasePath: DEFAULT_STATE_DATABASE_PATH, agentPath: `${DEFAULT_INSTALL_DIR}/agent.php`, lanWorkerPath: `${DEFAULT_INSTALL_DIR}/lan-worker.php`, serviceUnitPath: `${DEFAULT_INSTALL_DIR}/truckwash-edge-agent.service`, stackServiceUnitPath: `${DEFAULT_INSTALL_DIR}/${DEFAULT_STACK_SERVICE_NAME}`, serviceName: String(existingConfig.serviceName || hostname || DEFAULT_CONTAINER_NAME), stackServiceName: String(existingConfig.stackServiceName || DEFAULT_STACK_SERVICE_NAME), composeFileName: String(existingConfig.composeFileName || "docker-compose.gateway.yml"), composeProjectName: String(existingConfig.composeProjectName || "truckwash-edge-gateway"), launcherScriptName: String(existingConfig.launcherScriptName || "gateway-launcher.sh"), workerBaseUrl: String(existingConfig.workerBaseUrl || "http://lan-worker:8090"), updateWindow: String(existingConfig.updateWindow || "02:00-04:00"), runtimeMode: String(existingConfig.runtimeMode || "compose"), restartMode: "spawn", heartbeatIntervalSeconds, commandPollTimeoutSeconds: Number(existingConfig.commandPollTimeoutSeconds || 20), commandPollRetryDelayMs: Number(existingConfig.commandPollRetryDelayMs || 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", composeArgs(resolveComposeProjectName(rootDir), ["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, runtimeDir: config.runtimeDir ?? null, agentPath: config.agentPath ?? null, serviceUnitPath: config.serviceUnitPath ?? null, stackServiceUnitPath: config.stackServiceUnitPath ?? 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 = await resolveRootDir(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 currentRealPath = await fs.realpath(currentFilePath).catch(() => currentFilePath); const invokedScript = process.argv[1] ? path.resolve(process.argv[1]) : ""; const invokedRealPath = invokedScript !== "" ? await fs.realpath(invokedScript).catch(() => invokedScript) : ""; if (invokedRealPath === currentRealPath) { main().catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }); }