#!/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)`);