Documentation, comments and small changes
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "report/AUThReport"]
|
||||||
|
path = report/AUThReport
|
||||||
|
url = ssh://git@git.hoo2.net:222/hoo2/AUThReport.git
|
||||||
Submodule
+1
Submodule report/AUThReport added at 74ec4b5f6c
Regular → Executable
+14
-4
@@ -29,13 +29,15 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from typing import Optional, Sequence, Tuple, Union
|
from typing import Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
from topology_loader import TopologyLoadError, load
|
from topology_loader import TopologyLoadError, load_topology
|
||||||
from topology_parser import TopologyParseError
|
from topology_parser import TopologyParseError, parse_topology
|
||||||
from topology_plan import ExecutionPlan, TopologyPlanError, plan
|
from topology_plan import ExecutionPlan, TopologyPlanError, plan
|
||||||
from topology_renderer import render_python
|
from topology_renderer import render_python
|
||||||
from topology_validator import TopologyValidationError, validate
|
from topology_validator import TopologyValidationError, validate
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ Types and errors ============================
|
||||||
|
|
||||||
PathLike = Union[str, Path]
|
PathLike = Union[str, Path]
|
||||||
|
|
||||||
|
|
||||||
@@ -76,6 +78,8 @@ class EnvironmentReport:
|
|||||||
return all(item.available for item in self.requirements)
|
return all(item.available for item in self.requirements)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================== Environment checks ===========================
|
||||||
|
|
||||||
def _check_python_module(name: str, install_hint: str) -> EnvironmentRequirement:
|
def _check_python_module(name: str, install_hint: str) -> EnvironmentRequirement:
|
||||||
"""Check whether one Python module is importable.
|
"""Check whether one Python module is importable.
|
||||||
|
|
||||||
@@ -193,6 +197,8 @@ def check_environment() -> EnvironmentReport:
|
|||||||
return EnvironmentReport(requirements=tuple(requirements))
|
return EnvironmentReport(requirements=tuple(requirements))
|
||||||
|
|
||||||
|
|
||||||
|
# =================== Compilation and artifact execution ===================
|
||||||
|
|
||||||
def compile_topology(path: PathLike) -> Tuple[ExecutionPlan, str]:
|
def compile_topology(path: PathLike) -> Tuple[ExecutionPlan, str]:
|
||||||
"""Compile one topology file into a plan and executable Python source.
|
"""Compile one topology file into a plan and executable Python source.
|
||||||
|
|
||||||
@@ -212,7 +218,7 @@ def compile_topology(path: PathLike) -> Tuple[ExecutionPlan, str]:
|
|||||||
complete execution plan.
|
complete execution plan.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
topology = load(path)
|
topology = parse_topology(load_topology(path))
|
||||||
validate(topology)
|
validate(topology)
|
||||||
execution_plan = plan(topology)
|
execution_plan = plan(topology)
|
||||||
source = render_python(execution_plan)
|
source = render_python(execution_plan)
|
||||||
@@ -241,7 +247,7 @@ def write_generated_python(source: str, path: PathLike) -> Path:
|
|||||||
path: Destination path for the generated artifact.
|
path: Destination path for the generated artifact.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Resolved destination Path.
|
Destination Path, remaining relative if the supplied path is relative.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
BuildNetworkError: If the artifact cannot be written.
|
BuildNetworkError: If the artifact cannot be written.
|
||||||
@@ -294,6 +300,8 @@ def execute_generated_python(path: PathLike) -> int:
|
|||||||
return completed.returncode
|
return completed.returncode
|
||||||
|
|
||||||
|
|
||||||
|
# ================== Command-line reporting and arguments ==================
|
||||||
|
|
||||||
def _print_environment_report(report: EnvironmentReport) -> None:
|
def _print_environment_report(report: EnvironmentReport) -> None:
|
||||||
"""Print an environment report from the command-line layer.
|
"""Print an environment report from the command-line layer.
|
||||||
|
|
||||||
@@ -350,6 +358,8 @@ def _build_argument_parser() -> argparse.ArgumentParser:
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
# ======================== Command-line entry point ========================
|
||||||
|
|
||||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||||
"""Run the coordinator command-line interface.
|
"""Run the coordinator command-line interface.
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,16 @@ Author: Christos Choutouridis <cchoutou@ece.auth.gr>
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
|
||||||
|
from pathlib import Path
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
from typing import Any, Dict, Union
|
from typing import Any, Dict, Union
|
||||||
|
|
||||||
from topology_parser import Topology, parse_topology
|
from topology_parser import Topology, parse_topology
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ Types and errors ============================
|
||||||
|
|
||||||
PathLike = Union[str, Path]
|
PathLike = Union[str, Path]
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +32,8 @@ class TopologyLoadError(ValueError):
|
|||||||
"""Raised when a topology file cannot be loaded as a JSON object."""
|
"""Raised when a topology file cannot be loaded as a JSON object."""
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ Public interface ============================
|
||||||
|
|
||||||
def load_topology(path: PathLike) -> Dict[str, Any]:
|
def load_topology(path: PathLike) -> Dict[str, Any]:
|
||||||
"""Load a topology JSON file into a raw dictionary.
|
"""Load a topology JSON file into a raw dictionary.
|
||||||
|
|
||||||
@@ -66,23 +71,8 @@ def load_topology(path: PathLike) -> Dict[str, Any]:
|
|||||||
return raw
|
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:
|
def _self_test() -> None:
|
||||||
"""Run a small smoke test against load_topology() and load()."""
|
"""Run a small smoke test against load_topology() and load()."""
|
||||||
@@ -108,7 +98,7 @@ def _self_test() -> None:
|
|||||||
handle.flush()
|
handle.flush()
|
||||||
|
|
||||||
raw = load_topology(handle.name)
|
raw = load_topology(handle.name)
|
||||||
topology = load(handle.name)
|
topology = parse_topology(load_topology(handle.name))
|
||||||
|
|
||||||
assert raw["nodes"][0]["name"] == "s1"
|
assert raw["nodes"][0]["name"] == "s1"
|
||||||
assert topology.nodes[0].name == "s1"
|
assert topology.nodes[0].name == "s1"
|
||||||
|
|||||||
+264
-98
@@ -3,18 +3,20 @@
|
|||||||
This module contains the in-memory semantic model used by the rest of the
|
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.
|
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
|
The parser is intentionally independent of Mininet. Its job is only to turn
|
||||||
JSON-shaped data into typed Python objects. Cross-reference and semantic
|
JSON-shaped data into typed Python objects. Cross-reference and semantic
|
||||||
validation belong to the validation stage that follows parsing.
|
validation belong to the validation stage that follows parsing.
|
||||||
|
|
||||||
Author: Christos Choutouridis <cchoutou@ece.auth.gr>
|
Author: Christos Choutouridis <cchoutou@ece.auth.gr>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Dict, List, Mapping, Optional, Set, Tuple, Union
|
from typing import Any, Dict, List, Mapping, Optional, Set, Tuple, Union
|
||||||
|
|
||||||
|
|
||||||
|
# ======================= Semantic model and errors =======================
|
||||||
|
|
||||||
JSONMapping = Mapping[str, Any]
|
JSONMapping = Mapping[str, Any]
|
||||||
|
|
||||||
|
|
||||||
@@ -25,7 +27,7 @@ class TopologyParseError(ValueError):
|
|||||||
class NodeType(Enum):
|
class NodeType(Enum):
|
||||||
"""Supported logical node types."""
|
"""Supported logical node types."""
|
||||||
|
|
||||||
HOST = "host"
|
HOST = "host"
|
||||||
SWITCH = "switch"
|
SWITCH = "switch"
|
||||||
ROUTER = "router"
|
ROUTER = "router"
|
||||||
|
|
||||||
@@ -33,9 +35,9 @@ class NodeType(Enum):
|
|||||||
class AddressingMode(Enum):
|
class AddressingMode(Enum):
|
||||||
"""Supported Layer-3 addressing modes for an interface."""
|
"""Supported Layer-3 addressing modes for an interface."""
|
||||||
|
|
||||||
NONE = "none"
|
NONE = "none"
|
||||||
STATIC = "static"
|
STATIC = "static"
|
||||||
DHCP = "dhcp"
|
DHCP = "dhcp"
|
||||||
|
|
||||||
|
|
||||||
class LinkInterfaceSelector(Enum):
|
class LinkInterfaceSelector(Enum):
|
||||||
@@ -48,7 +50,7 @@ class ServiceInterfaceSelector(Enum):
|
|||||||
"""Special interface selectors that are valid for a service binding."""
|
"""Special interface selectors that are valid for a service binding."""
|
||||||
|
|
||||||
AUTO = "auto"
|
AUTO = "auto"
|
||||||
ALL = "all"
|
ALL = "all"
|
||||||
|
|
||||||
|
|
||||||
class ServiceType(Enum):
|
class ServiceType(Enum):
|
||||||
@@ -62,29 +64,33 @@ class Interface:
|
|||||||
"""Describe one logical network interface owned by a node.
|
"""Describe one logical network interface owned by a node.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Logical interface tag used by the topology description.
|
name: Logical interface tag used by the topology description.
|
||||||
addressing: Layer-3 addressing strategy. Omitting addressing in JSON
|
addressing: Layer-3 addressing strategy.
|
||||||
is represented as AddressingMode.NONE.
|
Omitting addressing in JSON is represented as AddressingMode.NONE.
|
||||||
address: Static CIDR address, valid when addressing is STATIC.
|
address: Static CIDR address, valid when addressing is STATIC.
|
||||||
gateway: Optional default gateway, valid when addressing is STATIC.
|
gateway: [Optional] default gateway, valid when addressing is STATIC.
|
||||||
mac: Optional explicit MAC address. Reserved for current/future use.
|
mac: [Optional] explicit MAC address applied to the concrete interface.
|
||||||
mtu: Optional MTU value. Reserved for current/future use.
|
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
|
addressing: AddressingMode = AddressingMode.NONE
|
||||||
address: Optional[str] = None
|
address: Optional[str] = None
|
||||||
gateway: Optional[str] = None
|
gateway: Optional[str] = None
|
||||||
mac: Optional[str] = None
|
mac: Optional[str] = None
|
||||||
mtu: Optional[int] = None
|
mtu: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class HostSettings:
|
class HostSettings:
|
||||||
"""Contain host-specific settings.
|
"""Contain host-specific settings.
|
||||||
|
|
||||||
The current grammar does not require host-specific settings yet. Keeping
|
The current grammar does not require host-specific settings yet.
|
||||||
a dedicated type gives the model a stable extension point.
|
Keeping a dedicated type gives the model a stable extension point.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -92,8 +98,8 @@ class HostSettings:
|
|||||||
class SwitchSettings:
|
class SwitchSettings:
|
||||||
"""Contain switch-specific settings.
|
"""Contain switch-specific settings.
|
||||||
|
|
||||||
The current grammar does not require switch-specific settings yet. Keeping
|
The current grammar does not require switch-specific settings yet.
|
||||||
a dedicated type gives the model a stable extension point.
|
Keping a dedicated type gives the model a stable extension point.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -107,9 +113,9 @@ class RouterSettings:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
ip_forward: Optional[bool] = None
|
ip_forward: Optional[bool] = None
|
||||||
rp_filter: Optional[bool] = None
|
rp_filter: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Mutable global
|
||||||
NodeSettings = Union[HostSettings, SwitchSettings, RouterSettings]
|
NodeSettings = Union[HostSettings, SwitchSettings, RouterSettings]
|
||||||
|
|
||||||
|
|
||||||
@@ -118,19 +124,21 @@ class Node:
|
|||||||
"""Describe one logical topology node.
|
"""Describe one logical topology node.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Unique logical node name.
|
name: Unique logical node name.
|
||||||
type: Semantic node type.
|
type: Semantic node type.
|
||||||
interfaces: Declared logical interfaces. None means that the node did
|
interfaces:
|
||||||
not declare an interface set and therefore allows dynamic interface
|
Declared logical interfaces.
|
||||||
allocation. An empty dictionary means that an explicit, fixed
|
None means that the node did not declare an interface set and therefore
|
||||||
interface set was declared and contains zero interfaces.
|
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.
|
settings: Type-specific node settings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
type: NodeType
|
type: NodeType
|
||||||
interfaces: Optional[Dict[str, Interface]]
|
interfaces: Optional[Dict[str, Interface]]
|
||||||
settings: NodeSettings
|
settings: NodeSettings
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -138,11 +146,11 @@ class LinkEndpoint:
|
|||||||
"""Describe one endpoint of a point-to-point logical link.
|
"""Describe one endpoint of a point-to-point logical link.
|
||||||
|
|
||||||
Args:
|
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.
|
interface: Explicit logical interface tag or the AUTO selector.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
node: str
|
node: str
|
||||||
interface: Union[str, LinkInterfaceSelector]
|
interface: Union[str, LinkInterfaceSelector]
|
||||||
|
|
||||||
|
|
||||||
@@ -151,22 +159,23 @@ class LinkSettings:
|
|||||||
"""Contain optional link-specific provisioning settings.
|
"""Contain optional link-specific provisioning settings.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
bandwidth_mbps: Optional link bandwidth limit in megabits per second.
|
bandwidth_mbps: [Optional] link bandwidth limit in megabits per second.
|
||||||
delay_ms: Optional one-way propagation delay in milliseconds.
|
delay_ms: [Optional] one-way propagation delay in milliseconds.
|
||||||
loss_percent: Optional packet-loss percentage.
|
loss_percent: [Optional] packet-loss percentage.
|
||||||
jitter_ms: Optional delay variation in milliseconds.
|
jitter_ms: [Optional] delay variation in milliseconds.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
These fields are reserved as extension points for later Mininet link
|
All four settings are implemented through Mininet TCLink parameters
|
||||||
provisioning, for example through traffic-controlled link classes.
|
in the generated program.
|
||||||
They are parsed now so the semantic model already has a stable place
|
Bandwidth maps to bw, loss to loss, and delay and jitter to millisecond strings.
|
||||||
for link-level configuration.
|
Additional provisioning settings require corresponding parser,
|
||||||
|
validator, planner, and renderer support before they can be used.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
bandwidth_mbps: Optional[float] = None
|
bandwidth_mbps: Optional[float] = None
|
||||||
delay_ms: Optional[float] = None
|
delay_ms: Optional[float] = None
|
||||||
loss_percent: Optional[float] = None
|
loss_percent: Optional[float] = None
|
||||||
jitter_ms: Optional[float] = None
|
jitter_ms: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -175,13 +184,13 @@ class Link:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
endpoints: Ordered pair of link endpoints.
|
endpoints: Ordered pair of link endpoints.
|
||||||
name: Optional logical link name used for diagnostics or extensions.
|
name: [ Optional] logical link name used for diagnostics or extensions.
|
||||||
settings: Link-specific provisioning settings.
|
settings: Link-specific provisioning settings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
endpoints: Tuple[LinkEndpoint, LinkEndpoint]
|
endpoints: Tuple[LinkEndpoint, LinkEndpoint]
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
settings: LinkSettings = field(default_factory=LinkSettings)
|
settings: LinkSettings = field(default_factory=LinkSettings)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -190,11 +199,11 @@ class DHCPRange:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
start: First address in the DHCP allocation range.
|
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
|
start: str
|
||||||
end: str
|
end: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -203,13 +212,14 @@ class DHCPServerSettings:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
address_range: Address range offered to DHCP clients.
|
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
|
address_range: DHCPRange
|
||||||
gateway: Optional[str] = None
|
gateway: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Mutable global
|
||||||
ServiceSettings = DHCPServerSettings
|
ServiceSettings = DHCPServerSettings
|
||||||
|
|
||||||
|
|
||||||
@@ -218,44 +228,48 @@ class Service:
|
|||||||
"""Describe a service attached to a topology node.
|
"""Describe a service attached to a topology node.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
type: Service implementation type.
|
type: Service implementation type.
|
||||||
node: Name of the node on which the service runs.
|
node: Name of the node on which the service runs.
|
||||||
interface: Explicit logical interface tag or a supported special
|
interface: Explicit logical interface tag or a supported special
|
||||||
selector such as AUTO or ALL.
|
selector such as AUTO or ALL.
|
||||||
settings: Type-specific service settings.
|
settings: Type-specific service settings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
type: ServiceType
|
type: ServiceType
|
||||||
node: str
|
node: str
|
||||||
interface: Union[str, ServiceInterfaceSelector]
|
interface: Union[str, ServiceInterfaceSelector]
|
||||||
settings: ServiceSettings
|
settings: ServiceSettings
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Topology:
|
class Topology:
|
||||||
"""Contain the complete parsed topology model.
|
"""Contain the complete parsed topology model.
|
||||||
|
|
||||||
Args:
|
nodes:
|
||||||
nodes: Nodes in declaration order. Keeping nodes as a list preserves
|
Nodes in declaration order. Keeping nodes as a list preserves
|
||||||
the parsed source faithfully, including duplicate names, so that
|
the parsed source faithfully, including duplicate names, so that
|
||||||
the separate validation stage can diagnose them instead of losing
|
the separate validation stage can diagnose them instead of losing
|
||||||
information during parsing.
|
information during parsing.
|
||||||
links: Links in declaration order. Order is preserved because it can
|
links:
|
||||||
make automatic interface allocation deterministic.
|
Links in declaration order. Order is preserved because it can
|
||||||
services: Services in declaration order. Order may later be useful for
|
make automatic interface allocation deterministic.
|
||||||
deterministic startup and shutdown.
|
services:
|
||||||
|
Services in declaration order. Order may later be useful for
|
||||||
|
deterministic startup and shutdown.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
nodes: List[Node] = field(default_factory=list)
|
nodes: List[Node] = field(default_factory=list)
|
||||||
links: List[Link] = field(default_factory=list)
|
links: List[Link] = field(default_factory=list)
|
||||||
services: List[Service] = field(default_factory=list)
|
services: List[Service] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ Parsing filters ============================
|
||||||
|
|
||||||
def _mapping(value: Any, context: str) -> JSONMapping:
|
def _mapping(value: Any, context: str) -> JSONMapping:
|
||||||
"""Return a value as a mapping or raise a parse error.
|
"""Return a value as a mapping or raise a parse error.
|
||||||
|
|
||||||
Args:
|
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.
|
context: Human-readable location used in the error message.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -278,7 +292,7 @@ def _reject_unknown_fields(
|
|||||||
"""Reject object members that are not part of the grammar.
|
"""Reject object members that are not part of the grammar.
|
||||||
|
|
||||||
Args:
|
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.
|
allowed: Set of field names accepted at this grammar location.
|
||||||
context: Human-readable location used in the error message.
|
context: Human-readable location used in the error message.
|
||||||
|
|
||||||
@@ -292,7 +306,7 @@ def _reject_unknown_fields(
|
|||||||
"%s contains unknown field%s: %s"
|
"%s contains unknown field%s: %s"
|
||||||
% (
|
% (
|
||||||
context,
|
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),
|
", ".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.
|
"""Return a value as a list or raise a parse error.
|
||||||
|
|
||||||
Args:
|
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.
|
context: Human-readable location used in the error message.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -321,7 +335,7 @@ def _string(value: Any, context: str) -> str:
|
|||||||
"""Return a value as a string or raise a parse error.
|
"""Return a value as a string or raise a parse error.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value: Value expected to be a string.
|
value: Value expected to be a string.
|
||||||
context: Human-readable location used in the error message.
|
context: Human-readable location used in the error message.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -340,7 +354,7 @@ def _optional_string(value: Any, context: str) -> Optional[str]:
|
|||||||
"""Return an optional string or raise a parse error.
|
"""Return an optional string or raise a parse error.
|
||||||
|
|
||||||
Args:
|
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.
|
context: Human-readable location used in the error message.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -359,7 +373,7 @@ def _optional_bool(value: Any, context: str) -> Optional[bool]:
|
|||||||
"""Return an optional boolean or raise a parse error.
|
"""Return an optional boolean or raise a parse error.
|
||||||
|
|
||||||
Args:
|
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.
|
context: Human-readable location used in the error message.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -380,7 +394,7 @@ def _optional_int(value: Any, context: str) -> Optional[int]:
|
|||||||
"""Return an optional integer or raise a parse error.
|
"""Return an optional integer or raise a parse error.
|
||||||
|
|
||||||
Args:
|
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.
|
context: Human-readable location used in the error message.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -392,6 +406,7 @@ def _optional_int(value: Any, context: str) -> Optional[int]:
|
|||||||
|
|
||||||
if value is None:
|
if value is None:
|
||||||
return 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):
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
raise TopologyParseError("%s must be an integer" % context)
|
raise TopologyParseError("%s must be an integer" % context)
|
||||||
return value
|
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.
|
"""Return an optional numeric value as float or raise a parse error.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value: None or a value expected to be an integer or floating-point
|
value: None or a value expected to be an integer or floating-point number.
|
||||||
number.
|
|
||||||
context: Human-readable location used in the error message.
|
context: Human-readable location used in the error message.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -414,22 +428,42 @@ def _optional_float(value: Any, context: str) -> Optional[float]:
|
|||||||
|
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
# Reject booleans before accepting Python's integer and float types.
|
||||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
raise TopologyParseError("%s must be a number" % context)
|
raise TopologyParseError("%s must be a number" % context)
|
||||||
return float(value)
|
return float(value)
|
||||||
|
|
||||||
|
# ============================= Entity parsing =============================
|
||||||
|
|
||||||
def _parse_interface(name: str, raw: Any, context: str) -> Interface:
|
def _parse_interface(name: str, raw: Any, context: str) -> Interface:
|
||||||
"""Parse one interface definition.
|
"""Parse one interface definition.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Logical interface tag taken from the JSON object key.
|
name: Logical interface tag taken from the JSON object key.
|
||||||
raw: Raw JSON value containing interface properties.
|
raw: Raw JSON value containing interface properties.
|
||||||
context: Human-readable location used in error messages.
|
context: Human-readable location used in error messages.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Parsed Interface object.
|
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:
|
Raises:
|
||||||
TopologyParseError: If the interface cannot be represented.
|
TopologyParseError: If the interface cannot be represented.
|
||||||
"""
|
"""
|
||||||
@@ -464,16 +498,34 @@ def _parse_interfaces(raw_node: JSONMapping, context: str) -> Optional[Dict[str,
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
raw_node: Raw JSON node object.
|
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:
|
Returns:
|
||||||
None when the interfaces field is omitted, otherwise a dictionary of
|
- None when the interfaces field is omitted (dynamic), otherwise
|
||||||
logical interface tags to Interface objects.
|
- 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:
|
Raises:
|
||||||
TopologyParseError: If the interfaces field has an invalid shape.
|
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:
|
if "interfaces" not in raw_node:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -496,12 +548,23 @@ def _parse_node_settings(node_type: NodeType, raw: Any, context: str) -> NodeSet
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
node_type: Parsed semantic node type.
|
node_type: Parsed semantic node type.
|
||||||
raw: Raw JSON settings object or None.
|
raw: Raw JSON settings object or None.
|
||||||
context: Human-readable node location used in error messages.
|
context: Human-readable node location used in error messages.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Type-specific node settings object.
|
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:
|
Raises:
|
||||||
TopologyParseError: If the settings object has an invalid shape or
|
TopologyParseError: If the settings object has an invalid shape or
|
||||||
contains values that cannot be represented by the selected type.
|
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.
|
"""Parse one node declaration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
raw: Raw JSON node object.
|
raw: Raw JSON node object.
|
||||||
index: Node index in the source array, used in diagnostics.
|
index: Node index in the source array, used in diagnostics.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Parsed Node object.
|
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:
|
Raises:
|
||||||
TopologyParseError: If the node cannot be represented.
|
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.
|
"""Parse a link endpoint interface reference.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value: Raw interface reference.
|
value: Raw interface reference.
|
||||||
context: Human-readable location used in error messages.
|
context: Human-readable location used in error messages.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -598,7 +683,7 @@ def _parse_link_endpoint(raw: Any, context: str) -> LinkEndpoint:
|
|||||||
"""Parse one link endpoint.
|
"""Parse one link endpoint.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
raw: Raw JSON endpoint object.
|
raw: Raw JSON endpoint object.
|
||||||
context: Human-readable location used in error messages.
|
context: Human-readable location used in error messages.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -624,7 +709,7 @@ def _parse_link_settings(raw: Any, context: str) -> LinkSettings:
|
|||||||
"""Parse optional link-specific provisioning settings.
|
"""Parse optional link-specific provisioning settings.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
raw: Raw JSON settings object or None.
|
raw: Raw JSON settings object or None.
|
||||||
context: Human-readable link location used in error messages.
|
context: Human-readable link location used in error messages.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -665,12 +750,35 @@ def _parse_link(raw: Any, index: int) -> Link:
|
|||||||
"""Parse one link declaration.
|
"""Parse one link declaration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
raw: Raw JSON link object.
|
raw: Raw JSON link object.
|
||||||
index: Link index in the source array, used in diagnostics.
|
index: Link index in the source array, used in diagnostics.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Parsed Link object.
|
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:
|
Raises:
|
||||||
TopologyParseError: If the link cannot be represented.
|
TopologyParseError: If the link cannot be represented.
|
||||||
"""
|
"""
|
||||||
@@ -712,7 +820,7 @@ def _parse_service_interface(
|
|||||||
"""Parse a service interface binding.
|
"""Parse a service interface binding.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value: Raw service interface reference.
|
value: Raw service interface reference.
|
||||||
context: Human-readable location used in error messages.
|
context: Human-readable location used in error messages.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -735,12 +843,32 @@ def _parse_dhcp_server_settings(raw: Any, context: str) -> DHCPServerSettings:
|
|||||||
"""Parse DHCP-server-specific settings.
|
"""Parse DHCP-server-specific settings.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
raw: Raw JSON DHCP server settings object.
|
raw: Raw JSON DHCP server settings object.
|
||||||
context: Human-readable location used in error messages.
|
context: Human-readable location used in error messages.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Parsed DHCPServerSettings object.
|
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:
|
Raises:
|
||||||
TopologyParseError: If required structural fields are missing or have
|
TopologyParseError: If required structural fields are missing or have
|
||||||
incompatible JSON types.
|
incompatible JSON types.
|
||||||
@@ -767,12 +895,43 @@ def _parse_service(raw: Any, index: int) -> Service:
|
|||||||
"""Parse one service declaration.
|
"""Parse one service declaration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
raw: Raw JSON service object.
|
raw: Raw JSON service object.
|
||||||
index: Service index in the source array, used in diagnostics.
|
index: Service index in the source array, used in diagnostics.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Parsed Service object.
|
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:
|
Raises:
|
||||||
TopologyParseError: If the service cannot be represented.
|
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:
|
def parse_topology(raw: Mapping[str, Any]) -> Topology:
|
||||||
"""Parse a raw JSON dictionary into the typed topology model.
|
"""Parse a raw JSON dictionary into the typed topology model.
|
||||||
|
|
||||||
@@ -828,8 +991,8 @@ def parse_topology(raw: Mapping[str, Any]) -> Topology:
|
|||||||
topology grammar.
|
topology grammar.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
This function intentionally does not validate cross references such as
|
This function intentionally does not validate cross-references such as
|
||||||
whether a link references an existing node. Those checks belong to
|
whether a link references an existing node. Those checks belong to
|
||||||
the separate validation stage.
|
the separate validation stage.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -858,6 +1021,9 @@ def parse_topology(raw: Mapping[str, Any]) -> Topology:
|
|||||||
return Topology(nodes=nodes, links=links, services=services)
|
return Topology(nodes=nodes, links=links, services=services)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# =============================== Self-test ===============================
|
||||||
|
|
||||||
def _self_test() -> None:
|
def _self_test() -> None:
|
||||||
"""Run a small smoke test against the public parse_topology() interface."""
|
"""Run a small smoke test against the public parse_topology() interface."""
|
||||||
|
|
||||||
|
|||||||
+75
-10
@@ -32,6 +32,8 @@ from topology_parser import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================== Execution plan model ==========================
|
||||||
|
|
||||||
class TopologyPlanError(ValueError):
|
class TopologyPlanError(ValueError):
|
||||||
"""Raised when a validated topology cannot be lowered into a plan."""
|
"""Raised when a validated topology cannot be lowered into a plan."""
|
||||||
|
|
||||||
@@ -182,6 +184,12 @@ class PlannedService:
|
|||||||
class ExecutionPlan:
|
class ExecutionPlan:
|
||||||
"""Contain the complete, fully resolved execution plan.
|
"""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:
|
Args:
|
||||||
nodes: Node creation operations in declaration order.
|
nodes: Node creation operations in declaration order.
|
||||||
links: Link creation operations in declaration order.
|
links: Link creation operations in declaration order.
|
||||||
@@ -201,21 +209,45 @@ class ExecutionPlan:
|
|||||||
services: Tuple[PlannedService, ...]
|
services: Tuple[PlannedService, ...]
|
||||||
|
|
||||||
|
|
||||||
|
# ======================== Mutable planning context ========================
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _PlanningContext:
|
class _PlanningContext:
|
||||||
"""Hold mutable state used while lowering a topology.
|
"""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:
|
Args:
|
||||||
nodes: Nodes indexed by logical name.
|
nodes: Source nodes indexed by name, safe after uniqueness validation.
|
||||||
reserved_interfaces: Explicitly referenced logical interfaces per node.
|
reserved_interfaces: All explicit link references per node, used to
|
||||||
free_interfaces: Remaining declared interfaces available to AUTO.
|
construct free pools and retained unchanged during allocation.
|
||||||
dynamic_interface_counters: Synthetic logical-name counters for nodes
|
free_interfaces: Ordered lists of unreserved declared tags for fixed
|
||||||
whose interface sets were omitted.
|
pools; AUTO removes the first entry.
|
||||||
physical_interface_counters: Concrete eth-index counters per node.
|
An empty list means exhaustion; dynamic nodes have no entry.
|
||||||
interface_names: Mapping from resolved logical interfaces to concrete
|
dynamic_interface_counters: Next synthetic logical-tag indices, such
|
||||||
Linux/Mininet interface names.
|
as auto0; advanced only for nodes with omitted interface sets.
|
||||||
interface_models: Mapping from resolved logical interfaces to their
|
physical_interface_counters: Next concrete eth-index per node;
|
||||||
source Interface objects.
|
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]
|
nodes: Dict[str, Node]
|
||||||
@@ -227,9 +259,15 @@ class _PlanningContext:
|
|||||||
interface_models: Dict[Tuple[str, str], Interface]
|
interface_models: Dict[Tuple[str, str], Interface]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================= Context initialization =========================
|
||||||
|
|
||||||
def _build_context(topology: Topology) -> _PlanningContext:
|
def _build_context(topology: Topology) -> _PlanningContext:
|
||||||
"""Create planning state and reserve all explicit link interfaces.
|
"""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:
|
Args:
|
||||||
topology: Validated topology to lower.
|
topology: Validated topology to lower.
|
||||||
|
|
||||||
@@ -267,6 +305,8 @@ def _build_context(topology: Topology) -> _PlanningContext:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================= Node and link planning =========================
|
||||||
|
|
||||||
def _plan_nodes(topology: Topology) -> Tuple[PlannedNode, ...]:
|
def _plan_nodes(topology: Topology) -> Tuple[PlannedNode, ...]:
|
||||||
"""Lower topology nodes into concrete node creation operations.
|
"""Lower topology nodes into concrete node creation operations.
|
||||||
|
|
||||||
@@ -296,6 +336,11 @@ def _allocate_logical_interface(
|
|||||||
) -> Tuple[str, Interface]:
|
) -> Tuple[str, Interface]:
|
||||||
"""Resolve one link endpoint to a concrete logical 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:
|
Args:
|
||||||
node: Node that owns the endpoint.
|
node: Node that owns the endpoint.
|
||||||
selector: Explicit interface tag or AUTO selector.
|
selector: Explicit interface tag or AUTO selector.
|
||||||
@@ -373,6 +418,7 @@ def _allocate_physical_interface(
|
|||||||
context.physical_interface_counters[node_name] = index + 1
|
context.physical_interface_counters[node_name] = index + 1
|
||||||
|
|
||||||
interface_name = "%s-eth%d" % (node_name, index)
|
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_names[key] = interface_name
|
||||||
context.interface_models[key] = interface
|
context.interface_models[key] = interface
|
||||||
|
|
||||||
@@ -385,6 +431,11 @@ def _plan_links(
|
|||||||
) -> Tuple[PlannedLink, ...]:
|
) -> Tuple[PlannedLink, ...]:
|
||||||
"""Resolve all link endpoints and concrete interface names.
|
"""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:
|
Args:
|
||||||
topology: Validated topology to lower.
|
topology: Validated topology to lower.
|
||||||
context: Mutable planning context.
|
context: Mutable planning context.
|
||||||
@@ -436,6 +487,8 @@ def _plan_links(
|
|||||||
return tuple(planned_links)
|
return tuple(planned_links)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Interface and node configuration ====================
|
||||||
|
|
||||||
def _interface_requires_materialization(interface: Interface) -> bool:
|
def _interface_requires_materialization(interface: Interface) -> bool:
|
||||||
"""Return whether an unused declared interface carries configuration.
|
"""Return whether an unused declared interface carries configuration.
|
||||||
|
|
||||||
@@ -462,6 +515,12 @@ def _plan_interfaces(
|
|||||||
) -> Tuple[PlannedInterface, ...]:
|
) -> Tuple[PlannedInterface, ...]:
|
||||||
"""Create interface-configuration operations for materialized interfaces.
|
"""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:
|
Args:
|
||||||
topology: Validated topology to lower.
|
topology: Validated topology to lower.
|
||||||
context: Planning context containing resolved interface mappings.
|
context: Planning context containing resolved interface mappings.
|
||||||
@@ -581,6 +640,8 @@ def _plan_node_settings(
|
|||||||
return tuple(planned)
|
return tuple(planned)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ Service planning ============================
|
||||||
|
|
||||||
def _resolve_service_logical_interface(
|
def _resolve_service_logical_interface(
|
||||||
service: Service,
|
service: Service,
|
||||||
node: Node,
|
node: Node,
|
||||||
@@ -694,6 +755,8 @@ def _plan_services(
|
|||||||
return tuple(planned)
|
return tuple(planned)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ Public interface ============================
|
||||||
|
|
||||||
def plan(topology: Topology) -> ExecutionPlan:
|
def plan(topology: Topology) -> ExecutionPlan:
|
||||||
"""Lower a validated topology into a fully resolved execution plan.
|
"""Lower a validated topology into a fully resolved execution plan.
|
||||||
|
|
||||||
@@ -730,6 +793,8 @@ def plan(topology: Topology) -> ExecutionPlan:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================== Self-test ===============================
|
||||||
|
|
||||||
def _self_test() -> None:
|
def _self_test() -> None:
|
||||||
"""Run a smoke test against the public plan() interface."""
|
"""Run a smoke test against the public plan() interface."""
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ from topology_plan import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================== Rendering helpers ===========================
|
||||||
|
|
||||||
def _python_string(value: str) -> str:
|
def _python_string(value: str) -> str:
|
||||||
"""Return a safe Python string literal.
|
"""Return a safe Python string literal.
|
||||||
|
|
||||||
@@ -187,6 +189,12 @@ def _render_gateway_configuration(interface: PlannedInterface) -> List[str]:
|
|||||||
def _render_service_start(service: PlannedService, index: int) -> List[str]:
|
def _render_service_start(service: PlannedService, index: int) -> List[str]:
|
||||||
"""Render startup code for one planned service.
|
"""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:
|
Args:
|
||||||
service: Fully resolved service operation.
|
service: Fully resolved service operation.
|
||||||
index: Stable service index used for temporary runtime files.
|
index: Stable service index used for temporary runtime files.
|
||||||
@@ -238,9 +246,18 @@ def _render_service_start(service: PlannedService, index: int) -> List[str]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ Public interface ============================
|
||||||
|
|
||||||
def render_python(plan: ExecutionPlan) -> str:
|
def render_python(plan: ExecutionPlan) -> str:
|
||||||
"""Render an execution plan as a complete executable Python program.
|
"""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:
|
Args:
|
||||||
plan: Fully resolved execution plan returned by topology_plan.plan().
|
plan: Fully resolved execution plan returned by topology_plan.plan().
|
||||||
|
|
||||||
@@ -377,6 +394,8 @@ def render_python(plan: ExecutionPlan) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================== Self-test ===============================
|
||||||
|
|
||||||
def _self_test() -> None:
|
def _self_test() -> None:
|
||||||
"""Run a smoke test against the public render_python() interface."""
|
"""Run a smoke test against the public render_python() interface."""
|
||||||
|
|
||||||
|
|||||||
+268
-80
@@ -3,11 +3,9 @@
|
|||||||
This module implements the validation stage of the topology pipeline.
|
This module implements the validation stage of the topology pipeline.
|
||||||
|
|
||||||
The parser answers:
|
The parser answers:
|
||||||
|
|
||||||
"Can this JSON be represented by the topology grammar?"
|
"Can this JSON be represented by the topology grammar?"
|
||||||
|
|
||||||
The validator answers:
|
The validator answers:
|
||||||
|
|
||||||
"Does the parsed topology describe a coherent network?"
|
"Does the parsed topology describe a coherent network?"
|
||||||
|
|
||||||
Validation is intentionally independent of Mininet. It does not create
|
Validation is intentionally independent of Mininet. It does not create
|
||||||
@@ -18,11 +16,11 @@ validation fails.
|
|||||||
Author: Christos Choutouridis <cchoutou@ece.auth.gr>
|
Author: Christos Choutouridis <cchoutou@ece.auth.gr>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
from typing import Dict, List, Optional, Sequence, Set, Tuple
|
from typing import Dict, List, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
from topology_parser import (
|
from topology_parser import (
|
||||||
AddressingMode,
|
AddressingMode,
|
||||||
@@ -43,6 +41,8 @@ from topology_parser import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================== Validation constants ==========================
|
||||||
|
|
||||||
_RESERVED_INTERFACE_TAGS = {
|
_RESERVED_INTERFACE_TAGS = {
|
||||||
LinkInterfaceSelector.AUTO.value,
|
LinkInterfaceSelector.AUTO.value,
|
||||||
ServiceInterfaceSelector.ALL.value,
|
ServiceInterfaceSelector.ALL.value,
|
||||||
@@ -53,6 +53,8 @@ _MAC_ADDRESS_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ====================== Diagnostics and lookup state ======================
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ValidationIssue:
|
class ValidationIssue:
|
||||||
"""Describe one semantic topology validation failure.
|
"""Describe one semantic topology validation failure.
|
||||||
@@ -63,7 +65,7 @@ class ValidationIssue:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
location: str
|
location: str
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
class TopologyValidationError(ValueError):
|
class TopologyValidationError(ValueError):
|
||||||
@@ -96,7 +98,7 @@ class TopologyValidationError(ValueError):
|
|||||||
count = len(self.issues)
|
count = len(self.issues)
|
||||||
header = "%d topology validation error%s" % (
|
header = "%d topology validation error%s" % (
|
||||||
count,
|
count,
|
||||||
"" if count == 1 else "s",
|
"" if count == 1 else "s", # ;)
|
||||||
)
|
)
|
||||||
|
|
||||||
lines = [header]
|
lines = [header]
|
||||||
@@ -110,26 +112,57 @@ class TopologyValidationError(ValueError):
|
|||||||
class _NodeIndex:
|
class _NodeIndex:
|
||||||
"""Hold node lookup information prepared for validation.
|
"""Hold node lookup information prepared for validation.
|
||||||
|
|
||||||
|
_build_node_index() retains the first declaration for each nonempty name
|
||||||
|
and records repeated names separately while reporting duplicate issues.
|
||||||
|
Link and service checks consult duplicate_names before resolving a known
|
||||||
|
node, avoiding secondary diagnostics based on an ambiguous declaration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
nodes: Unique node declarations indexed by name.
|
nodes: First declaration for each distinct nonempty node name.
|
||||||
duplicate_names: Node names that were declared more than once.
|
duplicate_names: Node names that were declared more than once.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Suppose the source list contains three Node objects:
|
||||||
|
topology.nodes = [first_h1, s1, second_h1]
|
||||||
|
first_h1.name = "h1"
|
||||||
|
s1.name = "s1"
|
||||||
|
second_h1.name = "h1"
|
||||||
|
|
||||||
|
The resulting lookup has this shape:
|
||||||
|
node_index = _NodeIndex(
|
||||||
|
nodes={"h1": first_h1, "s1": s1},
|
||||||
|
duplicate_names={"h1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
Dictionary keys are logical node names, and values reference the
|
||||||
|
original Node objects, not copies or positions in the source list.
|
||||||
|
node_index.nodes["s1"] therefore gives the s1 object directly.
|
||||||
|
The duplicate set contains names only; it is empty for unique names.
|
||||||
|
|
||||||
|
Membership in nodes means a declaration exists, not that it is unique.
|
||||||
|
Although nodes["h1"] retains first_h1, duplicate_names marks that
|
||||||
|
reference as ambiguous so link and service checks skip resolving it.
|
||||||
|
The original topology.nodes list still contains both h1 declarations.
|
||||||
|
Empty names are reported as issues and excluded from both collections.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
nodes: Dict[str, Node]
|
nodes: Dict[str, Node]
|
||||||
duplicate_names: Set[str]
|
duplicate_names: Set[str]
|
||||||
|
|
||||||
|
|
||||||
|
# =========================== Validation helpers ===========================
|
||||||
|
|
||||||
def _add_issue(
|
def _add_issue(
|
||||||
issues: List[ValidationIssue],
|
issues: List[ValidationIssue],
|
||||||
location: str,
|
location: str,
|
||||||
message: str,
|
message: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Append one validation issue.
|
"""Append one validation issue.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
issues: Mutable list receiving validation failures.
|
issues: Mutable list receiving validation failures.
|
||||||
location: Logical location associated with the failure.
|
location: Logical location associated with the failure.
|
||||||
message: Human-readable description of the failure.
|
message: Human-readable description of the failure.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
issues.append(ValidationIssue(location=location, message=message))
|
issues.append(ValidationIssue(location=location, message=message))
|
||||||
@@ -143,13 +176,21 @@ def _build_node_index(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
topology: Parsed topology being validated.
|
topology: Parsed topology being validated.
|
||||||
issues: Mutable list receiving validation failures.
|
issues: Mutable list receiving validation failures.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Node lookup information for later validation passes.
|
Node lookup information for later validation passes.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
The returned _NodeIndex holds name-to-object lookup and ambiguity
|
||||||
|
information; validation issues are appended to the supplied list.
|
||||||
|
first_index is a temporary name-to-source-position map used to point
|
||||||
|
duplicate diagnostics back to the first declaration.
|
||||||
|
It is not part of the returned index, whose shape is illustrated in
|
||||||
|
the _NodeIndex class docstring.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
nodes: Dict[str, Node] = {}
|
nodes: Dict[str, Node] = {}
|
||||||
first_index: Dict[str, int] = {}
|
first_index: Dict[str, int] = {}
|
||||||
duplicate_names: Set[str] = set()
|
duplicate_names: Set[str] = set()
|
||||||
|
|
||||||
@@ -179,17 +220,21 @@ def _build_node_index(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ Validation core =============================
|
||||||
|
|
||||||
|
|
||||||
def _validate_node_settings(
|
def _validate_node_settings(
|
||||||
node: Node,
|
node: Node,
|
||||||
location: str,
|
location: str,
|
||||||
issues: List[ValidationIssue],
|
issues: List[ValidationIssue],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Validate that a node carries settings matching its semantic type.
|
"""Validate that a node carries settings matching its semantic type.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
node: Node whose settings object is checked.
|
node: Node whose settings object is checked.
|
||||||
location: Logical location of the node.
|
location: Logical location of the node (where node data came from).
|
||||||
issues: Mutable list receiving validation failures.
|
issues: Mutable list receiving validation failures.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
expected_type = {
|
expected_type = {
|
||||||
@@ -206,20 +251,46 @@ def _validate_node_settings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_ipv4_interface(
|
def _validate_ipv4_interface(
|
||||||
value: str,
|
value: str,
|
||||||
location: str,
|
location: str,
|
||||||
issues: List[ValidationIssue],
|
issues: List[ValidationIssue],
|
||||||
) -> Optional[ipaddress.IPv4Interface]:
|
) -> Optional[ipaddress.IPv4Interface]:
|
||||||
"""Parse and validate one IPv4 CIDR interface address.
|
"""Validate one IPv4 CIDR interface address.
|
||||||
|
|
||||||
|
Require an explicit prefix and convert the string to an IPv4Interface for
|
||||||
|
subsequent subnet checks.
|
||||||
|
Invalid syntax or an unsupported address family records a validation issue
|
||||||
|
at the supplied location instead of raising a parsing exception.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value: Address string expected to contain an IPv4 address and prefix.
|
value: Address string expected to contain an IPv4 address and prefix.
|
||||||
location: Logical location associated with the address.
|
location: Logical location associated with the address(where address string--value-- came from).
|
||||||
issues: Mutable list receiving validation failures.
|
issues: Mutable list receiving validation failures.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Parsed IPv4Interface on success, otherwise None.
|
IPv4Interface on success, otherwise None after recording an issue.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Input:
|
||||||
|
value = "192.168.1.10/24"
|
||||||
|
location = "nodes[0].interfaces.lan.address"
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
Output (result names the return value):
|
||||||
|
result = ipaddress.IPv4Interface("192.168.1.10/24")
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
With value = "192.168.1.10" and a fresh empty issues list:
|
||||||
|
result = None
|
||||||
|
issues = [
|
||||||
|
ValidationIssue(
|
||||||
|
location="nodes[0].interfaces.lan.address",
|
||||||
|
message="static address must use CIDR notation",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
Failure appends to the supplied list rather than raising an exception.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if "/" not in value:
|
if "/" not in value:
|
||||||
@@ -243,20 +314,47 @@ def _parse_ipv4_interface(
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def _parse_ipv4_address(
|
def _validate_ipv4_address(
|
||||||
value: str,
|
value: str,
|
||||||
location: str,
|
location: str,
|
||||||
issues: List[ValidationIssue],
|
issues: List[ValidationIssue],
|
||||||
) -> Optional[ipaddress.IPv4Address]:
|
) -> Optional[ipaddress.IPv4Address]:
|
||||||
"""Parse and validate one IPv4 address without a prefix.
|
"""Validate one IPv4 address without a prefix.
|
||||||
|
|
||||||
|
Convert the string to an IPv4Address for subsequent semantic checks.
|
||||||
|
Invalid syntax or an unsupported address family records a validation issue
|
||||||
|
at the supplied location instead of raising a parsing exception.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value: Address string expected to contain one IPv4 address.
|
value: Address string expected to contain one IPv4 address.
|
||||||
location: Logical location associated with the address.
|
location: Logical location associated with the address (where value string came from).
|
||||||
issues: Mutable list receiving validation failures.
|
issues: Mutable list receiving validation failures.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Parsed IPv4Address on success, otherwise None.
|
IPv4Address on success, otherwise None after recording an issue.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Input:
|
||||||
|
value = "192.168.1.1"
|
||||||
|
location = "nodes[0].interfaces.lan.gateway"
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
Output (result names the return value):
|
||||||
|
result = ipaddress.IPv4Address("192.168.1.1")
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
With value = "::1" and a fresh empty issues list:
|
||||||
|
result = None
|
||||||
|
issues = [
|
||||||
|
ValidationIssue(
|
||||||
|
location="nodes[0].interfaces.lan.gateway",
|
||||||
|
message="only IPv4 addresses are supported",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
A prefixed value such as "192.168.1.1/24" also returns None and
|
||||||
|
appends an invalid-IPv4-address issue, since this helper expects
|
||||||
|
an address without a prefix.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -273,18 +371,18 @@ def _parse_ipv4_address(
|
|||||||
|
|
||||||
|
|
||||||
def _validate_interface(
|
def _validate_interface(
|
||||||
node: Node,
|
node: Node,
|
||||||
interface: Interface,
|
interface: Interface,
|
||||||
location: str,
|
location: str,
|
||||||
issues: List[ValidationIssue],
|
issues: List[ValidationIssue],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Validate one declared logical interface.
|
"""Validate one declared logical interface.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
node: Node that owns the interface.
|
node: Node that owns the interface.
|
||||||
interface: Interface being validated.
|
interface: Interface being validated.
|
||||||
location: Logical location of the interface.
|
location: Logical location of the interface (where the interface data came from).
|
||||||
issues: Mutable list receiving validation failures.
|
issues: Mutable list receiving validation failures.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not interface.name:
|
if not interface.name:
|
||||||
@@ -314,16 +412,18 @@ def _validate_interface(
|
|||||||
"static addressing requires an address",
|
"static addressing requires an address",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
parsed_address = _parse_ipv4_interface(
|
parsed_address = _validate_ipv4_interface(
|
||||||
interface.address,
|
interface.address,
|
||||||
location + ".address",
|
location + ".address",
|
||||||
issues,
|
issues,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# For subnets larger than two(2) addresses, reject assigning the network or broadcast
|
||||||
|
# address to an interface.
|
||||||
if parsed_address is not None:
|
if parsed_address is not None:
|
||||||
network = parsed_address.network
|
network = parsed_address.network
|
||||||
if (
|
if (
|
||||||
network.num_addresses > 2
|
network.num_addresses > 2 # /30 and up
|
||||||
and parsed_address.ip in {network.network_address, network.broadcast_address}
|
and parsed_address.ip in {network.network_address, network.broadcast_address}
|
||||||
):
|
):
|
||||||
_add_issue(
|
_add_issue(
|
||||||
@@ -333,7 +433,7 @@ def _validate_interface(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if interface.gateway is not None:
|
if interface.gateway is not None:
|
||||||
parsed_gateway = _parse_ipv4_address(
|
parsed_gateway = _validate_ipv4_address(
|
||||||
interface.gateway,
|
interface.gateway,
|
||||||
location + ".gateway",
|
location + ".gateway",
|
||||||
issues,
|
issues,
|
||||||
@@ -353,6 +453,7 @@ def _validate_interface(
|
|||||||
"gateway must not be the interface's own address",
|
"gateway must not be the interface's own address",
|
||||||
)
|
)
|
||||||
elif (
|
elif (
|
||||||
|
# Same here, /30 and up
|
||||||
parsed_address.network.num_addresses > 2
|
parsed_address.network.num_addresses > 2
|
||||||
and parsed_gateway
|
and parsed_gateway
|
||||||
in {
|
in {
|
||||||
@@ -398,7 +499,7 @@ def _validate_interface(
|
|||||||
|
|
||||||
def _validate_nodes(
|
def _validate_nodes(
|
||||||
topology: Topology,
|
topology: Topology,
|
||||||
issues: List[ValidationIssue],
|
issues: List[ValidationIssue],
|
||||||
) -> _NodeIndex:
|
) -> _NodeIndex:
|
||||||
"""Validate node declarations and their local interface configuration.
|
"""Validate node declarations and their local interface configuration.
|
||||||
|
|
||||||
@@ -413,6 +514,7 @@ def _validate_nodes(
|
|||||||
node_index = _build_node_index(topology, issues)
|
node_index = _build_node_index(topology, issues)
|
||||||
|
|
||||||
for node_number, node in enumerate(topology.nodes):
|
for node_number, node in enumerate(topology.nodes):
|
||||||
|
# keep node's location for error reporting
|
||||||
node_location = "nodes[%d]" % node_number
|
node_location = "nodes[%d]" % node_number
|
||||||
|
|
||||||
_validate_node_settings(node, node_location, issues)
|
_validate_node_settings(node, node_location, issues)
|
||||||
@@ -423,12 +525,14 @@ def _validate_nodes(
|
|||||||
gateway_locations: List[str] = []
|
gateway_locations: List[str] = []
|
||||||
|
|
||||||
for interface_name, interface in node.interfaces.items():
|
for interface_name, interface in node.interfaces.items():
|
||||||
|
# keep interface's location for error reporting
|
||||||
interface_location = "%s.interfaces.%s" % (
|
interface_location = "%s.interfaces.%s" % (
|
||||||
node_location,
|
node_location,
|
||||||
interface_name,
|
interface_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
if interface.name != interface_name:
|
if interface.name != interface_name:
|
||||||
|
# yes I have name inside the interface
|
||||||
_add_issue(
|
_add_issue(
|
||||||
issues,
|
issues,
|
||||||
interface_location,
|
interface_location,
|
||||||
@@ -437,10 +541,7 @@ def _validate_nodes(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_validate_interface(
|
_validate_interface(
|
||||||
node,
|
node, interface, interface_location, issues
|
||||||
interface,
|
|
||||||
interface_location,
|
|
||||||
issues,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if interface.gateway is not None:
|
if interface.gateway is not None:
|
||||||
@@ -457,24 +558,26 @@ def _validate_nodes(
|
|||||||
return node_index
|
return node_index
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ Link validation ============================
|
||||||
|
|
||||||
def _validate_link_settings(
|
def _validate_link_settings(
|
||||||
settings: LinkSettings,
|
settings: LinkSettings,
|
||||||
location: str,
|
location: str,
|
||||||
issues: List[ValidationIssue],
|
issues: List[ValidationIssue],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Validate optional link provisioning settings.
|
"""Validate optional link provisioning settings.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
settings: Link settings being validated.
|
settings: Link settings being validated.
|
||||||
location: Logical location of the settings object.
|
location: Logical location of the settings object (where the settings data came from).
|
||||||
issues: Mutable list receiving validation failures.
|
issues: Mutable list receiving validation failures.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
numeric_settings = {
|
numeric_settings = {
|
||||||
"bandwidth_mbps": settings.bandwidth_mbps,
|
"bandwidth_mbps": settings.bandwidth_mbps,
|
||||||
"delay_ms": settings.delay_ms,
|
"delay_ms": settings.delay_ms,
|
||||||
"loss_percent": settings.loss_percent,
|
"loss_percent": settings.loss_percent,
|
||||||
"jitter_ms": settings.jitter_ms,
|
"jitter_ms": settings.jitter_ms,
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, value in numeric_settings.items():
|
for name, value in numeric_settings.items():
|
||||||
@@ -525,7 +628,7 @@ def _validate_link_names(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
topology: Parsed topology being validated.
|
topology: Parsed topology being validated.
|
||||||
issues: Mutable list receiving validation failures.
|
issues: Mutable list receiving validation failures.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
first_index: Dict[str, int] = {}
|
first_index: Dict[str, int] = {}
|
||||||
@@ -559,10 +662,28 @@ def _validate_links(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Validate links, endpoint references, and interface allocation capacity.
|
"""Validate links, endpoint references, and interface allocation capacity.
|
||||||
|
|
||||||
|
First collect valid explicit interface usage and AUTO requests while
|
||||||
|
checking endpoint references and rejecting explicit reuse.
|
||||||
|
Then compare each fixed pool's AUTO demand with its remaining capacity
|
||||||
|
after all explicit reservations are known.
|
||||||
|
This checks allocation feasibility without selecting concrete interfaces;
|
||||||
|
dynamic pools need no capacity check.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
topology: Parsed topology being validated.
|
topology: Parsed topology being validated.
|
||||||
node_index: Node lookup information prepared by node validation.
|
node_index: Node lookup information prepared by node validation.
|
||||||
issues: Mutable list receiving validation failures.
|
issues: Mutable list receiving validation failures.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
For otherwise valid links involving a fixed pool on an example switch s1:
|
||||||
|
Declared interfaces: p1, p2
|
||||||
|
Explicit reservation: p2
|
||||||
|
AUTO requests: 2
|
||||||
|
Remaining capacity: 1
|
||||||
|
Result: None; one capacity issue appended
|
||||||
|
|
||||||
|
With only one AUTO request, no capacity issue is appended.
|
||||||
|
Neither case consumes the pool or assigns an interface to AUTO.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_validate_link_names(topology, issues)
|
_validate_link_names(topology, issues)
|
||||||
@@ -578,6 +699,7 @@ def _validate_links(
|
|||||||
)
|
)
|
||||||
|
|
||||||
for endpoint_index, endpoint in enumerate(link.endpoints):
|
for endpoint_index, endpoint in enumerate(link.endpoints):
|
||||||
|
# keep endpoint's location for error reporting
|
||||||
endpoint_location = "links[%d].endpoints[%d]" % (
|
endpoint_location = "links[%d].endpoints[%d]" % (
|
||||||
link_index,
|
link_index,
|
||||||
endpoint_index,
|
endpoint_index,
|
||||||
@@ -651,9 +773,11 @@ def _validate_links(
|
|||||||
|
|
||||||
used_explicit[key] = endpoint_location
|
used_explicit[key] = endpoint_location
|
||||||
|
|
||||||
|
# Count AUTO capacity only after collecting every explicit reservation.
|
||||||
for node_name, request_locations in auto_requests.items():
|
for node_name, request_locations in auto_requests.items():
|
||||||
node = node_index.nodes[node_name]
|
node = node_index.nodes[node_name]
|
||||||
|
|
||||||
|
# Dynamic check
|
||||||
if node.interfaces is None:
|
if node.interfaces is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -670,39 +794,58 @@ def _validate_links(
|
|||||||
issues,
|
issues,
|
||||||
"links",
|
"links",
|
||||||
"node %r has %d automatic interface request%s but only %d "
|
"node %r has %d automatic interface request%s but only %d "
|
||||||
"unused declared interface%s remain%s"
|
"unused declared interface%s remain%s" % (
|
||||||
% (
|
|
||||||
node_name,
|
node_name,
|
||||||
len(request_locations),
|
len(request_locations),
|
||||||
"" if len(request_locations) == 1 else "s",
|
"" if len(request_locations) == 1 else "s",
|
||||||
free_count,
|
free_count,
|
||||||
"" if free_count == 1 else "s",
|
"" if free_count == 1 else "s",
|
||||||
"; %d request%s cannot be satisfied"
|
"; %d request%s cannot be satisfied" % (
|
||||||
% (
|
|
||||||
shortage,
|
shortage,
|
||||||
"" if shortage == 1 else "s",
|
"" if shortage == 1 else "s",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
# Ok, laugh as you want, but I like my plurals!
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================== Service validation ===========================
|
||||||
|
|
||||||
def _resolve_service_interface(
|
def _resolve_service_interface(
|
||||||
service: Service,
|
service: Service,
|
||||||
service_location: str,
|
service_location: str,
|
||||||
node: Node,
|
node: Node,
|
||||||
issues: List[ValidationIssue],
|
issues: List[ValidationIssue],
|
||||||
) -> Optional[Interface]:
|
) -> Optional[Interface]:
|
||||||
"""Resolve a service binding to one declared interface when possible.
|
"""Resolve a service binding to one declared interface when possible.
|
||||||
|
|
||||||
|
Explicit tags select a declared model directly; AUTO requires exactly one
|
||||||
|
declared interface with static addressing.
|
||||||
|
Service AUTO never allocates an interface or consumes a link's free pool.
|
||||||
|
ALL is left unresolved for the service-specific validator to diagnose.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
service: Service whose interface binding is resolved.
|
service: Service whose interface binding is resolved.
|
||||||
service_location: Logical location of the service.
|
service_location: Logical location of the service (whre service data came from).
|
||||||
node: Node on which the service runs.
|
node: Node on which the service runs.
|
||||||
issues: Mutable list receiving validation failures.
|
issues: Mutable list receiving validation failures.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Resolved Interface when the binding identifies exactly one valid
|
Resolved Interface when the binding identifies exactly one valid
|
||||||
interface, otherwise None.
|
interface, otherwise None.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
With service.interface = ServiceInterfaceSelector.AUTO and issues = []:
|
||||||
|
Declared static ifaces: lan
|
||||||
|
Result: node.interfaces["lan"]
|
||||||
|
Issues: unchanged
|
||||||
|
|
||||||
|
With two declared static interfaces, lan and wan:
|
||||||
|
Result: None
|
||||||
|
Issues: one ambiguity issue appended at the service interface
|
||||||
|
|
||||||
|
These scenarios describe selection only; address validity and link
|
||||||
|
attachment are checked elsewhere in the pipeline.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
selector = service.interface
|
selector = service.interface
|
||||||
@@ -772,18 +915,34 @@ def _resolve_service_interface(
|
|||||||
def _validate_dhcp_server(
|
def _validate_dhcp_server(
|
||||||
service: Service,
|
service: Service,
|
||||||
service_location: str,
|
service_location: str,
|
||||||
node: Node,
|
|
||||||
interface: Optional[Interface],
|
interface: Optional[Interface],
|
||||||
issues: List[ValidationIssue],
|
issues: List[ValidationIssue],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Validate DHCP-server-specific semantics.
|
"""Validate DHCP-server-specific semantics.
|
||||||
|
|
||||||
|
Check address syntax and range ordering independently of the binding, so
|
||||||
|
those diagnostics remain available even when interface resolution fails.
|
||||||
|
With a usable static binding, check subnet membership and reserved subnet
|
||||||
|
addresses, then exclude the server and advertised gateway from the pool.
|
||||||
|
Skip checks whose required address or binding could not be parsed.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
service: DHCP server service being validated.
|
service: DHCP server service being validated.
|
||||||
service_location: Logical location of the service.
|
service_location: Logical location of the service (where service data came from).
|
||||||
node: Node on which the service runs.
|
interface: Resolved service interface, if available.
|
||||||
interface: Resolved service interface, if available.
|
issues: Mutable list receiving validation failures.
|
||||||
issues: Mutable list receiving validation failures.
|
|
||||||
|
Example:
|
||||||
|
For a valid static binding and otherwise valid service settings:
|
||||||
|
Server interface: 192.168.1.1/24
|
||||||
|
Advertised gateway: omitted
|
||||||
|
Pool: 192.168.1.100 through 192.168.1.200
|
||||||
|
Result: None; no issues appended
|
||||||
|
|
||||||
|
Changing the pool to 192.168.1.1 through 192.168.1.200 still keeps
|
||||||
|
it inside the subnet, but includes the server's own address.
|
||||||
|
The result remains None, with one server-address exclusion issue
|
||||||
|
appended to the supplied issues list.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if service.interface is ServiceInterfaceSelector.ALL:
|
if service.interface is ServiceInterfaceSelector.ALL:
|
||||||
@@ -801,12 +960,12 @@ def _validate_dhcp_server(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
start = _parse_ipv4_address(
|
start = _validate_ipv4_address(
|
||||||
service.settings.address_range.start,
|
service.settings.address_range.start,
|
||||||
service_location + ".settings.range.start",
|
service_location + ".settings.range.start",
|
||||||
issues,
|
issues,
|
||||||
)
|
)
|
||||||
end = _parse_ipv4_address(
|
end = _validate_ipv4_address(
|
||||||
service.settings.address_range.end,
|
service.settings.address_range.end,
|
||||||
service_location + ".settings.range.end",
|
service_location + ".settings.range.end",
|
||||||
issues,
|
issues,
|
||||||
@@ -821,7 +980,7 @@ def _validate_dhcp_server(
|
|||||||
|
|
||||||
gateway: Optional[ipaddress.IPv4Address] = None
|
gateway: Optional[ipaddress.IPv4Address] = None
|
||||||
if service.settings.gateway is not None:
|
if service.settings.gateway is not None:
|
||||||
gateway = _parse_ipv4_address(
|
gateway = _validate_ipv4_address(
|
||||||
service.settings.gateway,
|
service.settings.gateway,
|
||||||
service_location + ".settings.gateway",
|
service_location + ".settings.gateway",
|
||||||
issues,
|
issues,
|
||||||
@@ -842,7 +1001,7 @@ def _validate_dhcp_server(
|
|||||||
# The interface validator reports this as well.
|
# The interface validator reports this as well.
|
||||||
return
|
return
|
||||||
|
|
||||||
parsed_interface = _parse_ipv4_interface(
|
parsed_interface = _validate_ipv4_interface(
|
||||||
interface.address,
|
interface.address,
|
||||||
service_location + ".interface",
|
service_location + ".interface",
|
||||||
issues,
|
issues,
|
||||||
@@ -962,18 +1121,45 @@ def _validate_services(
|
|||||||
_validate_dhcp_server(
|
_validate_dhcp_server(
|
||||||
service,
|
service,
|
||||||
location,
|
location,
|
||||||
node,
|
|
||||||
interface,
|
interface,
|
||||||
issues,
|
issues,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================ Public interface ============================
|
||||||
|
|
||||||
def validate(topology: Topology) -> None:
|
def validate(topology: Topology) -> None:
|
||||||
"""Validate the semantic consistency of a parsed topology.
|
"""Validate the semantic consistency of a parsed topology.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
topology: Parsed topology produced by topology_parser.parse_topology().
|
topology: Parsed topology produced by topology_parser.parse_topology().
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None when no semantic issues are found.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Input constructed through the public parser:
|
||||||
|
topology = parse_topology({
|
||||||
|
"nodes": [{"name": "h1", "type": "host"}],
|
||||||
|
})
|
||||||
|
|
||||||
|
Output:
|
||||||
|
result = validate(topology)
|
||||||
|
result is None
|
||||||
|
|
||||||
|
Invalid input:
|
||||||
|
topology = parse_topology({
|
||||||
|
"nodes": [
|
||||||
|
{"name": "h1", "type": "host"},
|
||||||
|
{"name": "h1", "type": "host"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
validate(topology) raises TopologyValidationError instead of returning.
|
||||||
|
The exception's issues tuple contains one ValidationIssue with
|
||||||
|
location="nodes[1].name" and a duplicate-node-name message.
|
||||||
|
When multiple issues are discovered, they share one exception.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TopologyValidationError: If one or more semantic consistency errors are
|
TopologyValidationError: If one or more semantic consistency errors are
|
||||||
discovered.
|
discovered.
|
||||||
@@ -994,6 +1180,8 @@ def validate(topology: Topology) -> None:
|
|||||||
raise TopologyValidationError(issues)
|
raise TopologyValidationError(issues)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================== Self-test ===============================
|
||||||
|
|
||||||
def _self_test() -> None:
|
def _self_test() -> None:
|
||||||
"""Run smoke tests against the public validate() interface."""
|
"""Run smoke tests against the public validate() interface."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user