Files
Computer-Networks-II/source/topology_renderer.py
T

483 lines
14 KiB
Python

"""Render a resolved execution plan as executable Mininet Python source.
The renderer is intentionally a serialization stage. It does not perform
semantic validation, allocate AUTO interfaces, import Mininet itself, or make
networking decisions. All topology decisions must already be represented in
the ExecutionPlan produced by topology_plan.plan().
The returned Python source is both an inspectable intermediate artifact and the
canonical executable form of the planned topology.
Author: Christos Choutouridis <cchoutou@ece.auth.gr>
"""
import ast
from typing import List
from topology_parser import AddressingMode, NodeType, ServiceType
from topology_plan import (
ExecutionPlan,
PlannedDHCPServerSettings,
PlannedInterface,
PlannedLink,
PlannedService,
)
def _python_string(value: str) -> str:
"""Return a safe Python string literal.
Args:
value: String to serialize into generated Python source.
Returns:
Python source literal representing the string exactly.
"""
return repr(value)
def _render_link_call(link: PlannedLink) -> List[str]:
"""Render one Mininet addLink() call.
Args:
link: Fully resolved link to render.
Returns:
Source lines implementing the link creation operation.
"""
first, second = link.endpoints
settings = link.settings
arguments = [
"nodes[%s]" % _python_string(first.node),
"nodes[%s]" % _python_string(second.node),
"intfName1=%s" % _python_string(first.interface_name),
"intfName2=%s" % _python_string(second.interface_name),
]
has_tc_settings = any(
value is not None
for value in (
settings.bandwidth_mbps,
settings.delay_ms,
settings.loss_percent,
settings.jitter_ms,
)
)
if has_tc_settings:
arguments.append("cls=TCLink")
if settings.bandwidth_mbps is not None:
arguments.append("bw=%r" % settings.bandwidth_mbps)
if settings.delay_ms is not None:
arguments.append("delay=%s" % _python_string("%gms" % settings.delay_ms))
elif settings.jitter_ms is not None:
# Linux netem expresses jitter as variation around a base delay. A
# zero base delay preserves the declarative meaning when only jitter
# was provided in the topology.
arguments.append("delay=%s" % _python_string("0ms"))
if settings.loss_percent is not None:
arguments.append("loss=%r" % settings.loss_percent)
if settings.jitter_ms is not None:
arguments.append("jitter=%s" % _python_string("%gms" % settings.jitter_ms))
lines = [" net.addLink("]
for argument in arguments:
lines.append(" %s," % argument)
lines.append(" )")
return lines
def _render_interface_configuration(interface: PlannedInterface) -> List[str]:
"""Render configuration commands for one concrete interface.
Args:
interface: Fully resolved interface configuration operation.
Returns:
Generated Python source lines for the interface.
"""
node = "nodes[%s]" % _python_string(interface.node)
name = _python_string(interface.interface_name)
lines: List[str] = []
if interface.mac is not None:
lines.append(
" _node_cmd(%s, 'ip', 'link', 'set', 'dev', %s, "
"'address', %s)"
% (node, name, _python_string(interface.mac))
)
if interface.mtu is not None:
lines.append(
" _node_cmd(%s, 'ip', 'link', 'set', 'dev', %s, "
"'mtu', %s)"
% (node, name, _python_string(str(interface.mtu)))
)
if interface.addressing in {
AddressingMode.STATIC,
AddressingMode.DHCP,
}:
# addHost(ip=None) prevents Mininet from assigning its default address,
# and this flush makes the generated artifact explicit about the
# desired IPv4 state before static or manual-DHCP configuration.
lines.append(
" _node_cmd(%s, 'ip', '-4', 'addr', 'flush', 'dev', %s)"
% (node, name)
)
if interface.addressing is AddressingMode.STATIC:
if interface.address is None:
raise ValueError(
"planned static interface %s:%s has no address"
% (interface.node, interface.logical_interface)
)
lines.append(
" _node_cmd(%s, 'ip', 'addr', 'add', %s, 'dev', %s)"
% (
node,
_python_string(interface.address),
name,
)
)
if (
interface.addressing in {AddressingMode.STATIC, AddressingMode.DHCP}
or interface.mac is not None
or interface.mtu is not None
):
lines.append(
" _node_cmd(%s, 'ip', 'link', 'set', 'dev', %s, 'up')"
% (node, name)
)
return lines
def _render_gateway_configuration(interface: PlannedInterface) -> List[str]:
"""Render an optional static default route for one interface.
Args:
interface: Fully resolved interface configuration operation.
Returns:
Generated Python source lines, possibly empty.
"""
if interface.gateway is None:
return []
node = "nodes[%s]" % _python_string(interface.node)
return [
" _node_cmd(%s, 'ip', 'route', 'replace', 'default', 'via', "
"%s, 'dev', %s)"
% (
node,
_python_string(interface.gateway),
_python_string(interface.interface_name),
)
]
def _render_service_start(service: PlannedService, index: int) -> List[str]:
"""Render startup code for one planned service.
Args:
service: Fully resolved service operation.
index: Stable service index used for temporary runtime files.
Returns:
Generated Python source lines that start and track the service.
Raises:
ValueError: If the plan contains an unsupported service type or
incompatible settings object.
"""
if service.type is not ServiceType.DHCP_SERVER:
raise ValueError(
"unsupported planned service type %r" % service.type.value
)
if not isinstance(service.settings, PlannedDHCPServerSettings):
raise ValueError("DHCP service has incompatible planned settings")
node = "nodes[%s]" % _python_string(service.node)
lease_file = "/tmp/mininet-dnsmasq-%d.leases" % index
pid_file = "/tmp/mininet-dnsmasq-%d.pid" % index
log_file = "/tmp/mininet-dnsmasq-%d.log" % index
args = [
"dnsmasq",
"--no-daemon",
"--conf-file=",
"--port=0",
"--bind-interfaces",
"--dhcp-authoritative",
"--interface=%s" % service.interface_name,
"--dhcp-range=%s,%s,12h"
% (service.settings.range_start, service.settings.range_end),
"--dhcp-leasefile=%s" % lease_file,
"--pid-file=%s" % pid_file,
]
if service.settings.gateway is not None:
args.append(
"--dhcp-option=3,%s" % service.settings.gateway
)
rendered_args = ", ".join(_python_string(arg) for arg in args)
return [
" pid = _start_background(",
" %s," % node,
" [%s]," % rendered_args,
" %s," % _python_string(log_file),
" )",
" service_processes.append((%s, pid))" % node,
]
def render_python(plan: ExecutionPlan) -> str:
"""Render an execution plan as a complete executable Python program.
Args:
plan: Fully resolved execution plan returned by topology_plan.plan().
Returns:
Python source code that creates the network, applies configuration,
starts services, opens the Mininet CLI, and performs structured cleanup.
Raises:
ValueError: If the supplied plan contains an operation that this
renderer cannot serialize.
"""
lines: List[str] = [
"#!/usr/bin/env python3",
'"""Generated Mininet topology. Do not edit by hand."""',
"",
"import shlex",
"",
"from mininet.cli import CLI",
"from mininet.link import TCLink",
"from mininet.net import Mininet",
"",
"",
"def _shell_join(args):",
' """Quote command arguments for execution inside a Mininet node."""',
"",
" return ' '.join(shlex.quote(str(arg)) for arg in args)",
"",
"",
"def _node_cmd(node, *args):",
' """Execute one safely quoted command inside a Mininet node."""',
"",
" return node.cmd(_shell_join(args))",
"",
"",
"def _start_background(node, args, log_path):",
' """Start one background process and return its shell PID."""',
"",
" command = _shell_join(args)",
" shell_command = (",
" command",
" + ' >'",
" + shlex.quote(log_path)",
" + ' 2>&1 & echo $!'",
" )",
" return node.cmd('sh -c ' + shlex.quote(shell_command)).strip()",
"",
"",
"def main():",
' """Build, configure, expose, and clean up the planned network."""',
"",
" net = Mininet(controller=None, build=False)",
" nodes = {}",
" service_processes = []",
"",
" try:",
" # Create nodes.",
]
for node in plan.nodes:
if node.type in {NodeType.HOST, NodeType.ROUTER}:
lines.append(
" nodes[%s] = net.addHost(%s, ip=None)"
% (
_python_string(node.name),
_python_string(node.name),
)
)
elif node.type is NodeType.SWITCH:
lines.append(
" nodes[%s] = net.addSwitch(%s)"
% (
_python_string(node.name),
_python_string(node.name),
)
)
else:
raise ValueError("unsupported planned node type %r" % node.type)
lines.extend(["", " # Create links."])
for link in plan.links:
lines.extend(_render_link_call(link))
lines.extend(
[
"",
" # Materialize the Mininet topology.",
" net.build()",
" net.start()",
"",
" # Configure concrete interfaces.",
]
)
for interface in plan.interfaces:
lines.extend(_render_interface_configuration(interface))
lines.extend(["", " # Configure static default routes."])
for interface in plan.interfaces:
lines.extend(_render_gateway_configuration(interface))
lines.extend(["", " # Apply node-level networking settings."])
for setting in plan.node_settings:
lines.append(
" _node_cmd(nodes[%s], 'sysctl', '-w', %s)"
% (
_python_string(setting.node),
_python_string("%s=%d" % (setting.key, setting.value)),
)
)
lines.extend(["", " # Start planned services."])
for index, service in enumerate(plan.services):
lines.extend(_render_service_start(service, index))
lines.extend(
[
"",
" # DHCP clients are intentionally not started here.",
" # The assignment captures DHCP before dhclient is run manually.",
" CLI(net)",
"",
" finally:",
" # Stop only the service processes started by this script.",
" for node, pid in reversed(service_processes):",
" if pid:",
" _node_cmd(node, 'kill', pid)",
"",
" net.stop()",
"",
"",
"if __name__ == '__main__':",
" main()",
"",
]
)
return "\n".join(lines)
def _self_test() -> None:
"""Run a smoke test against the public render_python() interface."""
from topology_parser import parse_topology
from topology_plan import plan
from topology_validator import validate
raw = {
"nodes": [
{
"name": "h1",
"type": "host",
"interfaces": {
"net0": {
"addressing": "dhcp",
}
},
},
{
"name": "s1",
"type": "switch",
},
{
"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": "auto"},
],
"settings": {
"delay_ms": 1.5,
},
},
],
"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)
source = render_python(plan(topology))
ast.parse(source)
assert "net.addHost('h1', ip=None)" in source
assert "net.addSwitch('s1')" in source
assert "net.addLink(" in source
assert "cls=TCLink" in source
assert "dnsmasq" in source
assert "CLI(net)" in source
assert "dhclient" in source
if __name__ == "__main__":
_self_test()
print("topology_renderer: self-test passed")