diff --git a/docs/m3-hardware-loop.md b/docs/m3-hardware-loop.md index a1ddab9b..5397b109 100644 --- a/docs/m3-hardware-loop.md +++ b/docs/m3-hardware-loop.md @@ -74,3 +74,13 @@ 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. + +## Read-Only Continuation Evidence + +When DEV ingress is blocked, continuation work may still record read-only +evidence. The smoke report includes a static comparison of +`deploy/k8s/base/workloads.yaml` against the M3 requirement for two +`hwlab-box-simu` instances and two `hwlab-gateway-simu` instances. This is not +live runtime proof. If that static comparison or the live runtime still lacks +the required simulator cardinality, the fix is a separate authorized DEV deploy +or runtime task, not a mutation inside the M3 evidence collection task. diff --git a/reports/dev-gate/dev-m3-hardware-loop.json b/reports/dev-gate/dev-m3-hardware-loop.json index b567d4d9..c0f3e4c2 100644 --- a/reports/dev-gate/dev-m3-hardware-loop.json +++ b/reports/dev-gate/dev-m3-hardware-loop.json @@ -4,7 +4,7 @@ "reportVersion": "v1", "issue": "pikasTech/HWLAB#38", "taskId": "dev-m3-hardware-loop", - "commitId": "1e8d009e9531", + "commitId": "a79037243324", "acceptanceLevel": "dev_m3_hardware_loop", "devOnly": true, "prodDisabled": true, @@ -66,10 +66,10 @@ "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\"}}" + "{\"url\":\"http://74.48.78.17:6667/health/live\",\"method\":\"GET\",\"startedAt\":\"2026-05-21T16:45:27.143Z\",\"observedAt\":\"2026-05-21T16:45:27.314Z\",\"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:45:27.314Z\",\"observedAt\":\"2026-05-21T16:45:27.504Z\",\"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:45:27.504Z\",\"observedAt\":\"2026-05-21T16:45:27.669Z\",\"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:45:27.669Z\",\"observedAt\":\"2026-05-21T16:45:27.847Z\",\"reached\":false,\"ok\":false,\"error\":{\"name\":\"Error\",\"message\":\"connect ECONNREFUSED 74.48.78.17:6667\",\"code\":\"ECONNREFUSED\"}}" ] }, { @@ -121,6 +121,42 @@ ] } ], + "readOnlySupplementalEvidence": [ + { + "id": "deploy-skeleton-m3-cardinality", + "status": "gap", + "blockerClass": "runtime_blocker", + "source": "deploy/k8s/base/workloads.yaml", + "summary": "Read-only static comparison found the checked-in DEV deploy skeleton declares one box simulator replica and one gateway simulator replica; proving two of each in live DEV requires a separate deploy/runtime task or authorized runtime observation.", + "evidence": { + "required": { + "hwlab-box-simu": 2, + "hwlab-gateway-simu": 2, + "hwlab-patch-panel": 1, + "wiring": "box-simu-1 DO1 -> box-simu-2 DI1" + }, + "observed": { + "hwlab-box-simu": { + "replicas": 1, + "env": { + "HWLAB_BOX_ID": "boxsim_alpha" + } + }, + "hwlab-gateway-simu": { + "replicas": 1, + "env": { + "HWLAB_BOX_RESOURCES": "res_boxsim_alpha" + } + }, + "hwlab-patch-panel": { + "replicas": 1, + "env": {} + } + } + }, + "requiredFollowUp": "Create a separate authorized DEV deploy/runtime task to provision or expose two box-simu instances, two gateway-simu instances, M3 wiring, and audit/evidence endpoints; do not perform that mutation in this read-only continuation." + } + ], "localRuntimeObservations": [ { "id": "docker-hwlab-containers", @@ -153,7 +189,7 @@ ], "summary": { "status": "blocked", - "observedAt": "2026-05-21T16:38:29.315Z", + "observedAt": "2026-05-21T16:45:27.138Z", "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 index 649263a4..24d0293a 100644 --- a/scripts/dev-m3-hardware-loop-smoke.mjs +++ b/scripts/dev-m3-hardware-loop-smoke.mjs @@ -2,7 +2,7 @@ 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 { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { request as httpRequest } from "node:http"; import { request as httpsRequest } from "node:https"; import path from "node:path"; @@ -14,6 +14,7 @@ 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 deployWorkloadsPath = "deploy/k8s/base/workloads.yaml"; const serviceTargets = Object.freeze([ { @@ -204,6 +205,70 @@ function collectLocalRuntimeObservations() { return observations; } +async function collectReadOnlySupplementalEvidence() { + const raw = await readFile(path.join(repoRoot, deployWorkloadsPath), "utf8"); + const workloads = JSON.parse(raw); + const targets = ["hwlab-box-simu", "hwlab-gateway-simu", "hwlab-patch-panel"]; + const observed = workloads.items + .filter((item) => targets.includes(item.metadata?.name)) + .map((item) => { + const container = item.spec?.template?.spec?.containers?.[0] ?? {}; + const env = Object.fromEntries((container.env ?? []).map((entry) => [entry.name, entry.value])); + return { + name: item.metadata.name, + kind: item.kind, + namespace: item.metadata.namespace, + replicas: item.spec?.replicas ?? null, + image: container.image, + env + }; + }); + + const box = observed.find((item) => item.name === "hwlab-box-simu"); + const gateway = observed.find((item) => item.name === "hwlab-gateway-simu"); + const patchPanel = observed.find((item) => item.name === "hwlab-patch-panel"); + const boxReadyForM3 = box?.replicas === 2 && String(box.env.HWLAB_BOX_ID ?? "").includes(","); + const gatewayReadyForM3 = + gateway?.replicas === 2 && String(gateway.env.HWLAB_BOX_RESOURCES ?? "").includes(","); + + return [ + { + id: "deploy-skeleton-m3-cardinality", + status: boxReadyForM3 && gatewayReadyForM3 ? "pass" : "gap", + blockerClass: boxReadyForM3 && gatewayReadyForM3 ? null : "runtime_blocker", + source: deployWorkloadsPath, + summary: boxReadyForM3 && gatewayReadyForM3 + ? "Checked-in DEV deploy skeleton declares two simulator identities for M3." + : "Read-only static comparison found the checked-in DEV deploy skeleton declares one box simulator replica and one gateway simulator replica; proving two of each in live DEV requires a separate deploy/runtime task or authorized runtime observation.", + evidence: { + required: { + "hwlab-box-simu": 2, + "hwlab-gateway-simu": 2, + "hwlab-patch-panel": 1, + wiring: "box-simu-1 DO1 -> box-simu-2 DI1" + }, + observed: { + "hwlab-box-simu": { + replicas: box?.replicas ?? null, + env: box?.env ?? null + }, + "hwlab-gateway-simu": { + replicas: gateway?.replicas ?? null, + env: gateway?.env ?? null + }, + "hwlab-patch-panel": { + replicas: patchPanel?.replicas ?? null, + env: patchPanel?.env ?? null + } + } + }, + requiredFollowUp: boxReadyForM3 && gatewayReadyForM3 + ? "None from static deploy cardinality." + : "Create a separate authorized DEV deploy/runtime task to provision or expose two box-simu instances, two gateway-simu instances, M3 wiring, and audit/evidence endpoints; do not perform that mutation in this read-only continuation." + } + ]; +} + async function probeJson(url, options = {}) { const method = options.method ?? "GET"; const startedAt = isoNow(); @@ -533,6 +598,7 @@ function baseReport({ commitId, observedAt }) { evidence: [DEV_ENDPOINT] } ], + readOnlySupplementalEvidence: [], localRuntimeObservations: [], liveOperation: { status: "not_run", @@ -591,6 +657,7 @@ async function main() { commitId: currentCommit(), observedAt }); + report.readOnlySupplementalEvidence = await collectReadOnlySupplementalEvidence(); const ingress = await observeDevIngress(); report.localRuntimeObservations = collectLocalRuntimeObservations(); diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 9c993ee2..6f0bd591 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -339,6 +339,26 @@ async function validateDevM3Report(report, label) { } } + if (Object.hasOwn(report, "readOnlySupplementalEvidence")) { + assertArray(report.readOnlySupplementalEvidence, `${label}.readOnlySupplementalEvidence`); + for (const [index, evidence] of report.readOnlySupplementalEvidence.entries()) { + const evidenceLabel = `${label}.readOnlySupplementalEvidence[${index}]`; + assertObject(evidence, evidenceLabel); + for (const field of ["id", "status", "source", "summary", "evidence", "requiredFollowUp"]) { + assert.ok(Object.hasOwn(evidence, field), `${evidenceLabel} missing ${field}`); + } + assertString(evidence.id, `${evidenceLabel}.id`); + assert.ok(["pass", "gap"].includes(evidence.status), `${evidenceLabel}.status must be pass or gap`); + assertRepoRelativePath(evidence.source, `${evidenceLabel}.source`); + assertString(evidence.summary, `${evidenceLabel}.summary`); + assertObject(evidence.evidence, `${evidenceLabel}.evidence`); + assertString(evidence.requiredFollowUp, `${evidenceLabel}.requiredFollowUp`); + if (Object.hasOwn(evidence, "blockerClass") && evidence.blockerClass !== null) { + assert.ok(blockerTypes.has(evidence.blockerClass), `${evidenceLabel}.blockerClass must be known`); + } + } + } + assertObject(report.liveOperation, `${label}.liveOperation`); assertStatus(report.liveOperation.status, `${label}.liveOperation.status`); for (const field of ["operationId", "traceId", "auditId", "evidenceId", "summary"]) {