test: add dev m3 hardware loop smoke

This commit is contained in:
HWLAB Code Queue
2026-05-21 16:39:29 +00:00
parent 1e8d009e95
commit f1367da333
4 changed files with 1089 additions and 0 deletions
+31
View File
@@ -43,3 +43,34 @@ patch-panel state application, real or simulator box observation, persisted
audit events, and evidence records. That future work must preserve the same audit events, and evidence records. That future work must preserve the same
wiring contract and must keep routing decisions under `hwlab-patch-panel` wiring contract and must keep routing decisions under `hwlab-patch-panel`
ownership instead of mutating another box directly. ownership instead of mutating another box directly.
## DEV Runtime Smoke
Use this command only for the DEV simulator runtime:
```sh
node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production
```
The smoke writes `reports/dev-gate/dev-m3-hardware-loop.json`. It first checks
the frozen DEV endpoint, `http://74.48.78.17:6667`, and stops at the first
critical blocker from `docs/dev-acceptance-matrix.md`. When DEV ingress is not
observable, the report is `blocked` and the script does not run the live
`do.write true -> di.read true` operation.
If DEV ingress is reachable, the script requires explicit direct DEV simulator
targets through these environment variables:
```text
HWLAB_DEV_BOX_SIMU_1_URL
HWLAB_DEV_BOX_SIMU_2_URL
HWLAB_DEV_GATEWAY_SIMU_1_URL
HWLAB_DEV_GATEWAY_SIMU_2_URL
HWLAB_DEV_PATCH_PANEL_URL
```
Those targets must be HWLAB DEV simulator services, not UniDesk substitutes.
The script then checks two box simulators, two gateway simulators, active
patch-panel wiring for `box-simu-1 DO1 -> box-simu-2 DI1`, a live direct call
that reads `DI1=true`, and returned audit/evidence identifiers. Fixture-backed
local M3 output is never accepted as live DEV evidence.
+159
View File
@@ -0,0 +1,159 @@
{
"$schema": "https://hwlab.pikastech.local/schemas/dev-m3-hardware-loop-report.schema.json",
"$id": "https://hwlab.pikastech.local/reports/dev-gate/dev-m3-hardware-loop.json",
"reportVersion": "v1",
"issue": "pikasTech/HWLAB#38",
"taskId": "dev-m3-hardware-loop",
"commitId": "1e8d009e9531",
"acceptanceLevel": "dev_m3_hardware_loop",
"devOnly": true,
"prodDisabled": true,
"sourceContract": {
"status": "pass",
"documents": [
"docs/dev-acceptance-matrix.md",
"docs/m3-hardware-loop.md",
"fixtures/mvp/m3-hardware-loop/topology.json"
],
"summary": "Source contracts define DEV-only M3 simulator wiring and prohibit fixture output as live evidence."
},
"validationCommands": [
"node --check scripts/dev-m3-hardware-loop-smoke.mjs",
"node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production",
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs"
],
"runtimeTarget": {
"endpoint": "http://74.48.78.17:6667",
"namespace": "hwlab-dev",
"environment": "dev",
"requiredBoxSimulators": 2,
"requiredGatewaySimulators": 2,
"realHardwareAllowed": false,
"prodAllowed": false
},
"safetyGates": {
"liveFlagRequired": true,
"confirmDevRequired": true,
"confirmedNonProductionRequired": true,
"prodForbidden": true,
"realHardwareForbidden": true,
"secretReadForbidden": true,
"forcePushForbidden": true,
"unideskRuntimeSubstitutionForbidden": true
},
"liveChecks": [
{
"id": "static-contract-parse",
"status": "pass",
"summary": "DEV M3 smoke contract files were readable before live probing.",
"evidence": [
"docs/dev-acceptance-matrix.md",
"docs/m3-hardware-loop.md"
]
},
{
"id": "endpoint-freeze",
"status": "pass",
"summary": "DEV endpoint is frozen at http://74.48.78.17:6667.",
"evidence": [
"http://74.48.78.17:6667"
]
},
{
"id": "dev-ingress-health",
"status": "blocked",
"blockerClass": "network_blocker",
"summary": "DEV ingress at http://74.48.78.17:6667 did not return reachable HWLAB DEV health.",
"evidence": [
"{\"url\":\"http://74.48.78.17:6667/health/live\",\"method\":\"GET\",\"startedAt\":\"2026-05-21T16:38:29.319Z\",\"observedAt\":\"2026-05-21T16:38:33.884Z\",\"reached\":false,\"ok\":false,\"error\":{\"name\":\"Error\",\"message\":\"connect ECONNREFUSED 74.48.78.17:6667\",\"code\":\"ECONNREFUSED\"}}",
"{\"url\":\"http://74.48.78.17:6667/health\",\"method\":\"GET\",\"startedAt\":\"2026-05-21T16:38:33.885Z\",\"observedAt\":\"2026-05-21T16:38:36.674Z\",\"reached\":false,\"ok\":false,\"error\":{\"name\":\"Error\",\"message\":\"connect ECONNREFUSED 74.48.78.17:6667\",\"code\":\"ECONNREFUSED\"}}",
"{\"url\":\"http://74.48.78.17:6667/live\",\"method\":\"GET\",\"startedAt\":\"2026-05-21T16:38:36.674Z\",\"observedAt\":\"2026-05-21T16:38:39.674Z\",\"reached\":false,\"ok\":false,\"error\":{\"name\":\"Error\",\"message\":\"connect ECONNREFUSED 74.48.78.17:6667\",\"code\":\"ECONNREFUSED\"}}",
"{\"url\":\"http://74.48.78.17:6667/v1\",\"method\":\"GET\",\"startedAt\":\"2026-05-21T16:38:39.674Z\",\"observedAt\":\"2026-05-21T16:38:39.835Z\",\"reached\":false,\"ok\":false,\"error\":{\"name\":\"Error\",\"message\":\"connect ECONNREFUSED 74.48.78.17:6667\",\"code\":\"ECONNREFUSED\"}}"
]
},
{
"id": "two-box-simu-online",
"status": "not_run",
"summary": "Stopped at DEV ingress per the DEV smoke matrix.",
"evidence": [
"No live DEV evidence collected for this check."
]
},
{
"id": "two-gateway-simu-online",
"status": "not_run",
"summary": "Stopped at DEV ingress per the DEV smoke matrix.",
"evidence": [
"No live DEV evidence collected for this check."
]
},
{
"id": "patch-panel-healthy",
"status": "not_run",
"summary": "Stopped at DEV ingress per the DEV smoke matrix.",
"evidence": [
"No live DEV evidence collected for this check."
]
},
{
"id": "wiring-do1-di1-applied",
"status": "not_run",
"summary": "Stopped at DEV ingress per the DEV smoke matrix.",
"evidence": [
"No live DEV evidence collected for this check."
]
},
{
"id": "direct-call-do-write-di-read",
"status": "not_run",
"summary": "Stopped at DEV ingress per the DEV smoke matrix.",
"evidence": [
"No live DEV evidence collected for this check."
]
},
{
"id": "audit-evidence-traceable",
"status": "not_run",
"summary": "Stopped at DEV ingress per the DEV smoke matrix.",
"evidence": [
"No live DEV evidence collected for this check."
]
}
],
"localRuntimeObservations": [
{
"id": "docker-hwlab-containers",
"status": "not_observed",
"command": "docker ps --format {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
"summary": "No running docker container name/image/port line contained hwlab-."
},
{
"id": "kubectl-hwlab-dev",
"status": "unavailable",
"command": "command -v kubectl",
"summary": "kubectl is not available in this runner, so hwlab-dev namespace state was not observed."
}
],
"liveOperation": {
"status": "not_run",
"operationId": "not_observed",
"traceId": "not_observed",
"auditId": "not_observed",
"evidenceId": "not_observed",
"summary": "No live do.write -> di.read operation was attempted because DEV ingress is blocked."
},
"blockers": [
{
"type": "network_blocker",
"scope": "dev-ingress-health",
"status": "open",
"summary": "Blocked at the #33 DEV runtime readiness condition: public DEV ingress does not accept HWLAB health requests, so #36/M3 hardware-loop checks were not reached."
}
],
"summary": {
"status": "blocked",
"observedAt": "2026-05-21T16:38:29.315Z",
"result": "No live DEV M3 evidence was produced; fixture-backed local evidence was not used as a substitute."
}
}
+737
View File
@@ -0,0 +1,737 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { access, mkdir, writeFile } from "node:fs/promises";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultReportPath = "reports/dev-gate/dev-m3-hardware-loop.json";
const namespace = "hwlab-dev";
const issue = "pikasTech/HWLAB#38";
const serviceTargets = Object.freeze([
{
id: "box-simu-1",
serviceId: "hwlab-box-simu",
urlEnv: "HWLAB_DEV_BOX_SIMU_1_URL",
statusPath: "/status"
},
{
id: "box-simu-2",
serviceId: "hwlab-box-simu",
urlEnv: "HWLAB_DEV_BOX_SIMU_2_URL",
statusPath: "/status"
},
{
id: "gateway-simu-1",
serviceId: "hwlab-gateway-simu",
urlEnv: "HWLAB_DEV_GATEWAY_SIMU_1_URL",
statusPath: "/status"
},
{
id: "gateway-simu-2",
serviceId: "hwlab-gateway-simu",
urlEnv: "HWLAB_DEV_GATEWAY_SIMU_2_URL",
statusPath: "/status"
},
{
id: "patch-panel",
serviceId: "hwlab-patch-panel",
urlEnv: "HWLAB_DEV_PATCH_PANEL_URL",
statusPath: "/status",
wiringPath: "/wiring",
routePath: "/signals/route"
}
]);
function parseArgs(argv) {
const flags = new Set();
const values = new Map();
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg.startsWith("--")) {
throw new Error(`unexpected positional argument ${arg}`);
}
if (arg === "--output") {
const value = argv[index + 1];
assert.ok(value && !value.startsWith("--"), "--output requires a path");
values.set("output", value);
index += 1;
continue;
}
flags.add(arg);
}
return { flags, values };
}
function requireSafetyGates(flags) {
const forbiddenFlags = [
"--prod",
"--real-hardware",
"--read-secret",
"--read-token",
"--force-push",
"--restart-runtime",
"--restart-unidesk",
"--restart-code-queue",
"--restart-backend-core"
];
for (const flag of forbiddenFlags) {
assert.ok(!flags.has(flag), `${flag} is forbidden for DEV M3 smoke`);
}
const live = flags.has("--live") || flags.has("--allow-live");
assert.ok(live, "DEV M3 smoke requires --live or --allow-live");
assert.ok(flags.has("--confirm-dev"), "live DEV smoke requires --confirm-dev");
assert.ok(
flags.has("--confirmed-non-production"),
"live DEV smoke requires --confirmed-non-production"
);
}
function isoNow() {
return new Date().toISOString();
}
function relativePath(absolutePath) {
return path.relative(repoRoot, absolutePath).replaceAll(path.sep, "/");
}
function resolveReportPath(value) {
const candidate = path.resolve(repoRoot, value ?? defaultReportPath);
assert.ok(candidate.startsWith(`${repoRoot}${path.sep}`), "report path must stay inside repository");
return candidate;
}
function currentCommit() {
try {
return execFileSync("git", ["rev-parse", "--short=12", "HEAD"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"]
}).trim();
} catch {
return "unknown";
}
}
function oneLine(value) {
return String(value).replace(/\s+/g, " ").trim();
}
function commandResult(command, args, { timeoutMs = 5000, maxChars = 3000 } = {}) {
try {
const stdout = execFileSync(command, args, {
cwd: repoRoot,
encoding: "utf8",
timeout: timeoutMs,
stdio: ["ignore", "pipe", "pipe"]
});
return {
command: [command, ...args].join(" "),
exitCode: 0,
stdout: stdout.slice(0, maxChars)
};
} catch (error) {
return {
command: [command, ...args].join(" "),
exitCode: Number.isInteger(error.status) ? error.status : 1,
stdout: String(error.stdout ?? "").slice(0, maxChars),
stderr: String(error.stderr || error.message || "").slice(0, maxChars)
};
}
}
function commandExists(command) {
const result = commandResult("bash", ["-lc", `command -v ${command}`], { timeoutMs: 2000 });
return result.exitCode === 0 && result.stdout.trim().length > 0;
}
function collectLocalRuntimeObservations() {
const observations = [];
if (commandExists("docker")) {
const docker = commandResult("docker", [
"ps",
"--format",
"{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"
]);
const hwlabLines = docker.stdout
.split("\n")
.filter((line) => line.includes("hwlab-"))
.join("\n");
observations.push({
id: "docker-hwlab-containers",
status: hwlabLines ? "observed" : "not_observed",
command: docker.command,
summary: oneLine(hwlabLines || "No running docker container name/image/port line contained hwlab-.")
});
} else {
observations.push({
id: "docker-hwlab-containers",
status: "unavailable",
command: "command -v docker",
summary: "docker CLI is not available in this runner."
});
}
if (commandExists("kubectl")) {
const kubectl = commandResult("kubectl", ["get", "deploy,po,svc", "-n", namespace, "-o", "wide"]);
observations.push({
id: "kubectl-hwlab-dev",
status: kubectl.exitCode === 0 ? "observed" : "blocked",
command: kubectl.command,
summary: oneLine(kubectl.exitCode === 0 ? kubectl.stdout : kubectl.stderr || kubectl.stdout)
});
} else {
observations.push({
id: "kubectl-hwlab-dev",
status: "unavailable",
command: "command -v kubectl",
summary: "kubectl is not available in this runner, so hwlab-dev namespace state was not observed."
});
}
return observations;
}
async function probeJson(url, options = {}) {
const method = options.method ?? "GET";
const startedAt = isoNow();
const timeoutMs = options.timeoutMs ?? 8000;
const body = options.body ? JSON.stringify(options.body) : null;
const headers = body
? {
"content-type": "application/json",
"content-length": Buffer.byteLength(body)
}
: {};
return new Promise((resolve) => {
const parsed = new URL(url);
const requestFn = parsed.protocol === "https:" ? httpsRequest : httpRequest;
const req = requestFn(
parsed,
{
method,
headers,
timeout: timeoutMs
},
(response) => {
let text = "";
response.setEncoding("utf8");
response.on("data", (chunk) => {
text += chunk;
});
response.on("end", () => {
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
const status = response.statusCode ?? 0;
resolve({
url,
method,
startedAt,
observedAt: isoNow(),
reached: true,
ok: status >= 200 && status < 300,
status,
statusText: response.statusMessage ?? "",
json,
bodyPreview: json ? undefined : text.slice(0, 500)
});
});
}
);
req.on("timeout", () => {
req.destroy(new Error(`request timed out after ${timeoutMs}ms`));
});
req.on("error", (error) => {
resolve({
url,
method,
startedAt,
observedAt: isoNow(),
reached: false,
ok: false,
error: {
name: error.name,
message: error.message,
code: error.code
}
});
});
if (body) {
req.write(body);
}
req.end();
});
}
function joinUrl(baseUrl, routePath) {
return new URL(routePath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
}
function isHwlabDevIdentity(probe) {
const body = probe.json;
if (!probe.ok || !body || typeof body !== "object" || Array.isArray(body)) {
return false;
}
if (typeof body.serviceId !== "string" || !body.serviceId.startsWith("hwlab-")) {
return false;
}
return body.environment === undefined || body.environment === ENVIRONMENT_DEV;
}
async function observeDevIngress() {
const paths = ["/health/live", "/health", "/live", "/v1"];
const probes = [];
for (const routePath of paths) {
probes.push(await probeJson(joinUrl(DEV_ENDPOINT, routePath)));
}
const accepted = probes.find(isHwlabDevIdentity);
return {
accepted: Boolean(accepted),
acceptedPath: accepted ? new URL(accepted.url).pathname : null,
probes
};
}
function targetMapFromEnvironment() {
const missing = [];
const targets = new Map();
for (const target of serviceTargets) {
const baseUrl = process.env[target.urlEnv];
if (!baseUrl) {
missing.push(target.urlEnv);
continue;
}
targets.set(target.id, {
...target,
baseUrl
});
}
return { missing, targets };
}
function assertBoxState(state, label) {
assert.equal(state?.serviceId, "hwlab-box-simu", `${label} serviceId`);
assert.equal(state.live, true, `${label} live`);
assert.equal(state.resource?.environment ?? state.environment, ENVIRONMENT_DEV, `${label} environment`);
assert.ok(state.ports?.DO1, `${label} requires DO1`);
assert.ok(state.ports?.DI1, `${label} requires DI1`);
assert.equal(state.constraints?.crossDevicePropagation, "patch-panel-only", `${label} propagation`);
assert.equal(state.constraints?.localLoopbackEnabled, false, `${label} local loopback`);
}
function assertGatewayState(state, label) {
assert.equal(state?.serviceId, "hwlab-gateway-simu", `${label} serviceId`);
assert.equal(state.live, true, `${label} live`);
assert.equal(state.session?.status, "connected", `${label} session status`);
assert.equal(state.session?.environment, ENVIRONMENT_DEV, `${label} environment`);
assert.ok(Array.isArray(state.boxes) && state.boxes.length >= 1, `${label} boxes`);
}
function assertPatchPanelStatus(state) {
assert.equal(state?.serviceId, "hwlab-patch-panel", "patch-panel serviceId");
assert.equal(state.state, "active", "patch-panel state");
assert.equal(state.environment, ENVIRONMENT_DEV, "patch-panel environment");
assert.ok(Array.isArray(state.activeConnections), "patch-panel activeConnections");
}
function findM3Connection({ status, wiring, box1, box2 }) {
const fromResourceId = box1.resource?.resourceId;
const toResourceId = box2.resource?.resourceId;
const activeConnections = status.activeConnections ?? [];
const wiringConnections = wiring.connections ?? [];
const active = activeConnections.find(
(connection) =>
connection.fromResourceId === fromResourceId &&
connection.fromPort === "DO1" &&
connection.toResourceId === toResourceId &&
connection.toPort === "DI1"
);
const configured = wiringConnections.find(
(connection) =>
connection.from?.resourceId === fromResourceId &&
connection.from?.port === "DO1" &&
connection.to?.resourceId === toResourceId &&
connection.to?.port === "DI1" &&
connection.mode === "exclusive"
);
return { active, configured };
}
async function runLiveM3Targets(targets) {
const observed = {};
const probes = [];
for (const [id, target] of targets.entries()) {
const health = await probeJson(joinUrl(target.baseUrl, "/health/live"));
const status = await probeJson(joinUrl(target.baseUrl, target.statusPath));
probes.push({ id, kind: "health", probe: health }, { id, kind: "status", probe: status });
assert.ok(health.ok, `${id} health failed`);
assert.ok(status.ok, `${id} status failed`);
assert.equal(health.json?.serviceId, target.serviceId, `${id} health serviceId`);
observed[id] = status.json;
}
assertBoxState(observed["box-simu-1"], "box-simu-1");
assertBoxState(observed["box-simu-2"], "box-simu-2");
assertGatewayState(observed["gateway-simu-1"], "gateway-simu-1");
assertGatewayState(observed["gateway-simu-2"], "gateway-simu-2");
assertPatchPanelStatus(observed["patch-panel"]);
const patchTarget = targets.get("patch-panel");
const wiring = await probeJson(joinUrl(patchTarget.baseUrl, patchTarget.wiringPath));
probes.push({ id: "patch-panel", kind: "wiring", probe: wiring });
assert.ok(wiring.ok, "patch-panel wiring failed");
assert.equal(wiring.json?.status, "active", "wiring status");
assert.equal(wiring.json?.constraints?.propagation, "patch-panel-only", "wiring propagation");
const connection = findM3Connection({
status: observed["patch-panel"],
wiring: wiring.json,
box1: observed["box-simu-1"],
box2: observed["box-simu-2"]
});
assert.ok(connection.active, "patch-panel active DO1 -> DI1 connection missing");
assert.ok(connection.configured, "wiring config DO1 -> DI1 connection missing");
const operationId = `op_dev_m3_${randomUUID()}`;
const traceId = `trc_dev_m3_${randomUUID()}`;
const box1Target = targets.get("box-simu-1");
const box2Target = targets.get("box-simu-2");
const write = await probeJson(joinUrl(box1Target.baseUrl, "/ports/write"), {
method: "POST",
body: {
port: "DO1",
value: true,
operationId,
traceId
}
});
probes.push({ id: "box-simu-1", kind: "do.write", probe: write });
assert.ok(write.ok, "box-simu-1 DO1 write failed");
assert.equal(write.json?.accepted, true, "box-simu-1 DO1 write accepted");
const route = await probeJson(joinUrl(patchTarget.baseUrl, patchTarget.routePath), {
method: "POST",
body: {
fromResourceId: observed["box-simu-1"].resource.resourceId,
fromPort: "DO1",
value: true,
operationId,
traceId
}
});
probes.push({ id: "patch-panel", kind: "signals.route", probe: route });
assert.ok(route.ok, "patch-panel signal route failed");
assert.equal(route.json?.accepted, true, "patch-panel signal route accepted");
assert.equal(route.json?.deliveryCount, 1, "patch-panel signal route delivery count");
const after = await probeJson(joinUrl(box2Target.baseUrl, "/status"));
probes.push({ id: "box-simu-2", kind: "di.read", probe: after });
assert.ok(after.ok, "box-simu-2 DI1 read failed");
assert.equal(after.json?.ports?.DI1?.value, true, "box-simu-2 DI1 must read true");
const auditId = route.json?.auditId ?? write.json?.auditId ?? after.json?.auditId;
const evidenceId = route.json?.evidenceId ?? write.json?.evidenceId ?? after.json?.evidenceId;
assert.equal(typeof auditId, "string", "live M3 result must include auditId");
assert.equal(typeof evidenceId, "string", "live M3 result must include evidenceId");
return {
operationId,
traceId,
auditId,
evidenceId,
probes,
observed
};
}
function baseReport({ commitId, observedAt }) {
return {
$schema: "https://hwlab.pikastech.local/schemas/dev-m3-hardware-loop-report.schema.json",
$id: "https://hwlab.pikastech.local/reports/dev-gate/dev-m3-hardware-loop.json",
reportVersion: "v1",
issue,
taskId: "dev-m3-hardware-loop",
commitId,
acceptanceLevel: "dev_m3_hardware_loop",
devOnly: true,
prodDisabled: true,
sourceContract: {
status: "pass",
documents: [
"docs/dev-acceptance-matrix.md",
"docs/m3-hardware-loop.md",
"fixtures/mvp/m3-hardware-loop/topology.json"
],
summary: "Source contracts define DEV-only M3 simulator wiring and prohibit fixture output as live evidence."
},
validationCommands: [
"node --check scripts/dev-m3-hardware-loop-smoke.mjs",
"node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production",
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs"
],
runtimeTarget: {
endpoint: DEV_ENDPOINT,
namespace,
environment: ENVIRONMENT_DEV,
requiredBoxSimulators: 2,
requiredGatewaySimulators: 2,
realHardwareAllowed: false,
prodAllowed: false
},
safetyGates: {
liveFlagRequired: true,
confirmDevRequired: true,
confirmedNonProductionRequired: true,
prodForbidden: true,
realHardwareForbidden: true,
secretReadForbidden: true,
forcePushForbidden: true,
unideskRuntimeSubstitutionForbidden: true
},
liveChecks: [
{
id: "static-contract-parse",
status: "pass",
summary: "DEV M3 smoke contract files were readable before live probing.",
evidence: [
"docs/dev-acceptance-matrix.md",
"docs/m3-hardware-loop.md"
]
},
{
id: "endpoint-freeze",
status: "pass",
summary: `DEV endpoint is frozen at ${DEV_ENDPOINT}.`,
evidence: [DEV_ENDPOINT]
}
],
localRuntimeObservations: [],
liveOperation: {
status: "not_run",
operationId: "not_observed",
traceId: "not_observed",
auditId: "not_observed",
evidenceId: "not_observed",
summary: "No live DEV operation has run yet."
},
blockers: [],
summary: {
status: "not_run",
observedAt,
result: "DEV M3 smoke has not completed."
}
};
}
function addNotRunM3Checks(report, reason) {
for (const id of [
"two-box-simu-online",
"two-gateway-simu-online",
"patch-panel-healthy",
"wiring-do1-di1-applied",
"direct-call-do-write-di-read",
"audit-evidence-traceable"
]) {
report.liveChecks.push({
id,
status: "not_run",
summary: reason,
evidence: ["No live DEV evidence collected for this check."]
});
}
}
async function ensureContractFiles() {
await access(path.join(repoRoot, "docs/dev-acceptance-matrix.md"));
await access(path.join(repoRoot, "docs/m3-hardware-loop.md"));
await access(path.join(repoRoot, "fixtures/mvp/m3-hardware-loop/topology.json"));
}
async function writeReport(report, reportPath) {
await mkdir(path.dirname(reportPath), { recursive: true });
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
}
async function main() {
const { flags, values } = parseArgs(process.argv.slice(2));
requireSafetyGates(flags);
await ensureContractFiles();
const reportPath = resolveReportPath(values.get("output"));
const observedAt = isoNow();
const report = baseReport({
commitId: currentCommit(),
observedAt
});
const ingress = await observeDevIngress();
report.localRuntimeObservations = collectLocalRuntimeObservations();
report.liveChecks.push({
id: "dev-ingress-health",
status: ingress.accepted ? "pass" : "blocked",
blockerClass: ingress.accepted ? undefined : "network_blocker",
summary: ingress.accepted
? `DEV ingress returned HWLAB identity at ${ingress.acceptedPath}.`
: `DEV ingress at ${DEV_ENDPOINT} did not return reachable HWLAB DEV health.`,
evidence: ingress.probes.map((probe) => JSON.stringify(probe))
});
if (!ingress.accepted) {
addNotRunM3Checks(report, "Stopped at DEV ingress per the DEV smoke matrix.");
report.liveOperation = {
status: "not_run",
operationId: "not_observed",
traceId: "not_observed",
auditId: "not_observed",
evidenceId: "not_observed",
summary: "No live do.write -> di.read operation was attempted because DEV ingress is blocked."
};
report.blockers.push({
type: "network_blocker",
scope: "dev-ingress-health",
status: "open",
summary: "Blocked at the #33 DEV runtime readiness condition: public DEV ingress does not accept HWLAB health requests, so #36/M3 hardware-loop checks were not reached."
});
report.summary = {
status: "blocked",
observedAt,
result: "No live DEV M3 evidence was produced; fixture-backed local evidence was not used as a substitute."
};
await writeReport(report, reportPath);
console.log(`[dev-m3-smoke] status=blocked report=${relativePath(reportPath)}`);
console.log("[dev-m3-smoke] blocker=network_blocker scope=dev-ingress-health");
return;
}
const { missing, targets } = targetMapFromEnvironment();
if (missing.length > 0) {
addNotRunM3Checks(report, "Stopped before M3 direct checks because service target URLs were not provided.");
report.blockers.push({
type: "runtime_blocker",
scope: "m3-service-discovery",
status: "open",
summary: `DEV ingress is reachable, but M3 smoke lacks direct DEV service URLs: ${missing.join(", ")}.`
});
report.summary = {
status: "blocked",
observedAt,
result: "DEV ingress was reachable, but live M3 simulator and patch-panel targets were not discoverable."
};
await writeReport(report, reportPath);
console.log(`[dev-m3-smoke] status=blocked report=${relativePath(reportPath)}`);
console.log("[dev-m3-smoke] blocker=runtime_blocker scope=m3-service-discovery");
return;
}
try {
const live = await runLiveM3Targets(targets);
report.liveChecks.push(
{
id: "two-box-simu-online",
status: "pass",
summary: "Both DEV box simulators reported live status with DO1 and DI1 ports.",
evidence: ["box-simu-1 /status", "box-simu-2 /status"]
},
{
id: "two-gateway-simu-online",
status: "pass",
summary: "Both DEV gateway simulators reported connected sessions.",
evidence: ["gateway-simu-1 /status", "gateway-simu-2 /status"]
},
{
id: "patch-panel-healthy",
status: "pass",
summary: "DEV patch-panel reported active status.",
evidence: ["patch-panel /status"]
},
{
id: "wiring-do1-di1-applied",
status: "pass",
summary: "Patch-panel active wiring matched box-simu-1 DO1 -> box-simu-2 DI1.",
evidence: ["patch-panel /wiring"]
},
{
id: "direct-call-do-write-di-read",
status: "pass",
summary: "Live DEV direct call observed do.write true followed by di.read true.",
evidence: [`operationId=${live.operationId}`, `traceId=${live.traceId}`]
},
{
id: "audit-evidence-traceable",
status: "pass",
summary: "Live DEV direct call returned audit and evidence identifiers.",
evidence: [`auditId=${live.auditId}`, `evidenceId=${live.evidenceId}`]
}
);
report.liveOperation = {
status: "pass",
operationId: live.operationId,
traceId: live.traceId,
auditId: live.auditId,
evidenceId: live.evidenceId,
summary: "Live DEV M3 do.write true -> di.read true succeeded with traceable audit/evidence identifiers."
};
report.rawLiveProbeCount = live.probes.length;
report.summary = {
status: "pass",
observedAt,
result: "Live DEV M3 hardware loop passed without fixture substitution."
};
await writeReport(report, reportPath);
console.log(`[dev-m3-smoke] status=pass report=${relativePath(reportPath)}`);
console.log(
`[dev-m3-smoke] operationId=${live.operationId} auditId=${live.auditId} evidenceId=${live.evidenceId}`
);
} catch (error) {
addNotRunM3Checks(report, "A live DEV M3 runtime contract check failed before all checks passed.");
report.blockers.push({
type: "runtime_blocker",
scope: "m3-hardware-loop-runtime",
status: "open",
summary: error instanceof Error ? error.message : String(error)
});
report.summary = {
status: "blocked",
observedAt,
result: "Live DEV M3 runtime was reachable but did not satisfy the M3 hardware-loop contract."
};
await writeReport(report, reportPath);
console.log(`[dev-m3-smoke] status=blocked report=${relativePath(reportPath)}`);
console.log("[dev-m3-smoke] blocker=runtime_blocker scope=m3-hardware-loop-runtime");
}
}
try {
await main();
} catch (error) {
console.error(`[dev-m3-smoke] ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}
+162
View File
@@ -20,6 +20,15 @@ const requiredDocs = [
"docs/dev-acceptance-matrix.md", "docs/dev-acceptance-matrix.md",
"docs/m0-contract-audit.md" "docs/m0-contract-audit.md"
]; ];
const requiredDevM3Issue = "pikasTech/HWLAB#38";
const requiredDevM3ValidationCommands = [
"node --check scripts/dev-m3-hardware-loop-smoke.mjs",
"node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"
];
const requiredDevM3Docs = [
"docs/dev-acceptance-matrix.md",
"docs/m3-hardware-loop.md"
];
const statusValues = new Set(["pass", "blocked", "not_run", "not_applicable", "failed"]); const statusValues = new Set(["pass", "blocked", "not_run", "not_applicable", "failed"]);
const blockerTypes = new Set([ const blockerTypes = new Set([
@@ -52,6 +61,11 @@ function assertStatus(value, label) {
assert.ok(statusValues.has(value), `${label} must be one of ${Array.from(statusValues).join(", ")}`); assert.ok(statusValues.has(value), `${label} must be one of ${Array.from(statusValues).join(", ")}`);
} }
function assertTimestamp(value, label) {
assertString(value, label);
assert.ok(!Number.isNaN(Date.parse(value)), `${label} must be an RFC 3339 timestamp`);
}
function assertRepoRelativePath(value, label) { function assertRepoRelativePath(value, label) {
assertString(value, label); assertString(value, label);
assert.ok(!path.isAbsolute(value), `${label} must be repo-relative`); assert.ok(!path.isAbsolute(value), `${label} must be repo-relative`);
@@ -96,6 +110,11 @@ async function validateReport(relativePath) {
const label = relativePath; const label = relativePath;
assertObject(report, label); assertObject(report, label);
if (report.issue === requiredDevM3Issue || report.taskId === "dev-m3-hardware-loop") {
await validateDevM3Report(report, label);
return;
}
for (const field of [ for (const field of [
"$schema", "$schema",
"$id", "$id",
@@ -206,6 +225,149 @@ async function validateReport(relativePath) {
} }
} }
async function validateDevM3Report(report, label) {
for (const field of [
"$schema",
"$id",
"reportVersion",
"issue",
"taskId",
"commitId",
"acceptanceLevel",
"devOnly",
"prodDisabled",
"sourceContract",
"validationCommands",
"runtimeTarget",
"safetyGates",
"liveChecks",
"liveOperation",
"blockers",
"summary"
]) {
assert.ok(Object.hasOwn(report, field), `${label} missing ${field}`);
}
assertString(report.$schema, `${label}.$schema`);
assertString(report.$id, `${label}.$id`);
assert.equal(report.reportVersion, "v1", `${label}.reportVersion`);
assert.equal(report.issue, requiredDevM3Issue, `${label}.issue`);
assert.equal(report.taskId, "dev-m3-hardware-loop", `${label}.taskId`);
assert.match(report.commitId, /^([a-f0-9]{7,40}|unknown)$/, `${label}.commitId`);
assert.equal(report.acceptanceLevel, "dev_m3_hardware_loop", `${label}.acceptanceLevel`);
assert.equal(report.devOnly, true, `${label}.devOnly`);
assert.equal(report.prodDisabled, true, `${label}.prodDisabled`);
assertObject(report.sourceContract, `${label}.sourceContract`);
assertStatus(report.sourceContract.status, `${label}.sourceContract.status`);
assertStringArray(report.sourceContract.documents, `${label}.sourceContract.documents`, {
minLength: requiredDevM3Docs.length
});
assertString(report.sourceContract.summary, `${label}.sourceContract.summary`);
const documents = new Set(report.sourceContract.documents);
assertUnique(report.sourceContract.documents, `${label}.sourceContract.documents`);
for (const requiredDoc of requiredDevM3Docs) {
assert.ok(documents.has(requiredDoc), `${label}.sourceContract.documents missing ${requiredDoc}`);
}
for (const [index, documentPath] of report.sourceContract.documents.entries()) {
const absolutePath = assertRepoRelativePath(
documentPath,
`${label}.sourceContract.documents[${index}]`
);
await access(absolutePath, fsConstants.R_OK);
}
assertStringArray(report.validationCommands, `${label}.validationCommands`, { minLength: 2 });
assertUnique(report.validationCommands, `${label}.validationCommands`);
for (const requiredCommand of requiredDevM3ValidationCommands) {
assert.ok(
report.validationCommands.includes(requiredCommand),
`${label}.validationCommands missing ${requiredCommand}`
);
}
assertObject(report.runtimeTarget, `${label}.runtimeTarget`);
assert.equal(report.runtimeTarget.endpoint, "http://74.48.78.17:6667", `${label}.runtimeTarget.endpoint`);
assert.equal(report.runtimeTarget.namespace, "hwlab-dev", `${label}.runtimeTarget.namespace`);
assert.equal(report.runtimeTarget.environment, "dev", `${label}.runtimeTarget.environment`);
assert.equal(report.runtimeTarget.requiredBoxSimulators, 2, `${label}.runtimeTarget.requiredBoxSimulators`);
assert.equal(report.runtimeTarget.requiredGatewaySimulators, 2, `${label}.runtimeTarget.requiredGatewaySimulators`);
assert.equal(report.runtimeTarget.realHardwareAllowed, false, `${label}.runtimeTarget.realHardwareAllowed`);
assert.equal(report.runtimeTarget.prodAllowed, false, `${label}.runtimeTarget.prodAllowed`);
assertObject(report.safetyGates, `${label}.safetyGates`);
for (const field of [
"liveFlagRequired",
"confirmDevRequired",
"confirmedNonProductionRequired",
"prodForbidden",
"realHardwareForbidden",
"secretReadForbidden",
"forcePushForbidden",
"unideskRuntimeSubstitutionForbidden"
]) {
assert.equal(report.safetyGates[field], true, `${label}.safetyGates.${field}`);
}
assertArray(report.liveChecks, `${label}.liveChecks`);
assert.ok(report.liveChecks.length >= 8, `${label}.liveChecks must include DEV M3 checks`);
assertUnique(report.liveChecks.map((check) => check.id), `${label}.liveChecks`);
for (const [index, check] of report.liveChecks.entries()) {
const checkLabel = `${label}.liveChecks[${index}]`;
assertObject(check, checkLabel);
for (const field of ["id", "status", "summary", "evidence"]) {
assert.ok(Object.hasOwn(check, field), `${checkLabel} missing ${field}`);
}
assertString(check.id, `${checkLabel}.id`);
assertStatus(check.status, `${checkLabel}.status`);
assertString(check.summary, `${checkLabel}.summary`);
assertStringArray(check.evidence, `${checkLabel}.evidence`, { minLength: 1 });
if (Object.hasOwn(check, "blockerClass")) {
assert.ok(blockerTypes.has(check.blockerClass), `${checkLabel}.blockerClass must be known`);
}
}
if (Object.hasOwn(report, "localRuntimeObservations")) {
assertArray(report.localRuntimeObservations, `${label}.localRuntimeObservations`);
for (const [index, observation] of report.localRuntimeObservations.entries()) {
const observationLabel = `${label}.localRuntimeObservations[${index}]`;
assertObject(observation, observationLabel);
for (const field of ["id", "status", "command", "summary"]) {
assert.ok(Object.hasOwn(observation, field), `${observationLabel} missing ${field}`);
assertString(observation[field], `${observationLabel}.${field}`);
}
}
}
assertObject(report.liveOperation, `${label}.liveOperation`);
assertStatus(report.liveOperation.status, `${label}.liveOperation.status`);
for (const field of ["operationId", "traceId", "auditId", "evidenceId", "summary"]) {
assertString(report.liveOperation[field], `${label}.liveOperation.${field}`);
}
assertArray(report.blockers, `${label}.blockers`);
assertUnique(
report.blockers.map((blocker) => `${blocker.type}::${blocker.scope}`),
`${label}.blockers`
);
for (const [index, blocker] of report.blockers.entries()) {
const blockerLabel = `${label}.blockers[${index}]`;
assertObject(blocker, blockerLabel);
for (const field of ["type", "scope", "status", "summary"]) {
assert.ok(Object.hasOwn(blocker, field), `${blockerLabel} missing ${field}`);
}
assert.ok(blockerTypes.has(blocker.type), `${blockerLabel}.type must be a known blocker type`);
assertString(blocker.scope, `${blockerLabel}.scope`);
assert.ok(blockerStates.has(blocker.status), `${blockerLabel}.status must be open, acknowledged, or closed`);
assertString(blocker.summary, `${blockerLabel}.summary`);
}
assertObject(report.summary, `${label}.summary`);
assertStatus(report.summary.status, `${label}.summary.status`);
assertTimestamp(report.summary.observedAt, `${label}.summary.observedAt`);
assertString(report.summary.result, `${label}.summary.result`);
}
async function main() { async function main() {
const reportFiles = await collectReportFiles(); const reportFiles = await collectReportFiles();
assert.ok(reportFiles.length >= 1, "expected at least one dev-gate report JSON file"); assert.ok(reportFiles.length >= 1, "expected at least one dev-gate report JSON file");