416 lines
11 KiB
Python
416 lines
11 KiB
Python
#!/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
|
|
from topology_parser import TopologyParseError
|
|
from topology_plan import ExecutionPlan, TopologyPlanError, plan
|
|
from topology_renderer import render_python
|
|
from topology_validator import TopologyValidationError, validate
|
|
|
|
|
|
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)
|
|
|
|
|
|
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))
|
|
|
|
|
|
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 = load(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:
|
|
Resolved destination Path.
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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())
|