diff --git a/cmd/hwlab-box-simu/main.mjs b/cmd/hwlab-box-simu/main.mjs index 4d731b2a..5920dd0a 100644 --- a/cmd/hwlab-box-simu/main.mjs +++ b/cmd/hwlab-box-simu/main.mjs @@ -13,8 +13,15 @@ const gatewaySessionId = selectInstanceValue( process.env.HWLAB_GATEWAY_SESSION_ID, "gws_mvp_simu" ); +const resourceId = selectInstanceValue(process.env.HWLAB_BOX_RESOURCE_ID, ""); const port = parsePort(process.env.PORT, 7201); const state = createBoxState({ boxId, gatewaySessionId }); +if (resourceId) { + state.resource.resourceId = resourceId; + for (const capability of state.capabilities ?? []) { + capability.resourceId = resourceId; + } +} const routes = new Map(); diff --git a/cmd/hwlab-gateway-simu/main.mjs b/cmd/hwlab-gateway-simu/main.mjs index d1c6d6eb..9840adbc 100644 --- a/cmd/hwlab-gateway-simu/main.mjs +++ b/cmd/hwlab-gateway-simu/main.mjs @@ -16,7 +16,8 @@ const boxUrls = parseBoxUrls(process.env.HWLAB_BOX_URLS); const boxes = parseBoxes({ resources: process.env.HWLAB_BOX_RESOURCES, ids: process.env.HWLAB_BOX_IDS, - urls: boxUrls + urls: boxUrls, + instanceScoped: parseList(process.env.HWLAB_GATEWAY_ID).length > 1 }); const state = createGatewayState({ gatewayId, @@ -115,10 +116,11 @@ function parseList(value) { .filter(Boolean); } -function parseBoxes({ resources, ids, urls }) { - const explicitResources = parseList(resources); - const explicitIds = parseList(ids); - const count = Math.max(explicitResources.length, explicitIds.length, urls.length, 1); +function parseBoxes({ resources, ids, urls, instanceScoped = false }) { + const explicitResources = instanceScoped ? selectScopedList(resources) : parseList(resources); + const explicitIds = instanceScoped ? selectScopedList(ids) : parseList(ids); + const explicitUrls = instanceScoped ? selectScopedList(urls.join(",")) : urls; + const count = Math.max(explicitResources.length, explicitIds.length, explicitUrls.length, 1); const parsed = []; for (let index = 0; index < count; index += 1) { @@ -130,7 +132,7 @@ function parseBoxes({ resources, ids, urls }) { parsed.push({ boxId, resourceId, - url: urls[index] ?? null, + url: explicitUrls[index] ?? null, state: "registered" }); } @@ -138,6 +140,15 @@ function parseBoxes({ resources, ids, urls }) { return parsed; } +function selectScopedList(value) { + const items = parseList(value); + if (items.length <= 1) { + return items; + } + const selected = selectInstanceValue(items.join(","), ""); + return selected ? [selected] : []; +} + function normalizeBoxRegistration(body) { const boxId = body.boxId ?? body.identity?.boxId; const resourceId = body.resourceId ?? body.resource?.resourceId ?? (boxId ? resourceIdForBox(boxId) : null); diff --git a/cmd/hwlab-patch-panel/main.mjs b/cmd/hwlab-patch-panel/main.mjs index 9fdde4e2..c83ef3fc 100644 --- a/cmd/hwlab-patch-panel/main.mjs +++ b/cmd/hwlab-patch-panel/main.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { createMvpTopology } from "../../internal/patchpanel/fixture.mjs"; +import { createM3Topology } from "../../internal/patchpanel/fixture.mjs"; import { createPatchPanelRuntime, createPatchPanelRuntimeFromFile @@ -9,12 +9,16 @@ import { createJsonServer, listen, parsePort, readJson } from "../../internal/si const port = parsePort(process.env.PORT, 7301); const wiringConfigPath = process.env.HWLAB_WIRING_CONFIG_PATH; -const topology = createMvpTopology(); +const topology = createM3Topology(); +const endpointMap = parseEndpointMap(process.env.HWLAB_PATCH_PANEL_ENDPOINT_MAP) ?? { + res_boxsimu_2: "http://hwlab-box-simu-2.hwlab-dev.svc.cluster.local:7201" +}; const runtime = wiringConfigPath ? await createPatchPanelRuntimeFromFile({ path: wiringConfigPath }) : createPatchPanelRuntime({ wiringConfig: topology.wiringConfig, - configSource: "internal:mvp-topology" + endpointMap, + configSource: "internal:m3-topology" }); const routes = new Map(); @@ -38,3 +42,12 @@ routes.set("POST /signals/route", async ({ req }) => runtime.routeSignal(await r routes.set("POST /sync/tick", async ({ req }) => runtime.tick(await readJson(req))); listen(createJsonServer({ routes }), port); + +function parseEndpointMap(value) { + const source = String(value ?? "").trim(); + if (!source) { + return null; + } + + return JSON.parse(source); +} diff --git a/deploy/deploy.json b/deploy/deploy.json index b7d7d4f7..35ae5627 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -107,14 +107,28 @@ }, { "serviceId": "hwlab-gateway-simu", - "name": "hwlab-gateway-simu", + "name": "hwlab-gateway-simu-1", "namespace": "hwlab-dev", "port": 7101, "targetPort": "http" }, { "serviceId": "hwlab-box-simu", - "name": "hwlab-box-simu", + "name": "hwlab-box-simu-1", + "namespace": "hwlab-dev", + "port": 7201, + "targetPort": "http" + }, + { + "serviceId": "hwlab-gateway-simu", + "name": "hwlab-gateway-simu-2", + "namespace": "hwlab-dev", + "port": 7101, + "targetPort": "http" + }, + { + "serviceId": "hwlab-box-simu", + "name": "hwlab-box-simu-2", "namespace": "hwlab-dev", "port": 7201, "targetPort": "http" @@ -246,9 +260,14 @@ "healthPath": "/health/live", "profile": "dev", "replicas": 2, + "instanceNames": ["hwlab-gateway-simu-1", "hwlab-gateway-simu-2"], + "identityStrategy": "indexed-workloads", "env": { "HWLAB_GATEWAY_MODE": "simulator", - "HWLAB_BOX_RESOURCES": "res_boxsimu_1,res_boxsimu_2" + "HWLAB_GATEWAY_ID": "gwsimu_1,gwsimu_2", + "HWLAB_BOX_IDS": "boxsimu_1,boxsimu_2", + "HWLAB_BOX_RESOURCES": "res_boxsimu_1,res_boxsimu_2", + "HWLAB_BOX_URLS": "http://hwlab-box-simu-1.hwlab-dev.svc.cluster.local:7201,http://hwlab-box-simu-2.hwlab-dev.svc.cluster.local:7201" } }, { @@ -258,8 +277,12 @@ "healthPath": "/health/live", "profile": "dev", "replicas": 2, + "instanceNames": ["hwlab-box-simu-1", "hwlab-box-simu-2"], + "identityStrategy": "indexed-workloads", "env": { "HWLAB_BOX_ID": "boxsimu_1,boxsimu_2", + "HWLAB_BOX_RESOURCE_ID": "res_boxsimu_1,res_boxsimu_2", + "HWLAB_GATEWAY_SESSION_ID": "gws_gwsimu_1,gws_gwsimu_2", "HWLAB_ENVIRONMENT": "dev" } }, @@ -272,6 +295,7 @@ "replicas": 1, "env": { "HWLAB_PATCH_PANEL_MODE": "dev-skeleton", + "HWLAB_PATCH_PANEL_ENDPOINT_MAP": "{\"res_boxsimu_2\":\"http://hwlab-box-simu-2.hwlab-dev.svc.cluster.local:7201\"}", "HWLAB_ENVIRONMENT": "dev" } }, diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index 7da73051..21da3741 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -342,6 +342,17 @@ "type": "integer", "minimum": 0 }, + "instanceNames": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "identityStrategy": { + "type": "string", + "enum": ["indexed-workloads"] + }, "env": { "type": "object", "additionalProperties": { diff --git a/deploy/k8s/base/services.yaml b/deploy/k8s/base/services.yaml index 0193b9e4..555b6428 100644 --- a/deploy/k8s/base/services.yaml +++ b/deploy/k8s/base/services.yaml @@ -50,6 +50,58 @@ ] } }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "hwlab-gateway-simu-1", + "namespace": "hwlab-dev", + "labels": { + "app.kubernetes.io/name": "hwlab-gateway-simu", + "hwlab.pikastech.local/service-id": "hwlab-gateway-simu", + "hwlab.pikastech.local/instance-id": "gwsimu_1" + } + }, + "spec": { + "type": "ClusterIP", + "selector": { + "statefulset.kubernetes.io/pod-name": "hwlab-gateway-simu-0" + }, + "ports": [ + { + "name": "http", + "port": 7101, + "targetPort": "http" + } + ] + } + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "hwlab-gateway-simu-2", + "namespace": "hwlab-dev", + "labels": { + "app.kubernetes.io/name": "hwlab-gateway-simu", + "hwlab.pikastech.local/service-id": "hwlab-gateway-simu", + "hwlab.pikastech.local/instance-id": "gwsimu_2" + } + }, + "spec": { + "type": "ClusterIP", + "selector": { + "statefulset.kubernetes.io/pod-name": "hwlab-gateway-simu-1" + }, + "ports": [ + { + "name": "http", + "port": 7101, + "targetPort": "http" + } + ] + } + }, { "apiVersion": "v1", "kind": "Service", @@ -75,6 +127,58 @@ ] } }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "hwlab-box-simu-1", + "namespace": "hwlab-dev", + "labels": { + "app.kubernetes.io/name": "hwlab-box-simu", + "hwlab.pikastech.local/service-id": "hwlab-box-simu", + "hwlab.pikastech.local/instance-id": "res_boxsimu_1" + } + }, + "spec": { + "type": "ClusterIP", + "selector": { + "statefulset.kubernetes.io/pod-name": "hwlab-box-simu-0" + }, + "ports": [ + { + "name": "http", + "port": 7201, + "targetPort": "http" + } + ] + } + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "hwlab-box-simu-2", + "namespace": "hwlab-dev", + "labels": { + "app.kubernetes.io/name": "hwlab-box-simu", + "hwlab.pikastech.local/service-id": "hwlab-box-simu", + "hwlab.pikastech.local/instance-id": "res_boxsimu_2" + } + }, + "spec": { + "type": "ClusterIP", + "selector": { + "statefulset.kubernetes.io/pod-name": "hwlab-box-simu-1" + }, + "ports": [ + { + "name": "http", + "port": 7201, + "targetPort": "http" + } + ] + } + }, { "apiVersion": "v1", "kind": "Service", diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index bb501234..42959c8b 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -355,7 +355,7 @@ }, { "apiVersion": "apps/v1", - "kind": "Deployment", + "kind": "StatefulSet", "metadata": { "name": "hwlab-gateway-simu", "namespace": "hwlab-dev", @@ -365,6 +365,7 @@ } }, "spec": { + "serviceName": "hwlab-gateway-simu", "replicas": 2, "selector": { "matchLabels": { @@ -390,9 +391,21 @@ } ], "env": [ + { + "name": "HWLAB_GATEWAY_ID", + "value": "gwsimu_1,gwsimu_2" + }, + { + "name": "HWLAB_BOX_IDS", + "value": "boxsimu_1,boxsimu_2" + }, { "name": "HWLAB_BOX_RESOURCES", "value": "res_boxsimu_1,res_boxsimu_2" + }, + { + "name": "HWLAB_BOX_URLS", + "value": "http://hwlab-box-simu-1.hwlab-dev.svc.cluster.local:7201,http://hwlab-box-simu-2.hwlab-dev.svc.cluster.local:7201" } ], "readinessProbe": { @@ -415,7 +428,7 @@ }, { "apiVersion": "apps/v1", - "kind": "Deployment", + "kind": "StatefulSet", "metadata": { "name": "hwlab-box-simu", "namespace": "hwlab-dev", @@ -425,6 +438,7 @@ } }, "spec": { + "serviceName": "hwlab-box-simu", "replicas": 2, "selector": { "matchLabels": { @@ -453,6 +467,14 @@ { "name": "HWLAB_BOX_ID", "value": "boxsimu_1,boxsimu_2" + }, + { + "name": "HWLAB_BOX_RESOURCE_ID", + "value": "res_boxsimu_1,res_boxsimu_2" + }, + { + "name": "HWLAB_GATEWAY_SESSION_ID", + "value": "gws_gwsimu_1,gws_gwsimu_2" } ], "readinessProbe": { @@ -509,6 +531,12 @@ "containerPort": 7301 } ], + "env": [ + { + "name": "HWLAB_PATCH_PANEL_ENDPOINT_MAP", + "value": "{\"res_boxsimu_2\":\"http://hwlab-box-simu-2.hwlab-dev.svc.cluster.local:7201\"}" + } + ], "readinessProbe": { "httpGet": { "path": "/health/live", diff --git a/internal/mvp-gate/summary.mjs b/internal/mvp-gate/summary.mjs index 5c57109f..1f12ac68 100644 --- a/internal/mvp-gate/summary.mjs +++ b/internal/mvp-gate/summary.mjs @@ -6,6 +6,63 @@ import { DEV_ENDPOINT } from "../protocol/index.mjs"; export { DEV_ENDPOINT }; export const DEFAULT_GATE_REPORT_PATH = "reports/dev-gate/dev-mvp-gate-report.json"; export const DEFAULT_M5_PLAN_PATH = "fixtures/mvp/m5-e2e/dry-run-plan.json"; +export const M3_WORKBENCH_TOPOLOGY = Object.freeze({ + projectId: "prj_m3_hardware_loop", + projectStatus: "blocked", + gateways: Object.freeze([ + Object.freeze({ + gatewaySessionId: "gws_gwsimu_1", + serviceId: "hwlab-gateway-simu", + gatewayId: "gwsimu_1", + status: "connected", + environment: "dev", + endpoint: null + }), + Object.freeze({ + gatewaySessionId: "gws_gwsimu_2", + serviceId: "hwlab-gateway-simu", + gatewayId: "gwsimu_2", + status: "connected", + environment: "dev", + endpoint: null + }) + ]), + boxResources: Object.freeze([ + Object.freeze({ + resourceId: "res_boxsimu_1", + gatewaySessionId: "gws_gwsimu_1", + boxId: "boxsimu_1", + resourceType: "simulator_endpoint", + name: "BOX-SIMU 1 source DO1", + state: "available", + environment: "dev" + }), + Object.freeze({ + resourceId: "res_boxsimu_2", + gatewaySessionId: "gws_gwsimu_2", + boxId: "boxsimu_2", + resourceType: "simulator_endpoint", + name: "BOX-SIMU 2 target DI1", + state: "available", + environment: "dev" + }) + ]), + patchPanel: Object.freeze({ + patchPanelStatusId: "pps_m3_hardware_loop", + serviceId: "hwlab-patch-panel", + state: "blocked", + environment: "dev", + activeConnectionCount: 1, + activeConnections: Object.freeze([ + Object.freeze({ + fromResourceId: "res_boxsimu_1", + fromPort: "DO1", + toResourceId: "res_boxsimu_2", + toPort: "DI1" + }) + ]) + }) +}); function readJsonFile(repoRoot, relativePath) { const absolutePath = path.resolve(repoRoot, relativePath); @@ -181,6 +238,10 @@ function summarizeEvidence(record) { }; } +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + export function buildMvpGateSummary({ report, plan }) { assertRequiredMvpGate(report, plan); @@ -226,7 +287,8 @@ export function buildMvpGateSummary({ report, plan }) { cleanupCount: plan.cleanup.length, artifacts, health, - topology: { + topology: clone(M3_WORKBENCH_TOPOLOGY), + m5DryRunTopology: { projectId: plan.project.projectId, projectStatus: plan.project.status, gateways: plan.gatewaySessions.map(summarizeGatewaySession), diff --git a/internal/patchpanel/fixture.mjs b/internal/patchpanel/fixture.mjs index 3fb4b471..ac9ba37b 100644 --- a/internal/patchpanel/fixture.mjs +++ b/internal/patchpanel/fixture.mjs @@ -3,9 +3,16 @@ import { createPatchPanelState, createWiringConfig } from "./model.mjs"; export const MVP_BOX_IDS = Object.freeze(["boxsim_alpha", "boxsim_beta"]); export const MVP_GATEWAY_IDS = Object.freeze(["gwsimu_east", "gwsimu_west"]); +export const M3_BOX_IDS = Object.freeze(["boxsimu_1", "boxsimu_2"]); +export const M3_GATEWAY_IDS = Object.freeze(["gwsimu_1", "gwsimu_2"]); +export const M3_BOX_RESOURCES = Object.freeze(["res_boxsimu_1", "res_boxsimu_2"]); + +function createGatewaySessionIds(gatewayIds) { + return gatewayIds.map((gatewayId) => `gws_${gatewayId}`); +} export function createMvpTopology({ now = "2026-01-01T00:00:00.000Z" } = {}) { - const gatewaySessionIds = MVP_GATEWAY_IDS.map((gatewayId) => `gws_${gatewayId}`); + const gatewaySessionIds = createGatewaySessionIds(MVP_GATEWAY_IDS); const boxes = MVP_BOX_IDS.map((boxId, index) => createBoxState({ boxId, @@ -80,3 +87,107 @@ export function createMvpTopology({ now = "2026-01-01T00:00:00.000Z" } = {}) { } }; } + +export function createM3Topology({ now = "2026-05-21T00:00:00.000Z" } = {}) { + const projectId = "prj_m3_hardware_loop"; + const gatewaySessionIds = createGatewaySessionIds(M3_GATEWAY_IDS); + const boxes = M3_BOX_IDS.map((boxId, index) => + withProjectId(createBoxState({ + boxId, + gatewaySessionId: gatewaySessionIds[index], + ports: ["DO1", "DI1"], + now + }), projectId) + ); + + const gateways = M3_GATEWAY_IDS.map((gatewayId, index) => + withProjectId(createGatewayState({ + gatewayId, + endpoint: `http://127.0.0.1:${7101 + index}`, + boxes: [M3_BOX_RESOURCES[index]], + now + }), projectId) + ); + + const wiringConfig = createWiringConfig({ + wiringConfigId: "wir_m3_do1_di1", + projectId, + gatewaySessionId: gatewaySessionIds[0], + name: "M3 simulated DO1 to DI1 patch wiring", + connections: [ + { + from: { + resourceId: M3_BOX_RESOURCES[0], + port: "DO1" + }, + to: { + resourceId: M3_BOX_RESOURCES[1], + port: "DI1" + }, + mode: "exclusive" + } + ], + now + }); + + const patchPanel = createPatchPanelState({ + patchPanelStatusId: "pps_m3_hardware_loop", + gatewaySessionId: gatewaySessionIds[0], + wiringConfig, + metadata: { + signal: "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1" + }, + now + }); + + return { + topologyId: "top_m3_hardware_loop", + projectId, + environment: "dev", + services: { + boxes, + gateways, + patchPanel + }, + wiringConfig, + constraints: { + patchPanelIsOnlyCrossDeviceChannel: true, + boxSimuLocalLoopbackForCrossDeviceSync: false + }, + dryRunHints: { + cliTarget: "fixtures/mvp/m3-hardware-loop/topology.json", + expectedServices: ["hwlab-box-simu", "hwlab-gateway-simu", "hwlab-patch-panel"] + } + }; +} + +function withProjectId(record, projectId) { + return { + ...record, + projectId, + ...(record.resource + ? { + resource: { + ...record.resource, + projectId + } + } + : {}), + ...(record.capabilities + ? { + capabilities: record.capabilities.map((capability) => ({ + ...capability, + projectId + })) + } + : {}), + ...(record.session + ? { + session: { + ...record.session, + projectId + } + } + : {}) + }; +} diff --git a/internal/patchpanel/model.test.mjs b/internal/patchpanel/model.test.mjs index 19d16423..b3627ad9 100644 --- a/internal/patchpanel/model.test.mjs +++ b/internal/patchpanel/model.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { createMvpTopology } from "./fixture.mjs"; +import { createM3Topology, createMvpTopology } from "./fixture.mjs"; import { createPatchPanel, createWiringConfig } from "./model.mjs"; test("MVP topology includes two box simulators, two gateway simulators, and one patch panel", () => { @@ -19,6 +19,43 @@ test("MVP topology includes two box simulators, two gateway simulators, and one } }); +test("M3 topology is the default trusted DO1 to DI1 patch-panel route", () => { + const topology = createM3Topology(); + + assert.equal(topology.projectId, "prj_m3_hardware_loop"); + assert.equal(topology.services.boxes.length, 2); + assert.equal(topology.services.gateways.length, 2); + assert.deepEqual( + topology.services.boxes.map((box) => box.resource.resourceId), + ["res_boxsimu_1", "res_boxsimu_2"] + ); + assert.deepEqual( + topology.services.gateways.map((gateway) => gateway.gatewayId), + ["gwsimu_1", "gwsimu_2"] + ); + assert.deepEqual(topology.services.patchPanel.activeConnections, [ + { + fromResourceId: "res_boxsimu_1", + fromPort: "DO1", + toResourceId: "res_boxsimu_2", + toPort: "DI1" + } + ]); + assert.deepEqual(topology.wiringConfig.connections, [ + { + from: { + resourceId: "res_boxsimu_1", + port: "DO1" + }, + to: { + resourceId: "res_boxsimu_2", + port: "DI1" + }, + mode: "exclusive" + } + ]); +}); + test("patch panel routes cross-device signals and records deliveries", () => { const topology = createMvpTopology(); const patchPanel = createPatchPanel({ diff --git a/scripts/dev-m3-hardware-loop-smoke.mjs b/scripts/dev-m3-hardware-loop-smoke.mjs index db01176b..21a8a4b7 100644 --- a/scripts/dev-m3-hardware-loop-smoke.mjs +++ b/scripts/dev-m3-hardware-loop-smoke.mjs @@ -17,6 +17,7 @@ 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 deployServicesPath = "deploy/k8s/base/services.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"]); @@ -26,6 +27,7 @@ const requiredM3Connection = Object.freeze({ toResourceId: "res_boxsimu_2", toPort: "DI1" }); +const requiredM3GatewayIds = Object.freeze(["gwsimu_1", "gwsimu_2"]); const runnerK3sKubeconfigPath = "/etc/rancher/k3s/k3s.yaml"; const serviceTargets = Object.freeze([ @@ -285,11 +287,25 @@ function hasSameMembers(actual, expected) { return actual.length === expected.length && expected.every((item) => actual.includes(item)); } +function listItems(document) { + return document?.kind === "List" ? document.items ?? [] : [document].filter(Boolean); +} + +function serviceByName(services) { + return new Map( + listItems(services) + .filter((item) => item?.kind === "Service") + .map((item) => [item.metadata?.name, item]) + ); +} + async function collectReadOnlySupplementalEvidence() { - const raw = await readFile(path.join(repoRoot, deployWorkloadsPath), "utf8"); - const workloads = JSON.parse(raw); + const [workloads, services] = await Promise.all([ + readFile(path.join(repoRoot, deployWorkloadsPath), "utf8").then(JSON.parse), + readFile(path.join(repoRoot, deployServicesPath), "utf8").then(JSON.parse) + ]); const targets = ["hwlab-box-simu", "hwlab-gateway-simu", "hwlab-patch-panel"]; - const observed = workloads.items + const observed = listItems(workloads) .filter((item) => targets.includes(item.metadata?.name)) .map((item) => { const container = item.spec?.template?.spec?.containers?.[0] ?? {}; @@ -308,28 +324,56 @@ async function collectReadOnlySupplementalEvidence() { const gateway = observed.find((item) => item.name === "hwlab-gateway-simu"); const patchPanel = observed.find((item) => item.name === "hwlab-patch-panel"); const boxIds = splitList(box?.env.HWLAB_BOX_ID); + const boxResourceIds = splitList(box?.env.HWLAB_BOX_RESOURCE_ID); + const boxGatewaySessionIds = splitList(box?.env.HWLAB_GATEWAY_SESSION_ID); + const gatewayIds = splitList(gateway?.env.HWLAB_GATEWAY_ID); const gatewayBoxResources = splitList(gateway?.env.HWLAB_BOX_RESOURCES); - const boxReadyForM3 = box?.replicas === 2 && hasSameMembers(boxIds, requiredM3BoxIds); + const gatewayBoxUrls = splitList(gateway?.env.HWLAB_BOX_URLS); + const serviceMap = serviceByName(services); + const hasInstanceServices = [ + "hwlab-box-simu-1", + "hwlab-box-simu-2", + "hwlab-gateway-simu-1", + "hwlab-gateway-simu-2" + ].every((name) => serviceMap.has(name)); + const patchPanelEndpointMap = patchPanel?.env.HWLAB_PATCH_PANEL_ENDPOINT_MAP + ? JSON.parse(patchPanel.env.HWLAB_PATCH_PANEL_ENDPOINT_MAP) + : {}; + const boxReadyForM3 = + box?.kind === "StatefulSet" && + box?.replicas === 2 && + hasSameMembers(boxIds, requiredM3BoxIds) && + hasSameMembers(boxResourceIds, requiredM3BoxResources) && + hasSameMembers(boxGatewaySessionIds, ["gws_gwsimu_1", "gws_gwsimu_2"]); const gatewayReadyForM3 = - gateway?.replicas === 2 && hasSameMembers(gatewayBoxResources, requiredM3BoxResources); - const patchPanelReadyForM3 = patchPanel?.replicas === 1; - const manifestReadyForM3 = boxReadyForM3 && gatewayReadyForM3 && patchPanelReadyForM3; + gateway?.kind === "StatefulSet" && + gateway?.replicas === 2 && + hasSameMembers(gatewayIds, requiredM3GatewayIds) && + hasSameMembers(gatewayBoxResources, requiredM3BoxResources) && + hasSameMembers(gatewayBoxUrls, [ + "http://hwlab-box-simu-1.hwlab-dev.svc.cluster.local:7201", + "http://hwlab-box-simu-2.hwlab-dev.svc.cluster.local:7201" + ]); + const patchPanelReadyForM3 = + patchPanel?.replicas === 1 && + patchPanelEndpointMap.res_boxsimu_2 === "http://hwlab-box-simu-2.hwlab-dev.svc.cluster.local:7201"; + const manifestReadyForM3 = boxReadyForM3 && gatewayReadyForM3 && patchPanelReadyForM3 && hasInstanceServices; return [ { id: "deploy-skeleton-m3-cardinality", status: manifestReadyForM3 ? "manifest-ready" : "gap", blockerClass: manifestReadyForM3 ? null : "runtime_blocker", - source: deployWorkloadsPath, + source: [deployWorkloadsPath, deployServicesPath], 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.", + ? "Checked-in DEV deploy skeleton declares distinct indexed M3 identities for two box simulators, two gateway simulators, one patch panel, and the M3 DO1 -> DI1 endpoint map." + : "Read-only static comparison found the checked-in DEV deploy skeleton does not declare distinct indexed M3 simulator identities and patch-panel M3 wiring; proving live DEV still 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" + wiring: "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1" }, observed: { "hwlab-box-simu": { @@ -343,7 +387,10 @@ async function collectReadOnlySupplementalEvidence() { "hwlab-patch-panel": { replicas: patchPanel?.replicas ?? null, env: patchPanel?.env ?? null - } + }, + instanceServices: [...serviceMap.keys()].filter((name) => + name.startsWith("hwlab-box-simu-") || name.startsWith("hwlab-gateway-simu-") + ) } }, validationCommand: requiredManifestCardinalityCommand, diff --git a/scripts/l2-runtime-contract-smoke.mjs b/scripts/l2-runtime-contract-smoke.mjs index 163038b7..9ddc100b 100644 --- a/scripts/l2-runtime-contract-smoke.mjs +++ b/scripts/l2-runtime-contract-smoke.mjs @@ -172,11 +172,13 @@ async function main() { const boxA = startNode("cmd/hwlab-box-simu/main.mjs", { PORT: String(ports.boxA), HWLAB_BOX_ID: "box-a", + HWLAB_BOX_RESOURCE_ID: "res_box-a", HWLAB_GATEWAY_SESSION_ID: "gws_gateway-a" }); const boxB = startNode("cmd/hwlab-box-simu/main.mjs", { PORT: String(ports.boxB), HWLAB_BOX_ID: "box-b", + HWLAB_BOX_RESOURCE_ID: "res_box-b", HWLAB_GATEWAY_SESSION_ID: "gws_gateway-b" }); const gatewayA = startNode("cmd/hwlab-gateway-simu/main.mjs", { diff --git a/scripts/l6-cli-web-smoke.mjs b/scripts/l6-cli-web-smoke.mjs index ede7ce53..dfc22103 100644 --- a/scripts/l6-cli-web-smoke.mjs +++ b/scripts/l6-cli-web-smoke.mjs @@ -78,6 +78,16 @@ assert.equal(gateSummary.healthCount, 14); assert.equal(gateSummary.gatewaySessionCount, 2); assert.equal(gateSummary.boxResourceCount, 2); assert.equal(gateSummary.topology.patchPanel.serviceId, "hwlab-patch-panel"); +assert.deepEqual(gateSummary.topology.patchPanel.activeConnections, [ + { + fromResourceId: "res_boxsimu_1", + fromPort: "DO1", + toResourceId: "res_boxsimu_2", + toPort: "DI1" + } +]); +assert.doesNotMatch(JSON.stringify(gateSummary.topology), /res_m5-control-relay|res_m5-target-board|out1|reset/u); +assert.equal(gateSummary.m5DryRunTopology.patchPanel.patchPanelStatusId, "pps_m5-0001"); assert.equal(gateSummary.agent.agentServiceId, "hwlab-agent-mgr"); assert.equal(gateSummary.agent.workerServiceId, "hwlab-agent-worker"); assert.equal(gateSummary.evidenceRecords.length, 2); diff --git a/scripts/src/deploy-contract-plan.mjs b/scripts/src/deploy-contract-plan.mjs index fffdaf46..ce8bab44 100644 --- a/scripts/src/deploy-contract-plan.mjs +++ b/scripts/src/deploy-contract-plan.mjs @@ -9,6 +9,7 @@ const execFileAsync = promisify(execFile); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const expectedPorts = Object.freeze({ frontend: 16666, api: 16667 }); const legacyPublicPorts = new Set([6666, 6667]); +const duplicateMappingAllowed = new Set(["hwlab-box-simu", "hwlab-gateway-simu"]); function parseArgs(argv) { const args = { check: false, pretty: false }; @@ -83,6 +84,10 @@ function mapByServiceId(items) { ); } +function mapByName(items) { + return new Map(items.map((item) => [item?.metadata?.name, item])); +} + function parseEndpoint(ctx, id, endpoint) { const expectedPort = expectedPorts[id]; expect(ctx, Boolean(endpoint), "missing_source", `$.publicEndpoints.${id}`, `${id} public endpoint is required`); @@ -158,10 +163,19 @@ function validateSource(ctx, manifest) { const serviceMappings = manifest.k3s?.serviceMappings ?? []; const mappingIds = new Set(); + const mappingNames = new Set(); for (const [index, mapping] of serviceMappings.entries()) { const basePath = `$.k3s.serviceMappings[${index}]`; expect(ctx, servicesById.has(mapping.serviceId), "unknown_service", `${basePath}.serviceId`, `${mapping.serviceId} must exist in services`); - expect(ctx, !mappingIds.has(mapping.serviceId), "duplicate_source", `${basePath}.serviceId`, `duplicate k3s mapping ${mapping.serviceId}`); + expect(ctx, !mappingNames.has(mapping.name), "duplicate_source", `${basePath}.name`, `duplicate k3s mapping name ${mapping.name}`); + mappingNames.add(mapping.name); + expect( + ctx, + !mappingIds.has(mapping.serviceId) || duplicateMappingAllowed.has(mapping.serviceId), + "duplicate_source", + `${basePath}.serviceId`, + `duplicate k3s mapping ${mapping.serviceId}` + ); mappingIds.add(mapping.serviceId); expectEqual(ctx, mapping.namespace, manifest.namespace, `${basePath}.namespace`, `${mapping.serviceId} namespace`); expectEqual(ctx, mapping.targetPort, "http", `${basePath}.targetPort`, `${mapping.serviceId} targetPort`); @@ -256,10 +270,10 @@ function validateFrpArtifacts(ctx, source, rendered, frpc, frps) { } function validateK3sArtifacts(ctx, source, rendered, services, workloads, healthContract, masterEdge) { - const serviceItems = mapByServiceId(listItems(services)); + const serviceItems = mapByName(listItems(services)); const workloadItems = mapByServiceId(listItems(workloads)); for (const expected of rendered.k3sServices) { - const actual = serviceItems.get(expected.serviceId); + const actual = serviceItems.get(expected.name); expect(ctx, Boolean(actual), "missing_rendered_artifact", `deploy/k8s/base/services.yaml.${expected.serviceId}`, `${expected.serviceId} k3s service exists`); if (actual) { const port = (actual.spec?.ports ?? []).find((entry) => entry.name === "http") ?? actual.spec?.ports?.[0]; diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 5f6533d4..8cef0150 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -212,6 +212,11 @@ function runStaticSmoke() { evidence: requiredHardwareStatusTerms }); + addCheck(checks, blockers, "m3-default-wiring-source", defaultTopologyIsM3Only(), "Default 16666 workbench topology exposes only the M3 res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 wiring.", { + blocker: "runtime_blocker", + evidence: ["res_boxsimu_1:DO1", "hwlab-patch-panel", "res_boxsimu_2:DI1"] + }); + addCheck(checks, blockers, "outer-scroll-contract", hasScrollLockContract(files.styles), "Outer page scrolling is locked and internal workbench panes own scrolling.", { blocker: "contract_blocker", evidence: ["html/body overflow hidden", "workbench-shell 100vh/100dvh overflow hidden", ".view overflow auto"] @@ -488,6 +493,21 @@ function hardwareStatusStructureContract({ html, app, styles }) { ); } +function defaultTopologyIsM3Only() { + const activeConnections = gateSummary.topology.patchPanel.activeConnections ?? []; + return ( + gateSummary.topology.projectId === "prj_m3_hardware_loop" && + JSON.stringify(gateSummary.topology.gateways.map((gateway) => gateway.gatewayId)) === JSON.stringify(["gwsimu_1", "gwsimu_2"]) && + JSON.stringify(gateSummary.topology.boxResources.map((resource) => resource.resourceId)) === JSON.stringify(["res_boxsimu_1", "res_boxsimu_2"]) && + activeConnections.length === 1 && + activeConnections[0].fromResourceId === "res_boxsimu_1" && + activeConnections[0].fromPort === "DO1" && + activeConnections[0].toResourceId === "res_boxsimu_2" && + activeConnections[0].toPort === "DI1" && + !/res_m5-control-relay|res_m5-target-board|out1|reset/u.test(JSON.stringify(gateSummary.topology)) + ); +} + function finalRouteFallback(app) { const body = app.match(/function\s+routeFromLocation\s*\(\)\s*\{([\s\S]*?)\n\}/u)?.[1] ?? ""; const returns = [...body.matchAll(/return\s+["']([^"']+)["']\s*;/gu)].map((match) => match[1]); diff --git a/scripts/validate-dev-m3-cardinality.mjs b/scripts/validate-dev-m3-cardinality.mjs index 957f8f2e..a730e043 100644 --- a/scripts/validate-dev-m3-cardinality.mjs +++ b/scripts/validate-dev-m3-cardinality.mjs @@ -6,24 +6,74 @@ import { fileURLToPath } from "node:url"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const namespace = "hwlab-dev"; +const m3EndpointMap = Object.freeze({ + res_boxsimu_2: "http://hwlab-box-simu-2.hwlab-dev.svc.cluster.local:7201" +}); const expected = Object.freeze({ "hwlab-box-simu": { + kind: "StatefulSet", replicas: 2, + serviceName: "hwlab-box-simu", + instanceNames: ["hwlab-box-simu-1", "hwlab-box-simu-2"], env: { - HWLAB_BOX_ID: ["boxsimu_1", "boxsimu_2"] + HWLAB_BOX_ID: ["boxsimu_1", "boxsimu_2"], + HWLAB_BOX_RESOURCE_ID: ["res_boxsimu_1", "res_boxsimu_2"], + HWLAB_GATEWAY_SESSION_ID: ["gws_gwsimu_1", "gws_gwsimu_2"] } }, "hwlab-gateway-simu": { + kind: "StatefulSet", replicas: 2, + serviceName: "hwlab-gateway-simu", + instanceNames: ["hwlab-gateway-simu-1", "hwlab-gateway-simu-2"], env: { - HWLAB_BOX_RESOURCES: ["res_boxsimu_1", "res_boxsimu_2"] + HWLAB_GATEWAY_ID: ["gwsimu_1", "gwsimu_2"], + HWLAB_BOX_IDS: ["boxsimu_1", "boxsimu_2"], + HWLAB_BOX_RESOURCES: ["res_boxsimu_1", "res_boxsimu_2"], + HWLAB_BOX_URLS: [ + "http://hwlab-box-simu-1.hwlab-dev.svc.cluster.local:7201", + "http://hwlab-box-simu-2.hwlab-dev.svc.cluster.local:7201" + ] } }, "hwlab-patch-panel": { + kind: "Deployment", replicas: 1, - env: {} + env: { + HWLAB_PATCH_PANEL_ENDPOINT_MAP: m3EndpointMap + } } }); +const expectedInstanceServices = Object.freeze([ + { + serviceId: "hwlab-box-simu", + name: "hwlab-box-simu-1", + instanceId: "res_boxsimu_1", + podName: "hwlab-box-simu-0", + port: 7201 + }, + { + serviceId: "hwlab-box-simu", + name: "hwlab-box-simu-2", + instanceId: "res_boxsimu_2", + podName: "hwlab-box-simu-1", + port: 7201 + }, + { + serviceId: "hwlab-gateway-simu", + name: "hwlab-gateway-simu-1", + instanceId: "gwsimu_1", + podName: "hwlab-gateway-simu-0", + port: 7101 + }, + { + serviceId: "hwlab-gateway-simu", + name: "hwlab-gateway-simu-2", + instanceId: "gwsimu_2", + podName: "hwlab-gateway-simu-1", + port: 7101 + } +]); async function readJson(relativePath) { const raw = await readFile(path.join(repoRoot, relativePath), "utf8"); @@ -45,6 +95,14 @@ function splitList(value) { .filter(Boolean); } +function parseEnvValue(value) { + const text = String(value ?? "").trim(); + if (text.startsWith("{")) { + return JSON.parse(text); + } + return text; +} + function assertSameMembers(actual, required, label) { assert.deepEqual([...actual].sort(), [...required].sort(), label); } @@ -52,7 +110,15 @@ function assertSameMembers(actual, required, label) { function workloadByName(workloads) { return new Map( listItems(workloads) - .filter((item) => item?.kind === "Deployment") + .filter((item) => item?.kind === "Deployment" || item?.kind === "StatefulSet") + .map((item) => [item.metadata?.name, item]) + ); +} + +function servicesByName(services) { + return new Map( + listItems(services) + .filter((item) => item?.kind === "Service") .map((item) => [item.metadata?.name, item]) ); } @@ -62,9 +128,13 @@ function assertDevK8sCardinality(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.ok(deployment, `deploy/k8s/base/workloads.yaml missing ${serviceId} workload`); + assert.equal(deployment.kind, requirement.kind, `${serviceId} workload kind`); assert.equal(deployment.metadata?.namespace, namespace, `${serviceId} namespace`); assert.equal(deployment.spec?.replicas, requirement.replicas, `${serviceId} replicas`); + if (requirement.serviceName) { + assert.equal(deployment.spec?.serviceName, requirement.serviceName, `${serviceId} stateful serviceName`); + } const labels = deployment.metadata?.labels ?? {}; assert.equal(labels["hwlab.pikastech.local/service-id"], serviceId, `${serviceId} service label`); @@ -74,11 +144,43 @@ function assertDevK8sCardinality(workloads) { const env = envMap(container.env); for (const [name, values] of Object.entries(requirement.env)) { - assertSameMembers(splitList(env[name]), values, `${serviceId} ${name}`); + if (Array.isArray(values)) { + assertSameMembers(splitList(env[name]), values, `${serviceId} ${name}`); + } else { + assert.deepEqual(parseEnvValue(env[name]), values, `${serviceId} ${name}`); + } } } } +function assertDevK8sInstanceServices(services) { + const byName = servicesByName(services); + + for (const expectedService of expectedInstanceServices) { + const service = byName.get(expectedService.name); + assert.ok(service, `deploy/k8s/base/services.yaml missing ${expectedService.name}`); + assert.equal(service.metadata?.namespace, namespace, `${expectedService.name} namespace`); + assert.equal( + service.metadata?.labels?.["hwlab.pikastech.local/service-id"], + expectedService.serviceId, + `${expectedService.name} service-id label` + ); + assert.equal( + service.metadata?.labels?.["hwlab.pikastech.local/instance-id"], + expectedService.instanceId, + `${expectedService.name} instance-id label` + ); + assert.equal( + service.spec?.selector?.["statefulset.kubernetes.io/pod-name"], + expectedService.podName, + `${expectedService.name} pod selector` + ); + const port = service.spec?.ports?.find((entry) => entry.name === "http") ?? service.spec?.ports?.[0]; + assert.equal(port?.port, expectedService.port, `${expectedService.name} port`); + assert.equal(port?.targetPort, "http", `${expectedService.name} targetPort`); + } +} + function assertDeployManifestCardinality(deploy) { assert.equal(deploy.environment, "dev", "deploy/deploy.json environment"); assert.equal(deploy.namespace, namespace, "deploy/deploy.json namespace"); @@ -91,19 +193,69 @@ function assertDeployManifestCardinality(deploy) { 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`); + if (requirement.instanceNames) { + assertSameMembers(service.instanceNames ?? [], requirement.instanceNames, `${serviceId} deploy instanceNames`); + assert.equal(service.identityStrategy, "indexed-workloads", `${serviceId} deploy identityStrategy`); + } for (const [name, values] of Object.entries(requirement.env)) { - assertSameMembers(splitList(service.env?.[name]), values, `${serviceId} deploy ${name}`); + if (Array.isArray(values)) { + assertSameMembers(splitList(service.env?.[name]), values, `${serviceId} deploy ${name}`); + } else { + assert.deepEqual(parseEnvValue(service.env?.[name]), values, `${serviceId} deploy ${name}`); + } } } } -const [deploy, workloads] = await Promise.all([ +function assertDeployManifestInstanceMappings(deploy) { + const mappings = deploy.k3s?.serviceMappings ?? []; + const byName = new Map(mappings.map((mapping) => [mapping.name, mapping])); + for (const expectedService of expectedInstanceServices) { + const mapping = byName.get(expectedService.name); + assert.ok(mapping, `deploy/deploy.json missing k3s serviceMapping ${expectedService.name}`); + assert.equal(mapping.serviceId, expectedService.serviceId, `${expectedService.name} serviceId`); + assert.equal(mapping.namespace, namespace, `${expectedService.name} namespace`); + assert.equal(mapping.port, expectedService.port, `${expectedService.name} port`); + assert.equal(mapping.targetPort, "http", `${expectedService.name} targetPort`); + } +} + +function assertM3FixtureWiring(topology) { + assert.deepEqual(topology.services?.patchPanel?.activeConnections, [ + { + fromResourceId: "res_boxsimu_1", + fromPort: "DO1", + toResourceId: "res_boxsimu_2", + toPort: "DI1" + } + ], "M3 fixture active patch-panel wiring"); + assert.deepEqual(topology.wiringConfig?.connections, [ + { + from: { + resourceId: "res_boxsimu_1", + port: "DO1" + }, + to: { + resourceId: "res_boxsimu_2", + port: "DI1" + }, + mode: "exclusive" + } + ], "M3 fixture wiring config"); +} + +const [deploy, workloads, services, topology] = await Promise.all([ readJson("deploy/deploy.json"), - readJson("deploy/k8s/base/workloads.yaml") + readJson("deploy/k8s/base/workloads.yaml"), + readJson("deploy/k8s/base/services.yaml"), + readJson("fixtures/mvp/m3-hardware-loop/topology.json") ]); assertDeployManifestCardinality(deploy); +assertDeployManifestInstanceMappings(deploy); assertDevK8sCardinality(workloads); +assertDevK8sInstanceServices(services); +assertM3FixtureWiring(topology); -console.log("validated DEV M3 manifest cardinality: 2 box-simu, 2 gateway-simu, 1 patch-panel"); +console.log("validated DEV M3 manifest cardinality: 2 distinct box-simu, 2 distinct gateway-simu, 1 patch-panel, M3 DO1->DI1 wiring"); diff --git a/tools/hwlab-cli/lib/cli.mjs b/tools/hwlab-cli/lib/cli.mjs index 1d6dc49e..3598d878 100644 --- a/tools/hwlab-cli/lib/cli.mjs +++ b/tools/hwlab-cli/lib/cli.mjs @@ -68,17 +68,17 @@ function mvpProjectList(summary) { endpoint: summary.endpoint, projects: [ { - projectId: summary.topology.projectId, + projectId: summary.m5DryRunTopology.projectId, name: "HWLAB M5 MVP E2E Dry Run", - status: summary.topology.projectStatus, + status: summary.m5DryRunTopology.projectStatus, environment: summary.environment, source: summary.generatedFrom.m5Plan } ], topology: { - gatewaySessions: summary.topology.gateways.map((gateway) => gateway.gatewaySessionId), - boxResources: summary.topology.boxResources.map((resource) => resource.resourceId), - patchPanelStatusId: summary.topology.patchPanel.patchPanelStatusId + gatewaySessions: summary.m5DryRunTopology.gateways.map((gateway) => gateway.gatewaySessionId), + boxResources: summary.m5DryRunTopology.boxResources.map((resource) => resource.resourceId), + patchPanelStatusId: summary.m5DryRunTopology.patchPanel.patchPanelStatusId } }; } @@ -114,11 +114,11 @@ function mvpDryRunPlan(summary, command) { })) }, topology: { - projectId: summary.topology.projectId, + projectId: summary.m5DryRunTopology.projectId, gatewaySessions: summary.gatewaySessionCount, boxResources: summary.boxResourceCount, - patchPanelStatusId: summary.topology.patchPanel.patchPanelStatusId, - activeConnectionCount: summary.topology.patchPanel.activeConnectionCount + patchPanelStatusId: summary.m5DryRunTopology.patchPanel.patchPanelStatusId, + activeConnectionCount: summary.m5DryRunTopology.patchPanel.activeConnectionCount }, agent: summary.agent, evidence: { diff --git a/web/hwlab-cloud-web/gate-summary.mjs b/web/hwlab-cloud-web/gate-summary.mjs index 6d3d786b..c9041364 100644 --- a/web/hwlab-cloud-web/gate-summary.mjs +++ b/web/hwlab-cloud-web/gate-summary.mjs @@ -463,6 +463,63 @@ export const gateSummary = { } ], "topology": { + "projectId": "prj_m3_hardware_loop", + "projectStatus": "blocked", + "gateways": [ + { + "gatewaySessionId": "gws_gwsimu_1", + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_1", + "status": "connected", + "environment": "dev", + "endpoint": null + }, + { + "gatewaySessionId": "gws_gwsimu_2", + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_2", + "status": "connected", + "environment": "dev", + "endpoint": null + } + ], + "boxResources": [ + { + "resourceId": "res_boxsimu_1", + "gatewaySessionId": "gws_gwsimu_1", + "boxId": "boxsimu_1", + "resourceType": "simulator_endpoint", + "name": "BOX-SIMU 1 source DO1", + "state": "available", + "environment": "dev" + }, + { + "resourceId": "res_boxsimu_2", + "gatewaySessionId": "gws_gwsimu_2", + "boxId": "boxsimu_2", + "resourceType": "simulator_endpoint", + "name": "BOX-SIMU 2 target DI1", + "state": "available", + "environment": "dev" + } + ], + "patchPanel": { + "patchPanelStatusId": "pps_m3_hardware_loop", + "serviceId": "hwlab-patch-panel", + "state": "blocked", + "environment": "dev", + "activeConnectionCount": 1, + "activeConnections": [ + { + "fromResourceId": "res_boxsimu_1", + "fromPort": "DO1", + "toResourceId": "res_boxsimu_2", + "toPort": "DI1" + } + ] + } + }, + "m5DryRunTopology": { "projectId": "proj_m5-mvp-e2e", "projectStatus": "active", "gateways": [ diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index f92a5b79..722a9fe3 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -296,6 +296,22 @@ assert.equal(gateSummary.healthCount, 14); assert.equal(gateSummary.gatewaySessionCount, 2); assert.equal(gateSummary.boxResourceCount, 2); assert.equal(gateSummary.topology.patchPanel.serviceId, "hwlab-patch-panel"); +assert.deepEqual(gateSummary.topology.gateways.map((gateway) => gateway.gatewayId), ["gwsimu_1", "gwsimu_2"]); +assert.deepEqual(gateSummary.topology.boxResources.map((resource) => resource.resourceId), ["res_boxsimu_1", "res_boxsimu_2"]); +assert.deepEqual(gateSummary.topology.patchPanel.activeConnections, [ + { + fromResourceId: "res_boxsimu_1", + fromPort: "DO1", + toResourceId: "res_boxsimu_2", + toPort: "DI1" + } +]); +assert.doesNotMatch( + JSON.stringify(gateSummary.topology), + /res_m5-control-relay|res_m5-target-board|out1|reset/u, + "16666 default topology must not render M5/reset wiring" +); +assert.equal(gateSummary.m5DryRunTopology.patchPanel.patchPanelStatusId, "pps_m5-0001"); assert.equal(gateSummary.agent.agentServiceId, "hwlab-agent-mgr"); assert.equal(gateSummary.agent.workerServiceId, "hwlab-agent-worker"); assert.equal(gateSummary.evidenceRecords.length, 2); diff --git a/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs b/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs index 86ebee8d..fae26589 100644 --- a/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs +++ b/web/hwlab-cloud-web/scripts/m3-readonly-contract.mjs @@ -84,9 +84,21 @@ export function runCloudWebM3ReadonlyContract() { "M3 hardware-loop runtime blocker must remain visible" ); assert.equal(gateSummary.topology.patchPanel.serviceId, "hwlab-patch-panel", "topology must route through patch-panel"); - assert.ok(Array.isArray(gateSummary.topology.patchPanel.activeConnections), "patch-panel links must be visible"); - assert.ok(gateSummary.topology.gateways.length >= 1, "topology must expose gateway records"); - assert.ok(gateSummary.topology.boxResources.length >= 1, "topology must expose box resource records"); + assert.deepEqual(gateSummary.topology.gateways.map((gateway) => gateway.gatewayId), ["gwsimu_1", "gwsimu_2"], "M3 workbench topology must expose two distinct gateway-simu identities"); + assert.deepEqual(gateSummary.topology.boxResources.map((resource) => resource.resourceId), ["res_boxsimu_1", "res_boxsimu_2"], "M3 workbench topology must expose two distinct box-simu identities"); + assert.deepEqual(gateSummary.topology.patchPanel.activeConnections, [ + { + fromResourceId: "res_boxsimu_1", + fromPort: "DO1", + toResourceId: "res_boxsimu_2", + toPort: "DI1" + } + ], "M3 workbench patch-panel links must show only res_boxsimu_1:DO1 -> res_boxsimu_2:DI1"); + assert.doesNotMatch( + JSON.stringify(gateSummary.topology), + /res_m5-control-relay|res_m5-target-board|out1|reset/u, + "M5/reset dry-run wiring must not appear in the default M3 workbench topology" + ); assert.ok(gateSummary.health.length >= 1, "health diagnostics must remain visible"); assert.ok(gateSummary.evidenceRecords.every((record) => record.dryRun === true), "checked-in evidence must stay dry-run labeled");