620 lines
20 KiB
JavaScript
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)`);
|