test: add topology constraint validator
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)`);
|
||||
Reference in New Issue
Block a user