Files
pikasTech-HWLAB/scripts/validate-topology-constraints.mjs
2026-05-21 15:31:46 +00:00

620 lines
20 KiB
JavaScript

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