diff --git a/deploy/deploy.json b/deploy/deploy.json index c79edd79..46f0dc31 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -90,10 +90,10 @@ "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", - "replicas": 1, + "replicas": 2, "env": { "HWLAB_GATEWAY_MODE": "simulator", - "HWLAB_BOX_RESOURCES": "res_boxsim_alpha" + "HWLAB_BOX_RESOURCES": "res_boxsimu_1,res_boxsimu_2" } }, { @@ -102,9 +102,9 @@ "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", - "replicas": 1, + "replicas": 2, "env": { - "HWLAB_BOX_ID": "boxsim_alpha", + "HWLAB_BOX_ID": "boxsimu_1,boxsimu_2", "HWLAB_ENVIRONMENT": "dev" } }, diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 9b1a79aa..412bfe23 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -313,7 +313,7 @@ } }, "spec": { - "replicas": 1, + "replicas": 2, "selector": { "matchLabels": { "app.kubernetes.io/name": "hwlab-gateway-simu" @@ -340,7 +340,7 @@ "env": [ { "name": "HWLAB_BOX_RESOURCES", - "value": "res_boxsim_alpha" + "value": "res_boxsimu_1,res_boxsimu_2" } ], "readinessProbe": { @@ -373,7 +373,7 @@ } }, "spec": { - "replicas": 1, + "replicas": 2, "selector": { "matchLabels": { "app.kubernetes.io/name": "hwlab-box-simu" @@ -400,7 +400,7 @@ "env": [ { "name": "HWLAB_BOX_ID", - "value": "boxsim_alpha" + "value": "boxsimu_1,boxsimu_2" } ], "readinessProbe": { diff --git a/docs/m3-hardware-loop.md b/docs/m3-hardware-loop.md index 5397b109..a62a2dec 100644 --- a/docs/m3-hardware-loop.md +++ b/docs/m3-hardware-loop.md @@ -81,6 +81,10 @@ 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. +live runtime proof. The checked-in DEV manifests are source-ready only when +`node scripts/validate-dev-m3-cardinality.mjs` passes: two `hwlab-box-simu` +replicas with `boxsimu_1,boxsimu_2`, two `hwlab-gateway-simu` replicas with +`res_boxsimu_1,res_boxsimu_2`, and one `hwlab-patch-panel`. If 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/package.json b/package.json index adf262d4..9edddaf1 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/cloud/server.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node scripts/validate-contract.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/cloud/server.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", "dev:evidence": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty", diff --git a/reports/dev-gate/dev-m3-hardware-loop.json b/reports/dev-gate/dev-m3-hardware-loop.json index c0f3e4c2..c8f7e640 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": "a79037243324", + "commitId": "70bb9168ead2", "acceptanceLevel": "dev_m3_hardware_loop", "devOnly": true, "prodDisabled": true, @@ -19,6 +19,8 @@ }, "validationCommands": [ "node --check scripts/dev-m3-hardware-loop-smoke.mjs", + "node --check scripts/validate-dev-m3-cardinality.mjs", + "node scripts/validate-dev-m3-cardinality.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" @@ -66,10 +68,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: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\"}}" + "{\"url\":\"http://74.48.78.17:6667/health/live\",\"method\":\"GET\",\"startedAt\":\"2026-05-21T17:29:33.794Z\",\"observedAt\":\"2026-05-21T17:29:33.964Z\",\"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-21T17:29:33.964Z\",\"observedAt\":\"2026-05-21T17:29:34.132Z\",\"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-21T17:29:34.132Z\",\"observedAt\":\"2026-05-21T17:29:34.313Z\",\"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-21T17:29:34.314Z\",\"observedAt\":\"2026-05-21T17:29:34.479Z\",\"reached\":false,\"ok\":false,\"error\":{\"name\":\"Error\",\"message\":\"connect ECONNREFUSED 74.48.78.17:6667\",\"code\":\"ECONNREFUSED\"}}" ] }, { @@ -124,10 +126,10 @@ "readOnlySupplementalEvidence": [ { "id": "deploy-skeleton-m3-cardinality", - "status": "gap", - "blockerClass": "runtime_blocker", + "status": "manifest-ready", + "blockerClass": null, "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.", + "summary": "Checked-in DEV deploy skeleton declares M3 cardinality: two box simulators, two gateway simulators, and one patch panel.", "evidence": { "required": { "hwlab-box-simu": 2, @@ -137,15 +139,15 @@ }, "observed": { "hwlab-box-simu": { - "replicas": 1, + "replicas": 2, "env": { - "HWLAB_BOX_ID": "boxsim_alpha" + "HWLAB_BOX_ID": "boxsimu_1,boxsimu_2" } }, "hwlab-gateway-simu": { - "replicas": 1, + "replicas": 2, "env": { - "HWLAB_BOX_RESOURCES": "res_boxsim_alpha" + "HWLAB_BOX_RESOURCES": "res_boxsimu_1,res_boxsimu_2" } }, "hwlab-patch-panel": { @@ -154,7 +156,8 @@ } } }, - "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." + "validationCommand": "node scripts/validate-dev-m3-cardinality.mjs", + "requiredFollowUp": "Static DEV manifest cardinality is source-ready; live DEV M3 evidence still depends on edge/runtime reachability and direct DEV simulator targets." } ], "localRuntimeObservations": [ @@ -189,7 +192,7 @@ ], "summary": { "status": "blocked", - "observedAt": "2026-05-21T16:45:27.138Z", + "observedAt": "2026-05-21T17:29:33.789Z", "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 24d0293a..645c20aa 100644 --- a/scripts/dev-m3-hardware-loop-smoke.mjs +++ b/scripts/dev-m3-hardware-loop-smoke.mjs @@ -15,6 +15,9 @@ 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 requiredManifestCardinalityCommand = "node scripts/validate-dev-m3-cardinality.mjs"; +const requiredM3BoxIds = Object.freeze(["boxsimu_1", "boxsimu_2"]); +const requiredM3BoxResources = Object.freeze(["res_boxsimu_1", "res_boxsimu_2"]); const serviceTargets = Object.freeze([ { @@ -205,6 +208,17 @@ function collectLocalRuntimeObservations() { return observations; } +function splitList(value) { + return String(value ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function hasSameMembers(actual, expected) { + return actual.length === expected.length && expected.every((item) => actual.includes(item)); +} + async function collectReadOnlySupplementalEvidence() { const raw = await readFile(path.join(repoRoot, deployWorkloadsPath), "utf8"); const workloads = JSON.parse(raw); @@ -227,19 +241,23 @@ async function collectReadOnlySupplementalEvidence() { 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 boxIds = splitList(box?.env.HWLAB_BOX_ID); + const gatewayBoxResources = splitList(gateway?.env.HWLAB_BOX_RESOURCES); + const boxReadyForM3 = box?.replicas === 2 && hasSameMembers(boxIds, requiredM3BoxIds); const gatewayReadyForM3 = - gateway?.replicas === 2 && String(gateway.env.HWLAB_BOX_RESOURCES ?? "").includes(","); + gateway?.replicas === 2 && hasSameMembers(gatewayBoxResources, requiredM3BoxResources); + const patchPanelReadyForM3 = patchPanel?.replicas === 1; + const manifestReadyForM3 = boxReadyForM3 && gatewayReadyForM3 && patchPanelReadyForM3; return [ { id: "deploy-skeleton-m3-cardinality", - status: boxReadyForM3 && gatewayReadyForM3 ? "pass" : "gap", - blockerClass: boxReadyForM3 && gatewayReadyForM3 ? null : "runtime_blocker", + status: manifestReadyForM3 ? "manifest-ready" : "gap", + blockerClass: manifestReadyForM3 ? 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.", + summary: manifestReadyForM3 + ? "Checked-in DEV deploy skeleton declares M3 cardinality: two box simulators, two gateway simulators, and one patch panel." + : "Read-only static comparison found the checked-in DEV deploy skeleton does not declare the required M3 simulator and patch-panel cardinality; proving two of each simulator in live DEV requires a separate deploy/runtime task or authorized runtime observation.", evidence: { required: { "hwlab-box-simu": 2, @@ -262,8 +280,9 @@ async function collectReadOnlySupplementalEvidence() { } } }, - requiredFollowUp: boxReadyForM3 && gatewayReadyForM3 - ? "None from static deploy cardinality." + validationCommand: requiredManifestCardinalityCommand, + requiredFollowUp: manifestReadyForM3 + ? "Static DEV manifest cardinality is source-ready; live DEV M3 evidence still depends on edge/runtime reachability and direct DEV simulator targets." : "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." } ]; @@ -558,6 +577,8 @@ function baseReport({ commitId, observedAt }) { }, validationCommands: [ "node --check scripts/dev-m3-hardware-loop-smoke.mjs", + "node --check scripts/validate-dev-m3-cardinality.mjs", + requiredManifestCardinalityCommand, "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" diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index ec01e65b..168de1b9 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -45,6 +45,8 @@ const requiredDocs = [ const requiredDevM3Issue = issueFamily.DEV_M3_HARDWARE_LOOP; const requiredDevM3ValidationCommands = [ "node --check scripts/dev-m3-hardware-loop-smoke.mjs", + "node --check scripts/validate-dev-m3-cardinality.mjs", + "node scripts/validate-dev-m3-cardinality.mjs", "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production" ]; const requiredDevM3Docs = [ @@ -559,11 +561,17 @@ async function validateDevM3Report(report, label) { 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`); + assert.ok( + ["pass", "gap", "source-ready", "manifest-ready"].includes(evidence.status), + `${evidenceLabel}.status must be pass, gap, source-ready, or manifest-ready` + ); assertRepoRelativePath(evidence.source, `${evidenceLabel}.source`); assertString(evidence.summary, `${evidenceLabel}.summary`); assertObject(evidence.evidence, `${evidenceLabel}.evidence`); assertString(evidence.requiredFollowUp, `${evidenceLabel}.requiredFollowUp`); + if (Object.hasOwn(evidence, "validationCommand")) { + assertString(evidence.validationCommand, `${evidenceLabel}.validationCommand`); + } if (Object.hasOwn(evidence, "blockerClass") && evidence.blockerClass !== null) { assert.ok(blockerTypes.has(evidence.blockerClass), `${evidenceLabel}.blockerClass must be known`); } diff --git a/scripts/validate-dev-m3-cardinality.mjs b/scripts/validate-dev-m3-cardinality.mjs new file mode 100644 index 00000000..957f8f2e --- /dev/null +++ b/scripts/validate-dev-m3-cardinality.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const namespace = "hwlab-dev"; +const expected = Object.freeze({ + "hwlab-box-simu": { + replicas: 2, + env: { + HWLAB_BOX_ID: ["boxsimu_1", "boxsimu_2"] + } + }, + "hwlab-gateway-simu": { + replicas: 2, + env: { + HWLAB_BOX_RESOURCES: ["res_boxsimu_1", "res_boxsimu_2"] + } + }, + "hwlab-patch-panel": { + replicas: 1, + env: {} + } +}); + +async function readJson(relativePath) { + const raw = await readFile(path.join(repoRoot, relativePath), "utf8"); + return JSON.parse(raw); +} + +function listItems(document) { + return document?.kind === "List" ? document.items ?? [] : [document]; +} + +function envMap(env = []) { + return Object.fromEntries(env.map((item) => [item.name, item.value ?? ""])); +} + +function splitList(value) { + return String(value ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function assertSameMembers(actual, required, label) { + assert.deepEqual([...actual].sort(), [...required].sort(), label); +} + +function workloadByName(workloads) { + return new Map( + listItems(workloads) + .filter((item) => item?.kind === "Deployment") + .map((item) => [item.metadata?.name, item]) + ); +} + +function assertDevK8sCardinality(workloads) { + const byName = workloadByName(workloads); + + for (const [serviceId, requirement] of Object.entries(expected)) { + const deployment = byName.get(serviceId); + assert.ok(deployment, `deploy/k8s/base/workloads.yaml missing ${serviceId} Deployment`); + assert.equal(deployment.metadata?.namespace, namespace, `${serviceId} namespace`); + assert.equal(deployment.spec?.replicas, requirement.replicas, `${serviceId} replicas`); + + const labels = deployment.metadata?.labels ?? {}; + assert.equal(labels["hwlab.pikastech.local/service-id"], serviceId, `${serviceId} service label`); + + const container = deployment.spec?.template?.spec?.containers?.find((item) => item.name === serviceId); + assert.ok(container, `${serviceId} container`); + const env = envMap(container.env); + + for (const [name, values] of Object.entries(requirement.env)) { + assertSameMembers(splitList(env[name]), values, `${serviceId} ${name}`); + } + } +} + +function assertDeployManifestCardinality(deploy) { + assert.equal(deploy.environment, "dev", "deploy/deploy.json environment"); + assert.equal(deploy.namespace, namespace, "deploy/deploy.json namespace"); + assert.equal(deploy.profiles?.prod?.enabled, false, "deploy/deploy.json prod disabled"); + + const byId = new Map((deploy.services ?? []).map((service) => [service.serviceId, service])); + for (const [serviceId, requirement] of Object.entries(expected)) { + const service = byId.get(serviceId); + assert.ok(service, `deploy/deploy.json missing ${serviceId}`); + assert.equal(service.namespace, namespace, `${serviceId} deploy namespace`); + assert.equal(service.profile, "dev", `${serviceId} deploy profile`); + assert.equal(service.replicas, requirement.replicas, `${serviceId} deploy replicas`); + + for (const [name, values] of Object.entries(requirement.env)) { + assertSameMembers(splitList(service.env?.[name]), values, `${serviceId} deploy ${name}`); + } + } +} + +const [deploy, workloads] = await Promise.all([ + readJson("deploy/deploy.json"), + readJson("deploy/k8s/base/workloads.yaml") +]); + +assertDeployManifestCardinality(deploy); +assertDevK8sCardinality(workloads); + +console.log("validated DEV M3 manifest cardinality: 2 box-simu, 2 gateway-simu, 1 patch-panel");