495 lines
16 KiB
JavaScript
495 lines
16 KiB
JavaScript
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 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,
|
|
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", ["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,
|
|
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 = 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;
|
|
});
|
|
}
|