fix: wire distinct M3 simulator identities
Merge PR #159 after Code Queue rebase and validation. Runner verified M3 cardinality, hardware-loop smoke, patch-panel runtime smoke, m3 readonly contract, and git diff check. This is source/config blocker removal only; it does not claim DEV-LIVE M3 pass.
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+27
-3
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
: {})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,10 +144,42 @@ function assertDevK8sCardinality(workloads) {
|
||||
const env = envMap(container.env);
|
||||
|
||||
for (const [name, values] of Object.entries(requirement.env)) {
|
||||
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");
|
||||
@@ -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)) {
|
||||
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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user