import process from "node:process"; import path from "node:path"; import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433"; export const EXPECTED_INSTALL_VERSION = "compose-php-agent-v3"; export const REQUIRED_MANIFEST_ARTIFACTS = [ "agent.php", "lan-worker.php", "auto-updater.php", "docker-compose.gateway.yml", "Dockerfile.edge-agent", "Dockerfile.lan-worker", "Dockerfile.auto-updater", "gateway-launcher.sh", "truckwash-edge-gateway-stack.service", "truckwash-edge-agent.service", ]; export const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [ "/edge-agent/install-token/status", "/edge-agent/artifacts/manifest.json", "report_install_status", 'begin_install_phase "VERIFY_TOKEN"', 'begin_install_phase "VERIFY_ARTIFACTS"', 'begin_install_phase "WAIT_FOR_CLAIM"', 'report_install_status "FAILED"', "verify_manifest_artifact", EXPECTED_INSTALL_VERSION, ]; export function normalizeBaseUrl(url) { return String(url || "").trim().replace(/\/+$/, ""); } export function parseArgs(argv = process.argv.slice(2)) { const options = { baseUrl: DEFAULT_STAGING_BASE_URL, installToken: "", help: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; const next = argv[index + 1]; switch (arg) { case "--base-url": options.baseUrl = String(next || "").trim() || DEFAULT_STAGING_BASE_URL; index += 1; break; case "--install-token": options.installToken = String(next || "").trim(); index += 1; break; case "--help": case "-h": options.help = true; break; default: throw new Error(`Unknown argument: ${arg}`); } } return options; } export function buildChecks(baseUrl, installToken) { const normalizedBaseUrl = normalizeBaseUrl(baseUrl); return [ { name: "Ping", url: `${normalizedBaseUrl}/ping`, }, { name: "Artifact manifest", url: `${normalizedBaseUrl}/edge-agent/artifacts/manifest.json`, artifactName: "manifest.json", }, { name: "Agent PHP artifact", url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`, artifactName: "agent.php", }, { name: "Service unit artifact", url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`, artifactName: "truckwash-edge-agent.service", }, { name: "Installer script", url: `${normalizedBaseUrl}/edge-agent/install.sh?token=${encodeURIComponent(installToken)}`, }, ]; } export function validateArtifactManifestBody(body) { const manifest = JSON.parse(String(body || "")); if (manifest.version !== EXPECTED_INSTALL_VERSION) { throw new Error(`Artifact manifest version mismatch: expected ${EXPECTED_INSTALL_VERSION}, got ${manifest.version}`); } if (!Array.isArray(manifest.artifacts)) { throw new Error("Artifact manifest is missing artifacts."); } const byName = new Map(manifest.artifacts.map((artifact) => [artifact?.name, artifact])); const missingArtifacts = REQUIRED_MANIFEST_ARTIFACTS.filter((artifactName) => !byName.has(artifactName)); if (missingArtifacts.length) { throw new Error(`Artifact manifest is missing required artifacts: ${missingArtifacts.join(", ")}`); } return manifest; } export function validateArtifactBodyAgainstManifest(manifest, artifactName, body) { const artifact = manifest?.artifacts?.find((entry) => entry?.name === artifactName); if (!artifact) { throw new Error(`Artifact ${artifactName} is missing from manifest.`); } const buffer = Buffer.isBuffer(body) ? body : Buffer.from(String(body || "")); const sha256 = createHash("sha256").update(buffer).digest("hex"); if (sha256 !== artifact.sha256) { throw new Error(`Artifact ${artifactName} hash mismatch: ${sha256} !== ${artifact.sha256}`); } if (buffer.length !== artifact.bytes) { throw new Error(`Artifact ${artifactName} size mismatch: ${buffer.length} !== ${artifact.bytes}`); } return artifact; } export function validateInstallerScriptBody(body) { const source = String(body || ""); const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet)); if (missingSnippets.length) { throw new Error(`Installer script is missing required status wiring: ${missingSnippets.join(", ")}`); } return INSTALLER_SCRIPT_REQUIRED_SNIPPETS; } function printUsage() { process.stdout.write(`Usage: node scripts/staging-edge-gateway-smoke.mjs --install-token [--base-url ] Options: --install-token Real edge-gateway install token used to validate install.sh. --base-url Public staging base URL. Default: ${DEFAULT_STAGING_BASE_URL} --help Show this help text. `); } function previewBody(body, limit = 200) { const normalized = String(body || "").replace(/\s+/g, " ").trim(); if (normalized.length <= limit) { return normalized; } return `${normalized.slice(0, limit)}...`; } export async function runSmoke({ baseUrl, installToken }) { if (String(installToken || "").trim() === "") { throw new Error("Missing required --install-token value."); } const checks = buildChecks(baseUrl, installToken); const results = []; let artifactManifest = null; for (const check of checks) { process.stdout.write(`[staging-smoke] GET ${check.url}\n`); const response = await fetch(check.url); const body = Buffer.from(await response.arrayBuffer()); const result = { ...check, status: response.status, ok: response.ok, bodyPreview: previewBody(body.toString("utf8")), }; results.push(result); if (!response.ok) { throw new Error( `${check.name} failed with HTTP ${response.status} at ${check.url}. ` + `Body preview: ${result.bodyPreview || ""}` ); } if (check.name === "Artifact manifest") { artifactManifest = validateArtifactManifestBody(body.toString("utf8")); result.version = artifactManifest.version; result.artifactCount = artifactManifest.artifacts.length; } if (artifactManifest && check.artifactName && check.artifactName !== "manifest.json") { result.verifiedArtifact = validateArtifactBodyAgainstManifest(artifactManifest, check.artifactName, body); } if (check.name === "Installer script") { result.verifiedSnippets = validateInstallerScriptBody(body.toString("utf8")); } } return results; } async function main() { const options = parseArgs(); if (options.help) { printUsage(); return; } const results = await runSmoke(options); for (const result of results) { process.stdout.write(`[staging-smoke] OK ${result.status} ${result.name}\n`); } } const isDirectExecution = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; if (isDirectExecution) { main().catch((error) => { process.stderr.write(`[staging-smoke] ERROR: ${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }); }