diff --git a/docs/m1-local-smoke.md b/docs/m1-local-smoke.md new file mode 100644 index 00000000..ca86317f --- /dev/null +++ b/docs/m1-local-smoke.md @@ -0,0 +1,58 @@ +# M1 Local Contract Smoke + +The M1 local smoke is a fast contract check for the current HWLAB MVP +skeleton. It runs only on the local machine, uses short-lived localhost +processes, and does not deploy or call the frozen DEV endpoint. + +Run it from the repository root: + +```sh +node scripts/m1-contract-smoke.mjs +``` + +The equivalent npm entry point is: + +```sh +npm run m1:smoke +``` + +## What It Checks + +- Starts `hwlab-cloud-api` on `127.0.0.1` with an ephemeral port and checks + `/health`, `/live`, `GET /v1`, and `POST /rpc`. +- Verifies the cloud-api skeleton returns `system.health`, describes the REST + and JSON-RPC adapter surface, and rejects `hardware.operation.request` as a + known skeleton response instead of dispatching real hardware work. +- Starts `hwlab-gateway-simu`, `hwlab-box-simu`, and `hwlab-patch-panel` on + ephemeral localhost ports. +- Checks simulator health and status payloads, confirms cross-device + propagation is `patch-panel-only`, writes a local box port value, and routes a + signal through the patch panel fixture. +- Parses `fixtures/mvp/runtime.json` through the CLI dry-run path and verifies + the dry-run output names the MVP route, closed loops, evidence fixture, and + the no-DEV/PROD-change disclaimer. + +## Boundary + +This smoke is not the future DEV e2e acceptance test. It intentionally does not: + +- deploy to DEV or PROD; +- call `http://74.48.78.17:6667`; +- run browser e2e; +- read secrets, tokens, kubeconfig, tunnel credentials, or deploy manifests; +- validate real gateway, router, frp, edge proxy, agent, worker, or hardware + behavior; +- replace the L5 deploy manifest work. + +The check is scoped to local M1 contract readiness: existing cloud-api, +patch-panel, simulator, and CLI dry-run skeletons can be started or parsed and +still agree on the frozen service IDs, DEV environment, JSON-RPC surface, and +patch-panel-only topology assumptions. + +## Follow-On DEV e2e + +A later real DEV e2e should run against the deployed DEV route, exercise the +actual cloud-web/cloud-api, gateway/router/tunnel path, hardware trusted closed +loop, agent automation closed loop, evidence persistence, and cleanup behavior. +That later test should produce deployment/evidence artifacts and must remain +separate from this local smoke harness. diff --git a/package.json b/package.json index d2bcf54a..5aa3b0b3 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "validate": "node scripts/validate-contract.mjs", "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check scripts/validate-contract.mjs && node scripts/validate-contract.mjs && node --test internal/agent/index.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "m1:smoke": "node scripts/m1-contract-smoke.mjs", "cli:health": "node tools/hwlab-cli/bin/hwlab-cli.mjs health", "cli:dry-run": "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run", "cli:projects": "node tools/hwlab-cli/bin/hwlab-cli.mjs project list", diff --git a/scripts/m1-contract-smoke.mjs b/scripts/m1-contract-smoke.mjs new file mode 100644 index 00000000..992d36e2 --- /dev/null +++ b/scripts/m1-contract-smoke.mjs @@ -0,0 +1,325 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import net from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs"; +import { runCli } from "../tools/hwlab-cli/lib/cli.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const children = []; + +function logOk(name) { + process.stdout.write(`[m1-smoke] ok ${name}\n`); +} + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : null; + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(port); + } + }); + }); + }); +} + +function startNode(relativeScript, env = {}) { + const child = spawn(process.execPath, [relativeScript], { + cwd: repoRoot, + env: { + ...process.env, + ...env + }, + stdio: ["ignore", "pipe", "pipe"] + }); + const output = { + stdout: "", + stderr: "" + }; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + output.stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + output.stderr += chunk; + }); + + const record = { child, relativeScript, output }; + children.push(record); + return record; +} + +async function stopNode(record) { + const { child } = record; + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + + await new Promise((resolve) => { + const killTimer = setTimeout(() => { + child.kill("SIGKILL"); + }, 1500); + child.once("exit", () => { + clearTimeout(killTimer); + resolve(); + }); + child.kill("SIGTERM"); + }); +} + +async function requestJson(url, options = {}) { + const response = await fetch(url, { + ...options, + headers: { + "content-type": "application/json", + ...(options.headers ?? {}) + } + }); + const text = await response.text(); + const body = text ? JSON.parse(text) : null; + return { response, body }; +} + +async function waitForHttp(url, record) { + const startedAt = Date.now(); + let lastError; + + while (Date.now() - startedAt < 5000) { + if (record.child.exitCode !== null) { + throw new Error( + `${record.relativeScript} exited before becoming ready\nstdout:\n${record.output.stdout}\nstderr:\n${record.output.stderr}` + ); + } + + try { + const { response } = await requestJson(url); + if (response.ok) { + return; + } + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 75)); + } + + throw new Error( + `${record.relativeScript} did not become ready at ${url}: ${lastError?.message ?? "timeout"}\nstdout:\n${record.output.stdout}\nstderr:\n${record.output.stderr}` + ); +} + +function rpcEnvelope({ id, method, params = {} }) { + return { + jsonrpc: "2.0", + id, + method, + params, + meta: { + traceId: `trc_${id}`, + serviceId: "hwlab-cli", + environment: ENVIRONMENT_DEV + } + }; +} + +async function smokeCloudApi() { + const port = await freePort(); + const service = startNode("cmd/hwlab-cloud-api/main.mjs", { + HWLAB_CLOUD_API_HOST: "127.0.0.1", + HWLAB_CLOUD_API_PORT: String(port) + }); + const baseUrl = `http://127.0.0.1:${port}`; + + await waitForHttp(`${baseUrl}/live`, service); + + const health = await requestJson(`${baseUrl}/health`); + assert.equal(health.response.status, 200); + assert.equal(health.body.serviceId, "hwlab-cloud-api"); + assert.equal(health.body.environment, ENVIRONMENT_DEV); + assert.equal(health.body.status, "ok"); + assert.deepEqual(health.body.db, { + connected: false, + mode: "not_configured" + }); + + const live = await requestJson(`${baseUrl}/live`); + assert.equal(live.response.status, 200); + assert.equal(live.body.status, "live"); + + const adapter = await requestJson(`${baseUrl}/v1`); + assert.equal(adapter.response.status, 200); + assert.equal(adapter.body.status, "skeleton"); + assert.ok(adapter.body.methods.includes("system.health")); + assert.ok(adapter.body.methods.includes("hardware.operation.request")); + + const rpcHealth = await requestJson(`${baseUrl}/rpc`, { + method: "POST", + body: JSON.stringify(rpcEnvelope({ id: "req_m1_health", method: "system.health" })) + }); + assert.equal(rpcHealth.response.status, 200); + assert.equal(rpcHealth.body.jsonrpc, "2.0"); + assert.equal(rpcHealth.body.result.status, "ok"); + assert.equal(rpcHealth.body.result.serviceId, "hwlab-cloud-api"); + assert.equal(rpcHealth.body.meta.serviceId, "hwlab-cloud-api"); + + const rpcHardware = await requestJson(`${baseUrl}/rpc`, { + method: "POST", + body: JSON.stringify( + rpcEnvelope({ + id: "req_m1_hardware", + method: "hardware.operation.request", + params: { + projectId: "prj_mvp_topology" + } + }) + ) + }); + assert.equal(rpcHardware.response.status, 200); + assert.equal(rpcHardware.body.result.accepted, false); + assert.equal(rpcHardware.body.result.status, "skeleton"); + assert.equal(rpcHardware.body.result.audit.source.serviceId, "hwlab-cloud-api"); + + logOk("cloud-api health/live/rpc skeleton"); +} + +async function smokePatchPanelAndSimulators() { + const gatewayPort = await freePort(); + const boxPort = await freePort(); + const patchPort = await freePort(); + + const gateway = startNode("cmd/hwlab-gateway-simu/main.mjs", { + PORT: String(gatewayPort), + HWLAB_GATEWAY_ID: "gwsimu_m1", + HWLAB_BOX_RESOURCES: "res_boxsim_alpha,res_boxsim_beta" + }); + const box = startNode("cmd/hwlab-box-simu/main.mjs", { + PORT: String(boxPort), + HWLAB_BOX_ID: "boxsim_alpha" + }); + const patchPanel = startNode("cmd/hwlab-patch-panel/main.mjs", { + PORT: String(patchPort) + }); + + const gatewayBase = `http://127.0.0.1:${gatewayPort}`; + const boxBase = `http://127.0.0.1:${boxPort}`; + const patchBase = `http://127.0.0.1:${patchPort}`; + + await Promise.all([ + waitForHttp(`${gatewayBase}/health/live`, gateway), + waitForHttp(`${boxBase}/health/live`, box), + waitForHttp(`${patchBase}/health/live`, patchPanel) + ]); + + const gatewayStatus = await requestJson(`${gatewayBase}/status`); + assert.equal(gatewayStatus.response.status, 200); + assert.equal(gatewayStatus.body.serviceId, "hwlab-gateway-simu"); + assert.equal(gatewayStatus.body.gatewayId, "gwsimu_m1"); + assert.equal(gatewayStatus.body.live, true); + assert.deepEqual(gatewayStatus.body.boxes, ["res_boxsim_alpha", "res_boxsim_beta"]); + + const boxStatus = await requestJson(`${boxBase}/status`); + assert.equal(boxStatus.response.status, 200); + assert.equal(boxStatus.body.serviceId, "hwlab-box-simu"); + assert.equal(boxStatus.body.boxId, "boxsim_alpha"); + assert.equal(boxStatus.body.live, true); + assert.equal(boxStatus.body.constraints.crossDevicePropagation, "patch-panel-only"); + assert.equal(boxStatus.body.constraints.localLoopbackEnabled, false); + assert.ok(boxStatus.body.ports.uart0); + + const writePort = await requestJson(`${boxBase}/ports/write`, { + method: "POST", + body: JSON.stringify({ + port: "uart0", + value: "m1-smoke", + source: "local" + }) + }); + assert.equal(writePort.response.status, 200); + assert.equal(writePort.body.accepted, true); + assert.equal(writePort.body.crossDevicePropagation, "patch-panel-only"); + + const patchStatus = await requestJson(`${patchBase}/status`); + assert.equal(patchStatus.response.status, 200); + assert.equal(patchStatus.body.serviceId, "hwlab-patch-panel"); + assert.equal(patchStatus.body.state, "active"); + assert.equal(patchStatus.body.metadata.propagation, "patch-panel-only"); + assert.equal(patchStatus.body.activeConnections.length, 2); + + const routed = await requestJson(`${patchBase}/signals/route`, { + method: "POST", + body: JSON.stringify({ + fromResourceId: "res_boxsim_alpha", + fromPort: "uart0", + value: "m1-smoke" + }) + }); + assert.equal(routed.response.status, 200); + assert.equal(routed.body.accepted, true); + assert.equal(routed.body.propagatedBy, "hwlab-patch-panel"); + assert.equal(routed.body.deliveryCount, 1); + assert.deepEqual(routed.body.deliveries[0], { + resourceId: "res_boxsim_beta", + port: "uart0", + value: "m1-smoke", + sourceResourceId: "res_boxsim_alpha", + sourcePort: "uart0", + observedAt: routed.body.deliveries[0].observedAt + }); + + logOk("patch-panel/sim status and route skeleton"); +} + +async function smokeCliDryRunFixture() { + const runtime = JSON.parse(await readFile(path.join(repoRoot, "fixtures/mvp/runtime.json"), "utf8")); + assert.equal(runtime.endpoints.dev, DEV_ENDPOINT); + assert.ok(runtime.serviceRoute.includes("cloud-web/cloud-api")); + assert.ok(runtime.loops.some((loop) => loop.name === "hardware trusted closed loop")); + assert.ok(runtime.evidence.some((record) => record.evidenceId === "evidence_mvp-runtime-plan")); + + let stdout = ""; + let stderr = ""; + const exitCode = await runCli(["test", "e2e", "--env", "dev", "--mvp", "--dry-run"], { + cwd: repoRoot, + stdout: { + write(chunk) { + stdout += chunk; + } + }, + stderr: { + write(chunk) { + stderr += chunk; + } + } + }); + + assert.equal(exitCode, 0, stderr); + assert.match(stdout, /hwlab-cli test e2e --env dev --mvp --dry-run/); + assert.match(stdout, /cloud-web\/cloud-api/); + assert.match(stdout, /hardware trusted closed loop/); + assert.match(stdout, /evidence_mvp-runtime-plan/); + assert.match(stdout, /dry-run only: no DEV\/PROD changes were made/); + + logOk("CLI dry-run fixture parse"); +} + +try { + await smokeCloudApi(); + await smokePatchPanelAndSimulators(); + await smokeCliDryRunFixture(); + process.stdout.write("[m1-smoke] passed\n"); +} finally { + await Promise.allSettled(children.map(stopNode)); +}