diff --git a/docs/m3-hardware-loop.md b/docs/m3-hardware-loop.md index c0c608ab..a1ddab9b 100644 --- a/docs/m3-hardware-loop.md +++ b/docs/m3-hardware-loop.md @@ -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 wiring contract and must keep routing decisions under `hwlab-patch-panel` 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. diff --git a/reports/dev-gate/dev-m3-hardware-loop.json b/reports/dev-gate/dev-m3-hardware-loop.json new file mode 100644 index 00000000..b567d4d9 --- /dev/null +++ b/reports/dev-gate/dev-m3-hardware-loop.json @@ -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." + } +} diff --git a/scripts/dev-m3-hardware-loop-smoke.mjs b/scripts/dev-m3-hardware-loop-smoke.mjs new file mode 100644 index 00000000..649263a4 --- /dev/null +++ b/scripts/dev-m3-hardware-loop-smoke.mjs @@ -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; +} diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index a45195da..2b26866d 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -20,6 +20,15 @@ const requiredDocs = [ "docs/dev-acceptance-matrix.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 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(", ")}`); } +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) { assertString(value, label); assert.ok(!path.isAbsolute(value), `${label} must be repo-relative`); @@ -96,6 +110,11 @@ async function validateReport(relativePath) { const label = relativePath; assertObject(report, label); + if (report.issue === requiredDevM3Issue || report.taskId === "dev-m3-hardware-loop") { + await validateDevM3Report(report, label); + return; + } + for (const field of [ "$schema", "$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() { const reportFiles = await collectReportFiles(); assert.ok(reportFiles.length >= 1, "expected at least one dev-gate report JSON file");