From 277bdb0d44c7921921f38ee9cc4964ee51729e43 Mon Sep 17 00:00:00 2001 From: HWLAB Code Queue Date: Thu, 21 May 2026 15:31:11 +0000 Subject: [PATCH] test: add topology constraint validator --- docs/topology-constraints.md | 46 ++ .../topology-constraints/invalid-cycle.json | 202 ++++++ .../invalid-multiple-active-configs.json | 212 ++++++ .../invalid-patch-panel-override.json | 221 +++++++ .../topology-constraints/valid-topology.json | 185 ++++++ scripts/validate-topology-constraints.mjs | 619 ++++++++++++++++++ 6 files changed, 1485 insertions(+) create mode 100644 docs/topology-constraints.md create mode 100644 fixtures/mvp/topology-constraints/invalid-cycle.json create mode 100644 fixtures/mvp/topology-constraints/invalid-multiple-active-configs.json create mode 100644 fixtures/mvp/topology-constraints/invalid-patch-panel-override.json create mode 100644 fixtures/mvp/topology-constraints/valid-topology.json create mode 100644 scripts/validate-topology-constraints.mjs diff --git a/docs/topology-constraints.md b/docs/topology-constraints.md new file mode 100644 index 00000000..b092b46b --- /dev/null +++ b/docs/topology-constraints.md @@ -0,0 +1,46 @@ +# Topology Constraint Validator + +`scripts/validate-topology-constraints.mjs` is a local contract validator for +the MVP topology rules that were prose-only after M0. It reads fixtures from +`fixtures/mvp/topology-constraints/` and does not call DEV, PROD, real +hardware, databases, secrets, or service endpoints. + +Run it locally: + +```sh +node scripts/validate-topology-constraints.mjs +``` + +## Contract + +- The fixture must describe a DEV topology with `hwlab-box-simu`, + `hwlab-gateway-simu`, and one active `hwlab-patch-panel` status object. +- Cross-device propagation must remain `patch-panel-only`. Box simulator local + loopback is not a valid substitute for cross-device sync. +- Wiring endpoints must reference known box resources and declared ports. +- Active wiring configs must not contain resource-level directed cycles. +- Active config exclusivity is enforced per project and resource group. The + resource group is `wiringConfig.constraints.resourceGroupId` when present; + otherwise it is the sorted set of resources touched by the config. +- A resource group may have only one active wiring config unless the active + patch panel reports `metadata.activeConfigOverride`. +- A patch-panel override is valid only when it is reported by an active + `hwlab-patch-panel`, preserves `patch-panel-only`, lists exactly the active + config ids for the resource group, gives a non-empty reason, and selects the + same `wiringConfigId` as the patch panel status object. +- The patch panel's `activeConnections` must match the selected wiring config. + +## Fixtures + +| Fixture | Expected result | Purpose | +| --- | --- | --- | +| `valid-topology.json` | valid | One active acyclic config routed by `hwlab-patch-panel`. | +| `invalid-cycle.json` | `wiring_cycle` | Detects a directed resource cycle in active wiring. | +| `invalid-multiple-active-configs.json` | `active_config_exclusivity` | Rejects multiple active configs in the same resource group without an override. | +| `invalid-patch-panel-override.json` | `patch_panel_override_invalid` | Rejects a patch-panel override whose selected config disagrees with patch panel state. | + +Each fixture stores `expectedValidation` beside the topology. The validator +passes only when valid fixtures produce no violations and invalid fixtures +produce the expected violation code set. This makes the validator a pre-DEV +contract for the future real M3 flow while keeping the current M3 hardware loop +smoke focused on local patch-panel behavior. diff --git a/fixtures/mvp/topology-constraints/invalid-cycle.json b/fixtures/mvp/topology-constraints/invalid-cycle.json new file mode 100644 index 00000000..487a78c8 --- /dev/null +++ b/fixtures/mvp/topology-constraints/invalid-cycle.json @@ -0,0 +1,202 @@ +{ + "caseId": "invalid-cycle", + "expectedValidation": { + "valid": false, + "violationCodes": ["wiring_cycle"] + }, + "topology": { + "topologyId": "top_constraints_cycle", + "projectId": "prj_topology_constraints", + "environment": "dev", + "services": { + "boxes": [ + { + "serviceId": "hwlab-box-simu", + "boxId": "boxsim_alpha", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "live": true, + "resource": { + "resourceId": "res_constraints_alpha", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "boxId": "boxsim_alpha", + "resourceType": "simulator_endpoint", + "name": "boxsim_alpha simulator endpoint", + "state": "available", + "environment": "dev", + "metadata": { + "serviceId": "hwlab-box-simu", + "ports": ["DO1", "DI1"], + "propagation": "patch-panel-only" + } + }, + "ports": { + "DO1": { + "port": "DO1", + "direction": "output", + "value": false, + "source": "local" + }, + "DI1": { + "port": "DI1", + "direction": "input", + "value": false, + "source": "local" + } + }, + "constraints": { + "crossDevicePropagation": "patch-panel-only", + "localLoopbackEnabled": false + } + }, + { + "serviceId": "hwlab-box-simu", + "boxId": "boxsim_beta", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_beta", + "live": true, + "resource": { + "resourceId": "res_constraints_beta", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_beta", + "boxId": "boxsim_beta", + "resourceType": "simulator_endpoint", + "name": "boxsim_beta simulator endpoint", + "state": "available", + "environment": "dev", + "metadata": { + "serviceId": "hwlab-box-simu", + "ports": ["DO1", "DI1"], + "propagation": "patch-panel-only" + } + }, + "ports": { + "DO1": { + "port": "DO1", + "direction": "output", + "value": false, + "source": "local" + }, + "DI1": { + "port": "DI1", + "direction": "input", + "value": false, + "source": "local" + } + }, + "constraints": { + "crossDevicePropagation": "patch-panel-only", + "localLoopbackEnabled": false + } + } + ], + "gateways": [ + { + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_alpha", + "projectId": "prj_topology_constraints", + "live": true, + "session": { + "gatewaySessionId": "gws_constraints_alpha", + "projectId": "prj_topology_constraints", + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_alpha", + "endpoint": "http://127.0.0.1:7301", + "status": "connected", + "environment": "dev" + }, + "boxes": ["res_constraints_alpha"] + }, + { + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_beta", + "projectId": "prj_topology_constraints", + "live": true, + "session": { + "gatewaySessionId": "gws_constraints_beta", + "projectId": "prj_topology_constraints", + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_beta", + "endpoint": "http://127.0.0.1:7302", + "status": "connected", + "environment": "dev" + }, + "boxes": ["res_constraints_beta"] + } + ], + "patchPanel": { + "patchPanelStatusId": "pps_constraints_cycle", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "wiringConfigId": "wir_constraints_cycle", + "serviceId": "hwlab-patch-panel", + "state": "active", + "environment": "dev", + "activeConnections": [ + { + "fromResourceId": "res_constraints_alpha", + "fromPort": "DO1", + "toResourceId": "res_constraints_beta", + "toPort": "DI1" + }, + { + "fromResourceId": "res_constraints_beta", + "fromPort": "DO1", + "toResourceId": "res_constraints_alpha", + "toPort": "DI1" + } + ], + "observedAt": "2026-05-21T00:00:00.000Z", + "metadata": { + "propagation": "patch-panel-only", + "connectionCount": 2 + } + } + }, + "wiringConfigs": [ + { + "wiringConfigId": "wir_constraints_cycle", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "name": "Topology constraints cycle wiring", + "status": "active", + "connections": [ + { + "from": { + "resourceId": "res_constraints_alpha", + "port": "DO1" + }, + "to": { + "resourceId": "res_constraints_beta", + "port": "DI1" + }, + "mode": "exclusive" + }, + { + "from": { + "resourceId": "res_constraints_beta", + "port": "DO1" + }, + "to": { + "resourceId": "res_constraints_alpha", + "port": "DI1" + }, + "mode": "exclusive" + } + ], + "constraints": { + "resourceGroupId": "rg_constraints_alpha_beta", + "propagation": "patch-panel-only", + "localLoopbackSubstituteAllowed": false + }, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + } + ], + "constraints": { + "patchPanelIsOnlyCrossDeviceChannel": true, + "boxSimuLocalLoopbackForCrossDeviceSync": false + } + } +} diff --git a/fixtures/mvp/topology-constraints/invalid-multiple-active-configs.json b/fixtures/mvp/topology-constraints/invalid-multiple-active-configs.json new file mode 100644 index 00000000..95551a67 --- /dev/null +++ b/fixtures/mvp/topology-constraints/invalid-multiple-active-configs.json @@ -0,0 +1,212 @@ +{ + "caseId": "invalid-multiple-active-configs", + "expectedValidation": { + "valid": false, + "violationCodes": ["active_config_exclusivity"] + }, + "topology": { + "topologyId": "top_constraints_multiple_active", + "projectId": "prj_topology_constraints", + "environment": "dev", + "services": { + "boxes": [ + { + "serviceId": "hwlab-box-simu", + "boxId": "boxsim_alpha", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "live": true, + "resource": { + "resourceId": "res_constraints_alpha", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "boxId": "boxsim_alpha", + "resourceType": "simulator_endpoint", + "name": "boxsim_alpha simulator endpoint", + "state": "available", + "environment": "dev", + "metadata": { + "serviceId": "hwlab-box-simu", + "ports": ["DO1", "DI1"], + "propagation": "patch-panel-only" + } + }, + "ports": { + "DO1": { + "port": "DO1", + "direction": "output", + "value": false, + "source": "local" + }, + "DI1": { + "port": "DI1", + "direction": "input", + "value": false, + "source": "local" + } + }, + "constraints": { + "crossDevicePropagation": "patch-panel-only", + "localLoopbackEnabled": false + } + }, + { + "serviceId": "hwlab-box-simu", + "boxId": "boxsim_beta", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_beta", + "live": true, + "resource": { + "resourceId": "res_constraints_beta", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_beta", + "boxId": "boxsim_beta", + "resourceType": "simulator_endpoint", + "name": "boxsim_beta simulator endpoint", + "state": "available", + "environment": "dev", + "metadata": { + "serviceId": "hwlab-box-simu", + "ports": ["DO1", "DI1"], + "propagation": "patch-panel-only" + } + }, + "ports": { + "DO1": { + "port": "DO1", + "direction": "output", + "value": false, + "source": "local" + }, + "DI1": { + "port": "DI1", + "direction": "input", + "value": false, + "source": "local" + } + }, + "constraints": { + "crossDevicePropagation": "patch-panel-only", + "localLoopbackEnabled": false + } + } + ], + "gateways": [ + { + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_alpha", + "projectId": "prj_topology_constraints", + "live": true, + "session": { + "gatewaySessionId": "gws_constraints_alpha", + "projectId": "prj_topology_constraints", + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_alpha", + "endpoint": "http://127.0.0.1:7301", + "status": "connected", + "environment": "dev" + }, + "boxes": ["res_constraints_alpha"] + }, + { + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_beta", + "projectId": "prj_topology_constraints", + "live": true, + "session": { + "gatewaySessionId": "gws_constraints_beta", + "projectId": "prj_topology_constraints", + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_beta", + "endpoint": "http://127.0.0.1:7302", + "status": "connected", + "environment": "dev" + }, + "boxes": ["res_constraints_beta"] + } + ], + "patchPanel": { + "patchPanelStatusId": "pps_constraints_multiple_active", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "wiringConfigId": "wir_constraints_active_primary", + "serviceId": "hwlab-patch-panel", + "state": "active", + "environment": "dev", + "activeConnections": [ + { + "fromResourceId": "res_constraints_alpha", + "fromPort": "DO1", + "toResourceId": "res_constraints_beta", + "toPort": "DI1" + } + ], + "observedAt": "2026-05-21T00:00:00.000Z", + "metadata": { + "propagation": "patch-panel-only", + "connectionCount": 1 + } + } + }, + "wiringConfigs": [ + { + "wiringConfigId": "wir_constraints_active_primary", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "name": "Topology constraints active primary wiring", + "status": "active", + "connections": [ + { + "from": { + "resourceId": "res_constraints_alpha", + "port": "DO1" + }, + "to": { + "resourceId": "res_constraints_beta", + "port": "DI1" + }, + "mode": "exclusive" + } + ], + "constraints": { + "resourceGroupId": "rg_constraints_alpha_beta", + "propagation": "patch-panel-only", + "localLoopbackSubstituteAllowed": false + }, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + }, + { + "wiringConfigId": "wir_constraints_active_secondary", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "name": "Topology constraints active secondary wiring", + "status": "active", + "connections": [ + { + "from": { + "resourceId": "res_constraints_beta", + "port": "DO1" + }, + "to": { + "resourceId": "res_constraints_alpha", + "port": "DI1" + }, + "mode": "exclusive" + } + ], + "constraints": { + "resourceGroupId": "rg_constraints_alpha_beta", + "propagation": "patch-panel-only", + "localLoopbackSubstituteAllowed": false + }, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + } + ], + "constraints": { + "patchPanelIsOnlyCrossDeviceChannel": true, + "boxSimuLocalLoopbackForCrossDeviceSync": false + } + } +} diff --git a/fixtures/mvp/topology-constraints/invalid-patch-panel-override.json b/fixtures/mvp/topology-constraints/invalid-patch-panel-override.json new file mode 100644 index 00000000..3cb5b8c2 --- /dev/null +++ b/fixtures/mvp/topology-constraints/invalid-patch-panel-override.json @@ -0,0 +1,221 @@ +{ + "caseId": "invalid-patch-panel-override", + "expectedValidation": { + "valid": false, + "violationCodes": ["patch_panel_override_invalid"] + }, + "topology": { + "topologyId": "top_constraints_bad_override", + "projectId": "prj_topology_constraints", + "environment": "dev", + "services": { + "boxes": [ + { + "serviceId": "hwlab-box-simu", + "boxId": "boxsim_alpha", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "live": true, + "resource": { + "resourceId": "res_constraints_alpha", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "boxId": "boxsim_alpha", + "resourceType": "simulator_endpoint", + "name": "boxsim_alpha simulator endpoint", + "state": "available", + "environment": "dev", + "metadata": { + "serviceId": "hwlab-box-simu", + "ports": ["DO1", "DI1"], + "propagation": "patch-panel-only" + } + }, + "ports": { + "DO1": { + "port": "DO1", + "direction": "output", + "value": false, + "source": "local" + }, + "DI1": { + "port": "DI1", + "direction": "input", + "value": false, + "source": "local" + } + }, + "constraints": { + "crossDevicePropagation": "patch-panel-only", + "localLoopbackEnabled": false + } + }, + { + "serviceId": "hwlab-box-simu", + "boxId": "boxsim_beta", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_beta", + "live": true, + "resource": { + "resourceId": "res_constraints_beta", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_beta", + "boxId": "boxsim_beta", + "resourceType": "simulator_endpoint", + "name": "boxsim_beta simulator endpoint", + "state": "available", + "environment": "dev", + "metadata": { + "serviceId": "hwlab-box-simu", + "ports": ["DO1", "DI1"], + "propagation": "patch-panel-only" + } + }, + "ports": { + "DO1": { + "port": "DO1", + "direction": "output", + "value": false, + "source": "local" + }, + "DI1": { + "port": "DI1", + "direction": "input", + "value": false, + "source": "local" + } + }, + "constraints": { + "crossDevicePropagation": "patch-panel-only", + "localLoopbackEnabled": false + } + } + ], + "gateways": [ + { + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_alpha", + "projectId": "prj_topology_constraints", + "live": true, + "session": { + "gatewaySessionId": "gws_constraints_alpha", + "projectId": "prj_topology_constraints", + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_alpha", + "endpoint": "http://127.0.0.1:7301", + "status": "connected", + "environment": "dev" + }, + "boxes": ["res_constraints_alpha"] + }, + { + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_beta", + "projectId": "prj_topology_constraints", + "live": true, + "session": { + "gatewaySessionId": "gws_constraints_beta", + "projectId": "prj_topology_constraints", + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_beta", + "endpoint": "http://127.0.0.1:7302", + "status": "connected", + "environment": "dev" + }, + "boxes": ["res_constraints_beta"] + } + ], + "patchPanel": { + "patchPanelStatusId": "pps_constraints_bad_override", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "wiringConfigId": "wir_constraints_override_secondary", + "serviceId": "hwlab-patch-panel", + "state": "active", + "environment": "dev", + "activeConnections": [ + { + "fromResourceId": "res_constraints_beta", + "fromPort": "DO1", + "toResourceId": "res_constraints_alpha", + "toPort": "DI1" + } + ], + "observedAt": "2026-05-21T00:00:00.000Z", + "metadata": { + "propagation": "patch-panel-only", + "connectionCount": 1, + "activeConfigOverride": { + "resourceGroupId": "rg_constraints_alpha_beta", + "activeWiringConfigIds": [ + "wir_constraints_override_primary", + "wir_constraints_override_secondary" + ], + "selectedWiringConfigId": "wir_constraints_override_primary", + "reason": "Fixture intentionally disagrees with patch panel selected wiringConfigId." + } + } + } + }, + "wiringConfigs": [ + { + "wiringConfigId": "wir_constraints_override_primary", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "name": "Topology constraints override primary wiring", + "status": "active", + "connections": [ + { + "from": { + "resourceId": "res_constraints_alpha", + "port": "DO1" + }, + "to": { + "resourceId": "res_constraints_beta", + "port": "DI1" + }, + "mode": "exclusive" + } + ], + "constraints": { + "resourceGroupId": "rg_constraints_alpha_beta", + "propagation": "patch-panel-only", + "localLoopbackSubstituteAllowed": false + }, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + }, + { + "wiringConfigId": "wir_constraints_override_secondary", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "name": "Topology constraints override secondary wiring", + "status": "active", + "connections": [ + { + "from": { + "resourceId": "res_constraints_beta", + "port": "DO1" + }, + "to": { + "resourceId": "res_constraints_alpha", + "port": "DI1" + }, + "mode": "exclusive" + } + ], + "constraints": { + "resourceGroupId": "rg_constraints_alpha_beta", + "propagation": "patch-panel-only", + "localLoopbackSubstituteAllowed": false + }, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + } + ], + "constraints": { + "patchPanelIsOnlyCrossDeviceChannel": true, + "boxSimuLocalLoopbackForCrossDeviceSync": false + } + } +} diff --git a/fixtures/mvp/topology-constraints/valid-topology.json b/fixtures/mvp/topology-constraints/valid-topology.json new file mode 100644 index 00000000..ff44ee40 --- /dev/null +++ b/fixtures/mvp/topology-constraints/valid-topology.json @@ -0,0 +1,185 @@ +{ + "caseId": "valid-topology", + "expectedValidation": { + "valid": true, + "violationCodes": [] + }, + "topology": { + "topologyId": "top_constraints_valid", + "projectId": "prj_topology_constraints", + "environment": "dev", + "services": { + "boxes": [ + { + "serviceId": "hwlab-box-simu", + "boxId": "boxsim_alpha", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "live": true, + "resource": { + "resourceId": "res_constraints_alpha", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "boxId": "boxsim_alpha", + "resourceType": "simulator_endpoint", + "name": "boxsim_alpha simulator endpoint", + "state": "available", + "environment": "dev", + "metadata": { + "serviceId": "hwlab-box-simu", + "ports": ["DO1", "DI1"], + "propagation": "patch-panel-only" + } + }, + "ports": { + "DO1": { + "port": "DO1", + "direction": "output", + "value": false, + "source": "local" + }, + "DI1": { + "port": "DI1", + "direction": "input", + "value": false, + "source": "local" + } + }, + "constraints": { + "crossDevicePropagation": "patch-panel-only", + "localLoopbackEnabled": false + } + }, + { + "serviceId": "hwlab-box-simu", + "boxId": "boxsim_beta", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_beta", + "live": true, + "resource": { + "resourceId": "res_constraints_beta", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_beta", + "boxId": "boxsim_beta", + "resourceType": "simulator_endpoint", + "name": "boxsim_beta simulator endpoint", + "state": "available", + "environment": "dev", + "metadata": { + "serviceId": "hwlab-box-simu", + "ports": ["DO1", "DI1"], + "propagation": "patch-panel-only" + } + }, + "ports": { + "DO1": { + "port": "DO1", + "direction": "output", + "value": false, + "source": "local" + }, + "DI1": { + "port": "DI1", + "direction": "input", + "value": false, + "source": "local" + } + }, + "constraints": { + "crossDevicePropagation": "patch-panel-only", + "localLoopbackEnabled": false + } + } + ], + "gateways": [ + { + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_alpha", + "projectId": "prj_topology_constraints", + "live": true, + "session": { + "gatewaySessionId": "gws_constraints_alpha", + "projectId": "prj_topology_constraints", + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_alpha", + "endpoint": "http://127.0.0.1:7301", + "status": "connected", + "environment": "dev" + }, + "boxes": ["res_constraints_alpha"] + }, + { + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_beta", + "projectId": "prj_topology_constraints", + "live": true, + "session": { + "gatewaySessionId": "gws_constraints_beta", + "projectId": "prj_topology_constraints", + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gwsimu_beta", + "endpoint": "http://127.0.0.1:7302", + "status": "connected", + "environment": "dev" + }, + "boxes": ["res_constraints_beta"] + } + ], + "patchPanel": { + "patchPanelStatusId": "pps_constraints_valid", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "wiringConfigId": "wir_constraints_alpha_beta", + "serviceId": "hwlab-patch-panel", + "state": "active", + "environment": "dev", + "activeConnections": [ + { + "fromResourceId": "res_constraints_alpha", + "fromPort": "DO1", + "toResourceId": "res_constraints_beta", + "toPort": "DI1" + } + ], + "observedAt": "2026-05-21T00:00:00.000Z", + "metadata": { + "propagation": "patch-panel-only", + "connectionCount": 1 + } + } + }, + "wiringConfigs": [ + { + "wiringConfigId": "wir_constraints_alpha_beta", + "projectId": "prj_topology_constraints", + "gatewaySessionId": "gws_constraints_alpha", + "name": "Topology constraints valid alpha to beta wiring", + "status": "active", + "connections": [ + { + "from": { + "resourceId": "res_constraints_alpha", + "port": "DO1" + }, + "to": { + "resourceId": "res_constraints_beta", + "port": "DI1" + }, + "mode": "exclusive" + } + ], + "constraints": { + "resourceGroupId": "rg_constraints_alpha_beta", + "propagation": "patch-panel-only", + "localLoopbackSubstituteAllowed": false + }, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + } + ], + "constraints": { + "patchPanelIsOnlyCrossDeviceChannel": true, + "boxSimuLocalLoopbackForCrossDeviceSync": false + } + } +} diff --git a/scripts/validate-topology-constraints.mjs b/scripts/validate-topology-constraints.mjs new file mode 100644 index 00000000..f56ac916 --- /dev/null +++ b/scripts/validate-topology-constraints.mjs @@ -0,0 +1,619 @@ +#!/usr/bin/env node +import { readdir, readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const defaultFixtureDir = path.join(repoRoot, "fixtures/mvp/topology-constraints"); + +const PATCH_PANEL_SERVICE_ID = "hwlab-patch-panel"; +const PATCH_PANEL_ONLY = "patch-panel-only"; +const ACTIVE = "active"; + +function isObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOwn(value, key) { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function addViolation(violations, code, message, jsonPath = "$") { + violations.push({ code, message, path: jsonPath }); +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function uniqueSorted(values) { + return [...new Set(values)].sort(); +} + +function sameStringSet(left, right) { + const leftSorted = uniqueSorted(left); + const rightSorted = uniqueSorted(right); + return ( + leftSorted.length === rightSorted.length && + leftSorted.every((value, index) => value === rightSorted[index]) + ); +} + +async function collectJsonFiles(target) { + const targetStat = await stat(target); + if (targetStat.isFile()) { + return target.endsWith(".json") ? [target] : []; + } + + const entries = await readdir(target, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const entryPath = path.join(target, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectJsonFiles(entryPath))); + } else if (entry.isFile() && entry.name.endsWith(".json")) { + files.push(entryPath); + } + } + return files.sort(); +} + +function getFixtureTargets() { + const args = process.argv.slice(2); + if (args.length === 0) { + return [defaultFixtureDir]; + } + return args.map((arg) => path.resolve(process.cwd(), arg)); +} + +function getCaseId(filePath, fixture) { + return fixture.caseId ?? path.basename(filePath, ".json"); +} + +function getTopology(fixture) { + return fixture.topology ?? fixture; +} + +function getExpectedValidation(fixture) { + return ( + fixture.expectedValidation ?? { + valid: true, + violationCodes: [] + } + ); +} + +function getWiringConfigs(topology, violations) { + if (Array.isArray(topology.wiringConfigs)) { + return topology.wiringConfigs; + } + if (isObject(topology.wiringConfig)) { + return [topology.wiringConfig]; + } + + addViolation( + violations, + "topology_shape", + "topology requires wiringConfigs[] or wiringConfig", + "$.topology.wiringConfigs" + ); + return []; +} + +function indexBoxes(topology, violations) { + const boxes = topology.services?.boxes; + if (!Array.isArray(boxes) || boxes.length === 0) { + addViolation(violations, "topology_shape", "topology requires at least one box simulator", "$.topology.services.boxes"); + return new Map(); + } + + const resources = new Map(); + boxes.forEach((box, index) => { + const jsonPath = `$.topology.services.boxes[${index}]`; + const resource = box.resource; + const resourceId = resource?.resourceId; + + if (box.serviceId !== "hwlab-box-simu") { + addViolation(violations, "topology_shape", "box serviceId must be hwlab-box-simu", `${jsonPath}.serviceId`); + } + if (box.projectId !== topology.projectId || resource?.projectId !== topology.projectId) { + addViolation(violations, "topology_shape", "box projectId must match topology projectId", jsonPath); + } + if (!resourceId) { + addViolation(violations, "topology_shape", "box resource.resourceId is required", `${jsonPath}.resource.resourceId`); + return; + } + if (resources.has(resourceId)) { + addViolation(violations, "topology_shape", `duplicate resourceId ${resourceId}`, `${jsonPath}.resource.resourceId`); + } + if (box.constraints?.crossDevicePropagation !== PATCH_PANEL_ONLY) { + addViolation( + violations, + "patch_panel_only", + "box cross-device propagation must be patch-panel-only", + `${jsonPath}.constraints.crossDevicePropagation` + ); + } + if (box.constraints?.localLoopbackEnabled !== false) { + addViolation( + violations, + "patch_panel_only", + "box local loopback cannot substitute for cross-device propagation", + `${jsonPath}.constraints.localLoopbackEnabled` + ); + } + if (resource.metadata?.propagation !== PATCH_PANEL_ONLY) { + addViolation( + violations, + "patch_panel_only", + "box resource metadata propagation must be patch-panel-only", + `${jsonPath}.resource.metadata.propagation` + ); + } + + resources.set(resourceId, { + box, + jsonPath, + ports: isObject(box.ports) ? box.ports : {}, + declaredPorts: new Set(asArray(resource.metadata?.ports)) + }); + }); + + return resources; +} + +function validateGateways(topology, resources, violations) { + const gateways = topology.services?.gateways; + if (!Array.isArray(gateways) || gateways.length === 0) { + addViolation(violations, "topology_shape", "topology requires gateway simulators", "$.topology.services.gateways"); + return new Set(); + } + + const sessions = new Set(); + gateways.forEach((gateway, index) => { + const jsonPath = `$.topology.services.gateways[${index}]`; + if (gateway.serviceId !== "hwlab-gateway-simu") { + addViolation(violations, "topology_shape", "gateway serviceId must be hwlab-gateway-simu", `${jsonPath}.serviceId`); + } + if (gateway.projectId !== topology.projectId || gateway.session?.projectId !== topology.projectId) { + addViolation(violations, "topology_shape", "gateway projectId must match topology projectId", jsonPath); + } + if (gateway.session?.status !== "connected") { + addViolation(violations, "topology_shape", "gateway simulator session must be connected", `${jsonPath}.session.status`); + } + if (gateway.session?.gatewaySessionId) { + sessions.add(gateway.session.gatewaySessionId); + } + + asArray(gateway.boxes).forEach((resourceId, boxIndex) => { + if (!resources.has(resourceId)) { + addViolation( + violations, + "endpoint_reference", + `gateway references unknown box resource ${resourceId}`, + `${jsonPath}.boxes[${boxIndex}]` + ); + } + }); + }); + + return sessions; +} + +function validatePatchPanel(topology, sessions, violations) { + const patchPanel = topology.services?.patchPanel; + if (!isObject(patchPanel)) { + addViolation(violations, "topology_shape", "topology requires one patch panel status object", "$.topology.services.patchPanel"); + return {}; + } + + if (patchPanel.serviceId !== PATCH_PANEL_SERVICE_ID) { + addViolation( + violations, + "patch_panel_override_invalid", + "patch panel override must be reported by hwlab-patch-panel", + "$.topology.services.patchPanel.serviceId" + ); + } + if (patchPanel.state !== ACTIVE) { + addViolation(violations, "topology_shape", "patch panel status must be active", "$.topology.services.patchPanel.state"); + } + if (patchPanel.projectId !== topology.projectId) { + addViolation(violations, "topology_shape", "patch panel projectId must match topology projectId", "$.topology.services.patchPanel.projectId"); + } + if (patchPanel.metadata?.propagation !== PATCH_PANEL_ONLY) { + addViolation( + violations, + "patch_panel_only", + "patch panel propagation metadata must be patch-panel-only", + "$.topology.services.patchPanel.metadata.propagation" + ); + } + if (patchPanel.gatewaySessionId && sessions.size > 0 && !sessions.has(patchPanel.gatewaySessionId)) { + addViolation( + violations, + "endpoint_reference", + "patch panel gatewaySessionId must belong to a known gateway simulator", + "$.topology.services.patchPanel.gatewaySessionId" + ); + } + + return patchPanel; +} + +function portExists(resourceEntry, port) { + return hasOwn(resourceEntry.ports, port) || resourceEntry.declaredPorts.has(port); +} + +function validateEndpoint(endpoint, resources, violations, jsonPath) { + if (!isObject(endpoint)) { + addViolation(violations, "topology_shape", "connection endpoint must be an object", jsonPath); + return; + } + + const resource = resources.get(endpoint.resourceId); + if (!resource) { + addViolation(violations, "endpoint_reference", `unknown resource ${endpoint.resourceId}`, `${jsonPath}.resourceId`); + return; + } + if (!portExists(resource, endpoint.port)) { + addViolation( + violations, + "endpoint_reference", + `unknown port ${endpoint.resourceId}:${endpoint.port}`, + `${jsonPath}.port` + ); + } +} + +function validateWiringConfig(config, index, topology, resources, sessions, violations) { + const jsonPath = `$.topology.wiringConfigs[${index}]`; + if (config.projectId !== topology.projectId) { + addViolation(violations, "topology_shape", "wiring config projectId must match topology projectId", `${jsonPath}.projectId`); + } + if (config.gatewaySessionId && sessions.size > 0 && !sessions.has(config.gatewaySessionId)) { + addViolation( + violations, + "endpoint_reference", + "wiring config gatewaySessionId must belong to a known gateway simulator", + `${jsonPath}.gatewaySessionId` + ); + } + if (!Array.isArray(config.connections) || config.connections.length === 0) { + addViolation(violations, "topology_shape", "wiring config requires at least one connection", `${jsonPath}.connections`); + return; + } + if (config.constraints?.propagation !== PATCH_PANEL_ONLY) { + addViolation( + violations, + "patch_panel_only", + "wiring config propagation must be patch-panel-only", + `${jsonPath}.constraints.propagation` + ); + } + if (config.constraints?.localLoopbackSubstituteAllowed !== false) { + addViolation( + violations, + "patch_panel_only", + "wiring config cannot allow local loopback as a substitute", + `${jsonPath}.constraints.localLoopbackSubstituteAllowed` + ); + } + + config.connections.forEach((connection, connectionIndex) => { + const connectionPath = `${jsonPath}.connections[${connectionIndex}]`; + validateEndpoint(connection.from, resources, violations, `${connectionPath}.from`); + validateEndpoint(connection.to, resources, violations, `${connectionPath}.to`); + }); +} + +function getActiveConnectionKey(connection) { + return [ + connection.fromResourceId, + connection.fromPort, + connection.toResourceId, + connection.toPort + ].join("->"); +} + +function connectionToPatchPanelConnection(connection) { + return { + fromResourceId: connection.from.resourceId, + fromPort: connection.from.port, + toResourceId: connection.to.resourceId, + toPort: connection.to.port + }; +} + +function validatePatchPanelMatchesSelectedConfig(patchPanel, wiringConfigs, violations) { + if (!patchPanel.wiringConfigId) { + addViolation( + violations, + "patch_panel_config_mismatch", + "active patch panel status must name the selected wiringConfigId", + "$.topology.services.patchPanel.wiringConfigId" + ); + return; + } + + const selectedConfig = wiringConfigs.find((config) => config.wiringConfigId === patchPanel.wiringConfigId); + if (!selectedConfig) { + addViolation( + violations, + "patch_panel_config_mismatch", + `patch panel references unknown wiring config ${patchPanel.wiringConfigId}`, + "$.topology.services.patchPanel.wiringConfigId" + ); + return; + } + + const expectedConnections = selectedConfig.connections.map(connectionToPatchPanelConnection).map(getActiveConnectionKey); + const actualConnections = asArray(patchPanel.activeConnections).map(getActiveConnectionKey); + if (!sameStringSet(expectedConnections, actualConnections)) { + addViolation( + violations, + "patch_panel_config_mismatch", + "patch panel activeConnections must match the selected wiring config", + "$.topology.services.patchPanel.activeConnections" + ); + } +} + +function detectDirectedCycle(connections) { + const graph = new Map(); + + for (const connection of connections) { + const from = connection.from?.resourceId; + const to = connection.to?.resourceId; + if (!from || !to) { + continue; + } + if (!graph.has(from)) { + graph.set(from, new Set()); + } + if (!graph.has(to)) { + graph.set(to, new Set()); + } + graph.get(from).add(to); + } + + const visiting = new Set(); + const visited = new Set(); + + function visit(node) { + if (visiting.has(node)) { + return true; + } + if (visited.has(node)) { + return false; + } + + visiting.add(node); + for (const next of graph.get(node) ?? []) { + if (visit(next)) { + return true; + } + } + visiting.delete(node); + visited.add(node); + return false; + } + + return [...graph.keys()].some((node) => visit(node)); +} + +function validateCycles(wiringConfigs, violations) { + wiringConfigs.forEach((config, index) => { + if (config.status !== ACTIVE) { + return; + } + if (detectDirectedCycle(config.connections ?? [])) { + addViolation( + violations, + "wiring_cycle", + `active wiring config ${config.wiringConfigId} contains a directed resource cycle`, + `$.topology.wiringConfigs[${index}].connections` + ); + } + }); +} + +function getResourceGroupId(config) { + if (typeof config.constraints?.resourceGroupId === "string" && config.constraints.resourceGroupId.length > 0) { + return config.constraints.resourceGroupId; + } + + const resourceIds = []; + for (const connection of config.connections ?? []) { + if (connection.from?.resourceId) { + resourceIds.push(connection.from.resourceId); + } + if (connection.to?.resourceId) { + resourceIds.push(connection.to.resourceId); + } + } + return uniqueSorted(resourceIds).join("+"); +} + +function validateOverrideForActiveGroup(override, groupId, activeConfigs, patchPanel) { + if (!isObject(override)) { + return "patch panel override must be an object"; + } + + const activeIds = activeConfigs.map((config) => config.wiringConfigId); + const overrideIds = asArray(override.activeWiringConfigIds); + if (override.resourceGroupId !== groupId) { + return `override resourceGroupId must be ${groupId}`; + } + if (!sameStringSet(activeIds, overrideIds)) { + return "override activeWiringConfigIds must exactly list the active configs in the resource group"; + } + if (!activeIds.includes(override.selectedWiringConfigId)) { + return "override selectedWiringConfigId must be one of the active configs"; + } + if (override.selectedWiringConfigId !== patchPanel.wiringConfigId) { + return "override selectedWiringConfigId must match patch panel wiringConfigId"; + } + if (typeof override.reason !== "string" || override.reason.trim().length === 0) { + return "override reason is required"; + } + if (patchPanel.serviceId !== PATCH_PANEL_SERVICE_ID || patchPanel.state !== ACTIVE) { + return "override must be reported by an active hwlab-patch-panel status object"; + } + if (patchPanel.metadata?.propagation !== PATCH_PANEL_ONLY) { + return "override must preserve patch-panel-only propagation"; + } + + return null; +} + +function validateActiveConfigExclusivity(wiringConfigs, patchPanel, violations) { + const groups = new Map(); + wiringConfigs.forEach((config) => { + if (config.status !== ACTIVE) { + return; + } + + const groupId = getResourceGroupId(config); + const key = `${config.projectId}:${groupId}`; + if (!groups.has(key)) { + groups.set(key, { groupId, configs: [] }); + } + groups.get(key).configs.push(config); + }); + + for (const { groupId, configs } of groups.values()) { + if (configs.length <= 1) { + continue; + } + + const override = patchPanel.metadata?.activeConfigOverride; + if (!override) { + addViolation( + violations, + "active_config_exclusivity", + `resource group ${groupId} has ${configs.length} active wiring configs without a patch-panel override`, + "$.topology.wiringConfigs" + ); + continue; + } + + const overrideError = validateOverrideForActiveGroup(override, groupId, configs, patchPanel); + if (overrideError) { + addViolation( + violations, + "patch_panel_override_invalid", + overrideError, + "$.topology.services.patchPanel.metadata.activeConfigOverride" + ); + } + } +} + +function validatePatchPanelOnly(topology, violations) { + if (topology.environment !== "dev") { + addViolation(violations, "topology_shape", "topology validator fixtures must be DEV topologies", "$.topology.environment"); + } + if (topology.constraints?.patchPanelIsOnlyCrossDeviceChannel !== true) { + addViolation( + violations, + "patch_panel_only", + "topology must declare patchPanelIsOnlyCrossDeviceChannel", + "$.topology.constraints.patchPanelIsOnlyCrossDeviceChannel" + ); + } + if (topology.constraints?.boxSimuLocalLoopbackForCrossDeviceSync !== false) { + addViolation( + violations, + "patch_panel_only", + "topology must reject box simulator local loopback as cross-device sync", + "$.topology.constraints.boxSimuLocalLoopbackForCrossDeviceSync" + ); + } +} + +function validateTopology(topology) { + const violations = []; + if (!isObject(topology)) { + addViolation(violations, "topology_shape", "fixture topology must be an object"); + return violations; + } + + validatePatchPanelOnly(topology, violations); + const resources = indexBoxes(topology, violations); + const sessions = validateGateways(topology, resources, violations); + const patchPanel = validatePatchPanel(topology, sessions, violations); + const wiringConfigs = getWiringConfigs(topology, violations); + + wiringConfigs.forEach((config, index) => { + validateWiringConfig(config, index, topology, resources, sessions, violations); + }); + + validatePatchPanelMatchesSelectedConfig(patchPanel, wiringConfigs, violations); + validateCycles(wiringConfigs, violations); + validateActiveConfigExclusivity(wiringConfigs, patchPanel, violations); + + return violations; +} + +function compareValidation(caseId, expected, violations) { + const actualCodes = uniqueSorted(violations.map((violation) => violation.code)); + const expectedCodes = uniqueSorted(expected.violationCodes ?? []); + + if (expected.valid === true && actualCodes.length === 0) { + return []; + } + if (expected.valid === false && sameStringSet(actualCodes, expectedCodes)) { + return []; + } + + const details = violations + .map((violation) => ` - ${violation.code} at ${violation.path}: ${violation.message}`) + .join("\n"); + const expectedSummary = expected.valid + ? "valid" + : `invalid with [${expectedCodes.join(", ")}]`; + const actualSummary = actualCodes.length === 0 ? "valid" : `invalid with [${actualCodes.join(", ")}]`; + + return [ + `${caseId}: expected ${expectedSummary}, got ${actualSummary}`, + details || " - no violations" + ]; +} + +async function loadFixture(filePath) { + const raw = await readFile(filePath, "utf8"); + return JSON.parse(raw); +} + +const files = ( + await Promise.all(getFixtureTargets().map((target) => collectJsonFiles(target))) +).flat(); + +if (files.length === 0) { + console.error("No topology constraint fixtures found"); + process.exit(1); +} + +const failures = []; +for (const filePath of files) { + const fixture = await loadFixture(filePath); + const caseId = getCaseId(filePath, fixture); + const expected = getExpectedValidation(fixture); + const violations = validateTopology(getTopology(fixture)); + const comparisonFailures = compareValidation(caseId, expected, violations); + if (comparisonFailures.length > 0) { + failures.push(...comparisonFailures); + continue; + } + + const label = expected.valid ? "valid" : `expected ${uniqueSorted(expected.violationCodes ?? []).join(", ")}`; + console.log(`ok ${caseId}: ${label}`); +} + +if (failures.length > 0) { + console.error(failures.join("\n")); + process.exit(1); +} + +console.log(`Topology constraint validation passed (${files.length} fixtures)`);