231 lines
7.1 KiB
JavaScript
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;
|
|
});
|
|
}
|