"""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 """ 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, ) # ========================== Execution plan model ========================== 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. switch_fail_mode: Optional Open vSwitch fail mode selected by the planner for switch nodes. Non-switch nodes leave this unset. """ name: str type: NodeType switch_fail_mode: Optional[str] = None @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. Ordered tuples of frozen operation objects capture the result of planning. They carry concrete names and configuration into the renderer without exposing the mutable allocation context or the source model objects. The renderer emits the operations in lifecycle order: construction, configuration, and service startup. 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, ...] # ======================== Mutable planning context ======================== @dataclass class _PlanningContext: """Hold mutable state used while lowering a topology. _build_context() initializes node lookup, reservations, free pools, and counters from the validated topology. During _plan_links(), allocation helpers consume pool entries, advance counters, and record interface mappings. Interface, node-setting, and service planning then read those mappings. A logical interface is identified by (node_name, logical_name), since tags need only be unique within their owning node. interface_names and interface_models acquire the same keys on allocation: one supplies the backend name, and the other supplies its configuration. A reservation alone does not create an entry in either mapping. Args: nodes: Source nodes indexed by name, safe after uniqueness validation. reserved_interfaces: All explicit link references per node, used to construct free pools and retained unchanged during allocation. free_interfaces: Ordered lists of unreserved declared tags for fixed pools; AUTO removes the first entry. An empty list means exhaustion; dynamic nodes have no entry. dynamic_interface_counters: Next synthetic logical-tag indices, such as auto0; advanced only for nodes with omitted interface sets. physical_interface_counters: Next concrete eth-index per node; advanced for every allocation, including explicit selections. interface_names: Allocated (node, logical tag) pairs mapped to concrete Linux/Mininet names, in interface-configuration order. interface_models: The same allocated keys mapped to source Interface objects or default models created for dynamic interfaces. Example: A fixed pool declared as p1, p2, uplink with an explicit uplink reference starts with free_interfaces containing [p1, p2]. If the first endpoint for that node uses AUTO, it consumes p1 and receives -eth0, recording both mappings for (node, p1). """ 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] # ========================= Context initialization ========================= def _build_context(topology: Topology) -> _PlanningContext: """Create planning state and reserve all explicit link interfaces. Scan every link before constructing the free-interface pools so an AUTO endpoint cannot consume an interface explicitly referenced by a later link. Preserve declaration order in each pool for deterministic allocation. 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={}, ) # ========================= Node and link planning ========================= 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, switch_fail_mode=( "standalone" if node.type is NodeType.SWITCH else None ), ) 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. Explicit tags look up their declared models without consuming a free pool. AUTO consumes the first available tag for a fixed pool, or creates a synthetic tag and an unconfigured model for a dynamic pool. Concrete backend names are assigned separately by physical allocation. 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) # These mappings let later stages recover both the name and configuration. 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. Traverse links and their endpoints in source order, resolving each logical selection before assigning its backend name. Allocation records mappings in the shared context for subsequent interface, node-setting, and service planning, so traversal order determines naming. 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) # ==================== Interface and node configuration ==================== 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. First reject configured declarations absent from the allocation mappings: Mininet creates these interfaces through links, so there would be no concrete interface on which to apply their configuration. Empty unused declarations need no operation and may remain unallocated. Then pair each allocated name with its model in allocation order. 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) # ============================ Service planning ============================ 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) # ============================ Public interface ============================ 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, ) # =============================== Self-test =============================== 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 next( node for node in execution_plan.nodes if node.name == "s1" ).switch_fail_mode == "standalone" 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")