Documentation, comments and small changes

This commit is contained in:
2026-09-27 16:18:35 +03:00
parent 0e43b943c4
commit e1e164bd65
8 changed files with 653 additions and 211 deletions
+268 -80
View File
@@ -3,11 +3,9 @@
This module implements the validation stage of the topology pipeline.
The parser answers:
"Can this JSON be represented by the topology grammar?"
The validator answers:
"Does the parsed topology describe a coherent network?"
Validation is intentionally independent of Mininet. It does not create
@@ -18,11 +16,11 @@ validation fails.
Author: Christos Choutouridis <cchoutou@ece.auth.gr>
"""
from dataclasses import dataclass
from dataclasses import dataclass
import ipaddress
import math
import re
from typing import Dict, List, Optional, Sequence, Set, Tuple
from typing import Dict, List, Optional, Sequence, Set, Tuple
from topology_parser import (
AddressingMode,
@@ -43,6 +41,8 @@ from topology_parser import (
)
# ========================== Validation constants ==========================
_RESERVED_INTERFACE_TAGS = {
LinkInterfaceSelector.AUTO.value,
ServiceInterfaceSelector.ALL.value,
@@ -53,6 +53,8 @@ _MAC_ADDRESS_RE = re.compile(
)
# ====================== Diagnostics and lookup state ======================
@dataclass(frozen=True)
class ValidationIssue:
"""Describe one semantic topology validation failure.
@@ -63,7 +65,7 @@ class ValidationIssue:
"""
location: str
message: str
message: str
class TopologyValidationError(ValueError):
@@ -96,7 +98,7 @@ class TopologyValidationError(ValueError):
count = len(self.issues)
header = "%d topology validation error%s" % (
count,
"" if count == 1 else "s",
"" if count == 1 else "s", # ;)
)
lines = [header]
@@ -110,26 +112,57 @@ class TopologyValidationError(ValueError):
class _NodeIndex:
"""Hold node lookup information prepared for validation.
_build_node_index() retains the first declaration for each nonempty name
and records repeated names separately while reporting duplicate issues.
Link and service checks consult duplicate_names before resolving a known
node, avoiding secondary diagnostics based on an ambiguous declaration.
Args:
nodes: Unique node declarations indexed by name.
nodes: First declaration for each distinct nonempty node name.
duplicate_names: Node names that were declared more than once.
Example:
Suppose the source list contains three Node objects:
topology.nodes = [first_h1, s1, second_h1]
first_h1.name = "h1"
s1.name = "s1"
second_h1.name = "h1"
The resulting lookup has this shape:
node_index = _NodeIndex(
nodes={"h1": first_h1, "s1": s1},
duplicate_names={"h1"},
)
Dictionary keys are logical node names, and values reference the
original Node objects, not copies or positions in the source list.
node_index.nodes["s1"] therefore gives the s1 object directly.
The duplicate set contains names only; it is empty for unique names.
Membership in nodes means a declaration exists, not that it is unique.
Although nodes["h1"] retains first_h1, duplicate_names marks that
reference as ambiguous so link and service checks skip resolving it.
The original topology.nodes list still contains both h1 declarations.
Empty names are reported as issues and excluded from both collections.
"""
nodes: Dict[str, Node]
nodes: Dict[str, Node]
duplicate_names: Set[str]
# =========================== Validation helpers ===========================
def _add_issue(
issues: List[ValidationIssue],
issues: List[ValidationIssue],
location: str,
message: str,
message: str,
) -> None:
"""Append one validation issue.
Args:
issues: Mutable list receiving validation failures.
issues: Mutable list receiving validation failures.
location: Logical location associated with the failure.
message: Human-readable description of the failure.
message: Human-readable description of the failure.
"""
issues.append(ValidationIssue(location=location, message=message))
@@ -143,13 +176,21 @@ def _build_node_index(
Args:
topology: Parsed topology being validated.
issues: Mutable list receiving validation failures.
issues: Mutable list receiving validation failures.
Returns:
Node lookup information for later validation passes.
Notes:
The returned _NodeIndex holds name-to-object lookup and ambiguity
information; validation issues are appended to the supplied list.
first_index is a temporary name-to-source-position map used to point
duplicate diagnostics back to the first declaration.
It is not part of the returned index, whose shape is illustrated in
the _NodeIndex class docstring.
"""
nodes: Dict[str, Node] = {}
nodes: Dict[str, Node] = {}
first_index: Dict[str, int] = {}
duplicate_names: Set[str] = set()
@@ -179,17 +220,21 @@ def _build_node_index(
)
# ============================ Validation core =============================
def _validate_node_settings(
node: Node,
node: Node,
location: str,
issues: List[ValidationIssue],
issues: List[ValidationIssue],
) -> None:
"""Validate that a node carries settings matching its semantic type.
Args:
node: Node whose settings object is checked.
location: Logical location of the node.
issues: Mutable list receiving validation failures.
node: Node whose settings object is checked.
location: Logical location of the node (where node data came from).
issues: Mutable list receiving validation failures.
"""
expected_type = {
@@ -206,20 +251,46 @@ def _validate_node_settings(
)
def _parse_ipv4_interface(
value: str,
def _validate_ipv4_interface(
value: str,
location: str,
issues: List[ValidationIssue],
issues: List[ValidationIssue],
) -> Optional[ipaddress.IPv4Interface]:
"""Parse and validate one IPv4 CIDR interface address.
"""Validate one IPv4 CIDR interface address.
Require an explicit prefix and convert the string to an IPv4Interface for
subsequent subnet checks.
Invalid syntax or an unsupported address family records a validation issue
at the supplied location instead of raising a parsing exception.
Args:
value: Address string expected to contain an IPv4 address and prefix.
location: Logical location associated with the address.
issues: Mutable list receiving validation failures.
value: Address string expected to contain an IPv4 address and prefix.
location: Logical location associated with the address(where address string--value-- came from).
issues: Mutable list receiving validation failures.
Returns:
Parsed IPv4Interface on success, otherwise None.
IPv4Interface on success, otherwise None after recording an issue.
Example:
Input:
value = "192.168.1.10/24"
location = "nodes[0].interfaces.lan.address"
issues = []
Output (result names the return value):
result = ipaddress.IPv4Interface("192.168.1.10/24")
issues = []
With value = "192.168.1.10" and a fresh empty issues list:
result = None
issues = [
ValidationIssue(
location="nodes[0].interfaces.lan.address",
message="static address must use CIDR notation",
),
]
Failure appends to the supplied list rather than raising an exception.
"""
if "/" not in value:
@@ -243,20 +314,47 @@ def _parse_ipv4_interface(
return parsed
def _parse_ipv4_address(
value: str,
def _validate_ipv4_address(
value: str,
location: str,
issues: List[ValidationIssue],
issues: List[ValidationIssue],
) -> Optional[ipaddress.IPv4Address]:
"""Parse and validate one IPv4 address without a prefix.
"""Validate one IPv4 address without a prefix.
Convert the string to an IPv4Address for subsequent semantic checks.
Invalid syntax or an unsupported address family records a validation issue
at the supplied location instead of raising a parsing exception.
Args:
value: Address string expected to contain one IPv4 address.
location: Logical location associated with the address.
issues: Mutable list receiving validation failures.
value: Address string expected to contain one IPv4 address.
location: Logical location associated with the address (where value string came from).
issues: Mutable list receiving validation failures.
Returns:
Parsed IPv4Address on success, otherwise None.
IPv4Address on success, otherwise None after recording an issue.
Example:
Input:
value = "192.168.1.1"
location = "nodes[0].interfaces.lan.gateway"
issues = []
Output (result names the return value):
result = ipaddress.IPv4Address("192.168.1.1")
issues = []
With value = "::1" and a fresh empty issues list:
result = None
issues = [
ValidationIssue(
location="nodes[0].interfaces.lan.gateway",
message="only IPv4 addresses are supported",
),
]
A prefixed value such as "192.168.1.1/24" also returns None and
appends an invalid-IPv4-address issue, since this helper expects
an address without a prefix.
"""
try:
@@ -273,18 +371,18 @@ def _parse_ipv4_address(
def _validate_interface(
node: Node,
node: Node,
interface: Interface,
location: str,
issues: List[ValidationIssue],
location: str,
issues: List[ValidationIssue],
) -> None:
"""Validate one declared logical interface.
Args:
node: Node that owns the interface.
node: Node that owns the interface.
interface: Interface being validated.
location: Logical location of the interface.
issues: Mutable list receiving validation failures.
location: Logical location of the interface (where the interface data came from).
issues: Mutable list receiving validation failures.
"""
if not interface.name:
@@ -314,16 +412,18 @@ def _validate_interface(
"static addressing requires an address",
)
else:
parsed_address = _parse_ipv4_interface(
parsed_address = _validate_ipv4_interface(
interface.address,
location + ".address",
issues,
)
# For subnets larger than two(2) addresses, reject assigning the network or broadcast
# address to an interface.
if parsed_address is not None:
network = parsed_address.network
if (
network.num_addresses > 2
network.num_addresses > 2 # /30 and up
and parsed_address.ip in {network.network_address, network.broadcast_address}
):
_add_issue(
@@ -333,7 +433,7 @@ def _validate_interface(
)
if interface.gateway is not None:
parsed_gateway = _parse_ipv4_address(
parsed_gateway = _validate_ipv4_address(
interface.gateway,
location + ".gateway",
issues,
@@ -353,6 +453,7 @@ def _validate_interface(
"gateway must not be the interface's own address",
)
elif (
# Same here, /30 and up
parsed_address.network.num_addresses > 2
and parsed_gateway
in {
@@ -398,7 +499,7 @@ def _validate_interface(
def _validate_nodes(
topology: Topology,
issues: List[ValidationIssue],
issues: List[ValidationIssue],
) -> _NodeIndex:
"""Validate node declarations and their local interface configuration.
@@ -413,6 +514,7 @@ def _validate_nodes(
node_index = _build_node_index(topology, issues)
for node_number, node in enumerate(topology.nodes):
# keep node's location for error reporting
node_location = "nodes[%d]" % node_number
_validate_node_settings(node, node_location, issues)
@@ -423,12 +525,14 @@ def _validate_nodes(
gateway_locations: List[str] = []
for interface_name, interface in node.interfaces.items():
# keep interface's location for error reporting
interface_location = "%s.interfaces.%s" % (
node_location,
interface_name,
)
if interface.name != interface_name:
# yes I have name inside the interface
_add_issue(
issues,
interface_location,
@@ -437,10 +541,7 @@ def _validate_nodes(
)
_validate_interface(
node,
interface,
interface_location,
issues,
node, interface, interface_location, issues
)
if interface.gateway is not None:
@@ -457,24 +558,26 @@ def _validate_nodes(
return node_index
# ============================ Link validation ============================
def _validate_link_settings(
settings: LinkSettings,
location: str,
issues: List[ValidationIssue],
issues: List[ValidationIssue],
) -> None:
"""Validate optional link provisioning settings.
Args:
settings: Link settings being validated.
location: Logical location of the settings object.
issues: Mutable list receiving validation failures.
location: Logical location of the settings object (where the settings data came from).
issues: Mutable list receiving validation failures.
"""
numeric_settings = {
"bandwidth_mbps": settings.bandwidth_mbps,
"delay_ms": settings.delay_ms,
"loss_percent": settings.loss_percent,
"jitter_ms": settings.jitter_ms,
"delay_ms": settings.delay_ms,
"loss_percent": settings.loss_percent,
"jitter_ms": settings.jitter_ms,
}
for name, value in numeric_settings.items():
@@ -525,7 +628,7 @@ def _validate_link_names(
Args:
topology: Parsed topology being validated.
issues: Mutable list receiving validation failures.
issues: Mutable list receiving validation failures.
"""
first_index: Dict[str, int] = {}
@@ -559,10 +662,28 @@ def _validate_links(
) -> None:
"""Validate links, endpoint references, and interface allocation capacity.
First collect valid explicit interface usage and AUTO requests while
checking endpoint references and rejecting explicit reuse.
Then compare each fixed pool's AUTO demand with its remaining capacity
after all explicit reservations are known.
This checks allocation feasibility without selecting concrete interfaces;
dynamic pools need no capacity check.
Args:
topology: Parsed topology being validated.
topology: Parsed topology being validated.
node_index: Node lookup information prepared by node validation.
issues: Mutable list receiving validation failures.
issues: Mutable list receiving validation failures.
Example:
For otherwise valid links involving a fixed pool on an example switch s1:
Declared interfaces: p1, p2
Explicit reservation: p2
AUTO requests: 2
Remaining capacity: 1
Result: None; one capacity issue appended
With only one AUTO request, no capacity issue is appended.
Neither case consumes the pool or assigns an interface to AUTO.
"""
_validate_link_names(topology, issues)
@@ -578,6 +699,7 @@ def _validate_links(
)
for endpoint_index, endpoint in enumerate(link.endpoints):
# keep endpoint's location for error reporting
endpoint_location = "links[%d].endpoints[%d]" % (
link_index,
endpoint_index,
@@ -651,9 +773,11 @@ def _validate_links(
used_explicit[key] = endpoint_location
# Count AUTO capacity only after collecting every explicit reservation.
for node_name, request_locations in auto_requests.items():
node = node_index.nodes[node_name]
# Dynamic check
if node.interfaces is None:
continue
@@ -670,39 +794,58 @@ def _validate_links(
issues,
"links",
"node %r has %d automatic interface request%s but only %d "
"unused declared interface%s remain%s"
% (
"unused declared interface%s remain%s" % (
node_name,
len(request_locations),
"" if len(request_locations) == 1 else "s",
free_count,
"" if free_count == 1 else "s",
"; %d request%s cannot be satisfied"
% (
"; %d request%s cannot be satisfied" % (
shortage,
"" if shortage == 1 else "s",
),
),
# Ok, laugh as you want, but I like my plurals!
)
# =========================== Service validation ===========================
def _resolve_service_interface(
service: Service,
service: Service,
service_location: str,
node: Node,
issues: List[ValidationIssue],
node: Node,
issues: List[ValidationIssue],
) -> Optional[Interface]:
"""Resolve a service binding to one declared interface when possible.
Explicit tags select a declared model directly; AUTO requires exactly one
declared interface with static addressing.
Service AUTO never allocates an interface or consumes a link's free pool.
ALL is left unresolved for the service-specific validator to diagnose.
Args:
service: Service whose interface binding is resolved.
service_location: Logical location of the service.
node: Node on which the service runs.
issues: Mutable list receiving validation failures.
service: Service whose interface binding is resolved.
service_location: Logical location of the service (whre service data came from).
node: Node on which the service runs.
issues: Mutable list receiving validation failures.
Returns:
Resolved Interface when the binding identifies exactly one valid
interface, otherwise None.
Example:
With service.interface = ServiceInterfaceSelector.AUTO and issues = []:
Declared static ifaces: lan
Result: node.interfaces["lan"]
Issues: unchanged
With two declared static interfaces, lan and wan:
Result: None
Issues: one ambiguity issue appended at the service interface
These scenarios describe selection only; address validity and link
attachment are checked elsewhere in the pipeline.
"""
selector = service.interface
@@ -772,18 +915,34 @@ def _resolve_service_interface(
def _validate_dhcp_server(
service: Service,
service_location: str,
node: Node,
interface: Optional[Interface],
issues: List[ValidationIssue],
) -> None:
"""Validate DHCP-server-specific semantics.
Check address syntax and range ordering independently of the binding, so
those diagnostics remain available even when interface resolution fails.
With a usable static binding, check subnet membership and reserved subnet
addresses, then exclude the server and advertised gateway from the pool.
Skip checks whose required address or binding could not be parsed.
Args:
service: DHCP server service being validated.
service_location: Logical location of the service.
node: Node on which the service runs.
interface: Resolved service interface, if available.
issues: Mutable list receiving validation failures.
service: DHCP server service being validated.
service_location: Logical location of the service (where service data came from).
interface: Resolved service interface, if available.
issues: Mutable list receiving validation failures.
Example:
For a valid static binding and otherwise valid service settings:
Server interface: 192.168.1.1/24
Advertised gateway: omitted
Pool: 192.168.1.100 through 192.168.1.200
Result: None; no issues appended
Changing the pool to 192.168.1.1 through 192.168.1.200 still keeps
it inside the subnet, but includes the server's own address.
The result remains None, with one server-address exclusion issue
appended to the supplied issues list.
"""
if service.interface is ServiceInterfaceSelector.ALL:
@@ -801,12 +960,12 @@ def _validate_dhcp_server(
)
return
start = _parse_ipv4_address(
start = _validate_ipv4_address(
service.settings.address_range.start,
service_location + ".settings.range.start",
issues,
)
end = _parse_ipv4_address(
end = _validate_ipv4_address(
service.settings.address_range.end,
service_location + ".settings.range.end",
issues,
@@ -821,7 +980,7 @@ def _validate_dhcp_server(
gateway: Optional[ipaddress.IPv4Address] = None
if service.settings.gateway is not None:
gateway = _parse_ipv4_address(
gateway = _validate_ipv4_address(
service.settings.gateway,
service_location + ".settings.gateway",
issues,
@@ -842,7 +1001,7 @@ def _validate_dhcp_server(
# The interface validator reports this as well.
return
parsed_interface = _parse_ipv4_interface(
parsed_interface = _validate_ipv4_interface(
interface.address,
service_location + ".interface",
issues,
@@ -962,18 +1121,45 @@ def _validate_services(
_validate_dhcp_server(
service,
location,
node,
interface,
issues,
)
# ============================ Public interface ============================
def validate(topology: Topology) -> None:
"""Validate the semantic consistency of a parsed topology.
Args:
topology: Parsed topology produced by topology_parser.parse_topology().
Returns:
None when no semantic issues are found.
Example:
Input constructed through the public parser:
topology = parse_topology({
"nodes": [{"name": "h1", "type": "host"}],
})
Output:
result = validate(topology)
result is None
Invalid input:
topology = parse_topology({
"nodes": [
{"name": "h1", "type": "host"},
{"name": "h1", "type": "host"},
],
})
validate(topology) raises TopologyValidationError instead of returning.
The exception's issues tuple contains one ValidationIssue with
location="nodes[1].name" and a duplicate-node-name message.
When multiple issues are discovered, they share one exception.
Raises:
TopologyValidationError: If one or more semantic consistency errors are
discovered.
@@ -994,6 +1180,8 @@ def validate(topology: Topology) -> None:
raise TopologyValidationError(issues)
# =============================== Self-test ===============================
def _self_test() -> None:
"""Run smoke tests against the public validate() interface."""