Files
api/scripts/staging-edge-gateway-smoke.mjs
T

231 lines
7.1 KiB
JavaScript

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 <token> [--base-url <url>]
Options:
--install-token <token> Real edge-gateway install token used to validate install.sh.
--base-url <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 || "<empty>"}`
);
}
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;
});
}