Add topology inner representation and python assembly-like file generation(planer)

This commit is contained in:
2026-09-26 18:54:37 +03:00
parent 7b6acb84e4
commit 46f814effc
3 changed files with 1296 additions and 0 deletions
+812
View File
@@ -0,0 +1,812 @@
"""Lower a validated topology into a fully resolved execution plan.
This module is the planning stage of the topology pipeline.
The parser produces a typed description of the user's intent and the validator
proves that the description is semantically coherent. The planner then makes
all implementation choices that must be resolved before executable Mininet
Python can be generated.
In particular, the planner resolves logical and automatic interface references
into concrete Linux/Mininet interface names. It does not import Mininet,
execute commands, modify the network, or render Python source.
Author: Christos Choutouridis <cchoutou@ece.auth.gr>
"""
from dataclasses import dataclass
from typing import Dict, List, Optional, Set, Tuple, Union
from topology_parser import (
AddressingMode,
DHCPServerSettings,
Interface,
LinkInterfaceSelector,
Node,
NodeType,
RouterSettings,
Service,
ServiceInterfaceSelector,
ServiceType,
Topology,
)
class TopologyPlanError(ValueError):
"""Raised when a validated topology cannot be lowered into a plan."""
@dataclass(frozen=True)
class PlannedNode:
"""Describe one concrete node creation operation.
Args:
name: Logical node name used by Mininet.
type: Concrete semantic node type to create.
"""
name: str
type: NodeType
@dataclass(frozen=True)
class PlannedLinkEndpoint:
"""Describe one fully resolved endpoint of a planned link.
Args:
node: Logical node name that owns the endpoint.
logical_interface: Resolved logical interface identifier. For a node
with a dynamic interface set, the planner creates a deterministic
synthetic identifier for an automatically allocated interface.
interface_name: Concrete Linux/Mininet interface name.
"""
node: str
logical_interface: str
interface_name: str
@dataclass(frozen=True)
class PlannedLinkSettings:
"""Contain resolved provisioning settings for one link.
Args:
bandwidth_mbps: Optional bandwidth limit in megabits per second.
delay_ms: Optional one-way delay in milliseconds.
loss_percent: Optional packet-loss percentage.
jitter_ms: Optional delay variation in milliseconds.
"""
bandwidth_mbps: Optional[float] = None
delay_ms: Optional[float] = None
loss_percent: Optional[float] = None
jitter_ms: Optional[float] = None
@dataclass(frozen=True)
class PlannedLink:
"""Describe one fully resolved Mininet link creation operation.
Args:
endpoints: Ordered pair of fully resolved link endpoints.
name: Optional logical link name preserved for diagnostics.
settings: Concrete link provisioning settings.
"""
endpoints: Tuple[PlannedLinkEndpoint, PlannedLinkEndpoint]
name: Optional[str]
settings: PlannedLinkSettings
@dataclass(frozen=True)
class PlannedInterface:
"""Describe post-link configuration for one concrete interface.
Args:
node: Logical node name that owns the interface.
logical_interface: Resolved logical interface identifier.
interface_name: Concrete Linux/Mininet interface name.
addressing: Layer-3 addressing strategy.
address: Optional static CIDR address.
gateway: Optional static default gateway.
mac: Optional explicit MAC address.
mtu: Optional MTU value.
"""
node: str
logical_interface: str
interface_name: str
addressing: AddressingMode
address: Optional[str]
gateway: Optional[str]
mac: Optional[str]
mtu: Optional[int]
@dataclass(frozen=True)
class PlannedSysctl:
"""Describe one namespace-scoped sysctl operation.
Args:
node: Logical node on which the sysctl is applied.
key: Fully resolved sysctl key.
value: Integer value written to the key.
"""
node: str
key: str
value: int
@dataclass(frozen=True)
class PlannedDHCPServerSettings:
"""Contain fully resolved DHCP server settings.
Args:
range_start: First address offered to DHCP clients.
range_end: Last address offered to DHCP clients.
gateway: Optional default gateway advertised to DHCP clients.
"""
range_start: str
range_end: str
gateway: Optional[str]
PlannedServiceSettings = PlannedDHCPServerSettings
@dataclass(frozen=True)
class PlannedService:
"""Describe one fully resolved service startup operation.
Args:
type: Service implementation type.
node: Logical node on which the service runs.
logical_interface: Resolved logical interface identifier.
interface_name: Concrete Linux/Mininet interface name to bind.
settings: Service-type-specific resolved settings.
"""
type: ServiceType
node: str
logical_interface: str
interface_name: str
settings: PlannedServiceSettings
@dataclass(frozen=True)
class ExecutionPlan:
"""Contain the complete, fully resolved execution plan.
Args:
nodes: Node creation operations in declaration order.
links: Link creation operations in declaration order.
interfaces: Concrete interface configuration operations.
node_settings: Concrete namespace-level configuration operations.
services: Service startup operations in declaration order.
Notes:
The plan contains no AUTO or ALL selectors. All such decisions are
resolved before an ExecutionPlan is returned.
"""
nodes: Tuple[PlannedNode, ...]
links: Tuple[PlannedLink, ...]
interfaces: Tuple[PlannedInterface, ...]
node_settings: Tuple[PlannedSysctl, ...]
services: Tuple[PlannedService, ...]
@dataclass
class _PlanningContext:
"""Hold mutable state used while lowering a topology.
Args:
nodes: Nodes indexed by logical name.
reserved_interfaces: Explicitly referenced logical interfaces per node.
free_interfaces: Remaining declared interfaces available to AUTO.
dynamic_interface_counters: Synthetic logical-name counters for nodes
whose interface sets were omitted.
physical_interface_counters: Concrete eth-index counters per node.
interface_names: Mapping from resolved logical interfaces to concrete
Linux/Mininet interface names.
interface_models: Mapping from resolved logical interfaces to their
source Interface objects.
"""
nodes: Dict[str, Node]
reserved_interfaces: Dict[str, Set[str]]
free_interfaces: Dict[str, List[str]]
dynamic_interface_counters: Dict[str, int]
physical_interface_counters: Dict[str, int]
interface_names: Dict[Tuple[str, str], str]
interface_models: Dict[Tuple[str, str], Interface]
def _build_context(topology: Topology) -> _PlanningContext:
"""Create planning state and reserve all explicit link interfaces.
Args:
topology: Validated topology to lower.
Returns:
Initialized mutable planning context.
"""
nodes = {node.name: node for node in topology.nodes}
reserved: Dict[str, Set[str]] = {node.name: set() for node in topology.nodes}
for link in topology.links:
for endpoint in link.endpoints:
if isinstance(endpoint.interface, str):
reserved[endpoint.node].add(endpoint.interface)
free_interfaces: Dict[str, List[str]] = {}
for node in topology.nodes:
if node.interfaces is None:
continue
free_interfaces[node.name] = [
name
for name in node.interfaces.keys()
if name not in reserved[node.name]
]
return _PlanningContext(
nodes=nodes,
reserved_interfaces=reserved,
free_interfaces=free_interfaces,
dynamic_interface_counters={node.name: 0 for node in topology.nodes},
physical_interface_counters={node.name: 0 for node in topology.nodes},
interface_names={},
interface_models={},
)
def _plan_nodes(topology: Topology) -> Tuple[PlannedNode, ...]:
"""Lower topology nodes into concrete node creation operations.
Args:
topology: Validated topology to lower.
Returns:
Planned nodes in source declaration order.
"""
return tuple(
PlannedNode(name=node.name, type=node.type)
for node in topology.nodes
)
def _allocate_logical_interface(
node: Node,
selector: Union[str, LinkInterfaceSelector],
context: _PlanningContext,
) -> Tuple[str, Interface]:
"""Resolve one link endpoint to a concrete logical interface.
Args:
node: Node that owns the endpoint.
selector: Explicit interface tag or AUTO selector.
context: Mutable planning context.
Returns:
Pair containing the resolved logical interface name and its interface
configuration model.
Raises:
TopologyPlanError: If the topology violates a planner precondition.
"""
if isinstance(selector, str):
if node.interfaces is None or selector not in node.interfaces:
raise TopologyPlanError(
"explicit interface %r cannot be resolved on node %r"
% (selector, node.name)
)
return selector, node.interfaces[selector]
if selector is not LinkInterfaceSelector.AUTO:
raise TopologyPlanError(
"unsupported link interface selector %r" % selector
)
if node.interfaces is not None:
free = context.free_interfaces[node.name]
if not free:
raise TopologyPlanError(
"node %r has no declared interface available for AUTO"
% node.name
)
logical_name = free.pop(0)
return logical_name, node.interfaces[logical_name]
counter = context.dynamic_interface_counters[node.name]
context.dynamic_interface_counters[node.name] = counter + 1
logical_name = "auto%d" % counter
return logical_name, Interface(name=logical_name)
def _allocate_physical_interface(
node_name: str,
logical_name: str,
interface: Interface,
context: _PlanningContext,
) -> str:
"""Assign one deterministic concrete interface name.
Args:
node_name: Logical node that owns the interface.
logical_name: Resolved logical interface identifier.
interface: Interface configuration model.
context: Mutable planning context.
Returns:
Concrete Linux/Mininet interface name.
Raises:
TopologyPlanError: If the same logical interface is allocated twice.
"""
key = (node_name, logical_name)
if key in context.interface_names:
raise TopologyPlanError(
"interface %s:%s was allocated more than once"
% (node_name, logical_name)
)
index = context.physical_interface_counters[node_name]
context.physical_interface_counters[node_name] = index + 1
interface_name = "%s-eth%d" % (node_name, index)
context.interface_names[key] = interface_name
context.interface_models[key] = interface
return interface_name
def _plan_links(
topology: Topology,
context: _PlanningContext,
) -> Tuple[PlannedLink, ...]:
"""Resolve all link endpoints and concrete interface names.
Args:
topology: Validated topology to lower.
context: Mutable planning context.
Returns:
Planned links in source declaration order.
"""
planned_links: List[PlannedLink] = []
for link in topology.links:
planned_endpoints: List[PlannedLinkEndpoint] = []
for endpoint in link.endpoints:
node = context.nodes[endpoint.node]
logical_name, interface = _allocate_logical_interface(
node,
endpoint.interface,
context,
)
interface_name = _allocate_physical_interface(
node.name,
logical_name,
interface,
context,
)
planned_endpoints.append(
PlannedLinkEndpoint(
node=node.name,
logical_interface=logical_name,
interface_name=interface_name,
)
)
planned_links.append(
PlannedLink(
endpoints=(planned_endpoints[0], planned_endpoints[1]),
name=link.name,
settings=PlannedLinkSettings(
bandwidth_mbps=link.settings.bandwidth_mbps,
delay_ms=link.settings.delay_ms,
loss_percent=link.settings.loss_percent,
jitter_ms=link.settings.jitter_ms,
),
)
)
return tuple(planned_links)
def _interface_requires_materialization(interface: Interface) -> bool:
"""Return whether an unused declared interface carries configuration.
Args:
interface: Declared interface to inspect.
Returns:
True when silently leaving the interface unmaterialized would discard
requested configuration.
"""
return (
interface.addressing is not AddressingMode.NONE
or interface.address is not None
or interface.gateway is not None
or interface.mac is not None
or interface.mtu is not None
)
def _plan_interfaces(
topology: Topology,
context: _PlanningContext,
) -> Tuple[PlannedInterface, ...]:
"""Create interface-configuration operations for materialized interfaces.
Args:
topology: Validated topology to lower.
context: Planning context containing resolved interface mappings.
Returns:
Planned interface configuration operations.
Raises:
TopologyPlanError: If a configured declared interface is not attached to
any link and therefore has no concrete Mininet interface to
configure.
"""
for node in topology.nodes:
if node.interfaces is None:
continue
for logical_name, interface in node.interfaces.items():
key = (node.name, logical_name)
if (
key not in context.interface_names
and _interface_requires_materialization(interface)
):
raise TopologyPlanError(
"configured interface %s:%s is not attached to any link"
% (node.name, logical_name)
)
planned: List[PlannedInterface] = []
# The mapping insertion order is the exact allocation order from
# _plan_links(), which is deterministic on supported Python versions.
for key, interface_name in context.interface_names.items():
node_name, logical_name = key
interface = context.interface_models[key]
planned.append(
PlannedInterface(
node=node_name,
logical_interface=logical_name,
interface_name=interface_name,
addressing=interface.addressing,
address=interface.address,
gateway=interface.gateway,
mac=interface.mac,
mtu=interface.mtu,
)
)
return tuple(planned)
def _plan_node_settings(
topology: Topology,
context: _PlanningContext,
) -> Tuple[PlannedSysctl, ...]:
"""Lower node settings into concrete namespace sysctl operations.
Args:
topology: Validated topology to lower.
context: Planning context containing concrete interface names.
Returns:
Planned sysctl operations in deterministic order.
"""
planned: List[PlannedSysctl] = []
for node in topology.nodes:
if node.type is not NodeType.ROUTER:
continue
if not isinstance(node.settings, RouterSettings):
raise TopologyPlanError(
"router %r does not carry RouterSettings" % node.name
)
if node.settings.ip_forward is not None:
planned.append(
PlannedSysctl(
node=node.name,
key="net.ipv4.ip_forward",
value=1 if node.settings.ip_forward else 0,
)
)
if node.settings.rp_filter is not None:
value = 1 if node.settings.rp_filter else 0
planned.append(
PlannedSysctl(
node=node.name,
key="net.ipv4.conf.all.rp_filter",
value=value,
)
)
planned.append(
PlannedSysctl(
node=node.name,
key="net.ipv4.conf.default.rp_filter",
value=value,
)
)
for (owner, _), interface_name in context.interface_names.items():
if owner != node.name:
continue
planned.append(
PlannedSysctl(
node=node.name,
key="net.ipv4.conf.%s.rp_filter" % interface_name,
value=value,
)
)
return tuple(planned)
def _resolve_service_logical_interface(
service: Service,
node: Node,
) -> str:
"""Resolve one validated service interface selector.
Args:
service: Service whose interface binding is resolved.
node: Node on which the service runs.
Returns:
Concrete logical interface tag.
Raises:
TopologyPlanError: If the selector cannot be lowered uniquely.
"""
if isinstance(service.interface, str):
return service.interface
if service.interface is ServiceInterfaceSelector.AUTO:
if node.interfaces is None:
raise TopologyPlanError(
"automatic service binding on node %r requires declared "
"interfaces" % node.name
)
candidates = [
name
for name, interface in node.interfaces.items()
if interface.addressing is AddressingMode.STATIC
]
if len(candidates) != 1:
raise TopologyPlanError(
"automatic service binding on node %r is not uniquely "
"resolvable" % node.name
)
return candidates[0]
if service.interface is ServiceInterfaceSelector.ALL:
raise TopologyPlanError(
"service type %r cannot be lowered from the ALL selector"
% service.type.value
)
raise TopologyPlanError(
"unsupported service interface selector %r" % service.interface
)
def _plan_services(
topology: Topology,
context: _PlanningContext,
) -> Tuple[PlannedService, ...]:
"""Resolve service bindings and lower service-specific settings.
Args:
topology: Validated topology to lower.
context: Planning context containing concrete interface names.
Returns:
Planned service startup operations in declaration order.
Raises:
TopologyPlanError: If a service cannot be mapped to a materialized
interface or has unsupported settings.
"""
planned: List[PlannedService] = []
for service in topology.services:
node = context.nodes[service.node]
logical_name = _resolve_service_logical_interface(service, node)
key = (node.name, logical_name)
if key not in context.interface_names:
raise TopologyPlanError(
"service %r binds to interface %s:%s, but that interface is "
"not attached to any link"
% (service.type.value, node.name, logical_name)
)
if service.type is ServiceType.DHCP_SERVER:
if not isinstance(service.settings, DHCPServerSettings):
raise TopologyPlanError(
"DHCP service on %r has incompatible settings" % node.name
)
settings = PlannedDHCPServerSettings(
range_start=service.settings.address_range.start,
range_end=service.settings.address_range.end,
gateway=service.settings.gateway,
)
else:
raise TopologyPlanError(
"unsupported service type %r" % service.type.value
)
planned.append(
PlannedService(
type=service.type,
node=node.name,
logical_interface=logical_name,
interface_name=context.interface_names[key],
settings=settings,
)
)
return tuple(planned)
def plan(topology: Topology) -> ExecutionPlan:
"""Lower a validated topology into a fully resolved execution plan.
Args:
topology: Topology that has already passed semantic validation.
Returns:
Fully resolved execution plan suitable for source-code rendering.
Raises:
TopologyPlanError: If a planner precondition is violated or if a
validated construct cannot be lowered by the current backend.
Notes:
This function deliberately does not call validate(). Validation and
planning remain separate pipeline stages, and callers are expected to
validate the topology before invoking the planner.
"""
context = _build_context(topology)
nodes = _plan_nodes(topology)
links = _plan_links(topology, context)
interfaces = _plan_interfaces(topology, context)
node_settings = _plan_node_settings(topology, context)
services = _plan_services(topology, context)
return ExecutionPlan(
nodes=nodes,
links=links,
interfaces=interfaces,
node_settings=node_settings,
services=services,
)
def _self_test() -> None:
"""Run a smoke test against the public plan() interface."""
from topology_parser import parse_topology
from topology_validator import validate
raw = {
"nodes": [
{
"name": "h1",
"type": "host",
"interfaces": {
"net0": {
"addressing": "dhcp",
}
},
},
{
"name": "s1",
"type": "switch",
"interfaces": {
"client": {},
"uplink": {},
},
},
{
"name": "r0",
"type": "router",
"interfaces": {
"lan": {
"addressing": "static",
"address": "192.168.1.1/24",
}
},
"settings": {
"ip_forward": True,
"rp_filter": False,
},
},
],
"links": [
{
"endpoints": [
{"node": "h1", "interface": "net0"},
{"node": "s1", "interface": "auto"},
]
},
{
"endpoints": [
{"node": "r0", "interface": "lan"},
{"node": "s1", "interface": "uplink"},
]
},
],
"services": [
{
"type": "dhcp-server",
"node": "r0",
"interface": "lan",
"settings": {
"range": {
"start": "192.168.1.100",
"end": "192.168.1.200",
},
"gateway": "192.168.1.1",
},
}
],
}
topology = parse_topology(raw)
validate(topology)
execution_plan = plan(topology)
assert len(execution_plan.nodes) == 3
assert len(execution_plan.links) == 2
assert execution_plan.links[0].endpoints[1].logical_interface == "client"
assert execution_plan.links[0].endpoints[0].interface_name == "h1-eth0"
assert execution_plan.links[1].endpoints[0].interface_name == "r0-eth0"
assert execution_plan.services[0].interface_name == "r0-eth0"
assert any(
setting.key == "net.ipv4.ip_forward" and setting.value == 1
for setting in execution_plan.node_settings
)
if __name__ == "__main__":
_self_test()
print("topology_plan: self-test passed")