Add topology inner representation and python assembly-like file generation(planer)
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user