484 lines
14 KiB
Python
484 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, failMode=%r)"
|
|
% (
|
|
_python_string(node.name),
|
|
_python_string(node.name),
|
|
node.switch_fail_mode,
|
|
)
|
|
)
|
|
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', failMode='standalone')" 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")
|