12 Commits
27 changed files with 6856 additions and 117 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "report/AUThReport"]
path = report/AUThReport
url = ssh://git@git.hoo2.net:222/hoo2/AUThReport.git
+885
View File
@@ -0,0 +1,885 @@
# Mininet Topology JSON Language: Rationale and Reference
## 1. Purpose and design rationale
This document explains why the topology language has its current shape, then
serves as a reference for its JSON fields, defaults, and semantic constraints.
The starting point is the assignment: describe hosts, switches, a router, two
subnets, and DHCP configuration in a form that can also express other valid
topologies.
### 1.1 Describe network intent
The JSON describes the desired network and its configuration.
A reader should be able to identify participants, connections, addresses, and
services without tracing a sequence of Python or shell commands.
This makes a topology easier to inspect and change independently of the code
that constructs it.
The design combines top-down modeling with bottom-up implementability.
We start with the concepts needed to describe the network, while requiring each
concept to have a clear translation into Mininet and Linux networking
primitives.
Nodes, interfaces, links, network-stack settings, and processes provide that
translation without requiring a separate abstraction for every assignment step.
This is why the language distinguishes a router from a host even though both
can use similar underlying construction mechanisms.
Their network roles differ, so the description expresses that distinction.
Conversely, DHCP is a service on a node rather than a new kind of network node.
### 1.2 Give each property a clear owner
The network structure leads to five core entities:
| Entity | Question it answers |
| --- | --- |
| Node | What participates in the network, and what role does it have? |
| Interface | Where does a node attach, and how is that attachment configured? |
| Link | Which two interfaces are connected? |
| Node settings | How should the node's network stack behave? |
| Service | Which service runs on a node, and where is it bound? |
An interface belongs to its node because addressing, MAC, and MTU describe that
node's attachment to the network.
A link references two interfaces and owns connection properties such as delay
or bandwidth.
Keeping endpoint configuration out of links gives each property one clear
place in the description.
The same interface structure works for hosts, routers, and switches.
An interface is an attachment point even when it has no IP address, which makes
unaddressed switch ports natural to represent.
Addressing is therefore optional, while MAC and MTU remain independent fields.
### 1.3 Separate behavior from services
Forwarding and reverse-path filtering describe network-stack behavior and
belong in node settings.
A DHCP server is a running service with its own configuration, so it belongs in
`services`.
This separation lets a node host a service without changing its node type.
A service's `node` and `interface` fields describe its placement.
Its `settings` describe its behavior, such as the DHCP address range and
advertised gateway.
Keeping placement outside service-specific settings preserves the same shape
for future service types.
### 1.4 Make identity explicit where it matters
Logical interface tags such as `lan` and `wan` express the meaning of a
connection without depending on generated operating-system interface names.
Explicit tags let a topology identify a particular attachment point.
The `auto` selector avoids unnecessary port naming when only connectivity
matters, as is often the case for a switch.
An omitted interface set allows automatic allocation as needed.
An explicitly declared set defines the available ports, so automatic selection
must stay within that set.
This distinction preserves both convenience and the ability to express a fixed
interface limit; an explicit empty set consequently means zero available ports.
### 1.5 Describe configuration without recording runtime state
Static addressing declares a known address and, optionally, a default gateway.
DHCP addressing declares how configuration will be obtained; the acquired
address and lease are runtime results.
The language therefore does not require a DHCP client to contain an address
that is not yet known.
The same boundary excludes learned ARP entries, packet counters, captures, and
experimental commands from the topology description.
Those observations change as the network runs, while the declared topology
continues to describe its configuration intent.
### 1.6 Keep the language small and predictable
The current assignment needs a static default gateway, rather than a general
routing-policy language.
It needs a DHCP service with an address range and an optional advertised
gateway, rather than arbitrary daemon command lines.
The chosen fields cover those requirements while leaving settings and services
as clear places for future extensions.
Unknown fields are rejected so a misspelled setting cannot silently change the
meaning of a topology.
The reference below defines the supported vocabulary and the constraints that
make declarations consistent.
It proceeds from the entity model through nodes, interfaces, links, and
services, then collects the grammar and semantic rules before a complete
example.
For setup and usage, see the [README](README.md).
Its [Architecture section](README.md#architecture) describes the Python
processing pipeline, generated code, and execution behavior.
---
## 2. High-level entity model
The current grammar is:
```text
Network
├── Nodes
│ ├── name
│ ├── type
│ ├── interfaces
│ └── settings
│
├── Links
│ ├── name?
│ ├── endpoints
│ │ ├── node
│ │ └── interface
│ └── settings
│
└── Services
├── type
├── node
├── interface
└── settings
```
The actual JSON keys are lowercase: `nodes`, `links`, and `services`.
Each top-level list may be omitted and defaults to an empty list.
Node `interfaces` and node/link `settings` are optional.
Service `settings` must contain the fields required by its type.
The main entities intentionally have different responsibilities.
```text
Node
identifies a network participant
Link
connects two network attachment points
Service
represents a process or daemon running on a node
```
Common entities used in these entities are:
```text
Interface
identifies a network attachment point owned by a node
Settings
configure the node/link/service or its networking stack
```
---
## 3. Nodes
A node represents a logical network participant.
The current structure is:
```text
Node
├── name
├── type
├── interfaces
└── settings
```
### 3.1 Node name
`name` is the logical identifier used by the rest of the topology.
For example:
```json
{
"name": "r0",
"type": "router"
}
```
Node names are expected to be unique.
Duplicate node names are invalid.
### 3.2 Node type
The currently supported node types are:
```text
host
switch
router
```
A `host` is an end system.
A `switch` is an autonomous Layer-2 learning switch.
A `router` connects IP networks and supports forwarding and reverse-path-filter
settings.
Selecting `router` does not implicitly enable forwarding.
### 3.3 Node settings
`settings` contains configuration that changes the behavior of the node itself.
For a router, the currently supported settings are:
```json
"settings": {
"ip_forward": true,
"rp_filter": false
}
```
These values configure the networking behavior of the node namespace.
Omitted router settings leave the corresponding behavior unspecified.
Hosts and switches currently accept only empty settings objects.
They are not modeled as services because they do not represent independent
user-space processes.
The distinction is:
```text
Node settings
-> properties of the node or its networking stack
Services
-> processes or daemons running on the node
```
Node settings are type-specific.
Unknown settings for the selected node type are invalid.
---
## 4. Interfaces
Interfaces belong to nodes.
This is a deliberate modeling decision.
```text
Node owns Interfaces
Link connects Interfaces
```
An interface is broader than an object that merely owns an IP address.
It is a network attachment point with optional Layer-3 configuration and
optional interface-level properties.
Conceptually:
```text
Interface
=
network attachment point
+ optional L3 configuration
+ optional interface properties
```
A host or router interface will commonly have Layer-3 addressing.
A Layer-2 switch port normally will not.
Using one interface abstraction for all node types keeps link endpoints
uniform.
### 4.1 Logical interface tags
Interfaces are declared as members of the `interfaces` object.
The JSON key is the logical interface tag.
For example:
```json
"interfaces": {
"lan": {
"addressing": "static",
"address": "192.168.1.1/24"
},
"wan": {
"addressing": "static",
"address": "192.168.2.1/24"
}
}
```
`lan` and `wan` are topology-level identifiers.
They are not required to be the real Linux interface names.
Logical tags identify interfaces within the JSON independently of generated
operating-system interface names.
The same mechanism may be used for meaningful switch-port names.
For example:
```json
"interfaces": {
"client1": {},
"client2": {},
"server": {},
"uplink": {}
}
```
### 4.2 Interface properties
The current interface structure is flat.
There is no nested addressing object.
The currently modeled properties are:
```text
addressing? static | dhcp | none
address? required with static addressing
gateway? optional with static addressing
mac? optional interface property
mtu? optional interface property
```
Possible future interface properties can be added at the same level.
### 4.3 Addressing semantics
The supported addressing values are:
```text
static
dhcp
none
```
The `addressing` field is optional.
Omitting it is semantically equivalent to:
```json
"addressing": "none"
```
Therefore these two declarations mean the same thing:
```json
"p1": {}
```
```json
"p1": {
"addressing": "none"
}
```
Both mean that no Layer-3 addressing configuration is requested.
#### Static addressing
A static interface is written as:
```json
"net0": {
"addressing": "static",
"address": "192.168.2.10/24",
"gateway": "192.168.2.1"
}
```
`address` is required.
`gateway` is optional.
The gateway is kept with static interface configuration because this matches
the simple configuration model used by traditional Linux network
configuration.
For the scope of this assignment, this is simpler than introducing a general
routing language.
#### DHCP addressing
A DHCP interface is written as:
```json
"net0": {
"addressing": "dhcp"
}
```
A DHCP interface does not contain a predetermined address or gateway.
Those values are runtime configuration learned from the DHCP server.
The resulting distinction is:
```text
static
address and optional gateway are configured locally
dhcp
address and gateway are learned dynamically
none / omitted
no L3 configuration is requested
```
### 4.4 Switch interfaces
Switch ports use the same interface abstraction.
For example, a switch with a fixed set of four logical ports may be written as:
```json
{
"name": "s1",
"type": "switch",
"interfaces": {
"p1": {},
"p2": {},
"p3": {},
"uplink": {}
}
}
```
This is useful when link placement should refer to specific switch ports.
If exact port identity does not matter, the `interfaces` field may be omitted
and links may use automatic interface allocation.
### 4.5 Omitted interfaces versus an empty interface set
The distinction between these two forms is intentional.
```json
{
"name": "s1",
"type": "switch"
}
```
means:
```text
interface set omitted
-> dynamic interface set
```
while:
```json
{
"name": "s1",
"type": "switch",
"interfaces": {}
}
```
means:
```text
interface set explicitly declared
-> fixed interface set containing zero interfaces
```
This distinction is important for the semantics of `auto`.
### 4.6 Connected and unused interfaces
An interface requesting addressing, an address, a gateway, a MAC address, or an
MTU must be attached to a link.
Unused empty interface declarations may remain in a fixed interface pool.
An interface used by a service must also be attached to a link.
---
## 5. Links
A link is a first-class entity connecting exactly two endpoints.
The current structure is:
```text
Link
├── name?
├── endpoints
│ ├── endpoint A
│ │ ├── node
│ │ └── interface
│ └── endpoint B
│ ├── node
│ └── interface
└── settings
```
Keeping the `endpoints` wrapper is intentional.
A link may carry properties other than its endpoints, so flattening the link
into a raw pair would make future link-level configuration awkward.
### 5.1 Explicit interface references
An endpoint may reference an explicitly declared interface.
For example:
```json
{
"node": "r0",
"interface": "lan"
}
```
The referenced interface tag must exist on that node.
### 5.2 Automatic interface allocation
An endpoint may instead use:
```json
{
"node": "s1",
"interface": "auto"
}
```
The meaning of `auto` depends on whether the node declared an interface set.
If `interfaces` is omitted:
```text
dynamic interface set
-> auto may allocate/create interfaces as needed
```
If `interfaces` is explicitly declared:
```text
fixed interface set
-> auto selects an unused declared interface
-> auto must not silently create an additional interface
```
For example:
```json
"interfaces": {
"p1": {},
"p2": {},
"p3": {},
"uplink": {}
}
```
allows an explicit link to select:
```json
"interface": "uplink"
```
or an automatic link to select one of the remaining unused ports.
Explicit references reserve their interfaces before `auto` selections are made.
Each `auto` selection consumes the first remaining declared interface in
interface declaration order, following link and endpoint order.
If the fixed interface pool is exhausted, the topology is invalid.
### 5.3 Why interfaces remain under nodes
Interfaces remain under nodes because they may carry or receive:
- addressing configuration,
- MAC configuration,
- MTU configuration,
- service bindings,
- future interface-specific properties.
The selected relationship therefore remains:
```text
Node owns Interface
Link references Interface
```
### 5.4 Link settings
Links contain a `settings` object for provisioning properties.
The supported provisioning fields are:
```text
bandwidth_mbps?
delay_ms?
loss_percent?
jitter_ms?
```
Example:
```json
{
"endpoints": [
{
"node": "h1",
"interface": "net0"
},
{
"node": "s1",
"interface": "auto"
}
],
"settings": {
"bandwidth_mbps": 100,
"delay_ms": 1.5,
"loss_percent": 0.1
}
}
```
Bandwidth is expressed in megabits per second, delay and jitter in milliseconds,
and loss as a percentage.
The allowed ranges are:
```text
bandwidth_mbps > 0
delay_ms >= 0
jitter_ms >= 0
0 <= loss_percent <= 100
```
---
## 6. Services
A service represents a process or daemon running on a topology node.
The generic structure is:
```text
Service
├── type
├── node
├── interface
└── settings
```
The currently supported service type is:
```text
dhcp-server
```
Example:
```json
{
"type": "dhcp-server",
"node": "r0",
"interface": "lan",
"settings": {
"range": {
"start": "192.168.1.100",
"end": "192.168.1.200"
},
"gateway": "192.168.1.1"
}
}
```
### 6.1 Service binding
`node` and `interface` describe where the service runs and where it is bound.
They therefore remain outside `settings`.
The service interface grammar reserves:
```text
<tag>
auto
all
```
The validity and meaning of special selectors are service-type-specific.
A DHCP service accepts a specific declared interface tag.
The service also supports `auto` only when exactly one suitable statically
addressed interface can be selected unambiguously from the declared set.
The DHCP server does not support `all`.
### 6.2 DHCP settings
The current DHCP server settings are:
```text
range.start
range.end
gateway?
```
The service `gateway` is the gateway advertised to DHCP clients.
This is distinct from a static interface gateway.
```text
static interface gateway
-> locally configured default gateway
DHCP service gateway
-> gateway distributed to DHCP clients
```
---
## 7. Grammar summary
### Node
```text
name
required unique logical node identifier
type
host | switch | router
interfaces
optional map of logical interface tags to Interface objects
settings
optional node-type-specific configuration
```
### Interface
```text
<tag>
logical interface identifier
addressing
optional: static | dhcp | none
omission is equivalent to none
address
required when addressing = static
forbidden otherwise
gateway
optional when addressing = static
forbidden otherwise
mac
optional
mtu
optional
```
### Link
```text
name
optional logical link name
endpoints
exactly two endpoints
endpoint.node
node name
endpoint.interface
declared interface tag | auto
settings.bandwidth_mbps
optional
settings.delay_ms
optional
settings.loss_percent
optional
settings.jitter_ms
optional
```
### Service
```text
type
currently dhcp-server
node
node on which the service runs
interface
interface tag | auto | all
validity depends on service type
settings
service-type-specific configuration
```
---
## 8. Strict grammar
Unknown fields and unsupported tokens are invalid.
For example, `ip_foward` is not an alias for `ip_forward`, and `bananas` is not
an addressing mode.
Field names, value types, and closed token sets define the JSON grammar.
Cross-references and network constraints define semantic validity.
A recognized `static` addressing mode without an address is therefore a
semantic error, as is a link referring to an unknown node.
Only the fields described in this reference are currently supported.
Additional node types, services, or settings require a language extension.
---
## 9. Semantic constraints
A topology must satisfy the following constraints in addition to the JSON
grammar.
Invalid configurations include:
- duplicate node names,
- duplicate explicit link names,
- empty logical names,
- reserved interface tags,
- static interfaces without an address,
- invalid IPv4/CIDR syntax,
- invalid gateway syntax,
- gateways outside the interface subnet,
- addresses or gateways used with DHCP or no addressing,
- invalid MAC syntax,
- invalid MTU values,
- explicit link references to unknown nodes,
- explicit link references to unknown interfaces,
- explicit interface reuse across links,
- fixed interface-pool exhaustion caused by `auto`,
- invalid link provisioning ranges,
- services referencing unknown nodes,
- services referencing unknown interfaces,
- DHCP services bound to interfaces without static addressing,
- invalid DHCP IPv4 ranges,
- reversed DHCP ranges,
- DHCP range values outside the bound interface subnet,
- DHCP network or broadcast addresses,
- DHCP pools that include the server address,
- DHCP pools that include the advertised gateway.
Switch interfaces may not request Layer-3 addressing.
Only one static default gateway is supported per node.
Configured interfaces and service bindings must be connected as described in
[Connected and unused interfaces](#46-connected-and-unused-interfaces).
+476
View File
@@ -0,0 +1,476 @@
# Computer Networks II — Declarative Mininet Topology Builder
This repository implements the programming assignment for Computer Networks II
using a small declarative JSON topology language and a staged Python build
pipeline.
The JSON describes the desired network rather than the exact Mininet commands
used to create it.
The implementation loads that description, parses it into typed Python objects,
validates its semantics, resolves all abstract interface selections into a
concrete execution plan, renders executable Mininet Python, and then runs that
generated artifact.
Start with [Quick start](#quick-start) to generate and run a topology.
The [topology language overview](#topology-language) introduces the JSON format;
the [JSON language reference](Mininet_DSL_Design.md)
defines its fields, defaults, and semantic constraints.
## Project goals
The main goals of the implementation are:
- describe Mininet topologies declaratively in JSON,
- keep topology intent separate from Mininet implementation details,
- use typed Python objects throughout the processing pipeline,
- detect grammar and semantic errors before changing the network,
- generate an inspectable intermediate Python artifact,
- execute exactly that generated artifact for normal runs,
- keep runtime experiments separate from topology construction,
- clean up services and Mininet state safely after the CLI exits.
The topology language is intentionally small.
Its core semantic entities are:
```text
Node
Interface
Link
Node Settings
Service
```
## Repository layout
```text
source/
build_network.py CLI coordinator and environment checks
topology_loader.py JSON loading
topology_parser.py Strict parsing and typed semantic model
topology_validator.py Semantic and cross-reference checks
topology_plan.py Fully resolved execution plan
topology_renderer.py Executable Mininet Python generation
report/ Assignment report workspace
exercise/ Assignment execution files
Mininet_DSL_Design.md Mininet JSON domain specific language rules and semantics
README.md Repository's main documentation file
```
Save a topology source as `topology.json`.
Normal execution writes `topology.generated.py` beside that source file.
## Requirements
The implementation targets a Linux environment capable of running Mininet.
The current runtime and experiment dependencies are:
```text
Python Mininet module
mn
ovs-vsctl
ip
tc
sysctl
dnsmasq
dhclient
tcpdump
ping
```
On Ubuntu, the corresponding packages are typically provided by:
```bash
sudo apt install \
mininet \
openvswitch-switch \
iproute2 \
procps \
dnsmasq-base \
isc-dhcp-client \
tcpdump \
iputils-ping
```
The exact environment can be checked without modifying the system:
```bash
python3 source/build_network.py --env-check
```
The check reports each required module or executable as `OK` or `MISSING`.
## Quick start
Run these commands from the repository root.
Save the [complete assignment example](#complete-assignment-topology-example)
as `topology.json` first, or use your own topology file.
### 1. Check the environment
Run:
```bash
python3 source/build_network.py --env-check
```
### 2. Generate the intermediate Python only
Run:
```bash
python3 source/build_network.py topology.json --emit
```
This writes:
```text
topology.generated.py
```
and stops without creating a Mininet network.
A custom output path can be supplied:
```bash
python3 source/build_network.py topology.json --emit gen-test.py
```
Generation does not require root privileges.
### 3. Generate and execute
Run:
```bash
sudo python3 source/build_network.py topology.json
```
The coordinator:
```text
loads
parses
validates
plans
renders
writes topology.generated.py
executes topology.generated.py
```
Network execution requires root privileges because Mininet creates namespaces,
virtual interfaces, Open vSwitch state, and other kernel networking objects.
### 4. Exit the Mininet CLI
When finished, leave the CLI with:
```text
mininet> exit
```
The generated script then stops the service processes it started and calls
`net.stop()`.
## Topology language
The JSON describes `nodes`, their `interfaces`, `links`, and `services`.
Nodes may be hosts, Layer-2 switches, or routers; interfaces use static, DHCP,
or no addressing.
Links connect interfaces, and services describe processes such as a DHCP server.
Use the [JSON language reference](Mininet_DSL_Design.md) for field
definitions, defaults, interface selection rules, and semantic constraints.
The following example provides a complete starting point for the assignment.
## Complete assignment topology example
The example [here](exercise/topology.json) describes the required two-subnet topology.
## Configuration versus runtime state
The topology source describes configuration intent.
It does not record all runtime state.
Examples include:
```text
JSON declaration Runtime result
static address -----> configured IPv4 address
static gateway -----> configured default route
DHCP addressing -----> dynamically learned address
DHCP service settings -----> dynamically advertised options
links -----> Mininet/veth interfaces
services -----> running processes
```
DHCP leases, ARP entries, packet counters, and packet captures are runtime
state.
They do not belong in the topology grammar.
## Runtime experiments
After the generated topology enters the Mininet CLI, the assignment experiments
can be performed with tools such as:
```text
dhclient
tcpdump
ip neigh
ping
```
These commands operate on the running network and may create transient runtime
state.
They do not modify the declarative topology source.
### DHCP capture preparation
DHCP clients are intentionally not started by the generated network.
The assignment requires packet capture to begin before the DHCP exchange is
triggered manually.
A clean client-side DHCP experiment can use a temporary lease and PID file:
```text
mininet> h1 rm -f /tmp/h1-dhclient.leases /tmp/h1-dhclient.pid
mininet> h1 dhclient -v \
-lf /tmp/h1-dhclient.leases \
-pf /tmp/h1-dhclient.pid \
h1-eth0
```
A successful exchange should show the normal DORA sequence:
```text
DHCPDISCOVER
DHCPOFFER
DHCPREQUEST
DHCPACK
```
The DHCP server lease file generated by this project is reset before the
service starts, so stale server-side leases from earlier topology runs do not
affect a new experiment.
## Architecture
The implementation follows a compiler-like pipeline:
```text
topology.json
→ load → parse → validate → plan → render
→ write generated Python → execute → Mininet CLI → cleanup
```
The language describes network concepts that map to Mininet and Linux
primitives: nodes, interfaces, links, network-stack settings, and services.
Each stage has a separate responsibility so it can be tested independently.
The generated Python is the only executable Mininet backend.
### Loading and parsing
`topology_loader.py` provides `load_topology(path)`, which reads a JSON file into
a raw dictionary.
`topology_parser.py` provides `parse_topology(raw)`, which converts that dictionary
into a typed `Topology`.
Neither stage depends on Mininet.
The semantic model uses dataclasses for `Topology`, `Node`, `Interface`, `Link`,
`LinkEndpoint`, `Service`, and their structured settings.
Enums represent node types, addressing modes, service types, and selectors.
Interfaces and settings are composed into their owning entities.
Nodes, links, and services remain ordered lists during parsing.
Keeping nodes in a list preserves duplicate declarations for diagnostics;
converting them immediately to a name-indexed dictionary could hide duplicates.
`Node.interfaces` uses `Optional[Dict[str, Interface]]`: `None` preserves an
omitted interface set, while `{}` preserves an explicitly empty set.
### Validation
`topology_validator.py` exposes `validate(topology)`.
It checks semantic and cross-reference constraints after parsing has checked
field names, representation, and value types.
See the [language reference](Mininet_DSL_Design.md) for those rules.
The validator collects `ValidationIssue(location, message)` objects and raises
one `TopologyValidationError` containing all issues discovered in the pass.
It does not print diagnostics or create Mininet objects.
Validation checks whether automatic interface allocation is possible;
planning performs the concrete allocation.
### Planning
`topology_plan.py` exposes `plan(topology)` and produces an immutable
`ExecutionPlan` from a validated topology.
It does not import Mininet, execute commands, or render Python.
The plan contains ordered tuples of node, link, interface, sysctl, and service
operations.
Its types include `PlannedNode`, `PlannedLinkEndpoint`, `PlannedLink`,
`PlannedLinkSettings`, `PlannedInterface`, `PlannedSysctl`, `PlannedService`, and
`PlannedDHCPServerSettings`.
No unresolved `auto` or `all` selectors remain.
The planner reserves all explicit link-interface references before allocating
`auto` selections from fixed pools in declaration order.
Dynamic pools receive synthetic logical identifiers `auto0`, `auto1`, and so on.
Concrete interface names follow `<node>-eth<N>`, starting at zero per node in
link and endpoint traversal order.
For example, `(r0, lan)` may resolve to `r0-eth0`.
Mininet creates interfaces through links.
Planning therefore rejects configured interfaces or service bindings that have
no corresponding link, while allowing unused empty interface declarations.
Switches are planned with Open vSwitch `failMode="standalone"` to operate as
Layer-2 learning switches without an OpenFlow controller.
Hosts and routers are created with `net.addHost(..., ip=None)`.
Router settings generate sysctls only when explicitly supplied.
`rp_filter` operations cover `all`, `default`, and each materialized router
interface inside the router namespace.
### Rendering and runtime lifecycle
`topology_renderer.py` exposes `render_python(plan)`.
It serializes the resolved plan without allocating interfaces or validating
language semantics.
The resulting standalone Python program is preserved for inspection.
The generated program:
1. Creates nodes and links, then builds and starts Mininet.
2. Configures interfaces, static default routes, and namespace-scoped sysctls.
3. Starts services and enters the Mininet CLI.
4. Terminates tracked service processes and calls `net.stop()` in a `finally`
block.
When a link has traffic-control settings, the renderer emits `TCLink`.
Bandwidth maps to `bw`, loss to `loss`, and delay and jitter to millisecond
strings.
For example:
```python
net.addLink(
nodes["r0"],
nodes["s1"],
intfName1="r0-eth0",
intfName2="s1-eth1",
cls=TCLink,
delay="1.5ms",
)
```
Interface configuration uses Linux commands such as `ip addr add`,
`ip route replace`, and `sysctl -w` inside the appropriate node namespace.
DHCP services run through `dnsmasq`, with DNS disabled and a 12-hour lease time.
These are backend choices, not configurable JSON fields.
Each service uses indexed lease, PID, and log paths under `/tmp`.
The generated script removes its previous lease and PID files before startup
and tracks the background PID for cleanup.
DHCP clients are started manually after packet capture begins, as described in
[Runtime experiments](#runtime-experiments).
### Coordination and error reporting
`build_network.py` coordinates the stages through `compile_topology(path)`,
which returns the execution plan and rendered source.
The coordinator writes the generated artifact beside the input using the
`.generated.py` suffix unless `--emit PATH` selects a different destination.
`--emit` stops after writing, without requiring Mininet or root privileges.
Normal execution writes the same artifact, checks dependencies, and runs it
with the coordinator's Python interpreter.
Network execution requires root privileges.
`--env-check` reports dependencies and exits without compiling a topology.
See [Quick start](#quick-start) for commands.
Core stages raise typed exceptions; the command-line layer reports them.
| Stage | Exception |
| --- | --- |
| Loading | `TopologyLoadError` |
| Parsing | `TopologyParseError` |
| Validation | `TopologyValidationError` |
| Planning | `TopologyPlanError` |
| Artifact writing or process launch | `BuildNetworkError` |
The coordinator returns a failing status for reported errors and propagates the
exit status of the generated program.
## Self-tests
The five core pipeline modules contain lightweight `__main__` smoke tests.
The coordinator's `__main__` invokes its command-line interface instead.
They exercise each module's public interface.
They can be run directly:
```bash
python3 source/topology_loader.py
python3 source/topology_parser.py
python3 source/topology_validator.py
python3 source/topology_plan.py
python3 source/topology_renderer.py
```
These smoke tests do not require Mininet, root privileges, or network changes.
The environment can be checked separately with:
```bash
python3 source/build_network.py --env-check
```
A generated topology can then be tested in Mininet with:
```bash
sudo python3 source/build_network.py topology.json
```
## Current scope and extension points
The current language supports:
- hosts,
- Layer-2 learning switches,
- routers,
- static IPv4 addressing,
- DHCP client intent,
- one optional static default gateway per node,
- logical interface tags,
- automatic interface allocation,
- link bandwidth, delay, loss, and jitter,
- DHCP server services,
- router forwarding and reverse-path-filter settings.
Extending the language requires corresponding parser, validator, planner, and
renderer support.
Potential extensions include:
- additional node types,
- additional services,
- richer interface properties,
- richer link behavior,
- more general routing constructs.
The project currently supports IPv4 network configuration for the assignment.
## Further documentation
- [JSON language reference](Mininet_DSL_Design.md):
complete field definitions, defaults, and semantic constraints.
- [Assignment brief](Computer_Networks_II-Programming_task.pdf):
required topology, experiments, and deliverables.
@@ -0,0 +1,91 @@
10:58:29.655251 12:07:f8:bf:6e:ff > ff:ff:ff:ff:ff:ff, ethertype IPv4 (0x0800), length 342: (tos 0x10, ttl 128, id 0, offset 0, flags [none], proto UDP (17), length 328)
0.0.0.0.68 > 255.255.255.255.67: [udp sum ok] BOOTP/DHCP, Request from 12:07:f8:bf:6e:ff, length 300, xid 0xecb5a2e, Flags [none] (0x0000)
Client-Ethernet-Address 12:07:f8:bf:6e:ff
Vendor-rfc1048 Extensions
Magic Cookie 0x63825363
DHCP-Message Option 53, length 1: Discover
Hostname Option 12, length 10: "mininet-vm"
Parameter-Request Option 55, length 13:
Subnet-Mask, BR, Time-Zone, Default-Gateway
Domain-Name, Domain-Name-Server, Option 119, Hostname
Netbios-Name-Server, Netbios-Scope, MTU, Classless-Static-Route
NTP
10:58:29.655633 82:3b:16:e9:bb:17 > ff:ff:ff:ff:ff:ff, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Request who-has 192.168.1.131 tell 192.168.1.1, length 28
10:58:30.662723 82:3b:16:e9:bb:17 > ff:ff:ff:ff:ff:ff, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Request who-has 192.168.1.131 tell 192.168.1.1, length 28
10:58:31.687064 82:3b:16:e9:bb:17 > ff:ff:ff:ff:ff:ff, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Request who-has 192.168.1.131 tell 192.168.1.1, length 28
10:58:32.214530 12:07:f8:bf:6e:ff > ff:ff:ff:ff:ff:ff, ethertype IPv4 (0x0800), length 342: (tos 0x10, ttl 128, id 0, offset 0, flags [none], proto UDP (17), length 328)
0.0.0.0.68 > 255.255.255.255.67: [udp sum ok] BOOTP/DHCP, Request from 12:07:f8:bf:6e:ff, length 300, xid 0xecb5a2e, secs 3, Flags [none] (0x0000)
Client-Ethernet-Address 12:07:f8:bf:6e:ff
Vendor-rfc1048 Extensions
Magic Cookie 0x63825363
DHCP-Message Option 53, length 1: Discover
Hostname Option 12, length 10: "mininet-vm"
Parameter-Request Option 55, length 13:
Subnet-Mask, BR, Time-Zone, Default-Gateway
Domain-Name, Domain-Name-Server, Option 119, Hostname
Netbios-Name-Server, Netbios-Scope, MTU, Classless-Static-Route
NTP
10:58:32.670822 82:3b:16:e9:bb:17 > 12:07:f8:bf:6e:ff, ethertype IPv4 (0x0800), length 62: (tos 0x0, ttl 64, id 28856, offset 0, flags [DF], proto ICMP (1), length 48)
192.168.1.1 > 192.168.1.131: ICMP echo request, id 21591, seq 0, length 28
10:58:32.670897 82:3b:16:e9:bb:17 > 12:07:f8:bf:6e:ff, ethertype IPv4 (0x0800), length 342: (tos 0xc0, ttl 64, id 52736, offset 0, flags [none], proto UDP (17), length 328)
192.168.1.1.67 > 192.168.1.131.68: [udp sum ok] BOOTP/DHCP, Reply, length 300, xid 0xecb5a2e, Flags [none] (0x0000)
Your-IP 192.168.1.131
Server-IP 192.168.1.1
Client-Ethernet-Address 12:07:f8:bf:6e:ff
Vendor-rfc1048 Extensions
Magic Cookie 0x63825363
DHCP-Message Option 53, length 1: Offer
Server-ID Option 54, length 4: 192.168.1.1
Lease-Time Option 51, length 4: 43200
RN Option 58, length 4: 21600
RB Option 59, length 4: 37800
Subnet-Mask Option 1, length 4: 255.255.255.0
BR Option 28, length 4: 192.168.1.255
Default-Gateway Option 3, length 4: 192.168.1.1
10:58:32.671193 12:07:f8:bf:6e:ff > ff:ff:ff:ff:ff:ff, ethertype IPv4 (0x0800), length 342: (tos 0x10, ttl 128, id 0, offset 0, flags [none], proto UDP (17), length 328)
0.0.0.0.68 > 255.255.255.255.67: [udp sum ok] BOOTP/DHCP, Request from 12:07:f8:bf:6e:ff, length 300, xid 0xecb5a2e, secs 3, Flags [none] (0x0000)
Client-Ethernet-Address 12:07:f8:bf:6e:ff
Vendor-rfc1048 Extensions
Magic Cookie 0x63825363
DHCP-Message Option 53, length 1: Request
Server-ID Option 54, length 4: 192.168.1.1
Requested-IP Option 50, length 4: 192.168.1.131
Hostname Option 12, length 10: "mininet-vm"
Parameter-Request Option 55, length 13:
Subnet-Mask, BR, Time-Zone, Default-Gateway
Domain-Name, Domain-Name-Server, Option 119, Hostname
Netbios-Name-Server, Netbios-Scope, MTU, Classless-Static-Route
NTP
10:58:32.673025 82:3b:16:e9:bb:17 > 12:07:f8:bf:6e:ff, ethertype IPv4 (0x0800), length 342: (tos 0xc0, ttl 64, id 52738, offset 0, flags [none], proto UDP (17), length 328)
192.168.1.1.67 > 192.168.1.131.68: [bad udp cksum 0x851a -> 0x5896!] BOOTP/DHCP, Reply, length 300, xid 0xecb5a2e, secs 3, Flags [none] (0x0000)
Your-IP 192.168.1.131
Server-IP 192.168.1.1
Client-Ethernet-Address 12:07:f8:bf:6e:ff
Vendor-rfc1048 Extensions
Magic Cookie 0x63825363
DHCP-Message Option 53, length 1: ACK
Server-ID Option 54, length 4: 192.168.1.1
Lease-Time Option 51, length 4: 43200
RN Option 58, length 4: 21600
RB Option 59, length 4: 37800
Subnet-Mask Option 1, length 4: 255.255.255.0
BR Option 28, length 4: 192.168.1.255
Hostname Option 12, length 10: "mininet-vm"
Default-Gateway Option 3, length 4: 192.168.1.1
10:58:32.678132 82:3b:16:e9:bb:17 > 12:07:f8:bf:6e:ff, ethertype IPv4 (0x0800), length 342: (tos 0xc0, ttl 64, id 52737, offset 0, flags [none], proto UDP (17), length 328)
192.168.1.1.67 > 192.168.1.131.68: [udp sum ok] BOOTP/DHCP, Reply, length 300, xid 0xecb5a2e, secs 3, Flags [none] (0x0000)
Your-IP 192.168.1.131
Server-IP 192.168.1.1
Client-Ethernet-Address 12:07:f8:bf:6e:ff
Vendor-rfc1048 Extensions
Magic Cookie 0x63825363
DHCP-Message Option 53, length 1: Offer
Server-ID Option 54, length 4: 192.168.1.1
Lease-Time Option 51, length 4: 43200
RN Option 58, length 4: 21600
RB Option 59, length 4: 37800
Subnet-Mask Option 1, length 4: 255.255.255.0
BR Option 28, length 4: 192.168.1.255
Default-Gateway Option 3, length 4: 192.168.1.1
10:58:37.831406 82:3b:16:e9:bb:17 > 12:07:f8:bf:6e:ff, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Request who-has 192.168.1.131 tell 192.168.1.1, length 28
10:58:37.831440 12:07:f8:bf:6e:ff > 82:3b:16:e9:bb:17, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Reply 192.168.1.131 is-at 12:07:f8:bf:6e:ff, length 28
Binary file not shown.
+109
View File
@@ -0,0 +1,109 @@
mininet@mininet-vm:~/Networks-II/exercise$ sudo python3 ../source/build_network.py topology.json
Generated: topology.generated.py
mininet> h1 rm -f /var/lib/dhcp/dhclient.leases
mininet> h1 rm -f /tmp/dhcp.pcap
mininet> h1 tcpdump -i h1-eth0 -n -w /tmp/dhcp.pcap &
mininet> h1 ps aux | grep tcpdump
tcpdump: listening on h1-eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
tcpdump 4196 0.0 0.1 10904 5920 pts/2 S+ 10:57 0:00 tcpdump -i h1-eth0 -n -w /tmp/dhcp.pcap
root 4199 0.0 0.0 9368 2548 pts/2 S+ 10:58 0:00 grep tcpdump
mininet> h1 dhclient h1-eth0
mininet> h1 pkill -INT tcpdump
12 packets captured
12 packets received by filter
0 packets dropped by kernel
mininet> h1 tcpdump -n -e -vv -r /tmp/dhcp.pcap
reading from file /tmp/dhcp.pcap, link-type EN10MB (Ethernet)
10:58:29.655251 12:07:f8:bf:6e:ff > ff:ff:ff:ff:ff:ff, ethertype IPv4 (0x0800), length 342: (tos 0x10, ttl 128, id 0, offset 0, flags [none], proto UDP (17), length 328)
0.0.0.0.68 > 255.255.255.255.67: [udp sum ok] BOOTP/DHCP, Request from 12:07:f8:bf:6e:ff, length 300, xid 0xecb5a2e, Flags [none] (0x0000)
Client-Ethernet-Address 12:07:f8:bf:6e:ff
Vendor-rfc1048 Extensions
Magic Cookie 0x63825363
DHCP-Message Option 53, length 1: Discover
Hostname Option 12, length 10: "mininet-vm"
Parameter-Request Option 55, length 13:
Subnet-Mask, BR, Time-Zone, Default-Gateway
Domain-Name, Domain-Name-Server, Option 119, Hostname
Netbios-Name-Server, Netbios-Scope, MTU, Classless-Static-Route
NTP
10:58:29.655633 82:3b:16:e9:bb:17 > ff:ff:ff:ff:ff:ff, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Request who-has 192.168.1.131 tell 192.168.1.1, length 28
10:58:30.662723 82:3b:16:e9:bb:17 > ff:ff:ff:ff:ff:ff, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Request who-has 192.168.1.131 tell 192.168.1.1, length 28
10:58:31.687064 82:3b:16:e9:bb:17 > ff:ff:ff:ff:ff:ff, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Request who-has 192.168.1.131 tell 192.168.1.1, length 28
10:58:32.214530 12:07:f8:bf:6e:ff > ff:ff:ff:ff:ff:ff, ethertype IPv4 (0x0800), length 342: (tos 0x10, ttl 128, id 0, offset 0, flags [none], proto UDP (17), length 328)
0.0.0.0.68 > 255.255.255.255.67: [udp sum ok] BOOTP/DHCP, Request from 12:07:f8:bf:6e:ff, length 300, xid 0xecb5a2e, secs 3, Flags [none] (0x0000)
Client-Ethernet-Address 12:07:f8:bf:6e:ff
Vendor-rfc1048 Extensions
Magic Cookie 0x63825363
DHCP-Message Option 53, length 1: Discover
Hostname Option 12, length 10: "mininet-vm"
Parameter-Request Option 55, length 13:
Subnet-Mask, BR, Time-Zone, Default-Gateway
Domain-Name, Domain-Name-Server, Option 119, Hostname
Netbios-Name-Server, Netbios-Scope, MTU, Classless-Static-Route
NTP
10:58:32.670822 82:3b:16:e9:bb:17 > 12:07:f8:bf:6e:ff, ethertype IPv4 (0x0800), length 62: (tos 0x0, ttl 64, id 28856, offset 0, flags [DF], proto ICMP (1), length 48)
192.168.1.1 > 192.168.1.131: ICMP echo request, id 21591, seq 0, length 28
10:58:32.670897 82:3b:16:e9:bb:17 > 12:07:f8:bf:6e:ff, ethertype IPv4 (0x0800), length 342: (tos 0xc0, ttl 64, id 52736, offset 0, flags [none], proto UDP (17), length 328)
192.168.1.1.67 > 192.168.1.131.68: [udp sum ok] BOOTP/DHCP, Reply, length 300, xid 0xecb5a2e, Flags [none] (0x0000)
Your-IP 192.168.1.131
Server-IP 192.168.1.1
Client-Ethernet-Address 12:07:f8:bf:6e:ff
Vendor-rfc1048 Extensions
Magic Cookie 0x63825363
DHCP-Message Option 53, length 1: Offer
Server-ID Option 54, length 4: 192.168.1.1
Lease-Time Option 51, length 4: 43200
RN Option 58, length 4: 21600
RB Option 59, length 4: 37800
Subnet-Mask Option 1, length 4: 255.255.255.0
BR Option 28, length 4: 192.168.1.255
Default-Gateway Option 3, length 4: 192.168.1.1
10:58:32.671193 12:07:f8:bf:6e:ff > ff:ff:ff:ff:ff:ff, ethertype IPv4 (0x0800), length 342: (tos 0x10, ttl 128, id 0, offset 0, flags [none], proto UDP (17), length 328)
0.0.0.0.68 > 255.255.255.255.67: [udp sum ok] BOOTP/DHCP, Request from 12:07:f8:bf:6e:ff, length 300, xid 0xecb5a2e, secs 3, Flags [none] (0x0000)
Client-Ethernet-Address 12:07:f8:bf:6e:ff
Vendor-rfc1048 Extensions
Magic Cookie 0x63825363
DHCP-Message Option 53, length 1: Request
Server-ID Option 54, length 4: 192.168.1.1
Requested-IP Option 50, length 4: 192.168.1.131
Hostname Option 12, length 10: "mininet-vm"
Parameter-Request Option 55, length 13:
Subnet-Mask, BR, Time-Zone, Default-Gateway
Domain-Name, Domain-Name-Server, Option 119, Hostname
Netbios-Name-Server, Netbios-Scope, MTU, Classless-Static-Route
NTP
10:58:32.673025 82:3b:16:e9:bb:17 > 12:07:f8:bf:6e:ff, ethertype IPv4 (0x0800), length 342: (tos 0xc0, ttl 64, id 52738, offset 0, flags [none], proto UDP (17), length 328)
192.168.1.1.67 > 192.168.1.131.68: [bad udp cksum 0x851a -> 0x5896!] BOOTP/DHCP, Reply, length 300, xid 0xecb5a2e, secs 3, Flags [none] (0x0000)
Your-IP 192.168.1.131
Server-IP 192.168.1.1
Client-Ethernet-Address 12:07:f8:bf:6e:ff
Vendor-rfc1048 Extensions
Magic Cookie 0x63825363
DHCP-Message Option 53, length 1: ACK
Server-ID Option 54, length 4: 192.168.1.1
Lease-Time Option 51, length 4: 43200
RN Option 58, length 4: 21600
RB Option 59, length 4: 37800
Subnet-Mask Option 1, length 4: 255.255.255.0
BR Option 28, length 4: 192.168.1.255
Hostname Option 12, length 10: "mininet-vm"
Default-Gateway Option 3, length 4: 192.168.1.1
10:58:32.678132 82:3b:16:e9:bb:17 > 12:07:f8:bf:6e:ff, ethertype IPv4 (0x0800), length 342: (tos 0xc0, ttl 64, id 52737, offset 0, flags [none], proto UDP (17), length 328)
192.168.1.1.67 > 192.168.1.131.68: [udp sum ok] BOOTP/DHCP, Reply, length 300, xid 0xecb5a2e, secs 3, Flags [none] (0x0000)
Your-IP 192.168.1.131
Server-IP 192.168.1.1
Client-Ethernet-Address 12:07:f8:bf:6e:ff
Vendor-rfc1048 Extensions
Magic Cookie 0x63825363
DHCP-Message Option 53, length 1: Offer
Server-ID Option 54, length 4: 192.168.1.1
Lease-Time Option 51, length 4: 43200
RN Option 58, length 4: 21600
RB Option 59, length 4: 37800
Subnet-Mask Option 1, length 4: 255.255.255.0
BR Option 28, length 4: 192.168.1.255
Default-Gateway Option 3, length 4: 192.168.1.1
10:58:37.831406 82:3b:16:e9:bb:17 > 12:07:f8:bf:6e:ff, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Request who-has 192.168.1.131 tell 192.168.1.1, length 28
10:58:37.831440 12:07:f8:bf:6e:ff > 82:3b:16:e9:bb:17, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Reply 192.168.1.131 is-at 12:07:f8:bf:6e:ff, length 28
mininet> exit
@@ -0,0 +1,4 @@
11:24:23.554475 4a:6f:2a:18:f4:80 > ff:ff:ff:ff:ff:ff, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Request who-has 192.168.1.164 tell 192.168.1.131, length 28
11:24:23.554730 82:c0:6d:ae:3f:56 > 4a:6f:2a:18:f4:80, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Reply 192.168.1.164 is-at 82:c0:6d:ae:3f:56, length 28
11:24:28.679054 82:c0:6d:ae:3f:56 > 4a:6f:2a:18:f4:80, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Request who-has 192.168.1.131 tell 192.168.1.164, length 28
11:24:28.679071 4a:6f:2a:18:f4:80 > 82:c0:6d:ae:3f:56, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Reply 192.168.1.131 is-at 4a:6f:2a:18:f4:80, length 28
Binary file not shown.
+40
View File
@@ -0,0 +1,40 @@
mininet@mininet-vm:~/Networks-II/exercise$ sudo python3 ../source/build_network.py topology.json
Generated: topology.generated.py
mininet> h1 ip -4 -br addr
lo UNKNOWN 127.0.0.1/8
mininet> h2 ip -4 -br addr
lo UNKNOWN 127.0.0.1/8
mininet> h1 dhclient h1-eth0
mininet> h2 dhclient h2-eth0
mininet> h1 ip -4 -br addr
lo UNKNOWN 127.0.0.1/8
h1-eth0@if56 UP 192.168.1.131/24
mininet> h2 ip -4 -br addr
lo UNKNOWN 127.0.0.1/8
h2-eth0@if57 UP 192.168.1.164/24
mininet> h1 ip neigh flush all
mininet> h1 ip neigh show
mininet> h1 rm -f /tmp/arp.pcap
mininet> h1 tcpdump -i h1-eth0 -n arp -w /tmp/arp.pcap &
mininet> h1 ping -c 1 192.168.1.164
tcpdump: listening on h1-eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
PING 192.168.1.164 (192.168.1.164) 56(84) bytes of data.
64 bytes from 192.168.1.164: icmp_seq=1 ttl=64 time=0.425 ms
--- 192.168.1.164 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.425/0.425/0.425/0.000 ms
mininet> h1 ip neigh show
192.168.1.164 dev h1-eth0 lladdr 82:c0:6d:ae:3f:56 REACHABLE
mininet> h1 pkill -INT tcpdump
4 packets captured
4 packets received by filter
0 packets dropped by kernel
mininet> h1 tcpdump -n -e -vv -r /tmp/arp.pcap
reading from file /tmp/arp.pcap, link-type EN10MB (Ethernet)
11:24:23.554475 4a:6f:2a:18:f4:80 > ff:ff:ff:ff:ff:ff, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Request who-has 192.168.1.164 tell 192.168.1.131, length 28
11:24:23.554730 82:c0:6d:ae:3f:56 > 4a:6f:2a:18:f4:80, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Reply 192.168.1.164 is-at 82:c0:6d:ae:3f:56, length 28
11:24:28.679054 82:c0:6d:ae:3f:56 > 4a:6f:2a:18:f4:80, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Request who-has 192.168.1.131 tell 192.168.1.164, length 28
11:24:28.679071 4a:6f:2a:18:f4:80 > 82:c0:6d:ae:3f:56, ethertype ARP (0x0806), length 42: Ethernet (len 6), IPv4 (len 4), Reply 192.168.1.131 is-at 4a:6f:2a:18:f4:80, length 28
mininet>
@@ -0,0 +1,79 @@
mininet>
mininet> h1 ip neigh flush all
mininet> h3 ip neigh flush all
mininet> r0 ip neigh flush all
mininet> h1 ip -4 -br addr
lo UNKNOWN 127.0.0.1/8
h1-eth0@if56 UP 192.168.1.131/24
mininet> h1 ip route
default via 192.168.1.1 dev h1-eth0
192.168.1.0/24 dev h1-eth0 proto kernel scope link src 192.168.1.131
mininet> r0 ip -4 -br addr
lo UNKNOWN 127.0.0.1/8
r0-eth0@if59 UP 192.168.1.1/24
r0-eth1@if60 UP 192.168.2.1/24
mininet> r0 ip route
192.168.1.0/24 dev r0-eth0 proto kernel scope link src 192.168.1.1
192.168.2.0/24 dev r0-eth1 proto kernel scope link src 192.168.2.1
mininet> h3 ip -4 -br addr
lo UNKNOWN 127.0.0.1/8
h3-eth0@if58 UP 192.168.2.10/24
mininet> h3 ip route
default via 192.168.2.1 dev h3-eth0
192.168.2.0/24 dev h3-eth0 proto kernel scope link src 192.168.2.10
mininet> h1 rm -f /tmp/icmp-A.pcap
mininet> h3 rm -f /tmp/icmp-B.pcap
mininet> h1 tcpdump -i h1-eth0 -n icmp -w /tmp/icmp-A.pcap &
mininet> h3 tcpdump -i h3-eth0 -n icmp -w /tmp/icmp-B.pcap &
mininet> h1 ps aux | grep tcpdump
tcpdump: listening on h1-eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
tcpdump 4640 0.0 0.1 10904 5984 pts/2 S+ 11:41 0:00 tcpdump -i h1-eth0 -n icmp -w /tmp/icmp-A.pcap
tcpdump 4642 0.0 0.1 10904 6044 pts/4 S+ 11:41 0:00 tcpdump -i h3-eth0 -n icmp -w /tmp/icmp-B.pcap
root 4645 0.0 0.0 9368 2272 pts/2 S+ 11:41 0:00 grep tcpdump
mininet> h3 ps aux | grep tcpdump
tcpdump: listening on h3-eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
tcpdump 4640 0.0 0.1 10904 5984 pts/2 S+ 11:41 0:00 tcpdump -i h1-eth0 -n icmp -w /tmp/icmp-A.pcap
tcpdump 4642 0.0 0.1 10904 6044 pts/4 S+ 11:41 0:00 tcpdump -i h3-eth0 -n icmp -w /tmp/icmp-B.pcap
root 4648 0.0 0.0 9368 2304 pts/4 S+ 11:41 0:00 grep tcpdump
mininet>
mininet> h1 ping -c 1 192.168.2.10
PING 192.168.2.10 (192.168.2.10) 56(84) bytes of data.
64 bytes from 192.168.2.10: icmp_seq=1 ttl=63 time=1.66 ms
--- 192.168.2.10 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 1.659/1.659/1.659/0.000 ms
mininet> h1 pkill -INT tcpdump
2 packets captured
2 packets received by filter
0 packets dropped by kernel
mininet> h3 pkill -INT tcpdump
2 packets captured
2 packets received by filter
0 packets dropped by kernel
mininet> h1 ip link show h1-eth0
2: h1-eth0@if56: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
link/ether 4a:6f:2a:18:f4:80 brd ff:ff:ff:ff:ff:ff link-netnsid 0
mininet> h3 ip link show h3-eth0
2: h3-eth0@if58: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
link/ether 2e:ef:81:65:2c:c9 brd ff:ff:ff:ff:ff:ff link-netnsid 0
mininet> r0 ip link show r0-eth0
2: r0-eth0@if59: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
link/ether ea:2e:7b:6d:78:82 brd ff:ff:ff:ff:ff:ff link-netnsid 0
mininet> r0 ip link show r0-eth1
3: r0-eth1@if60: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
link/ether 7e:41:8e:cb:65:05 brd ff:ff:ff:ff:ff:ff link-netnsid 0
mininet> h1 tcpdump -n -e -vv -r /tmp/icmp-A.pcap
reading from file /tmp/icmp-A.pcap, link-type EN10MB (Ethernet)
11:42:14.509645 4a:6f:2a:18:f4:80 > ea:2e:7b:6d:78:82, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 64, id 33575, offset 0, flags [DF], proto ICMP (1), length 84)
192.168.1.131 > 192.168.2.10: ICMP echo request, id 4650, seq 1, length 64
11:42:14.510633 ea:2e:7b:6d:78:82 > 4a:6f:2a:18:f4:80, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 63, id 65287, offset 0, flags [none], proto ICMP (1), length 84)
192.168.2.10 > 192.168.1.131: ICMP echo reply, id 4650, seq 1, length 64
mininet> h3 tcpdump -n -e -vv -r /tmp/icmp-B.pcap
reading from file /tmp/icmp-B.pcap, link-type EN10MB (Ethernet)
11:42:14.510255 7e:41:8e:cb:65:05 > 2e:ef:81:65:2c:c9, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 63, id 33575, offset 0, flags [DF], proto ICMP (1), length 84)
192.168.1.131 > 192.168.2.10: ICMP echo request, id 4650, seq 1, length 64
11:42:14.510299 2e:ef:81:65:2c:c9 > 7e:41:8e:cb:65:05, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 64, id 65287, offset 0, flags [none], proto ICMP (1), length 84)
192.168.2.10 > 192.168.1.131: ICMP echo reply, id 4650, seq 1, length 64
@@ -0,0 +1,4 @@
11:42:14.509645 4a:6f:2a:18:f4:80 > ea:2e:7b:6d:78:82, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 64, id 33575, offset 0, flags [DF], proto ICMP (1), length 84)
192.168.1.131 > 192.168.2.10: ICMP echo request, id 4650, seq 1, length 64
11:42:14.510633 ea:2e:7b:6d:78:82 > 4a:6f:2a:18:f4:80, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 63, id 65287, offset 0, flags [none], proto ICMP (1), length 84)
192.168.2.10 > 192.168.1.131: ICMP echo reply, id 4650, seq 1, length 64
Binary file not shown.
@@ -0,0 +1,4 @@
11:42:14.510255 7e:41:8e:cb:65:05 > 2e:ef:81:65:2c:c9, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 63, id 33575, offset 0, flags [DF], proto ICMP (1), length 84)
192.168.1.131 > 192.168.2.10: ICMP echo request, id 4650, seq 1, length 64
11:42:14.510299 2e:ef:81:65:2c:c9 > 7e:41:8e:cb:65:05, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 64, id 65287, offset 0, flags [none], proto ICMP (1), length 84)
192.168.2.10 > 192.168.1.131: ICMP echo reply, id 4650, seq 1, length 64
Binary file not shown.
+136
View File
@@ -0,0 +1,136 @@
#!/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.
nodes['h1'] = net.addHost('h1', ip=None)
nodes['h2'] = net.addHost('h2', ip=None)
nodes['h3'] = net.addHost('h3', ip=None)
nodes['s1'] = net.addSwitch('s1', failMode='standalone')
nodes['s2'] = net.addSwitch('s2', failMode='standalone')
nodes['r0'] = net.addHost('r0', ip=None)
# Create links.
net.addLink(
nodes['h1'],
nodes['s1'],
intfName1='h1-eth0',
intfName2='s1-eth0',
)
net.addLink(
nodes['h2'],
nodes['s1'],
intfName1='h2-eth0',
intfName2='s1-eth1',
)
net.addLink(
nodes['h3'],
nodes['s2'],
intfName1='h3-eth0',
intfName2='s2-eth0',
)
net.addLink(
nodes['r0'],
nodes['s1'],
intfName1='r0-eth0',
intfName2='s1-eth2',
)
net.addLink(
nodes['r0'],
nodes['s2'],
intfName1='r0-eth1',
intfName2='s2-eth1',
)
# Materialize the Mininet topology.
net.build()
net.start()
# Configure concrete interfaces.
_node_cmd(nodes['h1'], 'ip', '-4', 'addr', 'flush', 'dev', 'h1-eth0')
_node_cmd(nodes['h1'], 'ip', 'link', 'set', 'dev', 'h1-eth0', 'up')
_node_cmd(nodes['h2'], 'ip', '-4', 'addr', 'flush', 'dev', 'h2-eth0')
_node_cmd(nodes['h2'], 'ip', 'link', 'set', 'dev', 'h2-eth0', 'up')
_node_cmd(nodes['h3'], 'ip', '-4', 'addr', 'flush', 'dev', 'h3-eth0')
_node_cmd(nodes['h3'], 'ip', 'addr', 'add', '192.168.2.10/24', 'dev', 'h3-eth0')
_node_cmd(nodes['h3'], 'ip', 'link', 'set', 'dev', 'h3-eth0', 'up')
_node_cmd(nodes['r0'], 'ip', '-4', 'addr', 'flush', 'dev', 'r0-eth0')
_node_cmd(nodes['r0'], 'ip', 'addr', 'add', '192.168.1.1/24', 'dev', 'r0-eth0')
_node_cmd(nodes['r0'], 'ip', 'link', 'set', 'dev', 'r0-eth0', 'up')
_node_cmd(nodes['r0'], 'ip', '-4', 'addr', 'flush', 'dev', 'r0-eth1')
_node_cmd(nodes['r0'], 'ip', 'addr', 'add', '192.168.2.1/24', 'dev', 'r0-eth1')
_node_cmd(nodes['r0'], 'ip', 'link', 'set', 'dev', 'r0-eth1', 'up')
# Configure static default routes.
_node_cmd(nodes['h3'], 'ip', 'route', 'replace', 'default', 'via', '192.168.2.1', 'dev', 'h3-eth0')
# Apply node-level networking settings.
_node_cmd(nodes['r0'], 'sysctl', '-w', 'net.ipv4.ip_forward=1')
_node_cmd(nodes['r0'], 'sysctl', '-w', 'net.ipv4.conf.all.rp_filter=0')
_node_cmd(nodes['r0'], 'sysctl', '-w', 'net.ipv4.conf.default.rp_filter=0')
_node_cmd(nodes['r0'], 'sysctl', '-w', 'net.ipv4.conf.r0-eth0.rp_filter=0')
_node_cmd(nodes['r0'], 'sysctl', '-w', 'net.ipv4.conf.r0-eth1.rp_filter=0')
# Start planned services.
_node_cmd(nodes['r0'], 'rm', '-f', '/tmp/mininet-dnsmasq-0.leases', '/tmp/mininet-dnsmasq-0.pid')
pid = _start_background(
nodes['r0'],
['dnsmasq', '--no-daemon', '--conf-file=', '--port=0', '--bind-interfaces', '--dhcp-authoritative', '--interface=r0-eth0', '--dhcp-range=192.168.1.100,192.168.1.200,12h', '--dhcp-leasefile=/tmp/mininet-dnsmasq-0.leases', '--pid-file=/tmp/mininet-dnsmasq-0.pid', '--dhcp-option=3,192.168.1.1'],
'/tmp/mininet-dnsmasq-0.log',
)
service_processes.append((nodes['r0'], pid))
# 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()
+95
View File
@@ -0,0 +1,95 @@
{
"nodes": [ {
"name": "h1",
"type": "host",
"interfaces": {
"net0": {
"addressing": "dhcp"
}
}
}, {
"name": "h2",
"type": "host",
"interfaces": {
"net0": {
"addressing": "dhcp"
}
}
}, {
"name": "h3",
"type": "host",
"interfaces": {
"net0": {
"addressing": "static",
"address": "192.168.2.10/24",
"gateway": "192.168.2.1"
}
}
}, {
"name": "s1",
"type": "switch"
}, {
"name": "s2",
"type": "switch"
}, {
"name": "r0",
"type": "router",
"interfaces": {
"netA": {
"addressing": "static",
"address": "192.168.1.1/24"
},
"netB": {
"addressing": "static",
"address": "192.168.2.1/24"
}
},
"settings": {
"ip_forward": true,
"rp_filter": false
}
}
],
"links": [ {
"endpoints": [
{ "node": "h1", "interface": "net0" },
{ "node": "s1", "interface": "auto" }
]
}, {
"endpoints": [
{ "node": "h2", "interface": "net0" },
{ "node": "s1", "interface": "auto" }
]
}, {
"endpoints": [
{ "node": "h3", "interface": "net0" },
{ "node": "s2", "interface": "auto" }
]
}, {
"endpoints": [
{ "node": "r0", "interface": "netA" },
{ "node": "s1", "interface": "auto" }
]
}, {
"endpoints": [
{ "node": "r0", "interface": "netB" },
{ "node": "s2", "interface": "auto" }
]
}
],
"services": [ {
"type": "dhcp-server",
"node": "r0",
"interface": "netA",
"settings": {
"range": {
"start": "192.168.1.100",
"end": "192.168.1.200"
},
"gateway": "192.168.1.1"
}
}
]
}
+8
View File
@@ -0,0 +1,8 @@
# LaTeX auxiliary files
*.aux
*.log
*.out
*.synctex.gz
_minted*/*
+1
Submodule report/AUThReport added at 74ec4b5f6c
BIN
View File
Binary file not shown.
+1505
View File
File diff suppressed because it is too large Load Diff
+425
View File
@@ -0,0 +1,425 @@
#!/usr/bin/env python3
"""Coordinate topology loading, validation, planning, rendering, and execution.
The coordinator is the user-facing entry point of the project.
Normal execution follows this pipeline:
topology.json
-> load and parse
-> validate
-> plan
-> render executable Mininet Python
-> write the generated intermediate artifact
-> execute that generated artifact
The generated Python file is intentionally preserved so it can be inspected
independently of the declarative topology source.
Author: Christos Choutouridis <cchoutou@ece.auth.gr>
"""
import argparse
from dataclasses import dataclass
import importlib.util
import os
from pathlib import Path
import shutil
import subprocess
import sys
from typing import Optional, Sequence, Tuple, Union
from topology_loader import TopologyLoadError, load_topology
from topology_parser import TopologyParseError, parse_topology
from topology_plan import ExecutionPlan, TopologyPlanError, plan
from topology_renderer import render_python
from topology_validator import TopologyValidationError, validate
# ============================ Types and errors ============================
PathLike = Union[str, Path]
class BuildNetworkError(RuntimeError):
"""Raised for coordinator-level generation or execution failures."""
@dataclass(frozen=True)
class EnvironmentRequirement:
"""Describe one external runtime requirement.
Args:
name: Human-readable requirement name.
available: Whether the requirement is available in the current
environment.
detail: Resolved path, explanatory status, or installation hint.
"""
name: str
available: bool
detail: str
@dataclass(frozen=True)
class EnvironmentReport:
"""Contain the result of checking external project dependencies.
Args:
requirements: Individual dependency checks.
"""
requirements: Tuple[EnvironmentRequirement, ...]
@property
def ok(self) -> bool:
"""Return whether every required dependency is available."""
return all(item.available for item in self.requirements)
# =========================== Environment checks ===========================
def _check_python_module(name: str, install_hint: str) -> EnvironmentRequirement:
"""Check whether one Python module is importable.
Args:
name: Importable Python module name.
install_hint: Human-readable installation hint used when missing.
Returns:
EnvironmentRequirement describing the result.
"""
available = importlib.util.find_spec(name) is not None
detail = "Python module available" if available else install_hint
return EnvironmentRequirement(
name="python module %s" % name,
available=available,
detail=detail,
)
def _check_command(
command: str,
purpose: str,
install_hint: str,
) -> EnvironmentRequirement:
"""Check whether one executable is available through PATH.
Args:
command: Executable name to locate.
purpose: Short description of why the executable is required.
install_hint: Human-readable installation hint used when missing.
Returns:
EnvironmentRequirement describing the result.
"""
path = shutil.which(command)
if path is not None:
detail = "%s (%s)" % (path, purpose)
else:
detail = "%s; %s" % (purpose, install_hint)
return EnvironmentRequirement(
name=command,
available=path is not None,
detail=detail,
)
def check_environment() -> EnvironmentReport:
"""Check dependencies required by the builder and assignment experiments.
Returns:
EnvironmentReport containing all required dependency checks.
Notes:
This function performs read-only availability checks.
It does not install packages or modify the system.
"""
requirements = [
_check_python_module(
"mininet",
"install Mininet, for example: sudo apt install mininet",
),
_check_command(
"mn",
"Mininet command-line utilities",
"install Mininet, for example: sudo apt install mininet",
),
_check_command(
"ovs-vsctl",
"Open vSwitch configuration used by Mininet switches",
"install Open vSwitch, for example: sudo apt install openvswitch-switch",
),
_check_command(
"ip",
"interface and route configuration",
"install iproute2: sudo apt install iproute2",
),
_check_command(
"tc",
"traffic-control settings for TCLink",
"install iproute2: sudo apt install iproute2",
),
_check_command(
"sysctl",
"router kernel settings",
"install procps: sudo apt install procps",
),
_check_command(
"dnsmasq",
"DHCP server service",
"install dnsmasq-base: sudo apt install dnsmasq-base",
),
_check_command(
"dhclient",
"manual DHCP client experiment",
"install isc-dhcp-client: sudo apt install isc-dhcp-client",
),
_check_command(
"tcpdump",
"packet capture for DHCP, ARP, and routing experiments",
"install tcpdump: sudo apt install tcpdump",
),
_check_command(
"ping",
"ICMP connectivity experiments",
"install iputils-ping: sudo apt install iputils-ping",
),
]
return EnvironmentReport(requirements=tuple(requirements))
# =================== Compilation and artifact execution ===================
def compile_topology(path: PathLike) -> Tuple[ExecutionPlan, str]:
"""Compile one topology file into a plan and executable Python source.
Args:
path: Path to the declarative topology JSON file.
Returns:
Pair containing the fully resolved ExecutionPlan and rendered Python
source.
Raises:
TopologyLoadError: If the JSON source cannot be loaded.
TopologyParseError: If the JSON violates the topology grammar.
TopologyValidationError: If the parsed topology is semantically
inconsistent.
TopologyPlanError: If the validated topology cannot be lowered into a
complete execution plan.
"""
topology = parse_topology(load_topology(path))
validate(topology)
execution_plan = plan(topology)
source = render_python(execution_plan)
return execution_plan, source
def default_generated_path(topology_path: PathLike) -> Path:
"""Return the default intermediate Python path for a topology source.
Args:
topology_path: Source topology path.
Returns:
Path alongside the topology using the `.generated.py` suffix.
"""
return Path(topology_path).with_suffix(".generated.py")
def write_generated_python(source: str, path: PathLike) -> Path:
"""Write generated executable Python source to disk.
Args:
source: Rendered Python program.
path: Destination path for the generated artifact.
Returns:
Destination Path, remaining relative if the supplied path is relative.
Raises:
BuildNetworkError: If the artifact cannot be written.
"""
destination = Path(path)
try:
destination.write_text(source, encoding="utf-8")
except OSError as exc:
raise BuildNetworkError(
"failed to write generated Python %s: %s" % (destination, exc)
) from exc
return destination
def execute_generated_python(path: PathLike) -> int:
"""Execute one generated Mininet Python artifact interactively.
Args:
path: Generated Python file to execute.
Returns:
Exit status returned by the generated process.
Raises:
BuildNetworkError: If execution is attempted without root privileges
or the generated process cannot be started.
"""
source = Path(path)
if os.geteuid() != 0:
raise BuildNetworkError(
"network execution requires root privileges; rerun with sudo or "
"use --emit to generate the intermediate Python without executing it"
)
try:
completed = subprocess.run(
[sys.executable, str(source)],
check=False,
)
except OSError as exc:
raise BuildNetworkError(
"failed to execute generated Python %s: %s" % (source, exc)
) from exc
return completed.returncode
# ================== Command-line reporting and arguments ==================
def _print_environment_report(report: EnvironmentReport) -> None:
"""Print an environment report from the command-line layer.
Args:
report: Environment report to present to the user.
"""
print("Environment check:")
for requirement in report.requirements:
marker = "OK" if requirement.available else "MISSING"
print("[%s] %-16s %s" % (marker, requirement.name, requirement.detail))
if report.ok:
print("Environment OK.")
else:
print("Environment check failed: one or more required dependencies are missing.")
def _build_argument_parser() -> argparse.ArgumentParser:
"""Create the command-line argument parser.
Returns:
Configured ArgumentParser instance.
"""
parser = argparse.ArgumentParser(
description=(
"Load, validate, plan, render, and execute a declarative Mininet "
"topology."
)
)
parser.add_argument(
"topology",
nargs="?",
help="path to topology.json; omitted only with --env-check",
)
parser.add_argument(
"--env-check",
action="store_true",
help=(
"check Mininet, Open vSwitch, dnsmasq, and experiment tools, then exit"
),
)
parser.add_argument(
"--emit",
nargs="?",
const="",
metavar="PATH",
help=(
"generate the intermediate Python and stop; optionally write it to PATH"
),
)
return parser
# ======================== Command-line entry point ========================
def main(argv: Optional[Sequence[str]] = None) -> int:
"""Run the coordinator command-line interface.
Args:
argv: Optional argument sequence excluding the executable name.
Returns:
Process exit status.
"""
parser = _build_argument_parser()
args = parser.parse_args(argv)
if args.env_check:
report = check_environment()
_print_environment_report(report)
return 0 if report.ok else 1
if args.topology is None:
parser.error("topology is required unless --env-check is used")
topology_path = Path(args.topology)
if args.emit is None:
generated_path = default_generated_path(topology_path)
execute = True
else:
generated_path = (
default_generated_path(topology_path)
if args.emit == ""
else Path(args.emit)
)
execute = False
try:
_, source = compile_topology(topology_path)
generated_path = write_generated_python(source, generated_path)
print("Generated: %s" % generated_path)
if not execute:
return 0
report = check_environment()
if not report.ok:
_print_environment_report(report)
return 1
return execute_generated_python(generated_path)
except (
TopologyLoadError,
TopologyParseError,
TopologyValidationError,
TopologyPlanError,
BuildNetworkError,
) as exc:
print("error: %s" % exc, file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+11 -19
View File
@@ -10,16 +10,21 @@ The loading stage has two public entry points:
model by calling topology_parser.parse_topology().
No Mininet code is imported or executed in this module.
Author: Christos Choutouridis <cchoutou@ece.auth.gr>
"""
import json
from pathlib import Path
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Any, Dict, Union
from typing import Any, Dict, Union
from topology_parser import Topology, parse_topology
# ============================ Types and errors ============================
PathLike = Union[str, Path]
@@ -27,6 +32,8 @@ class TopologyLoadError(ValueError):
"""Raised when a topology file cannot be loaded as a JSON object."""
# ============================ Public interface ============================
def load_topology(path: PathLike) -> Dict[str, Any]:
"""Load a topology JSON file into a raw dictionary.
@@ -64,23 +71,8 @@ def load_topology(path: PathLike) -> Dict[str, Any]:
return raw
def load(path: PathLike) -> Topology:
"""Load and parse a topology file in one convenience call.
Args:
path: Path to the topology JSON file.
Returns:
Parsed Topology model.
Raises:
TopologyLoadError: If the JSON file cannot be loaded.
topology_parser.TopologyParseError: If the loaded JSON cannot be
represented by the topology grammar.
"""
return parse_topology(load_topology(path))
# =============================== Self-test ===============================
def _self_test() -> None:
"""Run a small smoke test against load_topology() and load()."""
@@ -106,7 +98,7 @@ def _self_test() -> None:
handle.flush()
raw = load_topology(handle.name)
topology = load(handle.name)
topology = parse_topology(load_topology(handle.name))
assert raw["nodes"][0]["name"] == "s1"
assert topology.nodes[0].name == "s1"
+264 -98
View File
@@ -3,18 +3,20 @@
This module contains the in-memory semantic model used by the rest of the
pipeline and the parser that converts a raw JSON dictionary into that model.
The parser is intentionally independent of Mininet. Its job is only to turn
JSON-shaped data into typed Python objects. Cross-reference and semantic
The parser is intentionally independent of Mininet. Its job is only to turn
JSON-shaped data into typed Python objects. Cross-reference and semantic
validation belong to the validation stage that follows parsing.
Author: Christos Choutouridis <cchoutou@ece.auth.gr>
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Mapping, Optional, Set, Tuple, Union
from enum import Enum
from typing import Any, Dict, List, Mapping, Optional, Set, Tuple, Union
# ======================= Semantic model and errors =======================
JSONMapping = Mapping[str, Any]
@@ -25,7 +27,7 @@ class TopologyParseError(ValueError):
class NodeType(Enum):
"""Supported logical node types."""
HOST = "host"
HOST = "host"
SWITCH = "switch"
ROUTER = "router"
@@ -33,9 +35,9 @@ class NodeType(Enum):
class AddressingMode(Enum):
"""Supported Layer-3 addressing modes for an interface."""
NONE = "none"
NONE = "none"
STATIC = "static"
DHCP = "dhcp"
DHCP = "dhcp"
class LinkInterfaceSelector(Enum):
@@ -48,7 +50,7 @@ class ServiceInterfaceSelector(Enum):
"""Special interface selectors that are valid for a service binding."""
AUTO = "auto"
ALL = "all"
ALL = "all"
class ServiceType(Enum):
@@ -62,29 +64,33 @@ class Interface:
"""Describe one logical network interface owned by a node.
Args:
name: Logical interface tag used by the topology description.
addressing: Layer-3 addressing strategy. Omitting addressing in JSON
is represented as AddressingMode.NONE.
address: Static CIDR address, valid when addressing is STATIC.
gateway: Optional default gateway, valid when addressing is STATIC.
mac: Optional explicit MAC address. Reserved for current/future use.
mtu: Optional MTU value. Reserved for current/future use.
name: Logical interface tag used by the topology description.
addressing: Layer-3 addressing strategy.
Omitting addressing in JSON is represented as AddressingMode.NONE.
address: Static CIDR address, valid when addressing is STATIC.
gateway: [Optional] default gateway, valid when addressing is STATIC.
mac: [Optional] explicit MAC address applied to the concrete interface.
mtu: [Optional] MTU in bytes applied to the concrete interface.
Notes:
MAC and MTU are independent of the addressing mode.
The generated program applies them using Linux ip link commands.
"""
name: str
name: str
addressing: AddressingMode = AddressingMode.NONE
address: Optional[str] = None
gateway: Optional[str] = None
mac: Optional[str] = None
mtu: Optional[int] = None
address: Optional[str] = None
gateway: Optional[str] = None
mac: Optional[str] = None
mtu: Optional[int] = None
@dataclass
class HostSettings:
"""Contain host-specific settings.
The current grammar does not require host-specific settings yet. Keeping
a dedicated type gives the model a stable extension point.
The current grammar does not require host-specific settings yet.
Keeping a dedicated type gives the model a stable extension point.
"""
@@ -92,8 +98,8 @@ class HostSettings:
class SwitchSettings:
"""Contain switch-specific settings.
The current grammar does not require switch-specific settings yet. Keeping
a dedicated type gives the model a stable extension point.
The current grammar does not require switch-specific settings yet.
Keping a dedicated type gives the model a stable extension point.
"""
@@ -107,9 +113,9 @@ class RouterSettings:
"""
ip_forward: Optional[bool] = None
rp_filter: Optional[bool] = None
rp_filter: Optional[bool] = None
# Mutable global
NodeSettings = Union[HostSettings, SwitchSettings, RouterSettings]
@@ -118,19 +124,21 @@ class Node:
"""Describe one logical topology node.
Args:
name: Unique logical node name.
type: Semantic node type.
interfaces: Declared logical interfaces. None means that the node did
not declare an interface set and therefore allows dynamic interface
allocation. An empty dictionary means that an explicit, fixed
interface set was declared and contains zero interfaces.
name: Unique logical node name.
type: Semantic node type.
interfaces:
Declared logical interfaces.
None means that the node did not declare an interface set and therefore
allows dynamic interface allocation.
An empty dictionary means that an explicit, fixed interface set was declared
and contains zero interfaces.
settings: Type-specific node settings.
"""
name: str
type: NodeType
name: str
type: NodeType
interfaces: Optional[Dict[str, Interface]]
settings: NodeSettings
settings: NodeSettings
@dataclass
@@ -138,11 +146,11 @@ class LinkEndpoint:
"""Describe one endpoint of a point-to-point logical link.
Args:
node: Name of the node referenced by the endpoint.
node: Name of the node referenced by the endpoint.
interface: Explicit logical interface tag or the AUTO selector.
"""
node: str
node: str
interface: Union[str, LinkInterfaceSelector]
@@ -151,22 +159,23 @@ class LinkSettings:
"""Contain optional link-specific provisioning settings.
Args:
bandwidth_mbps: Optional link bandwidth limit in megabits per second.
delay_ms: Optional one-way propagation delay in milliseconds.
loss_percent: Optional packet-loss percentage.
jitter_ms: Optional delay variation in milliseconds.
bandwidth_mbps: [Optional] link bandwidth limit in megabits per second.
delay_ms: [Optional] one-way propagation delay in milliseconds.
loss_percent: [Optional] packet-loss percentage.
jitter_ms: [Optional] delay variation in milliseconds.
Notes:
These fields are reserved as extension points for later Mininet link
provisioning, for example through traffic-controlled link classes.
They are parsed now so the semantic model already has a stable place
for link-level configuration.
All four settings are implemented through Mininet TCLink parameters
in the generated program.
Bandwidth maps to bw, loss to loss, and delay and jitter to millisecond strings.
Additional provisioning settings require corresponding parser,
validator, planner, and renderer support before they can be used.
"""
bandwidth_mbps: Optional[float] = None
delay_ms: Optional[float] = None
loss_percent: Optional[float] = None
jitter_ms: Optional[float] = None
delay_ms: Optional[float] = None
loss_percent: Optional[float] = None
jitter_ms: Optional[float] = None
@dataclass
@@ -175,13 +184,13 @@ class Link:
Args:
endpoints: Ordered pair of link endpoints.
name: Optional logical link name used for diagnostics or extensions.
settings: Link-specific provisioning settings.
name: [ Optional] logical link name used for diagnostics or extensions.
settings: Link-specific provisioning settings.
"""
endpoints: Tuple[LinkEndpoint, LinkEndpoint]
name: Optional[str] = None
settings: LinkSettings = field(default_factory=LinkSettings)
name: Optional[str] = None
settings: LinkSettings = field(default_factory=LinkSettings)
@dataclass
@@ -190,11 +199,11 @@ class DHCPRange:
Args:
start: First address in the DHCP allocation range.
end: Last address in the DHCP allocation range.
end: Last address in the DHCP allocation range.
"""
start: str
end: str
end: str
@dataclass
@@ -203,13 +212,14 @@ class DHCPServerSettings:
Args:
address_range: Address range offered to DHCP clients.
gateway: Optional default gateway advertised to DHCP clients.
gateway: [Optional] default gateway advertised to DHCP clients.
"""
address_range: DHCPRange
gateway: Optional[str] = None
gateway: Optional[str] = None
# Mutable global
ServiceSettings = DHCPServerSettings
@@ -218,44 +228,48 @@ class Service:
"""Describe a service attached to a topology node.
Args:
type: Service implementation type.
node: Name of the node on which the service runs.
type: Service implementation type.
node: Name of the node on which the service runs.
interface: Explicit logical interface tag or a supported special
selector such as AUTO or ALL.
settings: Type-specific service settings.
settings: Type-specific service settings.
"""
type: ServiceType
node: str
interface: Union[str, ServiceInterfaceSelector]
settings: ServiceSettings
settings: ServiceSettings
@dataclass
class Topology:
"""Contain the complete parsed topology model.
Args:
nodes: Nodes in declaration order. Keeping nodes as a list preserves
the parsed source faithfully, including duplicate names, so that
the separate validation stage can diagnose them instead of losing
information during parsing.
links: Links in declaration order. Order is preserved because it can
make automatic interface allocation deterministic.
services: Services in declaration order. Order may later be useful for
deterministic startup and shutdown.
nodes:
Nodes in declaration order. Keeping nodes as a list preserves
the parsed source faithfully, including duplicate names, so that
the separate validation stage can diagnose them instead of losing
information during parsing.
links:
Links in declaration order. Order is preserved because it can
make automatic interface allocation deterministic.
services:
Services in declaration order. Order may later be useful for
deterministic startup and shutdown.
"""
nodes: List[Node] = field(default_factory=list)
links: List[Link] = field(default_factory=list)
nodes: List[Node] = field(default_factory=list)
links: List[Link] = field(default_factory=list)
services: List[Service] = field(default_factory=list)
# ============================ Parsing filters ============================
def _mapping(value: Any, context: str) -> JSONMapping:
"""Return a value as a mapping or raise a parse error.
Args:
value: Value expected to be a JSON object.
value: Value expected to be a JSON object.
context: Human-readable location used in the error message.
Returns:
@@ -278,7 +292,7 @@ def _reject_unknown_fields(
"""Reject object members that are not part of the grammar.
Args:
data: Parsed JSON object whose member names are checked.
data: Parsed JSON object whose member names are checked.
allowed: Set of field names accepted at this grammar location.
context: Human-readable location used in the error message.
@@ -292,7 +306,7 @@ def _reject_unknown_fields(
"%s contains unknown field%s: %s"
% (
context,
"s" if len(unknown) != 1 else "",
"s" if len(unknown) != 1 else "", # don't forget your English grammar ;)
", ".join(repr(name) for name in unknown),
)
)
@@ -302,7 +316,7 @@ def _list(value: Any, context: str) -> List[Any]:
"""Return a value as a list or raise a parse error.
Args:
value: Value expected to be a JSON array.
value: Value expected to be a JSON array.
context: Human-readable location used in the error message.
Returns:
@@ -321,7 +335,7 @@ def _string(value: Any, context: str) -> str:
"""Return a value as a string or raise a parse error.
Args:
value: Value expected to be a string.
value: Value expected to be a string.
context: Human-readable location used in the error message.
Returns:
@@ -340,7 +354,7 @@ def _optional_string(value: Any, context: str) -> Optional[str]:
"""Return an optional string or raise a parse error.
Args:
value: None or a value expected to be a string.
value: None or a value expected to be a string.
context: Human-readable location used in the error message.
Returns:
@@ -359,7 +373,7 @@ def _optional_bool(value: Any, context: str) -> Optional[bool]:
"""Return an optional boolean or raise a parse error.
Args:
value: None or a value expected to be a boolean.
value: None or a value expected to be a boolean.
context: Human-readable location used in the error message.
Returns:
@@ -380,7 +394,7 @@ def _optional_int(value: Any, context: str) -> Optional[int]:
"""Return an optional integer or raise a parse error.
Args:
value: None or a value expected to be an integer.
value: None or a value expected to be an integer.
context: Human-readable location used in the error message.
Returns:
@@ -392,6 +406,7 @@ def _optional_int(value: Any, context: str) -> Optional[int]:
if value is None:
return None
# bool is a subclass of int, but JSON booleans are not numeric settings.
if isinstance(value, bool) or not isinstance(value, int):
raise TopologyParseError("%s must be an integer" % context)
return value
@@ -401,8 +416,7 @@ def _optional_float(value: Any, context: str) -> Optional[float]:
"""Return an optional numeric value as float or raise a parse error.
Args:
value: None or a value expected to be an integer or floating-point
number.
value: None or a value expected to be an integer or floating-point number.
context: Human-readable location used in the error message.
Returns:
@@ -414,22 +428,42 @@ def _optional_float(value: Any, context: str) -> Optional[float]:
if value is None:
return None
# Reject booleans before accepting Python's integer and float types.
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TopologyParseError("%s must be a number" % context)
return float(value)
# ============================= Entity parsing =============================
def _parse_interface(name: str, raw: Any, context: str) -> Interface:
"""Parse one interface definition.
Args:
name: Logical interface tag taken from the JSON object key.
raw: Raw JSON value containing interface properties.
name: Logical interface tag taken from the JSON object key.
raw: Raw JSON value containing interface properties.
context: Human-readable location used in error messages.
Returns:
Parsed Interface object.
Example:
Input (diagnostic context omitted):
name = "lan"
raw = {
"addressing": "static",
"address": "192.168.1.1/24",
}
Output:
Interface(
name="lan",
addressing=AddressingMode.STATIC,
address="192.168.1.1/24",
gateway=None,
mac=None,
mtu=None,
)
Raises:
TopologyParseError: If the interface cannot be represented.
"""
@@ -464,16 +498,34 @@ def _parse_interfaces(raw_node: JSONMapping, context: str) -> Optional[Dict[str,
Args:
raw_node: Raw JSON node object.
context: Human-readable node location used in error messages.
context: Human-readable node location used in error messages.
Returns:
None when the interfaces field is omitted, otherwise a dictionary of
logical interface tags to Interface objects.
- None when the interfaces field is omitted (dynamic), otherwise
- a dictionary of logical interface tags to Interface objects.
Example:
Each case shows the value passed as raw_node and the returned value,
the diagnostic context argument is omitted.
raw_node is the node object, not just its interfaces member.
Unrelated node fields are omitted from these examples.
Interface set raw_node["interfaces"] return
--------------------------------------------------------
Omitted: {} None
Explicitly empty: {"interfaces": {}} {}
One declared: {"interfaces": {"lan": {}}} {"lan": Interface(name="lan")}
Omission preserves a dynamic interface set. An empty object preserves
a fixed pool containing zero interfaces.
Interface(name="lan") uses the model defaults, including NONE
addressing and no address, gateway, MAC, or MTU.
Raises:
TopologyParseError: If the interfaces field has an invalid shape.
"""
# Preserve the distinction between dynamic allocation and a fixed empty pool.
if "interfaces" not in raw_node:
return None
@@ -496,12 +548,23 @@ def _parse_node_settings(node_type: NodeType, raw: Any, context: str) -> NodeSet
Args:
node_type: Parsed semantic node type.
raw: Raw JSON settings object or None.
context: Human-readable node location used in error messages.
raw: Raw JSON settings object or None.
context: Human-readable node location used in error messages.
Returns:
Type-specific node settings object.
Example:
Input (Python values after JSON loading, diagnostic context omitted):
node_type = NodeType.ROUTER
raw = {"ip_forward": True, "rp_filter": False}
Output:
RouterSettings(ip_forward=True, rp_filter=False)
For node_type = NodeType.HOST and raw = {}, the output is
HostSettings().
Raises:
TopologyParseError: If the settings object has an invalid shape or
contains values that cannot be represented by the selected type.
@@ -538,12 +601,34 @@ def _parse_node(raw: Any, index: int) -> Node:
"""Parse one node declaration.
Args:
raw: Raw JSON node object.
raw: Raw JSON node object.
index: Node index in the source array, used in diagnostics.
Returns:
Parsed Node object.
Example:
Input raw (diagnostic index omitted):
{
"name": "h1",
"type": "host",
"interfaces": {"net0": {"addressing": "dhcp"}},
}
Output:
Node(
name="h1",
type=NodeType.HOST,
interfaces={
"net0": Interface(
name="net0", addressing=AddressingMode.DHCP,
),
},
settings=HostSettings(),
)
Unspecified Interface fields use their model defaults.
Raises:
TopologyParseError: If the node cannot be represented.
"""
@@ -578,7 +663,7 @@ def _parse_link_interface(value: Any, context: str) -> Union[str, LinkInterfaceS
"""Parse a link endpoint interface reference.
Args:
value: Raw interface reference.
value: Raw interface reference.
context: Human-readable location used in error messages.
Returns:
@@ -598,7 +683,7 @@ def _parse_link_endpoint(raw: Any, context: str) -> LinkEndpoint:
"""Parse one link endpoint.
Args:
raw: Raw JSON endpoint object.
raw: Raw JSON endpoint object.
context: Human-readable location used in error messages.
Returns:
@@ -624,7 +709,7 @@ def _parse_link_settings(raw: Any, context: str) -> LinkSettings:
"""Parse optional link-specific provisioning settings.
Args:
raw: Raw JSON settings object or None.
raw: Raw JSON settings object or None.
context: Human-readable link location used in error messages.
Returns:
@@ -665,12 +750,35 @@ def _parse_link(raw: Any, index: int) -> Link:
"""Parse one link declaration.
Args:
raw: Raw JSON link object.
raw: Raw JSON link object.
index: Link index in the source array, used in diagnostics.
Returns:
Parsed Link object.
Example:
Input raw (diagnostic index omitted):
{
"endpoints": [
{"node": "h1", "interface": "net0"},
{"node": "s1", "interface": "auto"},
],
}
Output:
Link(
endpoints=(
LinkEndpoint(node="h1", interface="net0"),
LinkEndpoint(
node="s1", interface=LinkInterfaceSelector.AUTO,
),
),
)
Note:
- Omitted name and settings use the Link defaults.
- Parsing converts the endpoint list to a tuple and recognizes AUTO.
- Validation checks whether the referenced nodes and interfaces exist.
Raises:
TopologyParseError: If the link cannot be represented.
"""
@@ -712,7 +820,7 @@ def _parse_service_interface(
"""Parse a service interface binding.
Args:
value: Raw service interface reference.
value: Raw service interface reference.
context: Human-readable location used in error messages.
Returns:
@@ -735,12 +843,32 @@ def _parse_dhcp_server_settings(raw: Any, context: str) -> DHCPServerSettings:
"""Parse DHCP-server-specific settings.
Args:
raw: Raw JSON DHCP server settings object.
raw: Raw JSON DHCP server settings object.
context: Human-readable location used in error messages.
Returns:
Parsed DHCPServerSettings object.
Example:
Input raw (diagnostic context omitted):
{
"range": {
"start": "192.168.1.100",
"end": "192.168.1.200",
},
"gateway": "192.168.1.1",
}
Output:
DHCPServerSettings(
address_range=DHCPRange(
start="192.168.1.100", end="192.168.1.200",
),
gateway="192.168.1.1",
)
The JSON range object becomes the typed address_range field.
Raises:
TopologyParseError: If required structural fields are missing or have
incompatible JSON types.
@@ -767,12 +895,43 @@ def _parse_service(raw: Any, index: int) -> Service:
"""Parse one service declaration.
Args:
raw: Raw JSON service object.
raw: Raw JSON service object.
index: Service index in the source array, used in diagnostics.
Returns:
Parsed Service object.
Example:
Input raw (diagnostic index omitted):
{
"type": "dhcp-server",
"node": "r0",
"interface": "lan",
"settings": {
"range": {
"start": "192.168.1.100",
"end": "192.168.1.200",
},
"gateway": "192.168.1.1",
},
}
Output:
Service(
type=ServiceType.DHCP_SERVER,
node="r0",
interface="lan",
settings=DHCPServerSettings(
address_range=DHCPRange(
start="192.168.1.100", end="192.168.1.200",
),
gateway="192.168.1.1",
),
)
Binding fields remain separate from service settings.
Parsing preserves the references. Validation checks their validity.
Raises:
TopologyParseError: If the service cannot be represented.
"""
@@ -814,6 +973,10 @@ def _parse_service(raw: Any, index: int) -> Service:
)
# ============================ Public interface ============================
def parse_topology(raw: Mapping[str, Any]) -> Topology:
"""Parse a raw JSON dictionary into the typed topology model.
@@ -828,8 +991,8 @@ def parse_topology(raw: Mapping[str, Any]) -> Topology:
topology grammar.
Notes:
This function intentionally does not validate cross references such as
whether a link references an existing node. Those checks belong to
This function intentionally does not validate cross-references such as
whether a link references an existing node. Those checks belong to
the separate validation stage.
"""
@@ -858,6 +1021,9 @@ def parse_topology(raw: Mapping[str, Any]) -> Topology:
return Topology(nodes=nodes, links=links, services=services)
# =============================== Self-test ===============================
def _self_test() -> None:
"""Run a small smoke test against the public parse_topology() interface."""
+889
View File
@@ -0,0 +1,889 @@
"""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,
)
# ========================== 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 <node>-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")
+487
View File
@@ -0,0 +1,487 @@
"""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,
)
# =========================== Rendering helpers ===========================
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:
lines.append(
" _node_cmd(%s, 'ip', 'addr', 'add', %s, 'dev', %s)"
% (
node,
repr(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.
Construct dnsmasq arguments from the resolved binding and DHCP settings,
using the service index for lease, PID, and log filenames.
Emit commands that remove previous lease and PID files, launch the daemon
in the background, and record its shell PID for later cleanup.
The emitted startup sequence does not check daemon readiness.
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.
"""
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 [
" _node_cmd(%s, 'rm', '-f', %s, %s)"
% (
node,
_python_string(lease_file),
_python_string(pid_file),
),
" pid = _start_background(",
" %s," % node,
" [%s]," % rendered_args,
" %s," % _python_string(log_file),
" )",
" service_processes.append((%s, pid))" % node,
]
# ============================ Public interface ============================
def render_python(plan: ExecutionPlan) -> str:
"""Render an execution plan as a complete executable Python program.
Assemble source lines for runtime helpers and the network lifecycle.
Emit construction first so interfaces exist before configuration; emit
addresses before default routes and services that depend on them.
Finish with the interactive CLI and a finally block that attempts tracked
service cleanup followed by network shutdown.
Rendering itself only produces text and does not execute these operations.
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.
"""
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),
)
)
else:
lines.append(
" nodes[%s] = net.addSwitch(%s, failMode=%r)"
% (
_python_string(node.name),
_python_string(node.name),
node.switch_fail_mode,
)
)
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)
# =============================== Self-test ===============================
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 "rm', '-f', '/tmp/mininet-dnsmasq-0.leases'" in source
assert "CLI(net)" in source
assert "dhclient" in source
if __name__ == "__main__":
_self_test()
print("topology_renderer: self-test passed")
File diff suppressed because it is too large Load Diff